add resiliation

This commit is contained in:
LathanDevers
2026-07-17 12:48:06 +02:00
parent 2706b3d5a3
commit b30a16b8a4
4 changed files with 128 additions and 48 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ export default function NewServiceCard({ onClick }) {
Demander une accréditation Demander une accréditation
</h3> </h3>
<p className="text-sm text-gray-500 mt-2"> <p className="text-sm text-gray-500 mt-2">
Déployer un nouveau serveur Web, VPS ou Cloud. Déployer un nouveau serveur Web, VPS, DB ou Cloud.
</p> </p>
</div> </div>
); );
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { CreditCard, Check, Globe, Loader, ToggleLeft, ShieldAlert, Calendar, TrendingUp, TrendingDown, RefreshCcw } from 'lucide-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 || ''; const CUSTOM_API_BASE_URL = import.meta.env.VITE_CUSTOM_API_BASE_URL || '';
export default function WebServiceSubscriptionManager({ order, onClose, onRefresh, onAlert }) { export default function WebServiceSubscriptionManager({ order, onClose, onRefresh, onAlert }) {
@@ -15,6 +15,9 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
const [billingPeriod, setBillingPeriod] = useState(order.period || '1M'); 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) // 1. Récupération des détails techniques (Domaine/IP)
useEffect(() => { useEffect(() => {
const fetchDetails = async () => { const fetchDetails = async () => {
@@ -39,8 +42,8 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
// 2. Extraction robuste du domaine // 2. Extraction robuste du domaine
let extractedDomain = "Aucun domaine lié"; let extractedDomain = "Aucun domaine lié";
if (serviceDetails?.domain || serviceDetails?.config?.domain) { if (serviceDetails?.config?.hostname || serviceDetails?.config?.sld && serviceDetails?.config?.tld) {
extractedDomain = serviceDetails.domain || serviceDetails.config.domain; extractedDomain = serviceDetails.config.hostname || serviceDetails.config.sld+serviceDetails.config.tld;
} }
const renderMarkdownFeatures = (text) => { const renderMarkdownFeatures = (text) => {
@@ -174,12 +177,7 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
isRefund = finalInvoicePrice < -0.01; 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); 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 baseCurrentPrice = currentPlanFromCatalog?.pricing?.recurrent?.['1M']?.price || 0;
const baseSelectedPrice = selectedPlan?.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; const isSamePeriod = currentBillingPeriod === billingPeriod;
if (isSamePlan && !isSamePeriod) { if (isSamePlan && !isSamePeriod) {
// Le pack est identique, seule la période change
migrationLabel = "Changer de cycle"; migrationLabel = "Changer de cycle";
buttonColor = "bg-blue-500 hover:bg-blue-400 text-black shadow-[0_0_20px_rgba(59,130,246,0.2)]"; buttonColor = "bg-blue-500 hover:bg-blue-400 text-black shadow-[0_0_20px_rgba(59,130,246,0.2)]";
ActionIcon = <RefreshCcw className="w-4 h-4" />; ActionIcon = <RefreshCcw className="w-4 h-4" />;
} else if (!isSamePlan) { } else if (!isSamePlan) {
// Le pack change, on compare la hiérarchie absolue
if (parseFloat(baseSelectedPrice) > parseFloat(baseCurrentPrice)) { if (parseFloat(baseSelectedPrice) > parseFloat(baseCurrentPrice)) {
migrationLabel = `Valider l'Upgrade`; migrationLabel = `Valider l'Upgrade`;
buttonColor = "bg-emerald-500 hover:bg-emerald-400 text-black shadow-[0_0_20px_rgba(16,185,129,0.2)]"; 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 () => { const handleMigration = async () => {
setLoading(true); setLoading(true);
// 🎯 On détermine dynamiquement le type d'action pour la facture
let actionType = 'upgrade'; let actionType = 'upgrade';
const currentPlanFromCatalog = catalog.find(p => p.id === currentPlanId); const currentPlanFromCatalog = catalog.find(p => p.id === currentPlanId);
const baseCurrent = parseFloat(currentPlanFromCatalog?.pricing?.recurrent?.['1M']?.price || 0); 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, target_plan_id: selectedPlan.id,
new_period: billingPeriod, new_period: billingPeriod,
new_price: finalInvoicePrice.toFixed(2), new_price: finalInvoicePrice.toFixed(2),
action_type: actionType // 🎯 On envoie l'information au backend ! action_type: actionType
}) })
}); });
const data = await resp.json(); 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.")) { // 🛡️ NOUVELLE LOGIQUE DE RÉSILIATION (US 2.1 / 2.2)
return; // ==========================================
} 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); setCancelLoading(true);
try { try {
const resp = await fetch(`${CUSTOM_API_BASE_URL}/custom_api/nexus_subscription.php`, { const data = await cancelOrder(order.id);
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
action: 'cancel',
order_id: order.id
})
});
const data = await resp.json();
// Succès normal
if (data.status === 'success') { 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 (onRefresh) onRefresh();
if (onClose) onClose(); if (onClose) onClose();
} else { } else {
if (onAlert) onAlert("Erreur", data.error || "Impossible de résilier l'abonnement.", "error"); if (onAlert) onAlert("Erreur", data.error, "error");
} }
} catch (err) { } 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 { } finally {
setCancelLoading(false); setCancelLoading(false);
} }
}; };
return ( return (
<div className="flex flex-col w-full max-h-[95vh] md:h-auto md:max-h-[90vh] max-w-6xl mx-auto text-white"> <div className="flex flex-col w-full max-h-[95vh] md:h-auto md:max-h-[90vh] max-w-6xl mx-auto text-white relative">
<div className="shrink-0 space-y-4 mb-4 pr-2"> <div className="shrink-0 space-y-4 mb-4 pr-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-[#090f1c] border border-gray-800 p-3.5 rounded-xl flex items-center gap-3"> <div className="bg-[#090f1c] border border-gray-800 p-3.5 rounded-xl flex items-center gap-3">
@@ -316,10 +323,7 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
</p> </p>
</div> </div>
</div> </div>
<a <a href={`/facturation`} className="text-xs font-bold text-cyan-400 hover:text-cyan-300 transition-colors flex items-center gap-1.5 bg-cyan-950/20 px-4 py-2 rounded-lg border border-cyan-900/30">
href={`/facturation`}
className="text-xs font-bold text-cyan-400 hover:text-cyan-300 transition-colors flex items-center gap-1.5 bg-cyan-950/20 px-4 py-2 rounded-lg border border-cyan-900/30"
>
Voir les factures Voir les factures
</a> </a>
</div> </div>
@@ -429,7 +433,6 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
</div> </div>
<div className="shrink-0 mt-auto pt-4 border-t border-gray-800/40 space-y-3"> <div className="shrink-0 mt-auto pt-4 border-t border-gray-800/40 space-y-3">
{/* 🎯 NOUVEAU : AFFICHAGE DYNAMIQUE (PRORATA ET REMBOURSEMENT) */}
{selectedPlan && !isNoChange && ( {selectedPlan && !isNoChange && (
<div className={`border p-3 rounded-xl mb-3 flex items-center justify-between transition-colors <div className={`border p-3 rounded-xl mb-3 flex items-center justify-between transition-colors
${isRefund ? 'bg-amber-950/20 border-amber-900/50' : 'bg-[#0e2238] border-cyan-900/50'}`} ${isRefund ? 'bg-amber-950/20 border-amber-900/50' : 'bg-[#0e2238] border-cyan-900/50'}`}
@@ -473,8 +476,9 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
</button> </button>
)} )}
{/* 🎯 NOUVEAU BOUTON : Ouvre la modale au lieu de faire un confirm() */}
<button <button
onClick={handleCancel} onClick={() => setIsCancelModalOpen(true)}
disabled={loading || cancelLoading} 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" 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"
> >
@@ -482,6 +486,71 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
{cancelLoading ? 'Résiliation en cours...' : 'Résilier l\'abonnement réseau'} {cancelLoading ? 'Résiliation en cours...' : 'Résilier l\'abonnement réseau'}
</button> </button>
</div> </div>
{/* ========================================================================= */}
{/* 🛡️ MODALE DE CONFIRMATION (Thème Sombre Intégré) */}
{/* ========================================================================= */}
{isCancelModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4">
<div className="bg-[#0d1527] border border-gray-800 rounded-2xl shadow-2xl max-w-md w-full p-6 transform transition-all">
<h3 className="text-xl font-bold text-white mb-4 flex items-center gap-2">
<ShieldAlert className="w-5 h-5 text-red-500" />
Confirmer la résiliation
</h3>
{/* Condition d'affichage : Rétractation J+14 vs Résiliation standard */}
{isEligibleForRefund ? (
<div className="bg-emerald-950/30 border border-emerald-900/50 p-4 mb-5 text-emerald-400 text-sm rounded-xl">
<p className="font-bold mb-1 flex items-center gap-2">
<Check className="w-4 h-4" /> Droit de rétractation ( 14 jours)
</p>
<p className="text-emerald-500/80 mt-2">
Votre abonnement a été activé il y a moins de 14 jours.
<strong className="text-emerald-400"> Vous allez être intégralement remboursé</strong>.
Vos services HestiaCP et bases de données seront supprimés immédiatement.
</p>
</div>
) : (
<div className="bg-amber-950/30 border border-amber-900/50 p-4 mb-5 text-amber-400 text-sm rounded-xl">
<p className="font-bold mb-1">📅 Résiliation standard (&gt; 14 jours)</p>
<p className="text-amber-500/80 mt-2">
La période de rétractation est dépassée. Votre abonnement restera actif jusqu'à sa date d'échéance officielle.
<strong className="text-amber-400"> Aucun renouvellement ni prélèvement n'aura lieu.</strong>
</p>
</div>
)}
<p className="text-gray-400 mb-6 text-sm">
Êtes-vous absolument sûr de vouloir procéder ? Cette action est irréversible une fois validée par nos serveurs.
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => 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
</button>
<button
onClick={handleConfirmCancel}
disabled={cancelLoading}
className="px-5 py-2.5 bg-red-600/90 hover:bg-red-500 text-white border border-red-500/50 rounded-xl flex items-center disabled:opacity-50 transition-colors text-sm font-bold tracking-wide shadow-[0_0_15px_rgba(220,38,38,0.3)]"
>
{cancelLoading ? (
<>
<Loader className="w-4 h-4 animate-spin mr-2" />
Traitement...
</>
) : (
"Confirmer"
)}
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }
+19 -6
View File
@@ -20,8 +20,10 @@ export default function Dashboard() {
const triggerAlert = (title, message, type = "info") => setCustomAlert({ title, message, type }); const triggerAlert = (title, message, type = "info") => setCustomAlert({ title, message, type });
const fetchInventory = async () => { const fetchInventory = async (retryCount = 0) => {
setIsLoading(true); // On n'affiche le loader global que lors du tout premier essai
if (retryCount === 0) setIsLoading(true);
try { try {
const data = await getOrdersList(); const data = await getOrdersList();
@@ -37,17 +39,28 @@ export default function Dashboard() {
title.startsWith('domaine ') || title.startsWith('domaine ') ||
title.startsWith('enregistrement '); title.startsWith('enregistrement ');
return !isGhostProduct; return (order.status === 'active' || order.status === 'pending_setup') && !isGhostProduct;
}); });
setOrders(filteredOrders); setOrders(filteredOrders);
} else { } else {
setOrders([]); setOrders([]);
} }
setError(null); // On efface l'erreur s'il y en avait une
setIsLoading(false); // Le chargement est terminé
} catch (err) { } catch (err) {
setError(err.message || "Impossible de récupérer la télémétrie des services."); // 🛡️ SYSTÈME D'AUTO-GUÉRISON : Si on détecte le redémarrage d'HestiaCP (Erreur HTML/JSON)
} finally { if (err.message && err.message.includes('Unexpected token') && retryCount < 3) {
setIsLoading(false); 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);
}
} }
}; };
+1 -3
View File
@@ -206,9 +206,7 @@ export const changeClientPassword = (current_password, new_password) =>
// Resiliation // Resiliation
// Action : Demande d'annulation (S'occupe de vérifier les 14 jours côté serveur) // Action : Demande d'annulation (S'occupe de vérifier les 14 jours côté serveur)
export const cancelOrder = (orderId) => { export const cancelOrder = (orderId) => {
// On utilise votre moteur apiCall existant, il transmettra automatiquement return apiCall(`${CUSTOM_API_BASE_URL}/custom_api/cancel_subscription.php`, 'POST', {
// le token "Bearer" dans le header Authorization !
return apiCall(`${CUSTOM_API_URL}/cancel_subscription.php`, 'POST', {
order_id: orderId order_id: orderId
}); });
}; };