From a4f9663f60eb6d23ab7a5525c7ac2b798342a547 Mon Sep 17 00:00:00 2001 From: maximus Date: Wed, 15 Jul 2026 10:46:09 +0200 Subject: [PATCH 1/6] add SubscriptionManager --- .../dashboard/SubscriptionManager.jsx | 487 ++++++++++++++++++ 1 file changed, 487 insertions(+) create mode 100644 src/components/dashboard/SubscriptionManager.jsx diff --git a/src/components/dashboard/SubscriptionManager.jsx b/src/components/dashboard/SubscriptionManager.jsx new file mode 100644 index 0000000..a7a9cd0 --- /dev/null +++ b/src/components/dashboard/SubscriptionManager.jsx @@ -0,0 +1,487 @@ +import { useState, useEffect } from 'react'; +import { CreditCard, Check, Globe, Loader, ToggleLeft, ShieldAlert, Calendar, TrendingUp, TrendingDown, RefreshCcw } from 'lucide-react'; +import { getProductList, getHostingServiceDetails } from '../../services/api'; +const CUSTOM_API_BASE_URL = import.meta.env.VITE_CUSTOM_API_BASE_URL || ''; + +export default function SubscriptionManager({ 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'); + + // 1. Récupération des détails techniques (Domaine/IP) + useEffect(() => { + const fetchDetails = async () => { + setIsDetailsLoading(true); + try { + const data = await getHostingServiceDetails(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?.domain || serviceDetails?.config?.domain) { + extractedDomain = serviceDetails.domain || serviceDetails.config.domain; + } + + const renderMarkdownFeatures = (text) => { + if (!text) return Aucune spécification disponible.; + return text.split('\n').map((line, index) => { + const cleanLine = line.trim(); + if (cleanLine.startsWith('* ') || cleanLine.startsWith('- ')) { + return ( +
+
+ +
+ {parseBold(cleanLine.substring(2))} +
+ ); + } + return

{parseBold(cleanLine)}

