3 Commits

Author SHA1 Message Date
maximus 9ad44d57b3 facturation: correction 2026-07-12 16:25:53 +02:00
LathanDevers eb48f506cf add billing system 2026-07-12 10:28:02 +02:00
LathanDevers ef8f4a96b4 add migration of servicew 2026-07-02 16:40:37 +02:00
16 changed files with 261 additions and 723 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NEXUS by gise | Portail d'Infrastructure Centralisée</title>
<meta name="description" content="Accédez au portail NEXUS by gise. Pilotez votre hébergement web, vos instances serveurs et votre stockage Cloud depuis une console unifiée.">
<meta name="description" content="Accédez au portail GISE Nexus. Pilotez votre hébergement web, vos instances serveurs et votre stockage Cloud depuis une console unifiée.">
</head>
<body>
<div id="root"></div>
@@ -1,9 +1,9 @@
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';
import { getProductList, getHostingServiceDetails } from '../../services/api';
const CUSTOM_API_BASE_URL = import.meta.env.VITE_CUSTOM_API_BASE_URL || '';
export default function WebServiceSubscriptionManager({ order, onClose, onRefresh, onAlert }) {
export default function SubscriptionManager({ order, onClose, onRefresh, onAlert }) {
const [loading, setLoading] = useState(false);
const [cancelLoading, setCancelLoading] = useState(false);
const [catalog, setCatalog] = useState([]);
@@ -15,15 +15,12 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
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);
const data = await getHostingServiceDetails(order.id);
setServiceDetails(data);
} catch (err) {
console.error("Erreur détails service:", err);
@@ -42,8 +39,8 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
// 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;
if (serviceDetails?.domain || serviceDetails?.config?.domain) {
extractedDomain = serviceDetails.domain || serviceDetails.config.domain;
}
const renderMarkdownFeatures = (text) => {
@@ -177,7 +174,12 @@ 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;
@@ -185,10 +187,12 @@ 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 = <RefreshCcw className="w-4 h-4" />;
} 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)]";
@@ -200,9 +204,12 @@ 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);
@@ -224,7 +231,7 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
target_plan_id: selectedPlan.id,
new_period: billingPeriod,
new_price: finalInvoicePrice.toFixed(2),
action_type: actionType
action_type: actionType // 🎯 On envoie l'information au backend !
})
});
const data = await resp.json();
@@ -242,55 +249,40 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
}
};
// ==========================================
// 🛡 NOUVELLE LOGIQUE DE RÉSILIATION (US 2.1 / 2.2)
// ==========================================
const calculateRefundEligibility = () => {
if (!order || !order.activated_at) return false;
const orderDate = new Date(order.activated_at);
console.log("Order Date:", orderDate);
const now = new Date();
const diffDays = Math.ceil(Math.abs(now - orderDate) / (1000 * 60 * 60 * 24));
return diffDays <= 14;
};
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;
}
const isEligibleForRefund = calculateRefundEligibility();
const handleConfirmCancel = async () => {
setCancelLoading(true);
try {
const data = await cancelOrder(order.id);
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();
// Succès normal
if (data.status === 'success') {
if (onAlert) onAlert("Résilié", data.message, "success");
setIsCancelModalOpen(false);
if (onAlert) onAlert("Résilié", data.message || "L'abonnement a été annulé avec succès.", "success");
if (onRefresh) onRefresh();
if (onClose) onClose();
} else {
if (onAlert) onAlert("Erreur", data.error, "error");
if (onAlert) onAlert("Erreur", data.error || "Impossible de résilier l'abonnement.", "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");
}
if (onAlert) onAlert("Erreur", "Problème réseau ou serveur.", "error");
} finally {
setCancelLoading(false);
}
};
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 relative">
<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="shrink-0 space-y-4 mb-4 pr-2">
<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">
@@ -324,7 +316,10 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
</p>
</div>
</div>
<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">
<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"
>
Voir les factures
</a>
</div>
@@ -434,6 +429,7 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
</div>
<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 && (
<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'}`}
@@ -477,9 +473,8 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
</button>
)}
{/* 🎯 NOUVEAU BOUTON : Ouvre la modale au lieu de faire un confirm() */}
<button
onClick={() => setIsCancelModalOpen(true)}
onClick={handleCancel}
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"
>
@@ -487,71 +482,6 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
{cancelLoading ? 'Résiliation en cours...' : 'Résilier l\'abonnement réseau'}
</button>
</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 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>
);
}
+81 -13
View File
@@ -1,19 +1,55 @@
import React, { useState, useEffect } from 'react';
import { Server, Database, Cloud, Globe, ExternalLink, Play, Loader } from 'lucide-react';
import { getHostingServiceDetails } from '../../services/api';
export default function InstanceCard({ service, vpcs, onAssignVpc, onRemoveVpc, onOpenConsole, isConnecting }) {
// 1. États pour les détails techniques chargés via API
const [serviceDetails, setServiceDetails] = useState(null);
const [isLoadingDetails, setIsLoadingDetails] = useState(true);
// 2. Chargement des détails au montage du composant
useEffect(() => {
let isMounted = true;
const fetchDetails = async () => {
try {
const data = await getHostingServiceDetails(service.id);
if (isMounted) {
setServiceDetails(data);
}
} catch (error) {
console.error("Erreur lors du chargement des détails du service:", error);
} finally {
if (isMounted) setIsLoadingDetails(false);
}
};
fetchDetails();
return () => { isMounted = false; };
}, [service.id]);
// 3. Logique d'affichage (Titre, Type de service)
const titleLower = (service.title || '').toLowerCase();
const isVPS = titleLower.includes('vps') || titleLower.includes('compute');
const isCloud = titleLower.includes('cloud') || titleLower.includes('nextcloud');
const isDB = titleLower.includes('db') || titleLower.includes('base') || titleLower.includes('sql');
const isWeb = !isVPS && !isCloud && !isDB;
const hasIP = service.hostingDetails?.ip && service.hostingDetails.ip !== '127.0.0.1' && service.hostingDetails.ip !== '';
// 4. Extraction intelligente du domaine et de l'IP
// Priorité : API > Titre de la commande
const rawTitle = service.title || '';
const titleMatch = rawTitle.match(/(?: for | pour )(.+)$/i);
const domainFromTitle = titleMatch ? titleMatch[1].trim() : null;
const displayDomain = serviceDetails?.domain || serviceDetails?.config?.domain || domainFromTitle || null;
const displayIP = serviceDetails?.ip || serviceDetails?.config?.ip || null;
// Logique des boutons
let buttonText = "CONSOLE D'ADMINISTRATION";
let ButtonIcon = ExternalLink;
let buttonStyle = "bg-cyan-400/10 hover:bg-cyan-400 text-cyan-400 hover:text-gray-900 border-cyan-400";
if (isVPS) {
if (hasIP) { buttonText = "GÉRER L'INSTANCE"; ButtonIcon = ExternalLink; }
if (displayIP) { buttonText = "GÉRER L'INSTANCE"; ButtonIcon = ExternalLink; }
else { buttonText = "INITIALISATION"; ButtonIcon = Play; buttonStyle = "bg-yellow-500/10 text-yellow-500 hover:bg-yellow-500 hover:text-gray-900 border-yellow-500/50"; }
} else if (isWeb) { buttonText = "GÉRER L'HÉBERGEMENT"; ButtonIcon = Globe; buttonStyle = "bg-emerald-400/10 hover:bg-emerald-400 text-emerald-400 hover:text-gray-900 border-emerald-400"; }
else if (isCloud) { buttonText = "ACCÉDER AU CLOUD"; buttonStyle = "bg-blue-400/10 hover:bg-blue-400 text-blue-400 hover:text-gray-900 border-blue-400"; }
@@ -26,12 +62,8 @@ export default function InstanceCard({ service, vpcs, onAssignVpc, onRemoveVpc,
return <Globe className="w-8 h-8 text-emerald-400" />;
};
const baseTitle = (service.title || '').split(/(?: for | pour )/i)[0].trim();
const baseTitle = rawTitle.split(/(?: for | pour )/i)[0].trim();
const shortTitle = baseTitle.split(' ').slice(0, 2).join(' ');
const titleMatch = (service.title || '').match(/(?: for | pour )(.+)$/i);
const domainFromTitle = titleMatch ? titleMatch[1].trim() : null;
let displayDomain = service.hostingDetails?.domain && service.hostingDetails.domain !== '127.0.0.1' ? service.hostingDetails.domain : domainFromTitle || service.domain;
if (!displayDomain || displayDomain === '127.0.0.1') displayDomain = null;
return (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5 flex flex-col justify-between hover:border-gray-700 transition-all shadow-lg">
@@ -40,27 +72,63 @@ export default function InstanceCard({ service, vpcs, onAssignVpc, onRemoveVpc,
<div className="p-2 bg-black/40 rounded-lg">{getServiceIcon()}</div>
{service.status === 'active' ? (
<span className="bg-emerald-500/10 text-emerald-400 px-2 py-1 rounded text-xs border border-emerald-500/20 font-bold tracking-widest">
{isVPS && !hasIP ? 'AWAITING INIT' : 'ONLINE'}
{isVPS && !displayIP ? 'AWAITING INIT' : 'ONLINE'}
</span>
) : (
<span className="bg-orange-500/10 text-orange-400 px-2 py-1 rounded text-xs border border-orange-500/20 animate-pulse">DEPLOYING</span>
)}
</div>
<h3 className="font-bold text-white text-lg truncate" title={baseTitle}>{shortTitle}</h3>
<div className="flex flex-col gap-1 mt-2 mb-4 h-8 justify-center">
{displayDomain ? ( <p className={`${isVPS ? 'text-cyan-400' : 'text-emerald-400'} text-xs font-mono truncate`} title={displayDomain}>{displayDomain}</p> ) : ( <p className="text-gray-600 text-xs font-mono italic">En attente de déploiement</p> )}
{/* Zone Domaine & IP */}
<div className="flex flex-col gap-1 mt-2 mb-4 h-12 justify-center">
{isLoadingDetails ? (
<div className="flex items-center text-gray-500 text-xs gap-2">
<Loader className="w-3 h-3 animate-spin" /> Chargement...
</div>
) : (
<>
{displayDomain ? (
<p className={`${isVPS ? 'text-cyan-400' : 'text-emerald-400'} text-xs font-mono truncate`} title={displayDomain}>
{displayDomain}
</p>
) : (
<p className="text-gray-600 text-xs font-mono italic">Aucun domaine</p>
)}
{displayIP && (
<p className="text-gray-500 text-[10px] font-mono mt-0.5" title={`IP: ${displayIP}`}>
IP: {displayIP}
</p>
)}
</>
)}
</div>
</div>
<div className="mt-auto space-y-3">
<div className="flex items-center justify-between text-sm border-t border-gray-800 pt-3">
<span className="text-gray-500 text-xs">Projet VPC:</span>
<select onChange={(e) => { const val = e.target.value; if (val === "free") onRemoveVpc(service.id); else if (val) onAssignVpc(service.id, val); }} className="bg-black border border-gray-700 text-gray-300 rounded px-2 py-1 outline-none focus:border-cyan-400 text-xs w-32.5" defaultValue={vpcs.find(v => v.services.includes(service.id))?.id || "free"}>
<select
onChange={(e) => { const val = e.target.value; if (val === "free") onRemoveVpc(service.id); else if (val) onAssignVpc(service.id, val); }}
className="bg-black border border-gray-700 text-gray-300 rounded px-2 py-1 outline-none focus:border-cyan-400 text-xs w-32.5"
defaultValue={vpcs.find(v => v.services.includes(service.id))?.id || "free"}
>
<option value="free">-- Libre --</option>
{vpcs.map(vpc => ( <option key={vpc.id} value={vpc.id}>{vpc.name}</option> ))}
</select>
</div>
<button onClick={() => onOpenConsole(service)} disabled={isConnecting === service.id || service.status !== 'active'} className={`w-full flex items-center justify-center space-x-2 py-2.5 rounded-lg font-bold text-sm tracking-wide transition-all disabled:opacity-50 border ${buttonStyle}`}>
{isConnecting === service.id ? ( <><Loader className="w-4 h-4 animate-spin" /><span>CONNEXION...</span></> ) : ( <><ButtonIcon className="w-4 h-4" /><span>{buttonText}</span></> )}
<button
onClick={() => onOpenConsole(service)}
disabled={isConnecting === service.id || service.status !== 'active'}
className={`w-full flex items-center justify-center space-x-2 py-2.5 rounded-lg font-bold text-sm tracking-wide transition-all disabled:opacity-50 border ${buttonStyle}`}
>
{isConnecting === service.id ? (
<><Loader className="w-4 h-4 animate-spin" /><span>CONNEXION...</span></>
) : (
<><ButtonIcon className="w-4 h-4" /><span>{buttonText}</span></>
)}
</button>
</div>
</div>
+4 -2
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react';
import { Plus, Lock } from 'lucide-react';
import { Plus, Lock, X } from 'lucide-react';
export default function CreateTicketModal({ isOpen, onClose, onSubmit, helpdesks, defaultHelpdesk, isSubmitting }) {
const [subject, setSubject] = useState('');
@@ -30,7 +30,9 @@ export default function CreateTicketModal({ isOpen, onClose, onSubmit, helpdesks
<h3 className="text-xl font-bold text-white tracking-wider flex items-center gap-2">
<Plus className="w-5 h-5 text-cyan-400" /> OUVRIR UNE REQUÊTE
</h3>
<button onClick={onClose} className="text-gray-400 hover:text-white transition text-xl"></button>
<button onClick={onClose} className="p-2 text-gray-500 hover:text-white bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
+4 -2
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react';
import { Send, Loader } from 'lucide-react';
import { Send, Loader, X } from 'lucide-react';
export default function TicketThreadModal({ ticket, onClose, onReply, isLoadingConversation }) {
const [replyMessage, setReplyMessage] = useState('');
@@ -32,7 +32,9 @@ export default function TicketThreadModal({ ticket, onClose, onReply, isLoadingC
</div>
<h3 className="text-xl font-bold text-white line-clamp-1">{ticket.subject}</h3>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-white transition text-xl bg-black/50 p-2 rounded-lg"></button>
<button onClick={onClose} className="p-2 text-gray-500 hover:text-white bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-6 scrollbar-thin scrollbar-thumb-gray-800">
+6 -5
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react';
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { getClientTickets } from '../services/api';
import { LayoutDashboard, Globe, Settings, FileText } from 'lucide-react';
import ConfirmLogoutModal from '../components/ui/ConfirmLogoutModal'; // Le modal extrait !
export default function AppLayout() {
@@ -15,7 +16,7 @@ export default function AppLayout() {
const unread = data.list.filter(t => t.status === 'on_hold' || t.unread).length;
setUnreadCount(unread);
}
} catch (err) {}
} catch (err) { }
};
useEffect(() => {
@@ -31,10 +32,9 @@ export default function AppLayout() {
// 🌟 La fonction magique Tailwind pour les liens du menu
const navLinkClass = ({ isActive }) =>
`block px-6 py-4 no-underline border-l-4 transition-all uppercase tracking-widest text-sm font-mono ${
isActive
? 'text-cyan-400 bg-cyan-400/5 border-cyan-400'
: 'text-[#888] border-transparent hover:text-gray-300 hover:bg-white/5'
`block px-6 py-4 no-underline border-l-4 transition-all uppercase tracking-widest text-sm font-mono ${isActive
? 'text-cyan-400 bg-cyan-400/5 border-cyan-400'
: 'text-[#888] border-transparent hover:text-gray-300 hover:bg-white/5'
}`;
return (
@@ -75,6 +75,7 @@ export default function AppLayout() {
</NavLink>
<NavLink className={navLinkClass} to="/facturation">Facturation</NavLink>
<div className="flex justify-between items-center px-6 py-4 text-[#444] border-l-4 border-transparent uppercase tracking-widest text-sm font-mono cursor-not-allowed select-none">
<span>Profil & Sécurité</span>
<span className="text-[9px] text-[#333] border border-[#333] px-1.5 py-0.5 rounded font-bold">WIP</span>
+7 -5
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { FileText, Download, CreditCard, Clock, CheckCircle2, XCircle, AlertCircle, Loader, X, Receipt, Trash2, ArrowRight, RefreshCcw } from 'lucide-react';
import NotificationModal from '../../components/ui/NotificationModal'; // Vérifie ton chemin
import { getClient, getInvoiceList } from '../../services/billing_api'; // Vérifie ton chemin
import { getClientProfile, getInvoicesHistory } from '../../services/api'; // Vérifie ton chemin
export default function BillingHistory() {
const [invoices, setInvoices] = useState([]);
@@ -21,12 +21,12 @@ export default function BillingHistory() {
setIsLoading(true);
setError(null);
try {
const profileData = await getClient();
const profileData = await getClientProfile();
if (!profileData || !profileData.id) {
throw new Error("Impossible de vérifier votre identité ou session expirée.");
}
const invoicesData = await getInvoiceList(profileData.id);
setInvoices(invoicesData.list || []);
const invoicesData = await getInvoicesHistory(profileData.id);
setInvoices(invoicesData.data || []);
} catch (err) {
setError(err.message || "Problème de connexion avec le serveur.");
} finally {
@@ -212,7 +212,9 @@ export default function BillingHistory() {
<div className="space-y-4">
{(() => {
// FILTRE : On ne garde que les items dont le prix est strictement supérieur à 0
const validItems = selectedInvoice.lines
const validItems = selectedInvoice.items
? selectedInvoice.items.filter(item => parseFloat(item.price) > 0)
: [];
if (validItems.length > 0) {
return validItems.map((item, idx) => (
+5 -4
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import { resetCart, addToCart, checkoutCart, getProductList, getClient } from '../../services/billing_api';
import { resetCart, addToCart, checkoutCart, getProductList, getClientProfile } from '../../services/api';
export default function Checkout() {
const { productId } = useParams();
@@ -18,7 +18,7 @@ export default function Checkout() {
useEffect(() => {
const loadData = async () => {
try {
// 1. On charge d'abord le produit
// 1. On charge d'abord le produit (Requête Publique)
const productData = await getProductList();
const foundProduct = productData.list?.find(p => p.id === parseInt(productId));
@@ -29,14 +29,15 @@ export default function Checkout() {
}
setProduct(foundProduct);
// 2. Ensuite, on tente de charger le profil
// 2. Ensuite, on tente de charger le profil (Requête Privée)
try {
const profileData = await getClient();
const profileData = await getClientProfile();
setUserProfile(profileData);
} catch (profileErr) {
// Si on tombe ici, c'est que FOSSBilling refuse l'accès au profil.
console.error("Rejet API Profil :", profileErr);
setError("Accès refusé. Vous devez être connecté à votre compte pour provisionner une instance.");
// Tu pourras décommenter la ligne suivante plus tard pour forcer la redirection :
// navigate('/login');
setIsLoading(false);
return;
+10 -24
View File
@@ -1,13 +1,13 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { getOrdersList } from '../../services/billing_api';
import { getClientOrders } from '../../services/api';
import { AlertCircle, Loader, FileText, X } from 'lucide-react';
// Importation des composants
import DashboardServiceCard from '../../components/dashboard/DashboardServiceCard';
import NewServiceCard from '../../components/dashboard/NewServiceCard';
import NotificationModal from '../../components/ui/NotificationModal';
import WebServiceSubscriptionManager from '../../components/dashboard/WebServiceSubscriptionManager';
import SubscriptionManager from '../../components/dashboard/SubscriptionManager'; // 🌟 Le nouveau composant !
export default function Dashboard() {
const navigate = useNavigate();
@@ -20,12 +20,10 @@ export default function Dashboard() {
const triggerAlert = (title, message, type = "info") => setCustomAlert({ title, message, type });
const fetchInventory = async (retryCount = 0) => {
// On n'affiche le loader global que lors du tout premier essai
if (retryCount === 0) setIsLoading(true);
const fetchInventory = async () => {
setIsLoading(true);
try {
const data = await getOrdersList();
const data = await getClientOrders();
if (data.list) {
// LE FILTRE CHIRURGICAL PAR PREFIXE
@@ -39,28 +37,17 @@ export default function Dashboard() {
title.startsWith('domaine ') ||
title.startsWith('enregistrement ');
return (order.status === 'active' || order.status === 'pending_setup') && !isGhostProduct;
return !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) {
// 🛡️ 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);
}
setError(err.message || "Impossible de récupérer la télémétrie des services.");
} finally {
setIsLoading(false);
}
};
@@ -70,7 +57,6 @@ export default function Dashboard() {
// 🌟 Plus besoin de charger les IPs, on ouvre juste la modale comptable !
const handleManageSubscription = (order) => {
console.log("Gestion de l'abonnement pour la commande :", order);
// 1. Détection du type de service
const titleLower = (order.title || '').toLowerCase();
const isVPS = titleLower.includes('vps') || titleLower.includes('compute');
@@ -142,7 +128,7 @@ export default function Dashboard() {
</button>
</div>
<WebServiceSubscriptionManager
<SubscriptionManager
order={activeSubscriptionModal}
onClose={() => setActiveSubscriptionModal(null)}
onRefresh={fetchInventory}
+8 -6
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react';
import { getOrdersList, getOrderDetails } from '../../services/billing_api';
import { getMyServices, getHostingServiceDetails } from '../../services/api';
import { useVPC } from '../../services/useVPC';
import { Server, Globe, Folder, Trash2, Loader, Plus, AlertCircle } from 'lucide-react';
import { Server, Globe, Folder, Trash2, Loader, Plus, AlertCircle, X } from 'lucide-react';
// Importation de tes nouveaux composants modulaires !
import NotificationModal from '../../components/ui/NotificationModal';
@@ -25,12 +25,12 @@ export default function Services() {
const fetchServices = useCallback(async () => {
try {
const data = await getOrdersList();
const data = await getMyServices();
if (data.list && data.list.length > 0) {
const detailedServices = await Promise.all(data.list.map(async (order) => {
let hDetails = null;
if (order.status === 'active' && (order.plugin === 'hosting' || (order.title || '').toLowerCase().includes('vps'))) {
try { hDetails = await getOrderDetails(order.id); } catch (e) {}
try { hDetails = await getHostingServiceDetails(order.id); } catch (e) { }
}
return { ...order, hostingDetails: hDetails };
}));
@@ -65,7 +65,7 @@ export default function Services() {
} else if (isDB) {
window.open('https://pma.gise.be/', '_blank');
} else if (isVPS || isWeb) {
const freshDetails = await getOrderDetails(service.id);
const freshDetails = await getHostingServiceDetails(service.id);
setActiveServiceModal({ type: isVPS ? 'vps' : 'web', data: service, details: freshDetails });
}
} catch (err) { triggerAlert("Erreur", err.message, "error"); }
@@ -132,7 +132,9 @@ export default function Services() {
<h3 className="text-xl font-bold text-white tracking-wider flex items-center gap-3">
{activeServiceModal.type === 'vps' ? <><Server className="w-6 h-6 text-cyan-400" /> GESTION VPS</> : <><Globe className="w-6 h-6 text-emerald-400" /> GESTION WEB</>}
</h3>
<button onClick={() => setActiveServiceModal(null)} className="text-gray-400 hover:text-white transition text-xl"></button>
<button onClick={() => setActiveServiceModal(null)} className="p-2 text-gray-500 hover:text-white bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{activeServiceModal.type === 'vps' ? (
activeServiceModal.details?.ip && activeServiceModal.details.ip !== '127.0.0.1' ? <VpsManager details={activeServiceModal.details} orderId={activeServiceModal.data.id} onRefresh={fetchServices} onAlert={triggerAlert} /> : <VpsDeployer serviceId={activeServiceModal.data.id} onDeploySuccess={() => { setActiveServiceModal(null); fetchServices(); }} onAlert={triggerAlert} />
+2 -2
View File
@@ -1,8 +1,8 @@
// src/pages/app/Store.jsx
import { useState, useEffect } from 'react';
import { Server, Database, Cloud, Globe, Loader, AlertCircle } from 'lucide-react';
import { getProductList } from '../../services/billing_api';
import CategorySection from '../../components/store/CategorySection';
import { getProductList } from '../../services/api';
import CategorySection from '../../components/store/CategorySection'; // L'import magique
export default function Store() {
const [groupedProducts, setGroupedProducts] = useState({});
+1 -1
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { loginClient } from '../../services/billing_api';
import { loginClient } from '../../services/api';
export default function Login() {
const [email, setEmail] = useState('');
+5 -5
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { createNewClient } from '../../services/billing_api';
import { registerUnifiedClient } from '../../services/api';
import NotificationModal from '../../components/ui/NotificationModal';
export default function Register() {
@@ -28,15 +28,15 @@ export default function Register() {
setCustomAlert({ type: 'error', title: 'Erreur de saisie', message: "Les clés d'accès ne correspondent pas." });
return;
}
if (!/^[a-zA-Z0-9]{3,20}$/.test(username)) {
if (!/^[a-zA-Z0-9]{3,12}$/.test(username)) {
setCustomAlert({ type: 'error', title: 'Identifiant invalide', message: "Le nom d'utilisateur doit contenir uniquement des lettres ou chiffres (entre 3 et 12 caractères)." });
return;
}
setLoading(true);
try {
await createNewClient(email, firstName, lastName, password, confirmPassword, "individual", username);
setCustomAlert({ type: 'success', title: 'PROVISIONNEMENT RÉUSSI', message: "Votre compte a été initialisé.\n\nVous pouvez maintenant vous connecter." });
await registerUnifiedClient(email, username, password, firstName, lastName);
setCustomAlert({ type: 'success', title: 'PROVISIONNEMENT RÉUSSI', message: "Vos comptes FOSSBilling, HestiaCP et Nextcloud ont été initialisés.\n\nVous pouvez maintenant vous connecter." });
} catch (err) {
setCustomAlert({ type: 'error', title: 'Échec du Déploiement', message: err.message || "Échec de l'initialisation." });
} finally {
@@ -56,7 +56,7 @@ export default function Register() {
Créer un accès réseau
</h2>
<p className="text-[#888] font-mono text-sm mb-6">
[ INITIALISATION DU PROVISIONNEMENT ]
[ INITIALISATION DU PROVISIONNEMENT TRIPLE ]
</p>
<form onSubmit={handleRegister} className="font-mono">
+75 -185
View File
@@ -1,7 +1,7 @@
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
const CUSTOM_API_BASE_URL = import.meta.env.VITE_CUSTOM_API_BASE_URL || '';
// Le moteur de requête
// Le moteur de requête unifié et intelligent
const apiCall = async (endpoint, param2 = 'GET', param3 = null) => {
let method = 'GET';
let body = null;
@@ -64,6 +64,24 @@ const apiCall = async (endpoint, param2 = 'GET', param3 = null) => {
}
};
// ==========================================
// ROUTES FOSSBILLING NATIVES (BACKTICKS INTÉGRÉS)
// ==========================================
export const loginClient = (email, password) =>
apiCall(`${BASE_URL}/api/guest/client/login`, { email, password });
// Récupère la liste des services/commandes du client
export const getClientOrders = () =>
apiCall(`${BASE_URL}/api/client/order/get_list`);
// Récupère les détails techniques du service rattaché à une commande
export const getOrderService = (order_id) =>
apiCall(`${BASE_URL}/api/client/order/service`, { id: order_id });
// Récupère le catalogue public des produits FOSSBilling
export const getProductList = () =>
apiCall(`${BASE_URL}/api/guest/product/get_list`);
// Vide le panier (Action PUBLIQUE : on passe par l'API Guest)
export const resetCart = async () => {
@@ -82,6 +100,22 @@ export const resetCart = async () => {
}
};
// Ajoute un produit au panier avec ses options étalées à la racine
export const addToCart = (productId, period, additionalData = {}) =>
apiCall(`${BASE_URL}/api/guest/cart/add_item`, 'POST', {
id: productId,
period: period,
...additionalData // Les 3 petits points "étalent" le contenu de l'objet
});
// Valide le panier (Action PRIVÉE : on reste sur l'API Client pour générer la facture)
export const checkoutCart = () =>
apiCall(`${BASE_URL}/api/client/cart/checkout`, 'POST');
// Récupère les informations du client connecté
export const getClientProfile = () =>
apiCall(`${BASE_URL}/api/client/profile/get`, 'GET');
// ==========================================
// ROUTES PERSONNALISÉES (CUSTOM API)
// ==========================================
@@ -110,6 +144,18 @@ export const registerUnifiedClient = async (email, username, password, firstName
}
}
// Récupère la liste de toutes les commandes actives du client
export const getMyServices = () =>
apiCall(`${BASE_URL}/api/client/order/get_list`, 'GET');
// Récupère les détails secrets d'un service (dont le mot de passe HestiaCP/VPS)
export const getServiceDetails = (orderId) =>
apiCall(`${BASE_URL}/api/client/order/get`, 'POST', { id: orderId });
// Récupère les secrets spécifiques du service physique attaché à une commande
export const getHostingServiceDetails = (orderId) =>
apiCall(`${BASE_URL}/api/client/order/service`, 'POST', { id: orderId });
// Force la réinitialisation du mot de passe sur le serveur distant (HestiaCP)
export const resetHostingPassword = (orderId, newPassword) =>
apiCall(`${BASE_URL}/api/client/servicehosting/change_password`, 'POST', {
@@ -145,204 +191,48 @@ export const launchSSOGateway = (username, password) => {
document.body.removeChild(form);
};
// #######################################################################################
// ==========================================
// GESTION DES ACCES
// ROUTES SUPPORT FOSSBILLING (NATIVES)
// ==========================================
// Connexion
export const loginClient = (email, password) =>
apiCall(`${BASE_URL}/api/guest/client/login`, { email, password });
// Récupère la liste de tous les tickets du client
export const getClientTickets = () =>
apiCall(`${BASE_URL}/api/client/support/ticket_get_list`, 'GET');
// Deconnexion
export const logoutClient = () =>
apiCall(`${BASE_URL}/api/client/profile/logout`);
// Récupère le contenu et les messages d'un ticket spécifique
export const getTicketDetails = (ticketId) =>
apiCall(`${BASE_URL}/api/client/support/ticket_get`, 'POST', { id: ticketId });
// ==========================================
// GESTION DU CLIENT
// ==========================================
// Creer un nouveau profil client
export const createClientProfile = (email, last_name, aid, gender, country, city, birthday, company, company_vat, company_number, type, address_1, address_2, postcode, state, phone, phone_cc, document_type, document_nr, notes, lang, custom_1, custom_2, custom_3, custom_4, custom_5, custom_6, custom_7, custom_8, custom_9, custom_10) =>
apiCall(`${BASE_URL}/api/guest/profile/create`, {
email: email,
last_name: last_name,
aid: aid,
gender: gender,
country: country,
city: city,
birthday: birthday,
company: company,
company_vat: company_vat,
company_number: company_number,
type: type,
address_1: address_1,
address_2: address_2,
postcode: postcode,
state: state,
phone: phone,
phone_cc: phone_cc,
document_type: document_type,
document_nr: document_nr,
notes: notes,
lang: lang,
custom_1: custom_1,
custom_2: custom_2,
custom_3: custom_3,
custom_4: custom_4,
custom_5: custom_5,
custom_6: custom_6,
custom_7: custom_7,
custom_8: custom_8,
custom_9: custom_9,
custom_10: custom_10
});
// Recuperer les informations du client
export const getClientProfile = () =>
apiCall(`${BASE_URL}/api/client/profile/get`, 'GET');
// Update les informations du client
export const updateClientProfile = (email, last_name, aid, gender, country, city, birthday, company, company_vat, company_number, type, address_1, address_2, postcode, state, phone, phone_cc, document_type, document_nr, notes, lang, custom_1, custom_2, custom_3, custom_4, custom_5, custom_6, custom_7, custom_8, custom_9, custom_10) =>
apiCall(`${BASE_URL}/api/client/profile/update`, {
email: email,
last_name: last_name,
aid: aid,
gender: gender,
country: country,
city: city,
birthday: birthday,
company: company,
company_vat: company_vat,
company_number: company_number,
type: type,
address_1: address_1,
address_2: address_2,
postcode: postcode,
state: state,
phone: phone,
phone_cc: phone_cc,
document_type: document_type,
document_nr: document_nr,
notes: notes,
lang: lang,
custom_1: custom_1,
custom_2: custom_2,
custom_3: custom_3,
custom_4: custom_4,
custom_5: custom_5,
custom_6: custom_6,
custom_7: custom_7,
custom_8: custom_8,
custom_9: custom_9,
custom_10: custom_10
});
// Changer le mot de passe
export const changeClientPassword = (current_password, new_password, confirm_password) =>
apiCall(`${BASE_URL}/api/client/profile/change_password`, {current_password:current_password, new_password:new_password, confirm_password:confirm_password});
// ==========================================
// GESTION DES ABONNEMENTS
// ==========================================
// Souscription
// Resiliation
// Suppression
export const deleteWrongOrder = (order_id) =>
apiCall(`${BASE_URL}/api/client/order/delete`, { id: order_id });
// Promotion (Surclassement)
// Lister
export const getUpgrades = (order_id) =>
apiCall(`${BASE_URL}/api/client/order/upgradables`, { id: order_id });
// Demotion (Declassement)
// Lister les abonnements du client
export const getClientOrders = () =>
apiCall(`${BASE_URL}/api/client/order/get_list`);
// Lister les produits disponibles
export const getProductList = () =>
apiCall(`${BASE_URL}/api/guest/product/get_list`);
// Recuperer les details d'un abonnement
export const getOrderDetails = (order_id) =>
apiCall(`${BASE_URL}/api/client/order/service`, { id: order_id });
// Recuperer les details secrets d'un abonnement
export const getServiceDetails = (orderId) =>
apiCall(`${BASE_URL}/api/client/order/get`, 'POST', { id: orderId });
// ==========================================
// GESTION DU PANIER
// ==========================================
// Ajouter au panier
export const addToCart = (productId, period, additionalData = {}) =>
apiCall(`${BASE_URL}/api/guest/cart/add_item`, 'POST', {
id: productId,
period: period,
...additionalData // Les 3 petits points "étalent" le contenu de l'objet
});
// Checkout
export const checkoutCart = () =>
apiCall(`${BASE_URL}/api/client/cart/checkout`, 'POST');
// ==========================================
// GESTION DES FACTURES
// ==========================================
// Creation
// Suppression
// Modification
// Lister
export const getInvoiceList = () =>
apiCall(`${BASE_URL}/api/client/invoice/get_list`, 'GET');
// Lire
export const getInvoiceDetails = (InvoiceHash) =>
apiCall(`${BASE_URL}/api/client/invoice/get`, 'GET');
// ==========================================
// GESTION DES TICKETS
// ==========================================
// Creation
// Crée un nouveau ticket de support
export const createTicket = (subject, message, helpdesk_id) =>
apiCall(`${BASE_URL}/api/client/support/ticket_create`, 'POST', {
support_helpdesk_id: helpdesk_id,
subject: subject,
content: message
});
// Suppression
// Lister les tickets
export const getClientTickets = () =>
apiCall(`${BASE_URL}/api/client/support/ticket_get_list`, 'GET');
// Lister les helpdesks
export const getHelpdesks = () =>
apiCall(`${BASE_URL}/api/client/support/helpdesk_get_pairs`, 'GET');
// Lire
export const getTicketDetails = (ticketId) =>
apiCall(`${BASE_URL}/api/client/support/ticket_get`, 'POST', { id: ticketId });
// Repondre
// Répond à un ticket existant
export const replyTicket = (ticketId, message) =>
apiCall(`${BASE_URL}/api/client/support/ticket_reply`, 'POST', {
id: ticketId,
content: message
});
// Récupère la liste dynamique des départements (Helpdesks) configurés sur FOSSBilling
export const getHelpdesks = () =>
apiCall(`${BASE_URL}/api/client/support/helpdesk_get_pairs`, 'GET');
// ==========================================
// ROUTES FACTURATION & REÇUS
// ==========================================
// Récupère l'historique des factures via notre script Custom PHP
export const getInvoicesHistory = (clientId) =>
apiCall(`${CUSTOM_API_BASE_URL}/custom_api/nexus_subscription.php`, 'POST', {
action: 'get_invoices',
client_id: clientId
});
// (Optionnel) FOSSBilling Native : Récupère les détails complets d'une facture
export const getInvoiceDetails = (invoiceHash) =>
apiCall(`${BASE_URL}/api/client/invoice/get`, 'POST', { hash: invoiceHash });
-346
View File
@@ -1,346 +0,0 @@
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
const CUSTOM_API_BASE_URL = import.meta.env.VITE_CUSTOM_API_BASE_URL || '';
// Le moteur de requête
const apiCall = async (endpoint, param2 = 'GET', param3 = null) => {
let method = 'GET';
let body = null;
// DÉTECTION DE SIGNATURE (Le bouclier anti-crash)
if (typeof param2 === 'string') {
// Cas 1 : On a bien envoyé (URL, "POST", {données})
method = param2.toUpperCase();
body = param3;
} else if (typeof param2 === 'object' && param2 !== null) {
// Cas 2 : L'ancienne méthode a envoyé (URL, {données})
// On redirige l'objet vers le body, et on force en POST
body = param2;
method = (typeof param3 === 'string') ? param3.toUpperCase() : 'POST';
}
// 1. Récupération du sésame
const token = localStorage.getItem('token');
// 2. Préparation de l'enveloppe
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json'
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// 3. Configuration finale garantie sans objets égarés
const options = {
method: method,
headers: headers,
credentials: 'include'
};
if (body) {
options.body = JSON.stringify(body);
}
try {
const response = await fetch(endpoint, options);
if (response.status === 401 || response.status === 403) {
throw new Error("Accès refusé. Session expirée ou non valide.");
}
const data = await response.json();
// Gestion des erreurs internes de l'API
if (data.error) {
throw new Error(data.error.message || "Erreur renvoyée par le serveur de facturation.");
}
return data.result || data;
} catch (error) {
console.error(`[API FAIL] ${method} ${endpoint} :`, error);
throw error;
}
};
// Vide le panier (Action PUBLIQUE : on passe par l'API Guest)
export const resetCart = async () => {
try {
const cart = await apiCall(`${BASE_URL}/api/guest/cart/get`);
if (cart && cart.items && cart.items.length > 0) {
for (const item of cart.items) {
await apiCall(`${BASE_URL}/api/guest/cart/remove_item`, { id: item.id });
}
}
} catch (err) {
console.warn("Nettoyage du panier ignoré :", err);
}
};
// ==========================================
// ROUTES PERSONNALISÉES (CUSTOM API)
// ==========================================
export const registerUnifiedClient = async (email, username, password, firstName, lastName) => {
try {
// Utilisation des backticks ici aussi !
const response = await fetch(`${CUSTOM_API_BASE_URL}/custom_api/signup.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: email,
username: username,
password: password,
first_name: firstName,
last_name: lastName
})
});
const data = await response.json();
if (data.error) throw new Error(data.error.message);
return data.result;
} catch (err) {
console.error("Erreur lors de l'inscription unifiée :", err);
throw new Error("Échec de l'inscription. Veuillez réessayer plus tard.");
}
}
// #######################################################################################
// ==========================================
// GESTION DES ACCES
// ==========================================
// Connexion
export const loginClient = (email, password) =>
apiCall(`${BASE_URL}/api/guest/client/login`, { email, password });
// Deconnexion
export const logoutClient = () =>
apiCall(`${BASE_URL}/api/client/profile/logout`);
// ==========================================
// GESTION DU CLIENT
// ==========================================
// Creer un nouveau profil client
export const createNewClient = (
email,
first_name,
last_name,
password,
password_confirm,
type,
custom_1) =>
apiCall(`${BASE_URL}/api/guest/client/create`, {
email: email,
first_name: first_name,
password: password,
password_confirm: password_confirm,
last_name: last_name,
type: type,
custom_1: custom_1
});
// Recuperer les informations du client
export const getClient = () =>
apiCall(`${BASE_URL}/api/client/profile/get`);
// Update les informations du client
export const updateClient = (email, last_name, aid, gender, country, city, birthday, company, company_vat, company_number, type, address_1, address_2, postcode, state, phone, phone_cc, document_type, document_nr, notes, lang, custom_1, custom_2, custom_3, custom_4, custom_5, custom_6, custom_7, custom_8, custom_9, custom_10) =>
apiCall(`${BASE_URL}/api/client/profile/update`, {
email: email,
last_name: last_name,
aid: aid,
gender: gender,
country: country,
city: city,
birthday: birthday,
company: company,
company_vat: company_vat,
company_number: company_number,
type: type,
address_1: address_1,
address_2: address_2,
postcode: postcode,
state: state,
phone: phone,
phone_cc: phone_cc,
document_type: document_type,
document_nr: document_nr,
notes: notes,
lang: lang,
custom_1: custom_1,
custom_2: custom_2,
custom_3: custom_3,
custom_4: custom_4,
custom_5: custom_5,
custom_6: custom_6,
custom_7: custom_7,
custom_8: custom_8,
custom_9: custom_9,
custom_10: custom_10
});
// Changer le mot de passe
export const changeClientPassword = (current_password, new_password) =>
apiCall(`${BASE_URL}/api/client/profile/change_password`, {
current_password: current_password,
new_password: new_password,
confirm_password: new_password
});
// ==========================================
// GESTION DES ABONNEMENTS
// ==========================================
// Souscription
// Resiliation
// Action : Demande d'annulation (S'occupe de vérifier les 14 jours côté serveur)
export const cancelOrder = (orderId) => {
return apiCall(`${CUSTOM_API_BASE_URL}/custom_api/cancel_subscription.php`, 'POST', {
order_id: orderId
});
};
// Suppression
export const deleteOrder = (order_id) =>
apiCall(`${BASE_URL}/api/client/order/delete`, { id: order_id });
// Promotion (Surclassement)
// Lister
export const getUpgrades = (order_id) =>
apiCall(`${BASE_URL}/api/client/order/upgradables`, { id: order_id });
// Demotion (Declassement)
// Lister les abonnements du client
export const getOrdersList = () =>
apiCall(`${BASE_URL}/api/client/order/get_list`);
// Lister les produits disponibles
export const getProductList = () =>
apiCall(`${BASE_URL}/api/guest/product/get_list`);
// Recuperer les details d'un abonnement
export const getOrderDetails = (order_id) =>
apiCall(`${BASE_URL}/api/client/order/service`, { id: order_id });
// Recuperer les details secrets d'un abonnement
export const getServiceDetails = (orderId) =>
apiCall(`${BASE_URL}/api/client/order/get`, { id: orderId });
// ==========================================
// GESTION DU PANIER
// ==========================================
// Ajouter au panier
export const addToCart = (productId, period, additionalData = {}) =>
apiCall(`${BASE_URL}/api/guest/cart/add_item`, {
id: productId,
period: period,
...additionalData // Les 3 petits points "étalent" le contenu de l'objet
});
// Checkout
export const checkoutCart = () =>
apiCall(`${BASE_URL}/api/client/cart/checkout`, 'POST');
// ==========================================
// GESTION DES SERVICES D'HEBERGEMENT
// ==========================================
// Modification du username
export const updateHostingUsername = (order_id, username) =>
apiCall(`${BASE_URL}/api/client/servicehosting/change_username`, {
order_id: order_id,
username: username
});
// Modification du mot de passe
export const updateHostingPassword = (order_id, new_password) =>
apiCall(`${BASE_URL}/api/client/servicehosting/change_password`, {
order_id: order_id,
password: new_password,
password_confirm: new_password
});
// Modification du domaine principal
export const updateHostingDomain = (order_id, new_domain) =>
apiCall(`${BASE_URL}/api/client/servicehosting/change_domain`, {
order_id: order_id,
domain: new_domain
});
// Login URL (SSO)
export const getHostingLoginUrl = (order_id) =>
apiCall(`${BASE_URL}/api/client/servicehosting/get_login_url`, {
order_id: order_id
});
// Modification du plan d'hébergement
export const updateHostingPlan = (order_id, new_plan_id) =>
apiCall(`${BASE_URL}/api/admin/servicehosting/change_plan`, {
order_id: order_id,
plan_id: new_plan_id
});
// ==========================================
// GESTION DES FACTURES
// ==========================================
// Creation
// Suppression
// Modification
// Lister
export const getInvoiceList = (client_id) =>
apiCall(`${BASE_URL}/api/client/invoice/get_list`, { client_id: client_id });
// Lire
export const getInvoiceDetails = (invoiceHash) =>
apiCall(`${BASE_URL}/api/client/invoice/get`, { hash: invoiceHash });
// ==========================================
// GESTION DES TICKETS
// ==========================================
// Creation
export const createTicket = (subject, message, helpdesk_id) =>
apiCall(`${BASE_URL}/api/client/support/ticket_create`, {
support_helpdesk_id: helpdesk_id,
subject: subject,
content: message
});
// Suppression
// Lister les tickets
export const getClientTickets = () =>
apiCall(`${BASE_URL}/api/client/support/ticket_get_list`);
// Lister les helpdesks
export const getHelpdesks = () =>
apiCall(`${BASE_URL}/api/client/support/helpdesk_get_pairs`);
// Lire
export const getTicketDetails = (ticketId) =>
apiCall(`${BASE_URL}/api/client/support/ticket_get`, { id: ticketId });
// Repondre
export const replyTicket = (ticketId, message) =>
apiCall(`${BASE_URL}/api/client/support/ticket_reply`, {
id: ticketId,
content: message
});