10 Commits

Author SHA1 Message Date
maximus c4fb70fbfa Merge pull request 'Feat/billing history' (#3) from feat/billing_history into master
Deploy Nexus Portal to HestiaCP (FTP) / build-and-deploy (push) Successful in 14s
Reviewed-on: maximus/portail-gise#3
2026-07-17 14:33:31 +02:00
LathanDevers 3120211909 add billing history 2026-07-17 14:32:39 +02:00
LathanDevers a09377eb4e add resiliation 2026-07-17 14:32:12 +02:00
maximus 9aeff38e7e 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
2026-07-17 12:55:41 +02:00
LathanDevers 5b5a02cf66 index 2026-07-17 12:55:15 +02:00
LathanDevers b30a16b8a4 add resiliation 2026-07-17 12:48:06 +02:00
LathanDevers 2706b3d5a3 change api 2026-07-16 22:26:55 +02:00
LathanDevers 15213bba21 change api 2026-07-16 14:51:57 +02:00
maximus 074e3ab195 rebuild api fossbilling 2026-07-15 14:42:44 +02:00
maximus a4f9663f60 add SubscriptionManager 2026-07-15 10:46:09 +02:00
15 changed files with 1562 additions and 136 deletions
+2 -2
View File
@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GISE Nexus | 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.">
<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.">
</head>
<body>
<div id="root"></div>
+2
View File
@@ -20,6 +20,7 @@ import Store from './pages/app/Store';
import Checkout from './pages/app/Checkout';
import Services from './pages/app/Services';
import Support from './pages/app/Support';
import BillingHistory from './pages/app/BillingHistory';
export default function App() {
return (
@@ -49,6 +50,7 @@ export default function App() {
<Route path="/checkout/:productId" element={<Checkout />} />
<Route path="/services" element={<Services />} />
<Route path="/support" element={<Support />} />
<Route path="/facturation" element={<BillingHistory />} />
</Route>
</Route>
+2 -2
View File
@@ -4,7 +4,7 @@ export default function NewServiceCard({ onClick }) {
return (
<div
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">
<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
</h3>
<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>
</div>
);
@@ -0,0 +1,557 @@
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.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 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 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 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>
{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">
<span className="text-4xl font-black text-cyan-400">{priceData.displayPrice} </span>
<span className="text-gray-500">{priceData.suffix}</span>
</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.originalPrice ? (
@@ -96,9 +96,9 @@ export default function ProductCard({ product, selectedPeriod, categoryName }) {
)}
</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">
<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."}
</ReactMarkdown>
</div>
+1 -4
View File
@@ -74,10 +74,7 @@ export default function AppLayout() {
</div>
</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>Facturation</span>
<span className="text-[9px] text-[#333] border border-[#333] px-1.5 py-0.5 rounded font-bold">WIP</span>
</div>
<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>
+336
View File
@@ -0,0 +1,336 @@
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
export default function BillingHistory() {
const [invoices, setInvoices] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [customAlert, setCustomAlert] = useState(null);
// NOUVEL ÉTAT : La facture actuellement ouverte dans la modale
const [selectedInvoice, setSelectedInvoice] = useState(null);
const triggerAlert = (title, message, type) => {
setCustomAlert({ title, message, type });
};
useEffect(() => {
const fetchBillingData = async () => {
setIsLoading(true);
setError(null);
try {
const profileData = await getClient();
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 || []);
} catch (err) {
setError(err.message || "Problème de connexion avec le serveur.");
} finally {
setIsLoading(false);
}
};
fetchBillingData();
}, []);
const getStatusBadge = (status, total = 0) => {
const isNegative = parseFloat(total) < 0;
switch (status?.toLowerCase()) {
case 'paid':
if (isNegative) {
return { label: 'REMBOURSÉE', css: 'bg-blue-500/10 text-blue-400 border-blue-500/20', icon: <RefreshCcw className="w-3.5 h-3.5" /> };
}
return { label: 'PAYÉE', css: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20', icon: <CheckCircle2 className="w-3.5 h-3.5" /> };
case 'refunded':
// NOUVEAU STATUT : REMBOURSÉE
case 'unpaid':
// SI IMPAYÉE MAIS NÉGATIVE = EN COURS DE TRAITEMENT
if (isNegative) {
return { label: 'REMBOURSEMENT EN COURS', css: 'bg-amber-500/10 text-amber-400 border-amber-500/20', icon: <Clock className="w-3.5 h-3.5" /> };
}
return { label: 'EN ATTENTE', css: 'bg-amber-500/10 text-amber-400 border-amber-500/20', icon: <Clock className="w-3.5 h-3.5" /> };
case 'canceled':
return { label: 'ANNULÉE', css: 'bg-gray-500/10 text-gray-400 border-gray-500/20', icon: <XCircle className="w-3.5 h-3.5" /> };
default:
return { label: status?.toUpperCase() || 'INCONNU', css: 'bg-blue-500/10 text-blue-400 border-blue-500/20', icon: <AlertCircle className="w-3.5 h-3.5" /> };
}
};
const formatDate = (dateString) => {
if (!dateString) return '--/--/----';
return new Date(dateString).toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' });
};
// Fonctions vides pour la future logique
const handlePayInvoice = (invoiceId) => {
triggerAlert("Paiement initié", `Logique de paiement Stripe/PayPal à venir pour la facture #${invoiceId}.`, "info");
};
const handleCancelInvoice = (invoiceId) => {
triggerAlert("Annulation", `Logique d'annulation à venir pour la facture #${invoiceId}.`, "info");
};
return (
<div className="w-full max-w-6xl p-6 mx-auto relative">
<header className="mb-8">
<h1 className="text-3xl font-black text-white tracking-wider">HISTORIQUE DE <span className="text-cyan-400">FACTURATION</span></h1>
<p className="text-gray-400 mt-2">Consultez vos reçus, payez vos transactions en attente et gérez vos factures.</p>
</header>
{isLoading && (
<div className="flex items-center space-x-3 text-cyan-400 mb-6">
<Loader className="w-6 h-6 animate-spin" />
<span>Synchronisation avec le registre comptable...</span>
</div>
)}
{error && (
<div className="flex items-center space-x-3 text-red-400 bg-red-400/10 border border-red-400 p-4 rounded-lg mb-6">
<AlertCircle className="w-6 h-6" />
<span>{error}</span>
</div>
)}
{!isLoading && !error && (
<div className="bg-[#090f1c] border border-gray-800 rounded-lg overflow-hidden shadow-xl">
{invoices.length === 0 ? (
<div className="text-center py-12 px-4">
<FileText className="w-10 h-10 mx-auto text-gray-600 mb-3" />
<p className="text-base font-bold text-gray-400 tracking-wider uppercase">Aucune facture</p>
<p className="text-sm text-gray-600 mt-1">Vos transactions apparaîtront ici dès qu'elles seront générées.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-gray-900 border-b border-gray-800 text-gray-500 text-[10px] uppercase font-black tracking-widest">
<th className="py-4 px-6">Référence</th>
<th className="py-4 px-6">Date d'émission</th>
<th className="py-4 px-6">Statut</th>
<th className="py-4 px-6 text-right">Montant</th>
<th className="py-4 px-6 text-center">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800/40 text-sm">
{invoices.map((invoice) => {
const badge = getStatusBadge(invoice.status, invoice.total);
return (
<tr key={invoice.id} className="hover:bg-gray-800/20 transition-colors">
<td className="py-5 px-6 font-mono text-xs font-bold text-gray-300">
{invoice.invoice_number}
</td>
<td className="py-5 px-6 text-gray-400 text-xs">
{formatDate(invoice.created_at)}
</td>
<td className="py-5 px-6">
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-black tracking-widest border ${badge.css}`}>
{badge.icon}
{badge.label}
</span>
</td>
<td className="py-5 px-6 text-right font-mono font-bold text-white tracking-wider">
{parseFloat(invoice.total).toFixed(2)} {invoice.currency || '€'}
</td>
<td className="py-5 px-6">
<div className="flex items-center justify-center gap-3">
{invoice.status?.toLowerCase() === 'unpaid' && parseFloat(invoice.total) >= 0 ? (
// 🎯 Facture positive et impayée : BOUTON PAYER
<button
onClick={() => setSelectedInvoice(invoice)}
className="flex items-center gap-1.5 px-4 py-2 bg-amber-500 hover:bg-amber-400 text-black text-[10px] font-black uppercase tracking-widest rounded transition-all shadow-[0_0_15px_rgba(245,158,11,0.2)]"
>
<CreditCard className="w-3.5 h-3.5" /> PAYER
</button>
) : (
// 🎯 Facture payée, annulée, ou NÉGATIVE (Remboursement) : BOUTON DÉTAILS UNIQUEMENT
<button
onClick={() => setSelectedInvoice(invoice)}
className="flex items-center gap-1.5 px-4 py-2 bg-gray-900 hover:bg-gray-800 text-gray-300 hover:text-cyan-400 border border-gray-700 hover:border-cyan-900 text-[10px] font-black uppercase tracking-widest rounded transition-all"
>
<Receipt className="w-3.5 h-3.5" /> DÉTAILS
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
)}
{/* ========================================== */}
{/* LA MODALE DE DÉTAILS DE FACTURE */}
{selectedInvoice && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
<div className="bg-[#090f1c] border border-gray-800 shadow-2xl shadow-cyan-500/10 rounded-2xl w-full max-w-2xl flex flex-col max-h-[90vh] overflow-hidden">
{/* HEADER MODALE */}
<div className="flex items-center justify-between p-6 border-b border-gray-800 shrink-0">
<div className="flex items-center gap-4">
<div className="p-3 bg-cyan-950/30 text-cyan-400 rounded-xl border border-cyan-900/50">
<Receipt className="w-6 h-6" />
</div>
<div>
<h3 className="text-xl font-black text-white tracking-widest uppercase">
FACTURE <span className="text-cyan-400">{selectedInvoice.invoice_number}</span>
</h3>
<p className="text-xs text-gray-500 font-medium mt-1">
Émise le {formatDate(selectedInvoice.created_at)}
</p>
</div>
</div>
<button onClick={() => setSelectedInvoice(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>
{/* CORPS DE LA MODALE */}
<div className="p-6 overflow-y-auto grow custom-scrollbar space-y-6">
{/* Statut Badge */}
<div className="flex items-center justify-between p-4 bg-[#0d1527] rounded-xl border border-gray-800">
<span className="text-xs font-bold text-gray-400 uppercase tracking-wider">Statut du document</span>
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-black tracking-widest border ${getStatusBadge(selectedInvoice.status).css}`}>
{getStatusBadge(selectedInvoice.status).icon}
{getStatusBadge(selectedInvoice.status).label}
</span>
</div>
{/* Lignes de facture (Filtrées : uniquement les prix > 0) */}
<div>
<h4 className="text-xs font-bold text-gray-500 uppercase tracking-widest mb-3 border-b border-gray-800 pb-2">Détail des prestations</h4>
<div className="space-y-4">
{(() => {
// FILTRE : On ne garde que les items dont le prix est strictement supérieur à 0
const validItems = selectedInvoice.lines
if (validItems.length > 0) {
return validItems.map((item, idx) => (
<div key={idx} className="flex justify-between items-start text-sm">
<div className="pr-4">
<p className="text-white font-medium">{item.title}</p>
<p className="text-xs text-gray-500 mt-0.5">Quantité : {item.quantity}</p>
</div>
<div className="text-right font-mono font-bold text-gray-300 shrink-0">
{(parseFloat(item.price) * parseInt(item.quantity)).toFixed(2)} {selectedInvoice.currency || '€'}
</div>
</div>
));
} else {
// Fallback si la facture n'a que des items à 0€ ou aucun détail
return (
<div className="flex justify-between items-start text-sm">
<div>
<p className="text-white font-medium">Services d'infrastructure technique</p>
<p className="text-xs text-gray-500 mt-0.5">Détail inclus ou facturation sans frais complémentaires.</p>
</div>
<div className="text-right font-mono font-bold text-gray-300 shrink-0">
{parseFloat(selectedInvoice.total).toFixed(2)} {selectedInvoice.currency || ''}
</div>
</div>
);
}
})()}
</div>
</div>
{/* CALCULS (EXTRACTION TVA DEPUIS LE TTC) */}
{(() => {
// Le total récupéré de la BDD est considéré comme le PRIX FINAL (TTC)
const totalTTC = parseFloat(selectedInvoice.total) || 0;
const vatRate = 0.21; // TVA Belge à 21%
// Extraction du Hors Taxe : TTC / 1.21
const subtotalHT = totalTTC / (1 + vatRate);
// Le montant exact de la taxe
const vatAmount = totalTTC - subtotalHT;
return (
<div className="flex flex-col items-end pt-5 border-t border-gray-800">
<div className="flex justify-between w-full sm:w-[60%] text-sm mb-2">
<span className="text-gray-500">Sous-total (HT)</span>
<span className="text-gray-400 font-mono">{subtotalHT.toFixed(2)} {selectedInvoice.currency || ''}</span>
</div>
<div className="flex justify-between w-full sm:w-[60%] text-sm mb-4">
<span className="text-gray-500">TVA (21%)</span>
<span className="text-gray-400 font-mono">{vatAmount.toFixed(2)} {selectedInvoice.currency || ''}</span>
</div>
<div className="flex justify-between w-full sm:w-[60%] text-lg border-t border-gray-800 pt-3">
<span className="font-bold text-white uppercase tracking-wider">Total TTC</span>
<span className="font-black text-cyan-400 font-mono text-xl">{totalTTC.toFixed(2)} {selectedInvoice.currency || ''}</span>
</div>
</div>
);
})()}
</div>
{/* PIED DE LA MODALE (Actions) */}
<div className="p-6 border-t border-gray-800 bg-gray-900/50 shrink-0 flex flex-col sm:flex-row items-center justify-between gap-4">
{selectedInvoice.status?.toLowerCase() === 'unpaid' && parseFloat(selectedInvoice.total) >= 0 ? (
<>
{/* Cas standard : Facture positive en attente de paiement */}
<button
onClick={() => handleCancelInvoice(selectedInvoice.id)}
className="w-full sm:w-auto px-5 py-3 text-red-400 hover:text-white bg-red-500/10 hover:bg-red-500 border border-red-500/20 hover:border-red-500 text-xs font-bold uppercase tracking-wider rounded-xl transition-all flex items-center justify-center gap-2"
>
<Trash2 className="w-4 h-4" /> Annuler la facture
</button>
<button
onClick={() => handlePayInvoice(selectedInvoice.id)}
className="w-full sm:w-auto px-8 py-3 bg-amber-500 hover:bg-amber-400 text-black text-xs font-black uppercase tracking-widest rounded-xl transition-all shadow-[0_0_20px_rgba(245,158,11,0.2)] flex items-center justify-center gap-2"
>
<CreditCard className="w-4 h-4" /> Procéder au paiement <ArrowRight className="w-4 h-4" />
</button>
</>
) : (
<>
{/* Cas alternatif : Facture payée, annulée, ou NÉGATIVE (Remboursement) */}
<button onClick={() => setSelectedInvoice(null)} className="w-full sm:w-auto px-5 py-3 text-gray-400 hover:text-white bg-gray-900 hover:bg-gray-800 border border-gray-800 text-xs font-bold uppercase tracking-wider rounded-xl transition-all">
Fermer
</button>
{parseFloat(selectedInvoice.total) < 0 && selectedInvoice.status?.toLowerCase() === 'unpaid' && (
<div className="text-xs font-bold text-amber-400 bg-amber-500/5 border border-amber-500/10 px-4 py-2.5 rounded-xl uppercase tracking-wider animate-pulse">
⚡ Flux financier en cours de traitement
</div>
)}
{selectedInvoice.status?.toLowerCase() === 'refunded' && (
<div className="text-xs font-bold text-blue-400 bg-blue-500/5 border border-blue-500/10 px-4 py-2.5 rounded-xl uppercase tracking-wider">
✓ Fonds reversés au client
</div>
)}
{selectedInvoice.status?.toLowerCase() === 'paid' && (
<a
href={`/invoice/pdf/${selectedInvoice.id}`}
target="_blank" rel="noopener noreferrer"
className="w-full sm:w-auto px-8 py-3 bg-cyan-500 hover:bg-cyan-400 text-black text-xs font-black uppercase tracking-widest rounded-xl transition-all shadow-[0_0_20px_rgba(6,182,212,0.2)] flex items-center justify-center gap-2"
>
<Download className="w-4 h-4" /> Télécharger le reçu
</a>
)}
</>
)}
</div>
</div>
</div>
)}
<NotificationModal notification={customAlert} onClose={() => setCustomAlert(null)} />
</div>
);
}
+4 -5
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
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() {
const { productId } = useParams();
@@ -18,7 +18,7 @@ export default function Checkout() {
useEffect(() => {
const loadData = async () => {
try {
// 1. On charge d'abord le produit (Requête Publique)
// 1. On charge d'abord le produit
const productData = await getProductList();
const foundProduct = productData.list?.find(p => p.id === parseInt(productId));
@@ -29,15 +29,14 @@ export default function Checkout() {
}
setProduct(foundProduct);
// 2. Ensuite, on tente de charger le profil (Requête Privée)
// 2. Ensuite, on tente de charger le profil
try {
const profileData = await getClientProfile();
const profileData = await getClient();
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;
+85 -21
View File
@@ -1,11 +1,13 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { getClientOrders } from '../../services/api';
import { AlertCircle, Loader } from 'lucide-react';
import { getOrdersList } from '../../services/billing_api';
import { AlertCircle, Loader, FileText, X } from 'lucide-react';
// Importation des composants isolés
// 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';
export default function Dashboard() {
const navigate = useNavigate();
@@ -13,11 +15,17 @@ export default function Dashboard() {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
// Chargement des données à l'ouverture du Sas
useEffect(() => {
const fetchInventory = async () => {
const [activeSubscriptionModal, setActiveSubscriptionModal] = useState(null);
const [customAlert, setCustomAlert] = useState(null);
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 {
const data = await getClientOrders();
const data = await getOrdersList();
if (data.list) {
// LE FILTRE CHIRURGICAL PAR PREFIXE
@@ -31,36 +39,72 @@ export default function Dashboard() {
title.startsWith('domaine ') ||
title.startsWith('enregistrement ');
return !isGhostProduct;
return (order.status === 'active' || order.status === 'pending_setup') && !isGhostProduct;
});
setOrders(filteredOrders);
} else {
setOrders([]);
}
setError(null); // On efface l'erreur s'il y en avait une
setIsLoading(false); // Le chargement est terminé
} catch (err) {
// 🛡️ 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.");
} finally {
setIsLoading(false);
}
}
};
useEffect(() => {
fetchInventory();
}, []);
return (
<div className="w-full max-w-6xl p-6 mx-auto">
// 🌟 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');
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">
<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>
{/* GESTION DES ERREURS & CHARGEMENT */}
{isLoading && (
<div className="flex items-center space-x-3 text-cyan-400">
<Loader className="w-6 h-6 animate-spin" />
<span>Synchronisation avec l'orchestrateur en cours...</span>
<span>Synchronisation avec le registre comptable...</span>
</div>
)}
@@ -71,24 +115,44 @@ export default function Dashboard() {
</div>
)}
{/* GRILLE DES SERVICES */}
{!isLoading && !error && (
<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) => (
<DashboardServiceCard
key={order.id}
order={order}
onClick={() => navigate(`/services/${order.id}`)}
onClick={() => handleManageSubscription(order)}
/>
))}
{/* Le composant carte d'ajout */}
<NewServiceCard onClick={() => navigate('/store')} />
</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>
);
}
+4 -4
View File
@@ -1,5 +1,5 @@
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 { Server, Globe, Folder, Trash2, Loader, Plus, AlertCircle } from 'lucide-react';
@@ -25,12 +25,12 @@ export default function Services() {
const fetchServices = useCallback(async () => {
try {
const data = await getMyServices();
const data = await getOrdersList();
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 getHostingServiceDetails(order.id); } catch (e) {}
try { hDetails = await getOrderDetails(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 getHostingServiceDetails(service.id);
const freshDetails = await getOrderDetails(service.id);
setActiveServiceModal({ type: isVPS ? 'vps' : 'web', data: service, details: freshDetails });
}
} catch (err) { triggerAlert("Erreur", err.message, "error"); }
+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/api';
import CategorySection from '../../components/store/CategorySection'; // L'import magique
import { getProductList } from '../../services/billing_api';
import CategorySection from '../../components/store/CategorySection';
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/api';
import { loginClient } from '../../services/billing_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 { registerUnifiedClient } from '../../services/api';
import { createNewClient } from '../../services/billing_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,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)." });
return;
}
setLoading(true);
try {
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." });
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." });
} 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 TRIPLE ]
[ INITIALISATION DU PROVISIONNEMENT ]
</p>
<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 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) => {
let method = 'GET';
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)
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)
// ==========================================
@@ -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)
export const resetHostingPassword = (orderId, newPassword) =>
apiCall(`${BASE_URL}/api/client/servicehosting/change_password`, 'POST', {
@@ -191,33 +145,204 @@ export const launchSSOGateway = (username, password) => {
document.body.removeChild(form);
};
// #######################################################################################
// ==========================================
// ROUTES SUPPORT FOSSBILLING (NATIVES)
// GESTION DES ACCES
// ==========================================
// Récupère la liste de tous les tickets du client
export const getClientTickets = () =>
apiCall(`${BASE_URL}/api/client/support/ticket_get_list`, 'GET');
// Connexion
export const loginClient = (email, password) =>
apiCall(`${BASE_URL}/api/guest/client/login`, { email, password });
// 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 });
// Deconnexion
export const logoutClient = () =>
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) =>
apiCall(`${BASE_URL}/api/client/support/ticket_create`, 'POST', {
support_helpdesk_id: helpdesk_id,
subject: subject,
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) =>
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');
+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 = (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
});