Merge pull request 'Feat/manage webservices' (#2) from feat/manage-webservices into master
Deploy Nexus Portal to HestiaCP (FTP) / build-and-deploy (push) Successful in 19s

Reviewed-on: maximus/portail-gise#2
This commit was merged in pull request #2.
This commit is contained in:
maximus
2026-07-17 12:55:41 +02:00
12 changed files with 1221 additions and 132 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GISE Nexus | Portail d'Infrastructure Centralisée</title> <title>NEXUS by gise | Portail d'Infrastructure Centralisée</title>
<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."> <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.">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+2 -2
View File
@@ -4,7 +4,7 @@ export default function NewServiceCard({ onClick }) {
return ( return (
<div <div
onClick={onClick} onClick={onClick}
className="bg-transparent border-2 border-dashed border-gray-700 hover:border-cyan-400 p-6 rounded-xl transition-colors cursor-pointer flex flex-col items-center justify-center text-center group min-h-[200px]" className="bg-transparent border-2 border-dashed border-gray-700 hover:border-cyan-400 p-6 rounded-xl transition-colors cursor-pointer flex flex-col items-center justify-center text-center group min-h-50"
> >
<div className="p-3 bg-gray-800/50 rounded-full group-hover:bg-cyan-400/20 transition-colors mb-4"> <div className="p-3 bg-gray-800/50 rounded-full group-hover:bg-cyan-400/20 transition-colors mb-4">
<Plus className="w-8 h-8 text-gray-400 group-hover:text-cyan-400" /> <Plus className="w-8 h-8 text-gray-400 group-hover:text-cyan-400" />
@@ -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>
); );
@@ -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 <span className="text-gray-500 italic">Aucune spécification disponible.</span>;
return text.split('\n').map((line, index) => {
const cleanLine = line.trim();
if (cleanLine.startsWith('* ') || cleanLine.startsWith('- ')) {
return (
<div key={index} className="flex items-center gap-3 text-sm font-semibold text-white my-2">
<div className="shrink-0 w-5 h-5 rounded-full border border-cyan-500/40 flex items-center justify-center bg-cyan-950/10">
<Check className="w-3 h-3 text-cyan-400 stroke-3" />
</div>
<span>{parseBold(cleanLine.substring(2))}</span>
</div>
);
}
return <p key={index} className="text-gray-400 text-xs my-1">{parseBold(cleanLine)}</p>;
});
};
const parseBold = (text) => text.split('**').map((part, i) => i % 2 === 1 ? <strong key={i} className="text-white font-bold">{part}</strong> : 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 = <RefreshCcw className="w-4 h-4" />;
} 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 = <TrendingUp className="w-4 h-4" />;
} 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 = <TrendingDown className="w-4 h-4" />;
}
}
}
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 (
<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="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="p-2 bg-gray-950 rounded-lg text-gray-400"><CreditCard className="w-4 h-4" /></div>
<div>
<p className="text-[10px] uppercase font-bold text-gray-500 tracking-wider">Abonnement Actuel</p>
<p className="text-sm font-bold text-white">{currentPlanName}</p>
</div>
</div>
<div className="bg-[#090f1c] border border-gray-800 p-3.5 rounded-xl flex items-center gap-3">
<div className="p-2 bg-gray-950 rounded-lg text-cyan-400"><Globe className="w-4 h-4" /></div>
<div className="truncate">
<p className="text-[10px] uppercase font-bold text-gray-500 tracking-wider">Espace Domaine</p>
<p className="text-sm font-mono text-cyan-400 truncate">{extractedDomain}</p>
</div>
</div>
</div>
<div className="bg-[#090f1c] border border-gray-800 p-4 rounded-xl flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-950 rounded-lg text-emerald-400 border border-gray-800/50">
<Calendar className="w-4 h-4" />
</div>
<div>
<p className="text-[10px] uppercase font-bold text-gray-500 tracking-wider">Prochain Renouvellement</p>
<p className="text-sm font-medium text-gray-300">
{order.expires_at ? order.expires_at : "Date non définie"}
<span className="text-gray-600 mx-2">|</span>
<span className="font-mono text-white font-bold">{order.price || '0.00'} {order.currency || '€'}</span>
<span className="text-[10px] text-gray-500 ml-1 uppercase">/ {order.period === '1W' ? 'semaine' : order.period === '1Y' ? 'an' : 'mois'}</span>
</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">
Voir les factures
</a>
</div>
<div className="bg-[#090f1c] p-2 rounded-xl border border-gray-800 flex items-center justify-between">
<span className="text-xs font-bold text-gray-400 ml-3 flex items-center gap-2">
<ToggleLeft className="w-4 h-4 text-cyan-400" /> Options de renouvellement
</span>
<div className="flex space-x-1">
{[
{ id: '1W', label: 'Semaine' },
{ id: '1M', label: 'Mois' },
{ id: '1Y', label: 'Année' }
].map((p) => (
<button
key={p.id}
onClick={() => 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}
</button>
))}
</div>
</div>
</div>
<div className="grow pr-2 custom-scrollbar">
{isCatalogLoading ? (
<div className="h-full flex flex-col items-center justify-center text-cyan-400">
<Loader className="w-8 h-8 animate-spin mx-auto mb-2" />
<p className="text-xs font-mono text-gray-500">Synchronisation des tarifs...</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 pt-3 pb-4">
{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 (
<div
key={plan.id}
onClick={() => !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' : ''}`}
>
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className={`text-[9px] font-black tracking-widest px-3 py-1 rounded-full border ${topBadge.color}`}>
{topBadge.label}
</span>
</div>
<div className="flex justify-between items-center w-full mb-5 mt-2">
<span className="text-[9px] font-black tracking-widest text-white bg-[#090f1c] px-2.5 py-1 rounded border border-gray-800 uppercase">
{plan.category?.title || "WEB"}
</span>
{metrics.saving > 0 && metrics.isAvailable && (
<span className="text-[10px] font-bold text-[#00df89] bg-[#00df89]/10 border border-[#00df89]/20 px-3 py-0.5 rounded-full uppercase tracking-wide">
Économie {metrics.saving}%
</span>
)}
</div>
<div className="mb-5 shrink-0">
<h4 className="font-bold text-xl text-white tracking-tight mb-3 flex items-center justify-between">
{plan.title}
{isCurrentActive && <span className="text-[9px] font-bold text-orange-500 uppercase tracking-widest bg-orange-900 px-2 py-0.5 rounded border border-orange-800">Actif</span>}
</h4>
{metrics.isAvailable ? (
<>
<div className="flex items-baseline gap-1.5">
<span className="text-3xl font-black text-cyan-400 tracking-tighter">{metrics.displayPrice} </span>
<span className="text-xs text-gray-400 font-medium">/ mois</span>
</div>
{metrics.saving > 0 && (
<p className="text-xs text-gray-500 line-through mt-1">
Au lieu de {metrics.oldDisplayPrice} / mois
</p>
)}
<div className="mt-3.5 inline-block bg-[#0e2238] border border-cyan-950/40 text-cyan-400 text-[9px] font-black tracking-widest uppercase px-3 py-1.5 rounded-md">
Facturé {metrics.billingPrice} par {billingPeriod === '1W' ? 'semaine' : billingPeriod === '1M' ? 'mois' : 'an'}
</div>
</>
) : (
<div className="py-2">
<span className="text-lg font-bold text-gray-500">Non disponible</span>
<p className="text-[10px] text-gray-600 mt-1 uppercase tracking-wider">Pour ce cycle</p>
</div>
)}
</div>
<div className="border-t border-gray-800/60 pt-4 grow space-y-1.5">
{renderMarkdownFeatures(plan.description)}
</div>
</div>
);
})}
</div>
)}
</div>
<div className="shrink-0 mt-auto pt-4 border-t border-gray-800/40 space-y-3">
{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'}`}
>
<div>
<p className={`text-xs font-bold uppercase tracking-widest ${isRefund ? 'text-amber-400' : 'text-cyan-400'}`}>
{isRefund
? "Remboursement estimé"
: finalInvoicePrice > 0.01
? "Montant à régler aujourd'hui"
: "Aucun frais immédiat"}
</p>
{isProrataApplied && <p className="text-[10px] text-gray-500 mt-0.5">Calculé au prorata des jours restants</p>}
</div>
<div className="text-right">
<span className={`text-xl font-black font-mono ${isRefund ? 'text-amber-400' : 'text-white'}`}>
{isRefund ? "-" : ""}{Math.abs(finalInvoicePrice).toFixed(2)}
</span>
</div>
</div>
)}
{selectedPlan && (
<button
onClick={handleMigration}
disabled={loading || cancelLoading || isNoChange}
className={`w-full py-4 rounded-xl font-black uppercase text-xs tracking-widest transition-all duration-300 flex items-center justify-center gap-3
${isNoChange
? 'bg-gray-900 text-gray-500 border border-gray-800 cursor-not-allowed shadow-none'
: buttonColor
}`}
>
{loading ? <Loader className="w-4 h-4 animate-spin" /> : (!isNoChange && ActionIcon)}
{loading
? 'Traitement en cours...'
: isNoChange
? 'Abonnement actuel (Aucune modification)'
: migrationLabel
}
</button>
)}
{/* 🎯 NOUVEAU BOUTON : Ouvre la modale au lieu de faire un confirm() */}
<button
onClick={() => 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 ? <Loader className="w-4 h-4 animate-spin" /> : <ShieldAlert className="w-4 h-4" />}
{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 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>
);
}
+5 -5
View File
@@ -66,16 +66,16 @@ export default function ProductCard({ product, selectedPeriod, categoryName }) {
</div> </div>
)} )}
<div className="p-8 pt-14 border-b border-gray-800 relative bg-gradient-to-b from-gray-800/30 to-transparent min-h-[190px] flex flex-col"> <div className="p-8 pt-14 border-b border-gray-800 relative bg-linear-to-b from-gray-800/30 to-transparent min-h-47.5 flex flex-col">
<h3 className="text-2xl font-bold text-white mb-4 relative z-10">{product.title}</h3> <h3 className="text-2xl font-bold text-white mb-4 relative z-10">{product.title}</h3>
{priceData.isAvailable ? ( {priceData.isAvailable ? (
<div className="flex-grow flex flex-col justify-end"> <div className="grow flex flex-col justify-end">
<div className="flex items-baseline space-x-2"> <div className="flex items-baseline space-x-2">
<span className="text-4xl font-black text-cyan-400">{priceData.displayPrice} </span> <span className="text-4xl font-black text-cyan-400">{priceData.displayPrice} </span>
<span className="text-gray-500">{priceData.suffix}</span> <span className="text-gray-500">{priceData.suffix}</span>
</div> </div>
<div className="mt-3 min-h-[44px] flex flex-col justify-end"> <div className="mt-3 min-h-11 flex flex-col justify-end">
{!priceData.isOnce && ( {!priceData.isOnce && (
<> <>
{priceData.originalPrice ? ( {priceData.originalPrice ? (
@@ -96,9 +96,9 @@ export default function ProductCard({ product, selectedPeriod, categoryName }) {
)} )}
</div> </div>
<div className="p-8 flex-grow flex flex-col justify-between"> <div className="p-8 grow flex flex-col justify-between">
<div className="text-gray-400 text-sm mb-8 space-y-3 prose prose-invert max-w-none"> <div className="text-gray-400 text-sm mb-8 space-y-3 prose prose-invert max-w-none">
<ReactMarkdown components={{ ul: ({node, ...props}) => <ul className="space-y-2" {...props} />, li: ({node, ...props}) => <li className="flex items-start space-x-2"><CheckCircle2 className="w-4 h-4 text-cyan-400 mt-0.5 flex-shrink-0"/> <span>{props.children}</span></li>, p: ({node, ...props}) => <p className="mb-2 text-gray-300" {...props} />, strong: ({node, ...props}) => <strong className="text-white font-semibold" {...props} /> }}> <ReactMarkdown components={{ ul: ({node, ...props}) => <ul className="space-y-2" {...props} />, li: ({node, ...props}) => <li className="flex items-start space-x-2"><CheckCircle2 className="w-4 h-4 text-cyan-400 mt-0.5 shrink-0"/> <span>{props.children}</span></li>, p: ({node, ...props}) => <p className="mb-2 text-gray-300" {...props} />, strong: ({node, ...props}) => <strong className="text-white font-semibold" {...props} /> }}>
{product.description || "Aucune description technique."} {product.description || "Aucune description technique."}
</ReactMarkdown> </ReactMarkdown>
</div> </div>
+4 -5
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom'; import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import { resetCart, addToCart, checkoutCart, getProductList, getClientProfile } from '../../services/api'; import { resetCart, addToCart, checkoutCart, getProductList, getClient } from '../../services/billing_api';
export default function Checkout() { export default function Checkout() {
const { productId } = useParams(); const { productId } = useParams();
@@ -18,7 +18,7 @@ export default function Checkout() {
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
try { try {
// 1. On charge d'abord le produit (Requête Publique) // 1. On charge d'abord le produit
const productData = await getProductList(); const productData = await getProductList();
const foundProduct = productData.list?.find(p => p.id === parseInt(productId)); const foundProduct = productData.list?.find(p => p.id === parseInt(productId));
@@ -29,15 +29,14 @@ export default function Checkout() {
} }
setProduct(foundProduct); setProduct(foundProduct);
// 2. Ensuite, on tente de charger le profil (Requête Privée) // 2. Ensuite, on tente de charger le profil
try { try {
const profileData = await getClientProfile(); const profileData = await getClient();
setUserProfile(profileData); setUserProfile(profileData);
} catch (profileErr) { } catch (profileErr) {
// Si on tombe ici, c'est que FOSSBilling refuse l'accès au profil. // Si on tombe ici, c'est que FOSSBilling refuse l'accès au profil.
console.error("Rejet API Profil :", profileErr); console.error("Rejet API Profil :", profileErr);
setError("Accès refusé. Vous devez être connecté à votre compte pour provisionner une instance."); 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'); // navigate('/login');
setIsLoading(false); setIsLoading(false);
return; return;
+84 -21
View File
@@ -1,11 +1,13 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { getClientOrders } from '../../services/api'; import { getOrdersList } from '../../services/billing_api';
import { AlertCircle, Loader } from 'lucide-react'; import { AlertCircle, Loader, FileText, X } from 'lucide-react';
// Importation des composants isolés // Importation des composants
import DashboardServiceCard from '../../components/dashboard/DashboardServiceCard'; import DashboardServiceCard from '../../components/dashboard/DashboardServiceCard';
import NewServiceCard from '../../components/dashboard/NewServiceCard'; import NewServiceCard from '../../components/dashboard/NewServiceCard';
import NotificationModal from '../../components/ui/NotificationModal';
import WebServiceSubscriptionManager from '../../components/dashboard/WebServiceSubscriptionManager';
export default function Dashboard() { export default function Dashboard() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -13,11 +15,17 @@ export default function Dashboard() {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Chargement des données à l'ouverture du Sas const [activeSubscriptionModal, setActiveSubscriptionModal] = useState(null);
useEffect(() => { const [customAlert, setCustomAlert] = useState(null);
const fetchInventory = async () => {
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);
try { try {
const data = await getClientOrders(); const data = await getOrdersList();
if (data.list) { if (data.list) {
// LE FILTRE CHIRURGICAL PAR PREFIXE // LE FILTRE CHIRURGICAL PAR PREFIXE
@@ -31,36 +39,71 @@ 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) {
// 🛡️ 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."); setError(err.message || "Impossible de récupérer la télémétrie des services.");
} finally {
setIsLoading(false); setIsLoading(false);
} }
}
}; };
useEffect(() => {
fetchInventory(); fetchInventory();
}, []); }, []);
return ( // 🌟 Plus besoin de charger les IPs, on ouvre juste la modale comptable !
<div className="w-full max-w-6xl p-6 mx-auto"> const handleManageSubscription = (order) => {
// 1. Détection du type de service
const titleLower = (order.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');
// C'est un hébergement web s'il ne correspond à aucun des cas ci-dessus
const isWeb = !isVPS && !isCloud && !isDB;
// 2. Logique de blocage
if (isWeb) {
// On ouvre la modale comptable uniquement pour le WEB
setActiveSubscriptionModal(order);
} else {
// On affiche une notification pour les autres types d'instances
triggerAlert(
"Action non disponible",
"La modification autonome d'abonnement est actuellement exclusive aux Hébergements Web. Pour restructurer cette instance, veuillez contacter les ingénieurs via le Centre de Support.",
"info"
);
}
};
return (
<div className="w-full max-w-6xl p-6 mx-auto relative">
<header className="mb-8"> <header className="mb-8">
<h1 className="text-3xl font-black text-white tracking-wider">TABLEAU DE <span className="text-cyan-400">BORD</span></h1> <h1 className="text-3xl font-black text-white tracking-wider">TABLEAU DE <span className="text-cyan-400">BORD</span></h1>
<p className="text-gray-400 mt-2">Aperçu de vos accréditations réseau et infrastructures.</p> <p className="text-gray-400 mt-2">Gestion financière et accréditations de vos infrastructures.</p>
</header> </header>
{/* GESTION DES ERREURS & CHARGEMENT */}
{isLoading && ( {isLoading && (
<div className="flex items-center space-x-3 text-cyan-400"> <div className="flex items-center space-x-3 text-cyan-400">
<Loader className="w-6 h-6 animate-spin" /> <Loader className="w-6 h-6 animate-spin" />
<span>Synchronisation avec l'orchestrateur en cours...</span> <span>Synchronisation avec le registre comptable...</span>
</div> </div>
)} )}
@@ -71,24 +114,44 @@ export default function Dashboard() {
</div> </div>
)} )}
{/* GRILLE DES SERVICES */}
{!isLoading && !error && ( {!isLoading && !error && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{/* Boucle sur les composants isolés */}
{orders.map((order) => ( {orders.map((order) => (
<DashboardServiceCard <DashboardServiceCard
key={order.id} key={order.id}
order={order} order={order}
onClick={() => navigate(`/services/${order.id}`)} onClick={() => handleManageSubscription(order)}
/> />
))} ))}
{/* Le composant carte d'ajout */}
<NewServiceCard onClick={() => navigate('/store')} /> <NewServiceCard onClick={() => navigate('/store')} />
</div> </div>
)} )}
{/* 🌟 LA MODALE DE GESTION D'ABONNEMENT */}
{activeSubscriptionModal && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-cyan-500/50 shadow-2xl shadow-cyan-500/10 rounded-lg max-w-xxl w-full p-6 text-gray-200">
<div className="flex justify-between items-center mb-6 border-b border-gray-800 pb-4">
<h3 className="text-xl font-bold text-white tracking-wider flex items-center gap-3">
<FileText className="w-6 h-6 text-cyan-400" />
GESTION DE L'ABONNEMENT
</h3>
<button onClick={() => setActiveSubscriptionModal(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>
<WebServiceSubscriptionManager
order={activeSubscriptionModal}
onClose={() => setActiveSubscriptionModal(null)}
onRefresh={fetchInventory}
onAlert={triggerAlert}
/>
</div>
</div>
)}
<NotificationModal notification={customAlert} onClose={() => setCustomAlert(null)} />
</div> </div>
); );
} }
+4 -4
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { getMyServices, getHostingServiceDetails } from '../../services/api'; import { getOrdersList, getOrderDetails } from '../../services/billing_api';
import { useVPC } from '../../services/useVPC'; import { useVPC } from '../../services/useVPC';
import { Server, Globe, Folder, Trash2, Loader, Plus, AlertCircle } from 'lucide-react'; import { Server, Globe, Folder, Trash2, Loader, Plus, AlertCircle } from 'lucide-react';
@@ -25,12 +25,12 @@ export default function Services() {
const fetchServices = useCallback(async () => { const fetchServices = useCallback(async () => {
try { try {
const data = await getMyServices(); const data = await getOrdersList();
if (data.list && data.list.length > 0) { if (data.list && data.list.length > 0) {
const detailedServices = await Promise.all(data.list.map(async (order) => { const detailedServices = await Promise.all(data.list.map(async (order) => {
let hDetails = null; let hDetails = null;
if (order.status === 'active' && (order.plugin === 'hosting' || (order.title || '').toLowerCase().includes('vps'))) { if (order.status === 'active' && (order.plugin === 'hosting' || (order.title || '').toLowerCase().includes('vps'))) {
try { hDetails = await getHostingServiceDetails(order.id); } catch (e) {} try { hDetails = await getOrderDetails(order.id); } catch (e) {}
} }
return { ...order, hostingDetails: hDetails }; return { ...order, hostingDetails: hDetails };
})); }));
@@ -65,7 +65,7 @@ export default function Services() {
} else if (isDB) { } else if (isDB) {
window.open('https://pma.gise.be/', '_blank'); window.open('https://pma.gise.be/', '_blank');
} else if (isVPS || isWeb) { } else if (isVPS || isWeb) {
const freshDetails = await getHostingServiceDetails(service.id); const freshDetails = await getOrderDetails(service.id);
setActiveServiceModal({ type: isVPS ? 'vps' : 'web', data: service, details: freshDetails }); setActiveServiceModal({ type: isVPS ? 'vps' : 'web', data: service, details: freshDetails });
} }
} catch (err) { triggerAlert("Erreur", err.message, "error"); } } catch (err) { triggerAlert("Erreur", err.message, "error"); }
+2 -2
View File
@@ -1,8 +1,8 @@
// src/pages/app/Store.jsx // src/pages/app/Store.jsx
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Server, Database, Cloud, Globe, Loader, AlertCircle } from 'lucide-react'; import { Server, Database, Cloud, Globe, Loader, AlertCircle } from 'lucide-react';
import { getProductList } from '../../services/api'; import { getProductList } from '../../services/billing_api';
import CategorySection from '../../components/store/CategorySection'; // L'import magique import CategorySection from '../../components/store/CategorySection';
export default function Store() { export default function Store() {
const [groupedProducts, setGroupedProducts] = useState({}); const [groupedProducts, setGroupedProducts] = useState({});
+1 -1
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { loginClient } from '../../services/api'; import { loginClient } from '../../services/billing_api';
export default function Login() { export default function Login() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
+5 -5
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { registerUnifiedClient } from '../../services/api'; import { createNewClient } from '../../services/billing_api';
import NotificationModal from '../../components/ui/NotificationModal'; import NotificationModal from '../../components/ui/NotificationModal';
export default function Register() { 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." }); setCustomAlert({ type: 'error', title: 'Erreur de saisie', message: "Les clés d'accès ne correspondent pas." });
return; return;
} }
if (!/^[a-zA-Z0-9]{3,12}$/.test(username)) { if (!/^[a-zA-Z0-9]{3,20}$/.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)." }); 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; return;
} }
setLoading(true); setLoading(true);
try { try {
await registerUnifiedClient(email, username, password, firstName, lastName); await createNewClient(email, firstName, lastName, password, confirmPassword, "individual", username);
setCustomAlert({ type: 'success', title: 'PROVISIONNEMENT RÉUSSI', message: "Vos comptes FOSSBilling, HestiaCP et Nextcloud ont été initialisés.\n\nVous pouvez maintenant vous connecter." }); setCustomAlert({ type: 'success', title: 'PROVISIONNEMENT RÉUSSI', message: "Votre compte a été initialisé.\n\nVous pouvez maintenant vous connecter." });
} catch (err) { } catch (err) {
setCustomAlert({ type: 'error', title: 'Échec du Déploiement', message: err.message || "Échec de l'initialisation." }); setCustomAlert({ type: 'error', title: 'Échec du Déploiement', message: err.message || "Échec de l'initialisation." });
} finally { } finally {
@@ -56,7 +56,7 @@ export default function Register() {
Créer un accès réseau Créer un accès réseau
</h2> </h2>
<p className="text-[#888] font-mono text-sm mb-6"> <p className="text-[#888] font-mono text-sm mb-6">
[ INITIALISATION DU PROVISIONNEMENT TRIPLE ] [ INITIALISATION DU PROVISIONNEMENT ]
</p> </p>
<form onSubmit={handleRegister} className="font-mono"> <form onSubmit={handleRegister} className="font-mono">
+185 -60
View File
@@ -1,7 +1,7 @@
const BASE_URL = import.meta.env.VITE_API_BASE_URL || ''; const BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
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 || '';
// Le moteur de requête unifié et intelligent // Le moteur de requête
const apiCall = async (endpoint, param2 = 'GET', param3 = null) => { const apiCall = async (endpoint, param2 = 'GET', param3 = null) => {
let method = 'GET'; let method = 'GET';
let body = null; let body = null;
@@ -64,24 +64,6 @@ 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) // Vide le panier (Action PUBLIQUE : on passe par l'API Guest)
export const resetCart = async () => { export const resetCart = async () => {
@@ -100,22 +82,6 @@ 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) // ROUTES PERSONNALISÉES (CUSTOM API)
// ========================================== // ==========================================
@@ -144,18 +110,6 @@ 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) // Force la réinitialisation du mot de passe sur le serveur distant (HestiaCP)
export const resetHostingPassword = (orderId, newPassword) => export const resetHostingPassword = (orderId, newPassword) =>
apiCall(`${BASE_URL}/api/client/servicehosting/change_password`, 'POST', { apiCall(`${BASE_URL}/api/client/servicehosting/change_password`, 'POST', {
@@ -191,33 +145,204 @@ export const launchSSOGateway = (username, password) => {
document.body.removeChild(form); document.body.removeChild(form);
}; };
// #######################################################################################
// ========================================== // ==========================================
// ROUTES SUPPORT FOSSBILLING (NATIVES) // GESTION DES ACCES
// ========================================== // ==========================================
// Récupère la liste de tous les tickets du client // Connexion
export const getClientTickets = () => export const loginClient = (email, password) =>
apiCall(`${BASE_URL}/api/client/support/ticket_get_list`, 'GET'); apiCall(`${BASE_URL}/api/guest/client/login`, { email, password });
// Récupère le contenu et les messages d'un ticket spécifique // Deconnexion
export const getTicketDetails = (ticketId) => export const logoutClient = () =>
apiCall(`${BASE_URL}/api/client/support/ticket_get`, 'POST', { id: ticketId }); apiCall(`${BASE_URL}/api/client/profile/logout`);
// Crée un nouveau ticket de support
// ==========================================
// 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
export const createTicket = (subject, message, helpdesk_id) => export const createTicket = (subject, message, helpdesk_id) =>
apiCall(`${BASE_URL}/api/client/support/ticket_create`, 'POST', { apiCall(`${BASE_URL}/api/client/support/ticket_create`, 'POST', {
support_helpdesk_id: helpdesk_id, support_helpdesk_id: helpdesk_id,
subject: subject, subject: subject,
content: message content: message
}); });
// Suppression
// Répond à un ticket existant // 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
export const replyTicket = (ticketId, message) => export const replyTicket = (ticketId, message) =>
apiCall(`${BASE_URL}/api/client/support/ticket_reply`, 'POST', { apiCall(`${BASE_URL}/api/client/support/ticket_reply`, 'POST', {
id: ticketId, id: ticketId,
content: message 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');
+346
View File
@@ -0,0 +1,346 @@
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 = () =>
apiCall(`${BASE_URL}/api/client/invoice/get_list`);
// Lire
export const getInvoiceDetails = (InvoiceHash) =>
apiCall(`${BASE_URL}/api/client/invoice/get`);
// ==========================================
// 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
});