; + }); + }; + + const parseBold = (text) => text.split('**').map((part, i) => i % 2 === 1 ? {part} : 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; + + // --- 3. Style du bouton (Triggers : Upgrade / Downgrade / Cycle) --- + + // On récupère le forfait actuel depuis le catalogue pour comparer la "puissance" brute des forfaits + const currentPlanFromCatalog = catalog.find(p => p.id === currentPlanId); + + // Pour définir la hiérarchie absolue, on compare les prix de base sur 1 Mois + const baseCurrentPrice = currentPlanFromCatalog?.pricing?.recurrent?.['1M']?.price || 0; + const baseSelectedPrice = selectedPlan?.pricing?.recurrent?.['1M']?.price || 0; + + const isSamePlan = currentPlanId === selectedPlan.id; + const isSamePeriod = currentBillingPeriod === billingPeriod; + + if (isSamePlan && !isSamePeriod) { + // Le pack est identique, seule la période change + migrationLabel = "Changer de cycle"; + buttonColor = "bg-blue-500 hover:bg-blue-400 text-black shadow-[0_0_20px_rgba(59,130,246,0.2)]"; + ActionIcon = ; + } else if (!isSamePlan) { + // Le pack change, on compare la hiérarchie absolue + if (parseFloat(baseSelectedPrice) > parseFloat(baseCurrentPrice)) { + migrationLabel = `Valider l'Upgrade`; + buttonColor = "bg-emerald-500 hover:bg-emerald-400 text-black shadow-[0_0_20px_rgba(16,185,129,0.2)]"; + ActionIcon = ; + } 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 = ; + } + } + } + // ========================================== + + const handleMigration = async () => { + setLoading(true); + + // 🎯 On détermine dynamiquement le type d'action pour la facture + let actionType = 'upgrade'; + const currentPlanFromCatalog = catalog.find(p => p.id === currentPlanId); + const baseCurrent = parseFloat(currentPlanFromCatalog?.pricing?.recurrent?.['1M']?.price || 0); + 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 // 🎯 On envoie l'information au backend ! + }) + }); + 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); + } + }; + + const handleCancel = async () => { + if (!window.confirm("Êtes-vous sûr de vouloir résilier cet abonnement ? Le service sera désactivé au prochain cycle de facturation.")) { + return; + } + + setCancelLoading(true); + try { + const resp = await fetch(`${CUSTOM_API_BASE_URL}/custom_api/nexus_subscription.php`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ + action: 'cancel', + order_id: order.id + }) + }); + const data = await resp.json(); + + if (data.status === 'success') { + if (onAlert) onAlert("Résilié", data.message || "L'abonnement a été annulé avec succès.", "success"); + if (onRefresh) onRefresh(); + if (onClose) onClose(); + } else { + if (onAlert) onAlert("Erreur", data.error || "Impossible de résilier l'abonnement.", "error"); + } + } catch (err) { + if (onAlert) onAlert("Erreur", "Problème réseau ou serveur.", "error"); + } finally { + setCancelLoading(false); + } + }; + + return ( +
+ +
+
+
+
+
+

Abonnement Actuel

+

{currentPlanName}

+
+
+
+
+
+

Espace Domaine

+

{extractedDomain}

+
+
+
+ +
+
+
+ +
+
+

Prochain Renouvellement

+

+ {order.expires_at ? order.expires_at : "Date non définie"} + | + {order.price || '0.00'} {order.currency || '€'} + / {order.period === '1W' ? 'semaine' : order.period === '1Y' ? 'an' : 'mois'} +

+
+
+ + Voir les factures + +
+ +
+ + Options de renouvellement + +
+ {[ + { id: '1W', label: 'Semaine' }, + { id: '1M', label: 'Mois' }, + { id: '1Y', label: 'Année' } + ].map((p) => ( + + ))} +
+
+
+ +
+ {isCatalogLoading ? ( +
+ +

Synchronisation des tarifs...

+
+ ) : ( +
+ {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 ( +
!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' : ''}`} + > +
+ + {topBadge.label} + +
+ +
+ + {plan.category?.title || "WEB"} + + {metrics.saving > 0 && metrics.isAvailable && ( + + Économie {metrics.saving}% + + )} +
+ +
+

+ {plan.title} + {isCurrentActive && Actif} +

+ + {metrics.isAvailable ? ( + <> +
+ {metrics.displayPrice} € + / mois +
+ + {metrics.saving > 0 && ( +

+ Au lieu de {metrics.oldDisplayPrice} € / mois +

+ )} + +
+ Facturé {metrics.billingPrice} € par {billingPeriod === '1W' ? 'semaine' : billingPeriod === '1M' ? 'mois' : 'an'} +
+ + ) : ( +
+ Non disponible +

Pour ce cycle

+
+ )} +
+ +
+ {renderMarkdownFeatures(plan.description)} +
+
+ ); + })} +
+ )} +
+ +
+ {/* 🎯 NOUVEAU : AFFICHAGE DYNAMIQUE (PRORATA ET REMBOURSEMENT) */} + {selectedPlan && !isNoChange && ( +
+
+

+ {isRefund + ? "Remboursement estimé" + : finalInvoicePrice > 0.01 + ? "Montant à régler aujourd'hui" + : "Aucun frais immédiat"} +

+ {isProrataApplied &&

Calculé au prorata des jours restants

} +
+
+ + {isRefund ? "-" : ""}{Math.abs(finalInvoicePrice).toFixed(2)} € + +
+
+ )} + + {selectedPlan && ( + + )} + + +
+
+ ); +} \ No newline at end of file -- 2.47.3 From 074e3ab195063574140e6201ca5b42b9c42b696e Mon Sep 17 00:00:00 2001 From: maximus Date: Wed, 15 Jul 2026 14:42:44 +0200 Subject: [PATCH 2/6] rebuild api fossbilling --- ....jsx => WebServiceSubscriptionManager.jsx} | 6 +- src/pages/app/Dashboard.jsx | 140 ++++++++---- src/pages/app/Services.jsx | 8 +- src/services/api.js | 212 +++++++++++++----- 4 files changed, 253 insertions(+), 113 deletions(-) rename src/components/dashboard/{SubscriptionManager.jsx => WebServiceSubscriptionManager.jsx} (99%) diff --git a/src/components/dashboard/SubscriptionManager.jsx b/src/components/dashboard/WebServiceSubscriptionManager.jsx similarity index 99% rename from src/components/dashboard/SubscriptionManager.jsx rename to src/components/dashboard/WebServiceSubscriptionManager.jsx index a7a9cd0..7707399 100644 --- a/src/components/dashboard/SubscriptionManager.jsx +++ b/src/components/dashboard/WebServiceSubscriptionManager.jsx @@ -1,9 +1,9 @@ import { useState, useEffect } from 'react'; import { CreditCard, Check, Globe, Loader, ToggleLeft, ShieldAlert, Calendar, TrendingUp, TrendingDown, RefreshCcw } from 'lucide-react'; -import { getProductList, getHostingServiceDetails } from '../../services/api'; +import { getProductList, getServiceDetails } from '../../services/api'; const CUSTOM_API_BASE_URL = import.meta.env.VITE_CUSTOM_API_BASE_URL || ''; -export default function SubscriptionManager({ order, onClose, onRefresh, onAlert }) { +export default function WebServiceSubscriptionManager({ order, onClose, onRefresh, onAlert }) { const [loading, setLoading] = useState(false); const [cancelLoading, setCancelLoading] = useState(false); const [catalog, setCatalog] = useState([]); @@ -20,7 +20,7 @@ export default function SubscriptionManager({ order, onClose, onRefresh, onAlert const fetchDetails = async () => { setIsDetailsLoading(true); try { - const data = await getHostingServiceDetails(order.id); + const data = await getServiceDetails(order.id); setServiceDetails(data); } catch (err) { console.error("Erreur détails service:", err); diff --git a/src/pages/app/Dashboard.jsx b/src/pages/app/Dashboard.jsx index b78f41b..58b2d24 100644 --- a/src/pages/app/Dashboard.jsx +++ b/src/pages/app/Dashboard.jsx @@ -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 { 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,54 +15,82 @@ 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 () => { - try { - const data = await getClientOrders(); - - if (data.list) { - // LE FILTRE CHIRURGICAL PAR PREFIXE - const filteredOrders = data.list.filter(order => { - const title = (order.title || '').toLowerCase(); - const type = (order.type || '').toLowerCase(); + const [activeSubscriptionModal, setActiveSubscriptionModal] = useState(null); + const [customAlert, setCustomAlert] = useState(null); - const isGhostProduct = - type === 'domain' || - title.startsWith('domain ') || - title.startsWith('domaine ') || - title.startsWith('enregistrement '); - - return !isGhostProduct; - }); + const triggerAlert = (title, message, type = "info") => setCustomAlert({ title, message, type }); - setOrders(filteredOrders); - } else { - setOrders([]); - } - } catch (err) { - setError(err.message || "Impossible de récupérer la télémétrie des services."); - } finally { - setIsLoading(false); + const fetchInventory = async () => { + setIsLoading(true); + try { + const data = await getClientOrders(); + + if (data.list) { + // LE FILTRE CHIRURGICAL PAR PREFIXE + const filteredOrders = data.list.filter(order => { + const title = (order.title || '').toLowerCase(); + const type = (order.type || '').toLowerCase(); + + const isGhostProduct = + type === 'domain' || + title.startsWith('domain ') || + title.startsWith('domaine ') || + title.startsWith('enregistrement '); + + return !isGhostProduct; + }); + + setOrders(filteredOrders); + } else { + setOrders([]); } - }; + } catch (err) { + setError(err.message || "Impossible de récupérer la télémétrie des services."); + } finally { + setIsLoading(false); + } + }; + useEffect(() => { fetchInventory(); }, []); + // 🌟 Plus besoin de charger les IPs, on ouvre juste la modale comptable ! + 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 ( -
- +

TABLEAU DE BORD

-

Aperçu de vos accréditations réseau et infrastructures.

+

Gestion financière et accréditations de vos infrastructures.

- {/* GESTION DES ERREURS & CHARGEMENT */} {isLoading && (
- Synchronisation avec l'orchestrateur en cours... + Synchronisation avec le registre comptable...
)} @@ -71,24 +101,44 @@ export default function Dashboard() {
)} - {/* GRILLE DES SERVICES */} {!isLoading && !error && (
- - {/* Boucle sur les composants isolés */} {orders.map((order) => ( - navigate(`/services/${order.id}`)} + handleManageSubscription(order)} /> ))} - - {/* Le composant carte d'ajout */} navigate('/store')} /> -
)} + + {/* 🌟 LA MODALE DE GESTION D'ABONNEMENT */} + {activeSubscriptionModal && ( +
+
+
+

+ + GESTION DE L'ABONNEMENT +

+ +
+ + setActiveSubscriptionModal(null)} + onRefresh={fetchInventory} + onAlert={triggerAlert} + /> +
+
+ )} + + setCustomAlert(null)} />
); } \ No newline at end of file diff --git a/src/pages/app/Services.jsx b/src/pages/app/Services.jsx index 75ab49b..e11a169 100644 --- a/src/pages/app/Services.jsx +++ b/src/pages/app/Services.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; -import { getMyServices, getHostingServiceDetails } from '../../services/api'; +import { getClientOrders, getOrderDetails } from '../../services/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 getClientOrders(); 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"); } diff --git a/src/services/api.js b/src/services/api.js index d546f75..07554f9 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -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,169 @@ 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 +// ========================================== + +// 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'); \ No newline at end of file + }); \ No newline at end of file -- 2.47.3 From 15213bba2178bf62531895404a4549332a0be189 Mon Sep 17 00:00:00 2001 From: LathanDevers Date: Thu, 16 Jul 2026 14:51:57 +0200 Subject: [PATCH 3/6] change api --- src/components/store/ProductCard.jsx | 10 +- src/pages/app/Checkout.jsx | 9 +- src/pages/app/Dashboard.jsx | 4 +- src/pages/app/Services.jsx | 4 +- src/pages/app/Store.jsx | 4 +- src/pages/public/Login.jsx | 2 +- src/pages/public/Register.jsx | 12 +- src/services/api.js | 37 ++- src/services/billing_api.js | 345 +++++++++++++++++++++++++++ 9 files changed, 403 insertions(+), 24 deletions(-) create mode 100644 src/services/billing_api.js diff --git a/src/components/store/ProductCard.jsx b/src/components/store/ProductCard.jsx index d9bcd74..3aee561 100644 --- a/src/components/store/ProductCard.jsx +++ b/src/components/store/ProductCard.jsx @@ -66,16 +66,16 @@ export default function ProductCard({ product, selectedPeriod, categoryName }) { )} -
+

{product.title}

{priceData.isAvailable ? ( -
+
{priceData.displayPrice} € {priceData.suffix}
-
+
{!priceData.isOnce && ( <> {priceData.originalPrice ? ( @@ -96,9 +96,9 @@ export default function ProductCard({ product, selectedPeriod, categoryName }) { )}
-
+
-
    , li: ({node, ...props}) =>
  • {props.children}
  • , p: ({node, ...props}) =>

    , strong: ({node, ...props}) => }}> +

      , li: ({node, ...props}) =>
    • {props.children}
    • , p: ({node, ...props}) =>

      , strong: ({node, ...props}) => }}> {product.description || "Aucune description technique."}

diff --git a/src/pages/app/Checkout.jsx b/src/pages/app/Checkout.jsx index 861a432..10bac82 100644 --- a/src/pages/app/Checkout.jsx +++ b/src/pages/app/Checkout.jsx @@ -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; diff --git a/src/pages/app/Dashboard.jsx b/src/pages/app/Dashboard.jsx index 58b2d24..4358746 100644 --- a/src/pages/app/Dashboard.jsx +++ b/src/pages/app/Dashboard.jsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; -import { getClientOrders } from '../../services/api'; +import { getOrdersList } from '../../services/billing_api'; import { AlertCircle, Loader, FileText, X } from 'lucide-react'; // Importation des composants @@ -23,7 +23,7 @@ export default function Dashboard() { const fetchInventory = async () => { setIsLoading(true); try { - const data = await getClientOrders(); + const data = await getOrdersList(); if (data.list) { // LE FILTRE CHIRURGICAL PAR PREFIXE diff --git a/src/pages/app/Services.jsx b/src/pages/app/Services.jsx index e11a169..c998546 100644 --- a/src/pages/app/Services.jsx +++ b/src/pages/app/Services.jsx @@ -1,5 +1,5 @@ import { useState, useEffect, useCallback } from 'react'; -import { getClientOrders, getOrderDetails } 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,7 +25,7 @@ export default function Services() { const fetchServices = useCallback(async () => { try { - const data = await getClientOrders(); + const data = await getOrdersList(); if (data.list && data.list.length > 0) { const detailedServices = await Promise.all(data.list.map(async (order) => { let hDetails = null; diff --git a/src/pages/app/Store.jsx b/src/pages/app/Store.jsx index 117fac6..cb357a6 100644 --- a/src/pages/app/Store.jsx +++ b/src/pages/app/Store.jsx @@ -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({}); diff --git a/src/pages/public/Login.jsx b/src/pages/public/Login.jsx index c5a3864..4f7acf1 100644 --- a/src/pages/public/Login.jsx +++ b/src/pages/public/Login.jsx @@ -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(''); diff --git a/src/pages/public/Register.jsx b/src/pages/public/Register.jsx index 674188c..cd31aa8 100644 --- a/src/pages/public/Register.jsx +++ b/src/pages/public/Register.jsx @@ -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() { @@ -10,7 +10,7 @@ export default function Register() { const [username, setUsername] = useState(''); const [password, setPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState(''); - + const [loading, setLoading] = useState(false); const [customAlert, setCustomAlert] = useState(null); const navigate = useNavigate(); @@ -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

- [ INITIALISATION DU PROVISIONNEMENT TRIPLE ] + [ INITIALISATION DU PROVISIONNEMENT ]

diff --git a/src/services/api.js b/src/services/api.js index 07554f9..a323ac5 100644 --- a/src/services/api.js +++ b/src/services/api.js @@ -164,6 +164,42 @@ export const logoutClient = () => // 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'); @@ -202,7 +238,6 @@ export const updateClientProfile = (email, last_name, aid, gender, country, city custom_8: custom_8, custom_9: custom_9, custom_10: custom_10 - }); // Changer le mot de passe diff --git a/src/services/billing_api.js b/src/services/billing_api.js new file mode 100644 index 0000000..8df04c7 --- /dev/null +++ b/src/services/billing_api.js @@ -0,0 +1,345 @@ +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 (après 14 jours) +export const cancelRenewal = (order_id) => + apiCall(`${BASE_URL}/api/admin/order/update`, { + id: order_id, + cancel_at_expiration: 1 + }); + +// Suppression +export const cancelOrder = (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 + }); \ No newline at end of file -- 2.47.3 From 2706b3d5a3a05924bb9100c27d05936e04126e7b Mon Sep 17 00:00:00 2001 From: LathanDevers Date: Thu, 16 Jul 2026 22:26:55 +0200 Subject: [PATCH 4/6] change api --- src/components/dashboard/NewServiceCard.jsx | 2 +- src/services/billing_api.js | 15 +++++++++------ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/src/components/dashboard/NewServiceCard.jsx b/src/components/dashboard/NewServiceCard.jsx index 9533ca3..c64dda4 100644 --- a/src/components/dashboard/NewServiceCard.jsx +++ b/src/components/dashboard/NewServiceCard.jsx @@ -4,7 +4,7 @@ export default function NewServiceCard({ onClick }) { return (
diff --git a/src/services/billing_api.js b/src/services/billing_api.js index 8df04c7..2accc99 100644 --- a/src/services/billing_api.js +++ b/src/services/billing_api.js @@ -203,15 +203,18 @@ export const changeClientPassword = (current_password, new_password) => // Souscription -// Resiliation (après 14 jours) -export const cancelRenewal = (order_id) => - apiCall(`${BASE_URL}/api/admin/order/update`, { - id: order_id, - cancel_at_expiration: 1 +// Resiliation +// Action : Demande d'annulation (S'occupe de vérifier les 14 jours côté serveur) +export const cancelOrder = (orderId) => { + // On utilise votre moteur apiCall existant, il transmettra automatiquement + // le token "Bearer" dans le header Authorization ! + return apiCall(`${CUSTOM_API_URL}/cancel_subscription.php`, 'POST', { + order_id: orderId }); +}; // Suppression -export const cancelOrder = (order_id) => +export const deleteOrder = (order_id) => apiCall(`${BASE_URL}/api/client/order/delete`, { id: order_id }); // Promotion (Surclassement) -- 2.47.3 From b30a16b8a4b1ad3a1d5b1c71748ce71d30de67e2 Mon Sep 17 00:00:00 2001 From: LathanDevers Date: Fri, 17 Jul 2026 12:48:06 +0200 Subject: [PATCH 5/6] add resiliation --- src/components/dashboard/NewServiceCard.jsx | 2 +- .../WebServiceSubscriptionManager.jsx | 145 +++++++++++++----- src/pages/app/Dashboard.jsx | 25 ++- src/services/billing_api.js | 4 +- 4 files changed, 128 insertions(+), 48 deletions(-) diff --git a/src/components/dashboard/NewServiceCard.jsx b/src/components/dashboard/NewServiceCard.jsx index c64dda4..d8f8c00 100644 --- a/src/components/dashboard/NewServiceCard.jsx +++ b/src/components/dashboard/NewServiceCard.jsx @@ -13,7 +13,7 @@ export default function NewServiceCard({ onClick }) { Demander une accréditation

- Déployer un nouveau serveur Web, VPS ou Cloud. + Déployer un nouveau serveur Web, VPS, DB ou Cloud.

); diff --git a/src/components/dashboard/WebServiceSubscriptionManager.jsx b/src/components/dashboard/WebServiceSubscriptionManager.jsx index 7707399..19f1092 100644 --- a/src/components/dashboard/WebServiceSubscriptionManager.jsx +++ b/src/components/dashboard/WebServiceSubscriptionManager.jsx @@ -1,6 +1,6 @@ import { useState, useEffect } from 'react'; import { CreditCard, Check, Globe, Loader, ToggleLeft, ShieldAlert, Calendar, TrendingUp, TrendingDown, RefreshCcw } from 'lucide-react'; -import { getProductList, getServiceDetails } from '../../services/api'; +import { getProductList, getServiceDetails, cancelOrder } from '../../services/billing_api'; const CUSTOM_API_BASE_URL = import.meta.env.VITE_CUSTOM_API_BASE_URL || ''; export default function WebServiceSubscriptionManager({ order, onClose, onRefresh, onAlert }) { @@ -14,6 +14,9 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres const [isDetailsLoading, setIsDetailsLoading] = useState(true); const [billingPeriod, setBillingPeriod] = useState(order.period || '1M'); + + // 🎯 NOUVEAU : État pour la modale de résiliation + const [isCancelModalOpen, setIsCancelModalOpen] = useState(false); // 1. Récupération des détails techniques (Domaine/IP) useEffect(() => { @@ -39,8 +42,8 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres // 2. Extraction robuste du domaine let extractedDomain = "Aucun domaine lié"; - if (serviceDetails?.domain || serviceDetails?.config?.domain) { - extractedDomain = serviceDetails.domain || serviceDetails.config.domain; + if (serviceDetails?.config?.hostname || serviceDetails?.config?.sld && serviceDetails?.config?.tld) { + extractedDomain = serviceDetails.config.hostname || serviceDetails.config.sld+serviceDetails.config.tld; } const renderMarkdownFeatures = (text) => { @@ -174,12 +177,7 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres isRefund = finalInvoicePrice < -0.01; - // --- 3. Style du bouton (Triggers : Upgrade / Downgrade / Cycle) --- - - // On récupère le forfait actuel depuis le catalogue pour comparer la "puissance" brute des forfaits const currentPlanFromCatalog = catalog.find(p => p.id === currentPlanId); - - // Pour définir la hiérarchie absolue, on compare les prix de base sur 1 Mois const baseCurrentPrice = currentPlanFromCatalog?.pricing?.recurrent?.['1M']?.price || 0; const baseSelectedPrice = selectedPlan?.pricing?.recurrent?.['1M']?.price || 0; @@ -187,12 +185,10 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres const isSamePeriod = currentBillingPeriod === billingPeriod; if (isSamePlan && !isSamePeriod) { - // Le pack est identique, seule la période change migrationLabel = "Changer de cycle"; buttonColor = "bg-blue-500 hover:bg-blue-400 text-black shadow-[0_0_20px_rgba(59,130,246,0.2)]"; ActionIcon = ; } else if (!isSamePlan) { - // Le pack change, on compare la hiérarchie absolue if (parseFloat(baseSelectedPrice) > parseFloat(baseCurrentPrice)) { migrationLabel = `Valider l'Upgrade`; buttonColor = "bg-emerald-500 hover:bg-emerald-400 text-black shadow-[0_0_20px_rgba(16,185,129,0.2)]"; @@ -204,12 +200,9 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres } } } - // ========================================== const handleMigration = async () => { setLoading(true); - - // 🎯 On détermine dynamiquement le type d'action pour la facture let actionType = 'upgrade'; const currentPlanFromCatalog = catalog.find(p => p.id === currentPlanId); const baseCurrent = parseFloat(currentPlanFromCatalog?.pricing?.recurrent?.['1M']?.price || 0); @@ -231,7 +224,7 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres target_plan_id: selectedPlan.id, new_period: billingPeriod, new_price: finalInvoicePrice.toFixed(2), - action_type: actionType // 🎯 On envoie l'information au backend ! + action_type: actionType }) }); const data = await resp.json(); @@ -249,40 +242,54 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres } }; - const handleCancel = async () => { - if (!window.confirm("Êtes-vous sûr de vouloir résilier cet abonnement ? Le service sera désactivé au prochain cycle de facturation.")) { - return; - } + // ========================================== + // 🛡️ NOUVELLE LOGIQUE DE RÉSILIATION (US 2.1 / 2.2) + // ========================================== + const calculateRefundEligibility = () => { + if (!order || !order.created_at) return false; + const orderDate = new Date(order.created_at); + const now = new Date(); + const diffDays = Math.ceil(Math.abs(now - orderDate) / (1000 * 60 * 60 * 24)); + return diffDays <= 14; + }; + const isEligibleForRefund = calculateRefundEligibility(); + + const handleConfirmCancel = async () => { setCancelLoading(true); try { - const resp = await fetch(`${CUSTOM_API_BASE_URL}/custom_api/nexus_subscription.php`, { - method: 'POST', - headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, - body: new URLSearchParams({ - action: 'cancel', - order_id: order.id - }) - }); - const data = await resp.json(); + const data = await cancelOrder(order.id); + // Succès normal if (data.status === 'success') { - if (onAlert) onAlert("Résilié", data.message || "L'abonnement a été annulé avec succès.", "success"); + if (onAlert) onAlert("Résilié", data.message, "success"); + setIsCancelModalOpen(false); if (onRefresh) onRefresh(); if (onClose) onClose(); } else { - if (onAlert) onAlert("Erreur", data.error || "Impossible de résilier l'abonnement.", "error"); + if (onAlert) onAlert("Erreur", data.error, "error"); } } catch (err) { - if (onAlert) onAlert("Erreur", "Problème réseau ou serveur.", "error"); + console.error("Erreur API Annulation:", err); + + // 🛡️ LE FILET DE SÉCURITÉ : Si on détecte l'erreur HTML 502 (Unexpected token) + // Cela signifie que le serveur a bien redémarré HestiaCP et coupé la connexion. + if (err.message && err.message.includes('Unexpected token')) { + if (onAlert) onAlert("Succès", "L'abonnement a été annulé et les services mis à jour.", "success"); + setIsCancelModalOpen(false); + // On met un petit délai avant de rafraîchir, le temps qu'HestiaCP finisse de recharger + if (onRefresh) setTimeout(() => onRefresh(), 2000); + if (onClose) setTimeout(() => onClose(), 2000); + } else { + if (onAlert) onAlert("Erreur", "Problème réseau lors de la résiliation.", "error"); + } } finally { setCancelLoading(false); } }; return ( -
- +
@@ -316,10 +323,7 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres

- + Voir les factures
@@ -429,7 +433,6 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
- {/* 🎯 NOUVEAU : AFFICHAGE DYNAMIQUE (PRORATA ET REMBOURSEMENT) */} {selectedPlan && !isNoChange && (
)} + {/* 🎯 NOUVEAU BOUTON : Ouvre la modale au lieu de faire un confirm() */}
+ + {/* ========================================================================= */} + {/* 🛡️ MODALE DE CONFIRMATION (Thème Sombre Intégré) */} + {/* ========================================================================= */} + {isCancelModalOpen && ( +
+
+ +

+ + Confirmer la résiliation +

+ + {/* Condition d'affichage : Rétractation J+14 vs Résiliation standard */} + {isEligibleForRefund ? ( +
+

+ Droit de rétractation (≤ 14 jours) +

+

+ Votre abonnement a été activé il y a moins de 14 jours. + Vous allez être intégralement remboursé. + Vos services HestiaCP et bases de données seront supprimés immédiatement. +

+
+ ) : ( +
+

📅 Résiliation standard (> 14 jours)

+

+ La période de rétractation est dépassée. Votre abonnement restera actif jusqu'à sa date d'échéance officielle. + Aucun renouvellement ni prélèvement n'aura lieu. +

+
+ )} + +

+ Êtes-vous absolument sûr de vouloir procéder ? Cette action est irréversible une fois validée par nos serveurs. +

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