5 Commits

Author SHA1 Message Date
LathanDevers 338dccf30b Merge branch 'feat/updatesub-web-process' of https://git.bunker.lan/maximus/portail-gise into feat/updatesub-web-process 2026-07-16 09:05:44 +02:00
LathanDevers 151aca69de us1 2026-07-16 09:05:24 +02:00
maximus 9ad44d57b3 facturation: correction 2026-07-12 16:25:53 +02:00
LathanDevers eb48f506cf add billing system 2026-07-12 10:28:02 +02:00
LathanDevers ef8f4a96b4 add migration of servicew 2026-07-02 16:40:37 +02:00
17 changed files with 390 additions and 732 deletions
+101
View File
@@ -0,0 +1,101 @@
### 🛒 Épique 1 : Souscription et Achat (Abonnement Web)
**US 1.1 : Souscription initiale et information légale**
> **En tant que** client,
> **Je veux** pouvoir choisir un abonnement web et le payer en ligne,
> **Afin d'** y accéder immédiatement tout en étant informé de mes droits.
* **CA 1 :** Le client doit pouvoir sélectionner un plan d'hébergement et un cycle de facturation (ex: 1 mois, 1 an).
* **CA 2 :** Dès validation du paiement, le compte HestiaCP est provisionné automatiquement et les accès sont envoyés par email.
* **CA 3 :** Le système enregistre l'horodatage exact (date et heure) de l'activation, définissant le "Jour 0".
* **CA 4 :** L'email de confirmation de commande **doit explicitement mentionner** le droit de rétractation de 14 jours prévu par le Code de droit économique belge.
---
### 🛑 Épique 2 : Résiliation et Rétractation
**US 2.1 : Droit de rétractation (Résiliation Jours 0 à 14 inclus)**
> **En tant que** client dans ma période d'essai légale de 14 jours,
> **Je veux** pouvoir annuler mon abonnement d'un simple clic depuis mon espace,
> **Afin de** stopper le service immédiatement et d'obtenir un remboursement complet.
* **CA 1 :** Un bouton "Annuler mon abonnement" est disponible sur le détail du service.
* **CA 2 :** **Règle métier :** Si la demande d'annulation intervient avant ou le 14ème jour à 23h59 suivant l'activation, le système affiche un message indiquant que le remboursement sera intégral et immédiat.
* **CA 3 :** Dès confirmation du client, le service (FOSSBilling + HestiaCP) est **immédiatement suspendu ou supprimé**.
* **CA 4 :** Une facture de remboursement (montant négatif) est générée, et l'ordre de remboursement est envoyé à la passerelle de paiement.
**US 2.2 : Résiliation standard (Après 14 jours)**
> **En tant que** client abonné depuis plus de 14 jours,
> **Je veux** pouvoir annuler le renouvellement automatique de mon abonnement,
> **Afin de** ne pas être facturé à la prochaine échéance.
* **CA 1 :** **Règle métier :** Si la demande d'annulation intervient après le 14ème jour, aucun remboursement n'est proposé.
* **CA 2 :** L'interface affiche clairement : *"Votre abonnement restera actif jusqu'au [Date de fin de période payée] et ne sera pas renouvelé."*
* **CA 3 :** Le statut de l'abonnement dans FOSSBilling passe en "Annulé à l'échéance".
* **CA 4 :** Le système coupe le service HestiaCP **uniquement à la date d'échéance**, sans action manuelle de l'administrateur.
---
### ⬆️ Épique 3 : Upgrade (Montée en gamme)
**US 3.1 : Upgrade d'une offre en cours de cycle**
> **En tant que** client,
> **Je veux** pouvoir passer à une offre supérieure à tout moment,
> **Afin de** bénéficier de plus de ressources immédiatement.
* **CA 1 :** L'interface propose les plans supérieurs disponibles et calcule le prix à payer au *Prorata Temporis* (différence de prix calculée au prorata des jours restants avant le prochain renouvellement).
* **CA 2 :** Le changement de ressources techniques sur HestiaCP et la mise à jour des paramètres sur FOSSBilling ont lieu **immédiatement après validation du paiement de l'upgrade**.
* **CA 3 :** **Légal :** L'upgrade constituant un nouveau contrat/avenant, si le client se rétracte dans les 14 jours suivant cet upgrade, il est remboursé du prorata payé et redescendu à son offre initiale (ses ressources HestiaCP sont rétrogradées).
---
### ⬇️ Épique 4 : Downgrade (Descente en gamme standard)
**US 4.1 : Demande de Downgrade pour le prochain cycle**
> **En tant que** client,
> **Je veux** pouvoir demander le passage à une offre inférieure,
> **Afin de** réduire mes coûts lors de mon prochain renouvellement.
* **CA 1 :** L'interface permet de sélectionner un plan inférieur.
* **CA 2 :** Un message d'avertissement clair doit s'afficher : *"Votre demande est enregistrée. Le changement d'offre (ressources et tarif) prendra effet à votre prochaine date de renouvellement, le [Date]. Aucun remboursement partiel ne sera effectué pour la période en cours."*
* **CA 3 :** Le système enregistre l'intention de downgrade (Modification du `product_id` et du tarif pour la prochaine facture, mais maintien du package actuel sur HestiaCP jusqu'à la date d'échéance).
* **CA 4 :** À la date anniversaire, un script Cron effectue la bascule effective des ressources sur HestiaCP et génère la facture au nouveau tarif.
### 🟢 Phase 1 : Le "Quick Win" légal (Épique 1)
C'est le plus facile et on peut le régler en 5 minutes directement dans l'interface de FOSSBilling, sans toucher au code.
* **L'objectif :** Modifier les templates d'emails automatiques envoyés lors d'une commande pour y inclure la mention légale obligatoire du droit de rétractation de 14 jours.
* **L'action :** Éditer le template `mod_email_order_activated` dans FOSSBilling.
### 🟠 Phase 2 : Le cœur du réacteur - Les 14 jours (Épique 2)
C'est ici qu'on va faire du vrai développement PHP. Par défaut, quand un client annule sur FOSSBilling, ça coupe à la fin du mois sans rembourser. On doit créer un "intercepteur" (un Hook).
* **L'objectif :** Quand le client clique sur "Annuler", le système calcule la différence entre la date d'aujourd'hui et la date de la commande.
* **La logique :**
* Si `<= 14 jours` : On déclenche l'API `refund` de FOSSBilling, on suspend HestiaCP immédiatement, et on passe la commande en annulée.
* Si `> 14 jours` : On laisse FOSSBilling faire son comportement par défaut (Annulation à la prochaine date d'échéance).
### 🔵 Phase 3 : L'adaptation du Downgrade (Épique 4)
Tu te souviens de notre script de Downgrade instantané qu'on avait fait il y a quelques jours ? Puisque tu as choisi le modèle "Standard SaaS" (le downgrade ne s'applique qu'à la prochaine facture), on va devoir le modifier !
* **L'objectif :** Le clic sur "Downgrade" ne doit plus modifier HestiaCP immédiatement. Il doit juste préparer la prochaine facture dans FOSSBilling, et c'est un Cron (une tâche planifiée) qui réduira les ressources HestiaCP le jour du renouvellement.
### 🟣 Phase 4 : L'Upgrade (Épique 3)
FOSSBilling gère plutôt bien les upgrades nativement (avec calcul du prorata). On devra juste s'assurer que l'appel API vers HestiaCP se fait correctement dès que la facture de prorata est payée.
---
**Comment on attaque ?**
Est-ce qu'on se met en chauffe avec la **Phase 1 (Les templates d'emails)**, ou est-ce qu'on plonge directement dans le code PHP avec la **Phase 2 (La logique de rétractation des 14 jours)** ?
+1 -1
View File
@@ -5,7 +5,7 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>NEXUS by gise | Portail d'Infrastructure Centralisée</title> <title>NEXUS by gise | Portail d'Infrastructure Centralisée</title>
<meta name="description" content="Accédez au portail NEXUS by gise. Pilotez votre hébergement web, vos instances serveurs et votre stockage Cloud depuis une console unifiée."> <meta name="description" content="Accédez au portail GISE Nexus. Pilotez votre hébergement web, vos instances serveurs et votre stockage Cloud depuis une console unifiée.">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
@@ -1,9 +1,9 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { CreditCard, Check, Globe, Loader, ToggleLeft, ShieldAlert, Calendar, TrendingUp, TrendingDown, RefreshCcw } from 'lucide-react'; import { CreditCard, Check, Globe, Loader, ToggleLeft, ShieldAlert, Calendar, TrendingUp, TrendingDown, RefreshCcw } from 'lucide-react';
import { getProductList, getServiceDetails, cancelOrder } from '../../services/billing_api'; import { getProductList, getHostingServiceDetails } from '../../services/api';
const CUSTOM_API_BASE_URL = import.meta.env.VITE_CUSTOM_API_BASE_URL || ''; const CUSTOM_API_BASE_URL = import.meta.env.VITE_CUSTOM_API_BASE_URL || '';
export default function WebServiceSubscriptionManager({ order, onClose, onRefresh, onAlert }) { export default function SubscriptionManager({ order, onClose, onRefresh, onAlert }) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [cancelLoading, setCancelLoading] = useState(false); const [cancelLoading, setCancelLoading] = useState(false);
const [catalog, setCatalog] = useState([]); const [catalog, setCatalog] = useState([]);
@@ -15,15 +15,12 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
const [billingPeriod, setBillingPeriod] = useState(order.period || '1M'); const [billingPeriod, setBillingPeriod] = useState(order.period || '1M');
// 🎯 NOUVEAU : État pour la modale de résiliation
const [isCancelModalOpen, setIsCancelModalOpen] = useState(false);
// 1. Récupération des détails techniques (Domaine/IP) // 1. Récupération des détails techniques (Domaine/IP)
useEffect(() => { useEffect(() => {
const fetchDetails = async () => { const fetchDetails = async () => {
setIsDetailsLoading(true); setIsDetailsLoading(true);
try { try {
const data = await getServiceDetails(order.id); const data = await getHostingServiceDetails(order.id);
setServiceDetails(data); setServiceDetails(data);
} catch (err) { } catch (err) {
console.error("Erreur détails service:", err); console.error("Erreur détails service:", err);
@@ -42,8 +39,8 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
// 2. Extraction robuste du domaine // 2. Extraction robuste du domaine
let extractedDomain = "Aucun domaine lié"; let extractedDomain = "Aucun domaine lié";
if (serviceDetails?.config?.hostname || serviceDetails?.config?.sld && serviceDetails?.config?.tld) { if (serviceDetails?.domain || serviceDetails?.config?.domain) {
extractedDomain = serviceDetails.config.hostname || serviceDetails.config.sld+serviceDetails.config.tld; extractedDomain = serviceDetails.domain || serviceDetails.config.domain;
} }
const renderMarkdownFeatures = (text) => { const renderMarkdownFeatures = (text) => {
@@ -177,7 +174,12 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
isRefund = finalInvoicePrice < -0.01; isRefund = finalInvoicePrice < -0.01;
// --- 3. Style du bouton (Triggers : Upgrade / Downgrade / Cycle) ---
// On récupère le forfait actuel depuis le catalogue pour comparer la "puissance" brute des forfaits
const currentPlanFromCatalog = catalog.find(p => p.id === currentPlanId); const currentPlanFromCatalog = catalog.find(p => p.id === currentPlanId);
// Pour définir la hiérarchie absolue, on compare les prix de base sur 1 Mois
const baseCurrentPrice = currentPlanFromCatalog?.pricing?.recurrent?.['1M']?.price || 0; const baseCurrentPrice = currentPlanFromCatalog?.pricing?.recurrent?.['1M']?.price || 0;
const baseSelectedPrice = selectedPlan?.pricing?.recurrent?.['1M']?.price || 0; const baseSelectedPrice = selectedPlan?.pricing?.recurrent?.['1M']?.price || 0;
@@ -185,10 +187,12 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
const isSamePeriod = currentBillingPeriod === billingPeriod; const isSamePeriod = currentBillingPeriod === billingPeriod;
if (isSamePlan && !isSamePeriod) { if (isSamePlan && !isSamePeriod) {
// Le pack est identique, seule la période change
migrationLabel = "Changer de cycle"; migrationLabel = "Changer de cycle";
buttonColor = "bg-blue-500 hover:bg-blue-400 text-black shadow-[0_0_20px_rgba(59,130,246,0.2)]"; buttonColor = "bg-blue-500 hover:bg-blue-400 text-black shadow-[0_0_20px_rgba(59,130,246,0.2)]";
ActionIcon = <RefreshCcw className="w-4 h-4" />; ActionIcon = <RefreshCcw className="w-4 h-4" />;
} else if (!isSamePlan) { } else if (!isSamePlan) {
// Le pack change, on compare la hiérarchie absolue
if (parseFloat(baseSelectedPrice) > parseFloat(baseCurrentPrice)) { if (parseFloat(baseSelectedPrice) > parseFloat(baseCurrentPrice)) {
migrationLabel = `Valider l'Upgrade`; migrationLabel = `Valider l'Upgrade`;
buttonColor = "bg-emerald-500 hover:bg-emerald-400 text-black shadow-[0_0_20px_rgba(16,185,129,0.2)]"; buttonColor = "bg-emerald-500 hover:bg-emerald-400 text-black shadow-[0_0_20px_rgba(16,185,129,0.2)]";
@@ -200,9 +204,12 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
} }
} }
} }
// ==========================================
const handleMigration = async () => { const handleMigration = async () => {
setLoading(true); setLoading(true);
// 🎯 On détermine dynamiquement le type d'action pour la facture
let actionType = 'upgrade'; let actionType = 'upgrade';
const currentPlanFromCatalog = catalog.find(p => p.id === currentPlanId); const currentPlanFromCatalog = catalog.find(p => p.id === currentPlanId);
const baseCurrent = parseFloat(currentPlanFromCatalog?.pricing?.recurrent?.['1M']?.price || 0); const baseCurrent = parseFloat(currentPlanFromCatalog?.pricing?.recurrent?.['1M']?.price || 0);
@@ -224,7 +231,7 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
target_plan_id: selectedPlan.id, target_plan_id: selectedPlan.id,
new_period: billingPeriod, new_period: billingPeriod,
new_price: finalInvoicePrice.toFixed(2), new_price: finalInvoicePrice.toFixed(2),
action_type: actionType action_type: actionType // 🎯 On envoie l'information au backend !
}) })
}); });
const data = await resp.json(); const data = await resp.json();
@@ -242,55 +249,51 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
} }
}; };
// ========================================== const handleCancel = async () => {
// 🛡 NOUVELLE LOGIQUE DE RÉSILIATION (US 2.1 / 2.2) if (!window.confirm("Êtes-vous sûr de vouloir résilier cet abonnement ? Le service sera désactivé au prochain cycle de facturation.")) {
// ========================================== try {
const calculateRefundEligibility = () => { const response = await cancelSubscription(user.id, order.id);
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(); // Le message s'adapte tout seul !
// Soit: "Annulé avec effet immédiat et remboursé"
// Soit: "Actif jusqu'à la fin de la période"
alert(response.message);
// Rafraîchir la liste des abonnements...
} catch (error) {
alert("Erreur lors de l'annulation : " + error.message);
}
}
const handleConfirmCancel = async () => {
setCancelLoading(true); setCancelLoading(true);
try { try {
const data = await cancelOrder(order.id); const resp = await fetch(`${CUSTOM_API_BASE_URL}/custom_api/nexus_subscription.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
action: 'cancel',
order_id: order.id
})
});
const data = await resp.json();
// Succès normal
if (data.status === 'success') { if (data.status === 'success') {
if (onAlert) onAlert("Résilié", data.message, "success"); if (onAlert) onAlert("Résilié", data.message || "L'abonnement a été annulé avec succès.", "success");
setIsCancelModalOpen(false);
if (onRefresh) onRefresh(); if (onRefresh) onRefresh();
if (onClose) onClose(); if (onClose) onClose();
} else { } else {
if (onAlert) onAlert("Erreur", data.error, "error"); if (onAlert) onAlert("Erreur", data.error || "Impossible de résilier l'abonnement.", "error");
} }
} catch (err) { } catch (err) {
console.error("Erreur API Annulation:", err); if (onAlert) onAlert("Erreur", "Problème réseau ou serveur.", "error");
// 🛡 LE FILET DE SÉCURITÉ : Si on détecte l'erreur HTML 502 (Unexpected token)
// Cela signifie que le serveur a bien redémarré HestiaCP et coupé la connexion.
if (err.message && err.message.includes('Unexpected token')) {
if (onAlert) onAlert("Succès", "L'abonnement a été annulé et les services mis à jour.", "success");
setIsCancelModalOpen(false);
// On met un petit délai avant de rafraîchir, le temps qu'HestiaCP finisse de recharger
if (onRefresh) setTimeout(() => onRefresh(), 2000);
if (onClose) setTimeout(() => onClose(), 2000);
} else {
if (onAlert) onAlert("Erreur", "Problème réseau lors de la résiliation.", "error");
}
} finally { } finally {
setCancelLoading(false); setCancelLoading(false);
} }
}; };
return ( return (
<div className="flex flex-col w-full max-h-[95vh] md:h-auto md:max-h-[90vh] max-w-6xl mx-auto text-white relative"> <div className="flex flex-col w-full max-h-[95vh] md:h-auto md:max-h-[90vh] max-w-6xl mx-auto text-white">
<div className="shrink-0 space-y-4 mb-4 pr-2"> <div className="shrink-0 space-y-4 mb-4 pr-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-[#090f1c] border border-gray-800 p-3.5 rounded-xl flex items-center gap-3"> <div className="bg-[#090f1c] border border-gray-800 p-3.5 rounded-xl flex items-center gap-3">
@@ -324,7 +327,10 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
</p> </p>
</div> </div>
</div> </div>
<a href={`/facturation`} className="text-xs font-bold text-cyan-400 hover:text-cyan-300 transition-colors flex items-center gap-1.5 bg-cyan-950/20 px-4 py-2 rounded-lg border border-cyan-900/30"> <a
href={`/facturation`}
className="text-xs font-bold text-cyan-400 hover:text-cyan-300 transition-colors flex items-center gap-1.5 bg-cyan-950/20 px-4 py-2 rounded-lg border border-cyan-900/30"
>
Voir les factures Voir les factures
</a> </a>
</div> </div>
@@ -434,6 +440,7 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
</div> </div>
<div className="shrink-0 mt-auto pt-4 border-t border-gray-800/40 space-y-3"> <div className="shrink-0 mt-auto pt-4 border-t border-gray-800/40 space-y-3">
{/* 🎯 NOUVEAU : AFFICHAGE DYNAMIQUE (PRORATA ET REMBOURSEMENT) */}
{selectedPlan && !isNoChange && ( {selectedPlan && !isNoChange && (
<div className={`border p-3 rounded-xl mb-3 flex items-center justify-between transition-colors <div className={`border p-3 rounded-xl mb-3 flex items-center justify-between transition-colors
${isRefund ? 'bg-amber-950/20 border-amber-900/50' : 'bg-[#0e2238] border-cyan-900/50'}`} ${isRefund ? 'bg-amber-950/20 border-amber-900/50' : 'bg-[#0e2238] border-cyan-900/50'}`}
@@ -477,9 +484,8 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
</button> </button>
)} )}
{/* 🎯 NOUVEAU BOUTON : Ouvre la modale au lieu de faire un confirm() */}
<button <button
onClick={() => setIsCancelModalOpen(true)} onClick={handleCancel}
disabled={loading || cancelLoading} disabled={loading || cancelLoading}
className="w-full flex items-center justify-center gap-2 text-red-400/80 hover:text-red-400 border border-red-950/30 bg-red-950/5 hover:bg-red-950/15 p-3 rounded-xl text-xs font-bold uppercase tracking-wider transition-all disabled:opacity-50 disabled:cursor-not-allowed" className="w-full flex items-center justify-center gap-2 text-red-400/80 hover:text-red-400 border border-red-950/30 bg-red-950/5 hover:bg-red-950/15 p-3 rounded-xl text-xs font-bold uppercase tracking-wider transition-all disabled:opacity-50 disabled:cursor-not-allowed"
> >
@@ -487,71 +493,6 @@ export default function WebServiceSubscriptionManager({ order, onClose, onRefres
{cancelLoading ? 'Résiliation en cours...' : 'Résilier l\'abonnement réseau'} {cancelLoading ? 'Résiliation en cours...' : 'Résilier l\'abonnement réseau'}
</button> </button>
</div> </div>
{/* ========================================================================= */}
{/* 🛡️ MODALE DE CONFIRMATION (Thème Sombre Intégré) */}
{/* ========================================================================= */}
{isCancelModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4">
<div className="bg-[#0d1527] border border-gray-800 rounded-2xl shadow-2xl max-w-md w-full p-6 transform transition-all">
<h3 className="text-xl font-bold text-white mb-4 flex items-center gap-2">
<ShieldAlert className="w-5 h-5 text-red-500" />
Confirmer la résiliation
</h3>
{/* Condition d'affichage : Rétractation J+14 vs Résiliation standard */}
{isEligibleForRefund ? (
<div className="bg-emerald-950/30 border border-emerald-900/50 p-4 mb-5 text-emerald-400 text-sm rounded-xl">
<p className="font-bold mb-1 flex items-center gap-2">
<Check className="w-4 h-4" /> Droit de rétractation ( 14 jours)
</p>
<p className="text-emerald-500/80 mt-2">
Votre abonnement a été activé il y a moins de 14 jours.
<strong className="text-emerald-400"> Vous allez être intégralement remboursé</strong>.
Vos services seront supprimés immédiatement.
</p>
</div>
) : (
<div className="bg-amber-950/30 border border-amber-900/50 p-4 mb-5 text-amber-400 text-sm rounded-xl">
<p className="font-bold mb-1">📅 Résiliation standard (&gt; 14 jours)</p>
<p className="text-amber-500/80 mt-2">
La période de rétractation est dépassée. Votre abonnement restera actif jusqu'à sa date d'échéance officielle.
<strong className="text-amber-400"> Aucun renouvellement ni prélèvement n'aura lieu.</strong>
</p>
</div>
)}
<p className="text-gray-400 mb-6 text-sm">
Êtes-vous absolument sûr de vouloir procéder ? Cette action est irréversible une fois validée par nos serveurs.
</p>
<div className="flex justify-end gap-3">
<button
onClick={() => setIsCancelModalOpen(false)}
disabled={cancelLoading}
className="px-5 py-2.5 bg-[#090f1c] text-gray-300 border border-gray-800 rounded-xl hover:bg-gray-800 hover:text-white disabled:opacity-50 transition-colors text-sm font-bold tracking-wide"
>
Retour
</button>
<button
onClick={handleConfirmCancel}
disabled={cancelLoading}
className="px-5 py-2.5 bg-red-600/90 hover:bg-red-500 text-white border border-red-500/50 rounded-xl flex items-center disabled:opacity-50 transition-colors text-sm font-bold tracking-wide shadow-[0_0_15px_rgba(220,38,38,0.3)]"
>
{cancelLoading ? (
<>
<Loader className="w-4 h-4 animate-spin mr-2" />
Traitement...
</>
) : (
"Confirmer"
)}
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
} }
+81 -13
View File
@@ -1,19 +1,55 @@
import React, { useState, useEffect } from 'react';
import { Server, Database, Cloud, Globe, ExternalLink, Play, Loader } from 'lucide-react'; import { Server, Database, Cloud, Globe, ExternalLink, Play, Loader } from 'lucide-react';
import { getHostingServiceDetails } from '../../services/api';
export default function InstanceCard({ service, vpcs, onAssignVpc, onRemoveVpc, onOpenConsole, isConnecting }) { export default function InstanceCard({ service, vpcs, onAssignVpc, onRemoveVpc, onOpenConsole, isConnecting }) {
// 1. États pour les détails techniques chargés via API
const [serviceDetails, setServiceDetails] = useState(null);
const [isLoadingDetails, setIsLoadingDetails] = useState(true);
// 2. Chargement des détails au montage du composant
useEffect(() => {
let isMounted = true;
const fetchDetails = async () => {
try {
const data = await getHostingServiceDetails(service.id);
if (isMounted) {
setServiceDetails(data);
}
} catch (error) {
console.error("Erreur lors du chargement des détails du service:", error);
} finally {
if (isMounted) setIsLoadingDetails(false);
}
};
fetchDetails();
return () => { isMounted = false; };
}, [service.id]);
// 3. Logique d'affichage (Titre, Type de service)
const titleLower = (service.title || '').toLowerCase(); const titleLower = (service.title || '').toLowerCase();
const isVPS = titleLower.includes('vps') || titleLower.includes('compute'); const isVPS = titleLower.includes('vps') || titleLower.includes('compute');
const isCloud = titleLower.includes('cloud') || titleLower.includes('nextcloud'); const isCloud = titleLower.includes('cloud') || titleLower.includes('nextcloud');
const isDB = titleLower.includes('db') || titleLower.includes('base') || titleLower.includes('sql'); const isDB = titleLower.includes('db') || titleLower.includes('base') || titleLower.includes('sql');
const isWeb = !isVPS && !isCloud && !isDB; const isWeb = !isVPS && !isCloud && !isDB;
const hasIP = service.hostingDetails?.ip && service.hostingDetails.ip !== '127.0.0.1' && service.hostingDetails.ip !== '';
// 4. Extraction intelligente du domaine et de l'IP
// Priorité : API > Titre de la commande
const rawTitle = service.title || '';
const titleMatch = rawTitle.match(/(?: for | pour )(.+)$/i);
const domainFromTitle = titleMatch ? titleMatch[1].trim() : null;
const displayDomain = serviceDetails?.domain || serviceDetails?.config?.domain || domainFromTitle || null;
const displayIP = serviceDetails?.ip || serviceDetails?.config?.ip || null;
// Logique des boutons
let buttonText = "CONSOLE D'ADMINISTRATION"; let buttonText = "CONSOLE D'ADMINISTRATION";
let ButtonIcon = ExternalLink; let ButtonIcon = ExternalLink;
let buttonStyle = "bg-cyan-400/10 hover:bg-cyan-400 text-cyan-400 hover:text-gray-900 border-cyan-400"; let buttonStyle = "bg-cyan-400/10 hover:bg-cyan-400 text-cyan-400 hover:text-gray-900 border-cyan-400";
if (isVPS) { if (isVPS) {
if (hasIP) { buttonText = "GÉRER L'INSTANCE"; ButtonIcon = ExternalLink; } if (displayIP) { buttonText = "GÉRER L'INSTANCE"; ButtonIcon = ExternalLink; }
else { buttonText = "INITIALISATION"; ButtonIcon = Play; buttonStyle = "bg-yellow-500/10 text-yellow-500 hover:bg-yellow-500 hover:text-gray-900 border-yellow-500/50"; } else { buttonText = "INITIALISATION"; ButtonIcon = Play; buttonStyle = "bg-yellow-500/10 text-yellow-500 hover:bg-yellow-500 hover:text-gray-900 border-yellow-500/50"; }
} else if (isWeb) { buttonText = "GÉRER L'HÉBERGEMENT"; ButtonIcon = Globe; buttonStyle = "bg-emerald-400/10 hover:bg-emerald-400 text-emerald-400 hover:text-gray-900 border-emerald-400"; } } else if (isWeb) { buttonText = "GÉRER L'HÉBERGEMENT"; ButtonIcon = Globe; buttonStyle = "bg-emerald-400/10 hover:bg-emerald-400 text-emerald-400 hover:text-gray-900 border-emerald-400"; }
else if (isCloud) { buttonText = "ACCÉDER AU CLOUD"; buttonStyle = "bg-blue-400/10 hover:bg-blue-400 text-blue-400 hover:text-gray-900 border-blue-400"; } else if (isCloud) { buttonText = "ACCÉDER AU CLOUD"; buttonStyle = "bg-blue-400/10 hover:bg-blue-400 text-blue-400 hover:text-gray-900 border-blue-400"; }
@@ -26,12 +62,8 @@ export default function InstanceCard({ service, vpcs, onAssignVpc, onRemoveVpc,
return <Globe className="w-8 h-8 text-emerald-400" />; return <Globe className="w-8 h-8 text-emerald-400" />;
}; };
const baseTitle = (service.title || '').split(/(?: for | pour )/i)[0].trim(); const baseTitle = rawTitle.split(/(?: for | pour )/i)[0].trim();
const shortTitle = baseTitle.split(' ').slice(0, 2).join(' '); const shortTitle = baseTitle.split(' ').slice(0, 2).join(' ');
const titleMatch = (service.title || '').match(/(?: for | pour )(.+)$/i);
const domainFromTitle = titleMatch ? titleMatch[1].trim() : null;
let displayDomain = service.hostingDetails?.domain && service.hostingDetails.domain !== '127.0.0.1' ? service.hostingDetails.domain : domainFromTitle || service.domain;
if (!displayDomain || displayDomain === '127.0.0.1') displayDomain = null;
return ( return (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5 flex flex-col justify-between hover:border-gray-700 transition-all shadow-lg"> <div className="bg-gray-900 border border-gray-800 rounded-xl p-5 flex flex-col justify-between hover:border-gray-700 transition-all shadow-lg">
@@ -40,27 +72,63 @@ export default function InstanceCard({ service, vpcs, onAssignVpc, onRemoveVpc,
<div className="p-2 bg-black/40 rounded-lg">{getServiceIcon()}</div> <div className="p-2 bg-black/40 rounded-lg">{getServiceIcon()}</div>
{service.status === 'active' ? ( {service.status === 'active' ? (
<span className="bg-emerald-500/10 text-emerald-400 px-2 py-1 rounded text-xs border border-emerald-500/20 font-bold tracking-widest"> <span className="bg-emerald-500/10 text-emerald-400 px-2 py-1 rounded text-xs border border-emerald-500/20 font-bold tracking-widest">
{isVPS && !hasIP ? 'AWAITING INIT' : 'ONLINE'} {isVPS && !displayIP ? 'AWAITING INIT' : 'ONLINE'}
</span> </span>
) : ( ) : (
<span className="bg-orange-500/10 text-orange-400 px-2 py-1 rounded text-xs border border-orange-500/20 animate-pulse">DEPLOYING</span> <span className="bg-orange-500/10 text-orange-400 px-2 py-1 rounded text-xs border border-orange-500/20 animate-pulse">DEPLOYING</span>
)} )}
</div> </div>
<h3 className="font-bold text-white text-lg truncate" title={baseTitle}>{shortTitle}</h3> <h3 className="font-bold text-white text-lg truncate" title={baseTitle}>{shortTitle}</h3>
<div className="flex flex-col gap-1 mt-2 mb-4 h-8 justify-center">
{displayDomain ? ( <p className={`${isVPS ? 'text-cyan-400' : 'text-emerald-400'} text-xs font-mono truncate`} title={displayDomain}>{displayDomain}</p> ) : ( <p className="text-gray-600 text-xs font-mono italic">En attente de déploiement</p> )} {/* Zone Domaine & IP */}
<div className="flex flex-col gap-1 mt-2 mb-4 h-12 justify-center">
{isLoadingDetails ? (
<div className="flex items-center text-gray-500 text-xs gap-2">
<Loader className="w-3 h-3 animate-spin" /> Chargement...
</div>
) : (
<>
{displayDomain ? (
<p className={`${isVPS ? 'text-cyan-400' : 'text-emerald-400'} text-xs font-mono truncate`} title={displayDomain}>
{displayDomain}
</p>
) : (
<p className="text-gray-600 text-xs font-mono italic">Aucun domaine</p>
)}
{displayIP && (
<p className="text-gray-500 text-[10px] font-mono mt-0.5" title={`IP: ${displayIP}`}>
IP: {displayIP}
</p>
)}
</>
)}
</div> </div>
</div> </div>
<div className="mt-auto space-y-3"> <div className="mt-auto space-y-3">
<div className="flex items-center justify-between text-sm border-t border-gray-800 pt-3"> <div className="flex items-center justify-between text-sm border-t border-gray-800 pt-3">
<span className="text-gray-500 text-xs">Projet VPC:</span> <span className="text-gray-500 text-xs">Projet VPC:</span>
<select onChange={(e) => { const val = e.target.value; if (val === "free") onRemoveVpc(service.id); else if (val) onAssignVpc(service.id, val); }} className="bg-black border border-gray-700 text-gray-300 rounded px-2 py-1 outline-none focus:border-cyan-400 text-xs w-32.5" defaultValue={vpcs.find(v => v.services.includes(service.id))?.id || "free"}> <select
onChange={(e) => { const val = e.target.value; if (val === "free") onRemoveVpc(service.id); else if (val) onAssignVpc(service.id, val); }}
className="bg-black border border-gray-700 text-gray-300 rounded px-2 py-1 outline-none focus:border-cyan-400 text-xs w-32.5"
defaultValue={vpcs.find(v => v.services.includes(service.id))?.id || "free"}
>
<option value="free">-- Libre --</option> <option value="free">-- Libre --</option>
{vpcs.map(vpc => ( <option key={vpc.id} value={vpc.id}>{vpc.name}</option> ))} {vpcs.map(vpc => ( <option key={vpc.id} value={vpc.id}>{vpc.name}</option> ))}
</select> </select>
</div> </div>
<button onClick={() => onOpenConsole(service)} disabled={isConnecting === service.id || service.status !== 'active'} className={`w-full flex items-center justify-center space-x-2 py-2.5 rounded-lg font-bold text-sm tracking-wide transition-all disabled:opacity-50 border ${buttonStyle}`}>
{isConnecting === service.id ? ( <><Loader className="w-4 h-4 animate-spin" /><span>CONNEXION...</span></> ) : ( <><ButtonIcon className="w-4 h-4" /><span>{buttonText}</span></> )} <button
onClick={() => onOpenConsole(service)}
disabled={isConnecting === service.id || service.status !== 'active'}
className={`w-full flex items-center justify-center space-x-2 py-2.5 rounded-lg font-bold text-sm tracking-wide transition-all disabled:opacity-50 border ${buttonStyle}`}
>
{isConnecting === service.id ? (
<><Loader className="w-4 h-4 animate-spin" /><span>CONNEXION...</span></>
) : (
<><ButtonIcon className="w-4 h-4" /><span>{buttonText}</span></>
)}
</button> </button>
</div> </div>
</div> </div>
+4 -2
View File
@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Plus, Lock } from 'lucide-react'; import { Plus, Lock, X } from 'lucide-react';
export default function CreateTicketModal({ isOpen, onClose, onSubmit, helpdesks, defaultHelpdesk, isSubmitting }) { export default function CreateTicketModal({ isOpen, onClose, onSubmit, helpdesks, defaultHelpdesk, isSubmitting }) {
const [subject, setSubject] = useState(''); const [subject, setSubject] = useState('');
@@ -30,7 +30,9 @@ export default function CreateTicketModal({ isOpen, onClose, onSubmit, helpdesks
<h3 className="text-xl font-bold text-white tracking-wider flex items-center gap-2"> <h3 className="text-xl font-bold text-white tracking-wider flex items-center gap-2">
<Plus className="w-5 h-5 text-cyan-400" /> OUVRIR UNE REQUÊTE <Plus className="w-5 h-5 text-cyan-400" /> OUVRIR UNE REQUÊTE
</h3> </h3>
<button onClick={onClose} className="text-gray-400 hover:text-white transition text-xl"></button> <button onClick={onClose} className="p-2 text-gray-500 hover:text-white bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors">
<X className="w-5 h-5" />
</button>
</div> </div>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
+4 -2
View File
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect, useRef } from 'react';
import { Send, Loader } from 'lucide-react'; import { Send, Loader, X } from 'lucide-react';
export default function TicketThreadModal({ ticket, onClose, onReply, isLoadingConversation }) { export default function TicketThreadModal({ ticket, onClose, onReply, isLoadingConversation }) {
const [replyMessage, setReplyMessage] = useState(''); const [replyMessage, setReplyMessage] = useState('');
@@ -32,7 +32,9 @@ export default function TicketThreadModal({ ticket, onClose, onReply, isLoadingC
</div> </div>
<h3 className="text-xl font-bold text-white line-clamp-1">{ticket.subject}</h3> <h3 className="text-xl font-bold text-white line-clamp-1">{ticket.subject}</h3>
</div> </div>
<button onClick={onClose} className="text-gray-400 hover:text-white transition text-xl bg-black/50 p-2 rounded-lg"></button> <button onClick={onClose} className="p-2 text-gray-500 hover:text-white bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors">
<X className="w-5 h-5" />
</button>
</div> </div>
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-6 scrollbar-thin scrollbar-thumb-gray-800"> <div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-6 scrollbar-thin scrollbar-thumb-gray-800">
+6 -5
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Outlet, NavLink, useNavigate } from 'react-router-dom'; import { Outlet, NavLink, useNavigate } from 'react-router-dom';
import { getClientTickets } from '../services/api'; import { getClientTickets } from '../services/api';
import { LayoutDashboard, Globe, Settings, FileText } from 'lucide-react';
import ConfirmLogoutModal from '../components/ui/ConfirmLogoutModal'; // Le modal extrait ! import ConfirmLogoutModal from '../components/ui/ConfirmLogoutModal'; // Le modal extrait !
export default function AppLayout() { export default function AppLayout() {
@@ -15,7 +16,7 @@ export default function AppLayout() {
const unread = data.list.filter(t => t.status === 'on_hold' || t.unread).length; const unread = data.list.filter(t => t.status === 'on_hold' || t.unread).length;
setUnreadCount(unread); setUnreadCount(unread);
} }
} catch (err) {} } catch (err) { }
}; };
useEffect(() => { useEffect(() => {
@@ -31,10 +32,9 @@ export default function AppLayout() {
// 🌟 La fonction magique Tailwind pour les liens du menu // 🌟 La fonction magique Tailwind pour les liens du menu
const navLinkClass = ({ isActive }) => const navLinkClass = ({ isActive }) =>
`block px-6 py-4 no-underline border-l-4 transition-all uppercase tracking-widest text-sm font-mono ${ `block px-6 py-4 no-underline border-l-4 transition-all uppercase tracking-widest text-sm font-mono ${isActive
isActive ? 'text-cyan-400 bg-cyan-400/5 border-cyan-400'
? 'text-cyan-400 bg-cyan-400/5 border-cyan-400' : 'text-[#888] border-transparent hover:text-gray-300 hover:bg-white/5'
: 'text-[#888] border-transparent hover:text-gray-300 hover:bg-white/5'
}`; }`;
return ( return (
@@ -75,6 +75,7 @@ export default function AppLayout() {
</NavLink> </NavLink>
<NavLink className={navLinkClass} to="/facturation">Facturation</NavLink> <NavLink className={navLinkClass} to="/facturation">Facturation</NavLink>
<div className="flex justify-between items-center px-6 py-4 text-[#444] border-l-4 border-transparent uppercase tracking-widest text-sm font-mono cursor-not-allowed select-none"> <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>Profil & Sécurité</span>
<span className="text-[9px] text-[#333] border border-[#333] px-1.5 py-0.5 rounded font-bold">WIP</span> <span className="text-[9px] text-[#333] border border-[#333] px-1.5 py-0.5 rounded font-bold">WIP</span>
+7 -5
View File
@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { FileText, Download, CreditCard, Clock, CheckCircle2, XCircle, AlertCircle, Loader, X, Receipt, Trash2, ArrowRight, RefreshCcw } from 'lucide-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 NotificationModal from '../../components/ui/NotificationModal'; // Vérifie ton chemin
import { getClient, getInvoiceList } from '../../services/billing_api'; // Vérifie ton chemin import { getClientProfile, getInvoicesHistory } from '../../services/api'; // Vérifie ton chemin
export default function BillingHistory() { export default function BillingHistory() {
const [invoices, setInvoices] = useState([]); const [invoices, setInvoices] = useState([]);
@@ -21,12 +21,12 @@ export default function BillingHistory() {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
try { try {
const profileData = await getClient(); const profileData = await getClientProfile();
if (!profileData || !profileData.id) { if (!profileData || !profileData.id) {
throw new Error("Impossible de vérifier votre identité ou session expirée."); throw new Error("Impossible de vérifier votre identité ou session expirée.");
} }
const invoicesData = await getInvoiceList(profileData.id); const invoicesData = await getInvoicesHistory(profileData.id);
setInvoices(invoicesData.list || []); setInvoices(invoicesData.data || []);
} catch (err) { } catch (err) {
setError(err.message || "Problème de connexion avec le serveur."); setError(err.message || "Problème de connexion avec le serveur.");
} finally { } finally {
@@ -212,7 +212,9 @@ export default function BillingHistory() {
<div className="space-y-4"> <div className="space-y-4">
{(() => { {(() => {
// FILTRE : On ne garde que les items dont le prix est strictement supérieur à 0 // FILTRE : On ne garde que les items dont le prix est strictement supérieur à 0
const validItems = selectedInvoice.lines const validItems = selectedInvoice.items
? selectedInvoice.items.filter(item => parseFloat(item.price) > 0)
: [];
if (validItems.length > 0) { if (validItems.length > 0) {
return validItems.map((item, idx) => ( return validItems.map((item, idx) => (
+5 -4
View File
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams, useSearchParams, useNavigate } from 'react-router-dom'; import { useParams, useSearchParams, useNavigate } from 'react-router-dom';
import { resetCart, addToCart, checkoutCart, getProductList, getClient } from '../../services/billing_api'; import { resetCart, addToCart, checkoutCart, getProductList, getClientProfile } from '../../services/api';
export default function Checkout() { export default function Checkout() {
const { productId } = useParams(); const { productId } = useParams();
@@ -18,7 +18,7 @@ export default function Checkout() {
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadData = async () => {
try { try {
// 1. On charge d'abord le produit // 1. On charge d'abord le produit (Requête Publique)
const productData = await getProductList(); const productData = await getProductList();
const foundProduct = productData.list?.find(p => p.id === parseInt(productId)); const foundProduct = productData.list?.find(p => p.id === parseInt(productId));
@@ -29,14 +29,15 @@ export default function Checkout() {
} }
setProduct(foundProduct); setProduct(foundProduct);
// 2. Ensuite, on tente de charger le profil // 2. Ensuite, on tente de charger le profil (Requête Privée)
try { try {
const profileData = await getClient(); const profileData = await getClientProfile();
setUserProfile(profileData); setUserProfile(profileData);
} catch (profileErr) { } catch (profileErr) {
// Si on tombe ici, c'est que FOSSBilling refuse l'accès au profil. // Si on tombe ici, c'est que FOSSBilling refuse l'accès au profil.
console.error("Rejet API Profil :", profileErr); console.error("Rejet API Profil :", profileErr);
setError("Accès refusé. Vous devez être connecté à votre compte pour provisionner une instance."); setError("Accès refusé. Vous devez être connecté à votre compte pour provisionner une instance.");
// Tu pourras décommenter la ligne suivante plus tard pour forcer la redirection :
// navigate('/login'); // navigate('/login');
setIsLoading(false); setIsLoading(false);
return; return;
+10 -24
View File
@@ -1,13 +1,13 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { getOrdersList } from '../../services/billing_api'; import { getClientOrders } from '../../services/api';
import { AlertCircle, Loader, FileText, X } from 'lucide-react'; import { AlertCircle, Loader, FileText, X } from 'lucide-react';
// Importation des composants // Importation des composants
import DashboardServiceCard from '../../components/dashboard/DashboardServiceCard'; import DashboardServiceCard from '../../components/dashboard/DashboardServiceCard';
import NewServiceCard from '../../components/dashboard/NewServiceCard'; import NewServiceCard from '../../components/dashboard/NewServiceCard';
import NotificationModal from '../../components/ui/NotificationModal'; import NotificationModal from '../../components/ui/NotificationModal';
import WebServiceSubscriptionManager from '../../components/dashboard/WebServiceSubscriptionManager'; import SubscriptionManager from '../../components/dashboard/SubscriptionManager'; // 🌟 Le nouveau composant !
export default function Dashboard() { export default function Dashboard() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -20,12 +20,10 @@ export default function Dashboard() {
const triggerAlert = (title, message, type = "info") => setCustomAlert({ title, message, type }); const triggerAlert = (title, message, type = "info") => setCustomAlert({ title, message, type });
const fetchInventory = async (retryCount = 0) => { const fetchInventory = async () => {
// On n'affiche le loader global que lors du tout premier essai setIsLoading(true);
if (retryCount === 0) setIsLoading(true);
try { try {
const data = await getOrdersList(); const data = await getClientOrders();
if (data.list) { if (data.list) {
// LE FILTRE CHIRURGICAL PAR PREFIXE // LE FILTRE CHIRURGICAL PAR PREFIXE
@@ -39,28 +37,17 @@ export default function Dashboard() {
title.startsWith('domaine ') || title.startsWith('domaine ') ||
title.startsWith('enregistrement '); title.startsWith('enregistrement ');
return (order.status === 'active' || order.status === 'pending_setup') && !isGhostProduct; return !isGhostProduct;
}); });
setOrders(filteredOrders); setOrders(filteredOrders);
} else { } else {
setOrders([]); setOrders([]);
} }
setError(null); // On efface l'erreur s'il y en avait une
setIsLoading(false); // Le chargement est terminé
} catch (err) { } catch (err) {
// 🛡️ SYSTÈME D'AUTO-GUÉRISON : Si on détecte le redémarrage d'HestiaCP (Erreur HTML/JSON) setError(err.message || "Impossible de récupérer la télémétrie des services.");
if (err.message && err.message.includes('Unexpected token') && retryCount < 3) { } finally {
console.warn(`Redémarrage d'infrastructure détecté. Nouvelle tentative... (Essai ${retryCount + 1}/3)`); setIsLoading(false);
// 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);
}
} }
}; };
@@ -70,7 +57,6 @@ export default function Dashboard() {
// 🌟 Plus besoin de charger les IPs, on ouvre juste la modale comptable ! // 🌟 Plus besoin de charger les IPs, on ouvre juste la modale comptable !
const handleManageSubscription = (order) => { const handleManageSubscription = (order) => {
console.log("Gestion de l'abonnement pour la commande :", order);
// 1. Détection du type de service // 1. Détection du type de service
const titleLower = (order.title || '').toLowerCase(); const titleLower = (order.title || '').toLowerCase();
const isVPS = titleLower.includes('vps') || titleLower.includes('compute'); const isVPS = titleLower.includes('vps') || titleLower.includes('compute');
@@ -142,7 +128,7 @@ export default function Dashboard() {
</button> </button>
</div> </div>
<WebServiceSubscriptionManager <SubscriptionManager
order={activeSubscriptionModal} order={activeSubscriptionModal}
onClose={() => setActiveSubscriptionModal(null)} onClose={() => setActiveSubscriptionModal(null)}
onRefresh={fetchInventory} onRefresh={fetchInventory}
+8 -6
View File
@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { getOrdersList, getOrderDetails } from '../../services/billing_api'; import { getMyServices, getHostingServiceDetails } from '../../services/api';
import { useVPC } from '../../services/useVPC'; import { useVPC } from '../../services/useVPC';
import { Server, Globe, Folder, Trash2, Loader, Plus, AlertCircle } from 'lucide-react'; import { Server, Globe, Folder, Trash2, Loader, Plus, AlertCircle, X } from 'lucide-react';
// Importation de tes nouveaux composants modulaires ! // Importation de tes nouveaux composants modulaires !
import NotificationModal from '../../components/ui/NotificationModal'; import NotificationModal from '../../components/ui/NotificationModal';
@@ -25,12 +25,12 @@ export default function Services() {
const fetchServices = useCallback(async () => { const fetchServices = useCallback(async () => {
try { try {
const data = await getOrdersList(); const data = await getMyServices();
if (data.list && data.list.length > 0) { if (data.list && data.list.length > 0) {
const detailedServices = await Promise.all(data.list.map(async (order) => { const detailedServices = await Promise.all(data.list.map(async (order) => {
let hDetails = null; let hDetails = null;
if (order.status === 'active' && (order.plugin === 'hosting' || (order.title || '').toLowerCase().includes('vps'))) { if (order.status === 'active' && (order.plugin === 'hosting' || (order.title || '').toLowerCase().includes('vps'))) {
try { hDetails = await getOrderDetails(order.id); } catch (e) {} try { hDetails = await getHostingServiceDetails(order.id); } catch (e) { }
} }
return { ...order, hostingDetails: hDetails }; return { ...order, hostingDetails: hDetails };
})); }));
@@ -65,7 +65,7 @@ export default function Services() {
} else if (isDB) { } else if (isDB) {
window.open('https://pma.gise.be/', '_blank'); window.open('https://pma.gise.be/', '_blank');
} else if (isVPS || isWeb) { } else if (isVPS || isWeb) {
const freshDetails = await getOrderDetails(service.id); const freshDetails = await getHostingServiceDetails(service.id);
setActiveServiceModal({ type: isVPS ? 'vps' : 'web', data: service, details: freshDetails }); setActiveServiceModal({ type: isVPS ? 'vps' : 'web', data: service, details: freshDetails });
} }
} catch (err) { triggerAlert("Erreur", err.message, "error"); } } catch (err) { triggerAlert("Erreur", err.message, "error"); }
@@ -132,7 +132,9 @@ export default function Services() {
<h3 className="text-xl font-bold text-white tracking-wider flex items-center gap-3"> <h3 className="text-xl font-bold text-white tracking-wider flex items-center gap-3">
{activeServiceModal.type === 'vps' ? <><Server className="w-6 h-6 text-cyan-400" /> GESTION VPS</> : <><Globe className="w-6 h-6 text-emerald-400" /> GESTION WEB</>} {activeServiceModal.type === 'vps' ? <><Server className="w-6 h-6 text-cyan-400" /> GESTION VPS</> : <><Globe className="w-6 h-6 text-emerald-400" /> GESTION WEB</>}
</h3> </h3>
<button onClick={() => setActiveServiceModal(null)} className="text-gray-400 hover:text-white transition text-xl"></button> <button onClick={() => setActiveServiceModal(null)} className="p-2 text-gray-500 hover:text-white bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors">
<X className="w-5 h-5" />
</button>
</div> </div>
{activeServiceModal.type === 'vps' ? ( {activeServiceModal.type === 'vps' ? (
activeServiceModal.details?.ip && activeServiceModal.details.ip !== '127.0.0.1' ? <VpsManager details={activeServiceModal.details} orderId={activeServiceModal.data.id} onRefresh={fetchServices} onAlert={triggerAlert} /> : <VpsDeployer serviceId={activeServiceModal.data.id} onDeploySuccess={() => { setActiveServiceModal(null); fetchServices(); }} onAlert={triggerAlert} /> activeServiceModal.details?.ip && activeServiceModal.details.ip !== '127.0.0.1' ? <VpsManager details={activeServiceModal.details} orderId={activeServiceModal.data.id} onRefresh={fetchServices} onAlert={triggerAlert} /> : <VpsDeployer serviceId={activeServiceModal.data.id} onDeploySuccess={() => { setActiveServiceModal(null); fetchServices(); }} onAlert={triggerAlert} />
+2 -2
View File
@@ -1,8 +1,8 @@
// src/pages/app/Store.jsx // src/pages/app/Store.jsx
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Server, Database, Cloud, Globe, Loader, AlertCircle } from 'lucide-react'; import { Server, Database, Cloud, Globe, Loader, AlertCircle } from 'lucide-react';
import { getProductList } from '../../services/billing_api'; import { getProductList } from '../../services/api';
import CategorySection from '../../components/store/CategorySection'; import CategorySection from '../../components/store/CategorySection'; // L'import magique
export default function Store() { export default function Store() {
const [groupedProducts, setGroupedProducts] = useState({}); const [groupedProducts, setGroupedProducts] = useState({});
+1 -1
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { loginClient } from '../../services/billing_api'; import { loginClient } from '../../services/api';
export default function Login() { export default function Login() {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
+5 -5
View File
@@ -1,6 +1,6 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { createNewClient } from '../../services/billing_api'; import { registerUnifiedClient } from '../../services/api';
import NotificationModal from '../../components/ui/NotificationModal'; import NotificationModal from '../../components/ui/NotificationModal';
export default function Register() { export default function Register() {
@@ -28,15 +28,15 @@ export default function Register() {
setCustomAlert({ type: 'error', title: 'Erreur de saisie', message: "Les clés d'accès ne correspondent pas." }); setCustomAlert({ type: 'error', title: 'Erreur de saisie', message: "Les clés d'accès ne correspondent pas." });
return; return;
} }
if (!/^[a-zA-Z0-9]{3,20}$/.test(username)) { if (!/^[a-zA-Z0-9]{3,12}$/.test(username)) {
setCustomAlert({ type: 'error', title: 'Identifiant invalide', message: "Le nom d'utilisateur doit contenir uniquement des lettres ou chiffres (entre 3 et 12 caractères)." }); setCustomAlert({ type: 'error', title: 'Identifiant invalide', message: "Le nom d'utilisateur doit contenir uniquement des lettres ou chiffres (entre 3 et 12 caractères)." });
return; return;
} }
setLoading(true); setLoading(true);
try { try {
await createNewClient(email, firstName, lastName, password, confirmPassword, "individual", username); await registerUnifiedClient(email, username, password, firstName, lastName);
setCustomAlert({ type: 'success', title: 'PROVISIONNEMENT RÉUSSI', message: "Votre compte a été initialisé.\n\nVous pouvez maintenant vous connecter." }); setCustomAlert({ type: 'success', title: 'PROVISIONNEMENT RÉUSSI', message: "Vos comptes FOSSBilling, HestiaCP et Nextcloud ont été initialisés.\n\nVous pouvez maintenant vous connecter." });
} catch (err) { } catch (err) {
setCustomAlert({ type: 'error', title: 'Échec du Déploiement', message: err.message || "Échec de l'initialisation." }); setCustomAlert({ type: 'error', title: 'Échec du Déploiement', message: err.message || "Échec de l'initialisation." });
} finally { } finally {
@@ -56,7 +56,7 @@ export default function Register() {
Créer un accès réseau Créer un accès réseau
</h2> </h2>
<p className="text-[#888] font-mono text-sm mb-6"> <p className="text-[#888] font-mono text-sm mb-6">
[ INITIALISATION DU PROVISIONNEMENT ] [ INITIALISATION DU PROVISIONNEMENT TRIPLE ]
</p> </p>
<form onSubmit={handleRegister} className="font-mono"> <form onSubmit={handleRegister} className="font-mono">
+83 -185
View File
@@ -1,7 +1,7 @@
const BASE_URL = import.meta.env.VITE_API_BASE_URL || ''; const BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
const CUSTOM_API_BASE_URL = import.meta.env.VITE_CUSTOM_API_BASE_URL || ''; const CUSTOM_API_BASE_URL = import.meta.env.VITE_CUSTOM_API_BASE_URL || '';
// Le moteur de requête // Le moteur de requête unifié et intelligent
const apiCall = async (endpoint, param2 = 'GET', param3 = null) => { const apiCall = async (endpoint, param2 = 'GET', param3 = null) => {
let method = 'GET'; let method = 'GET';
let body = null; let body = null;
@@ -64,6 +64,24 @@ const apiCall = async (endpoint, param2 = 'GET', param3 = null) => {
} }
}; };
// ==========================================
// ROUTES FOSSBILLING NATIVES (BACKTICKS INTÉGRÉS)
// ==========================================
export const loginClient = (email, password) =>
apiCall(`${BASE_URL}/api/guest/client/login`, { email, password });
// Récupère la liste des services/commandes du client
export const getClientOrders = () =>
apiCall(`${BASE_URL}/api/client/order/get_list`);
// Récupère les détails techniques du service rattaché à une commande
export const getOrderService = (order_id) =>
apiCall(`${BASE_URL}/api/client/order/service`, { id: order_id });
// Récupère le catalogue public des produits FOSSBilling
export const getProductList = () =>
apiCall(`${BASE_URL}/api/guest/product/get_list`);
// Vide le panier (Action PUBLIQUE : on passe par l'API Guest) // Vide le panier (Action PUBLIQUE : on passe par l'API Guest)
export const resetCart = async () => { export const resetCart = async () => {
@@ -82,6 +100,22 @@ export const resetCart = async () => {
} }
}; };
// Ajoute un produit au panier avec ses options étalées à la racine
export const addToCart = (productId, period, additionalData = {}) =>
apiCall(`${BASE_URL}/api/guest/cart/add_item`, 'POST', {
id: productId,
period: period,
...additionalData // Les 3 petits points "étalent" le contenu de l'objet
});
// Valide le panier (Action PRIVÉE : on reste sur l'API Client pour générer la facture)
export const checkoutCart = () =>
apiCall(`${BASE_URL}/api/client/cart/checkout`, 'POST');
// Récupère les informations du client connecté
export const getClientProfile = () =>
apiCall(`${BASE_URL}/api/client/profile/get`, 'GET');
// ========================================== // ==========================================
// ROUTES PERSONNALISÉES (CUSTOM API) // ROUTES PERSONNALISÉES (CUSTOM API)
// ========================================== // ==========================================
@@ -110,6 +144,18 @@ export const registerUnifiedClient = async (email, username, password, firstName
} }
} }
// Récupère la liste de toutes les commandes actives du client
export const getMyServices = () =>
apiCall(`${BASE_URL}/api/client/order/get_list`, 'GET');
// Récupère les détails secrets d'un service (dont le mot de passe HestiaCP/VPS)
export const getServiceDetails = (orderId) =>
apiCall(`${BASE_URL}/api/client/order/get`, 'POST', { id: orderId });
// Récupère les secrets spécifiques du service physique attaché à une commande
export const getHostingServiceDetails = (orderId) =>
apiCall(`${BASE_URL}/api/client/order/service`, 'POST', { id: orderId });
// Force la réinitialisation du mot de passe sur le serveur distant (HestiaCP) // Force la réinitialisation du mot de passe sur le serveur distant (HestiaCP)
export const resetHostingPassword = (orderId, newPassword) => export const resetHostingPassword = (orderId, newPassword) =>
apiCall(`${BASE_URL}/api/client/servicehosting/change_password`, 'POST', { apiCall(`${BASE_URL}/api/client/servicehosting/change_password`, 'POST', {
@@ -145,204 +191,56 @@ export const launchSSOGateway = (username, password) => {
document.body.removeChild(form); document.body.removeChild(form);
}; };
// #######################################################################################
// ========================================== // ==========================================
// GESTION DES ACCES // ROUTES SUPPORT FOSSBILLING (NATIVES)
// ========================================== // ==========================================
// Connexion // Récupère la liste de tous les tickets du client
export const loginClient = (email, password) => export const getClientTickets = () =>
apiCall(`${BASE_URL}/api/guest/client/login`, { email, password }); apiCall(`${BASE_URL}/api/client/support/ticket_get_list`, 'GET');
// Deconnexion // Récupère le contenu et les messages d'un ticket spécifique
export const logoutClient = () => export const getTicketDetails = (ticketId) =>
apiCall(`${BASE_URL}/api/client/profile/logout`); apiCall(`${BASE_URL}/api/client/support/ticket_get`, 'POST', { id: ticketId });
// Crée un nouveau ticket de support
// ==========================================
// GESTION DU CLIENT
// ==========================================
// Creer un nouveau profil client
export const createClientProfile = (email, last_name, aid, gender, country, city, birthday, company, company_vat, company_number, type, address_1, address_2, postcode, state, phone, phone_cc, document_type, document_nr, notes, lang, custom_1, custom_2, custom_3, custom_4, custom_5, custom_6, custom_7, custom_8, custom_9, custom_10) =>
apiCall(`${BASE_URL}/api/guest/profile/create`, {
email: email,
last_name: last_name,
aid: aid,
gender: gender,
country: country,
city: city,
birthday: birthday,
company: company,
company_vat: company_vat,
company_number: company_number,
type: type,
address_1: address_1,
address_2: address_2,
postcode: postcode,
state: state,
phone: phone,
phone_cc: phone_cc,
document_type: document_type,
document_nr: document_nr,
notes: notes,
lang: lang,
custom_1: custom_1,
custom_2: custom_2,
custom_3: custom_3,
custom_4: custom_4,
custom_5: custom_5,
custom_6: custom_6,
custom_7: custom_7,
custom_8: custom_8,
custom_9: custom_9,
custom_10: custom_10
});
// Recuperer les informations du client
export const getClientProfile = () =>
apiCall(`${BASE_URL}/api/client/profile/get`, 'GET');
// Update les informations du client
export const updateClientProfile = (email, last_name, aid, gender, country, city, birthday, company, company_vat, company_number, type, address_1, address_2, postcode, state, phone, phone_cc, document_type, document_nr, notes, lang, custom_1, custom_2, custom_3, custom_4, custom_5, custom_6, custom_7, custom_8, custom_9, custom_10) =>
apiCall(`${BASE_URL}/api/client/profile/update`, {
email: email,
last_name: last_name,
aid: aid,
gender: gender,
country: country,
city: city,
birthday: birthday,
company: company,
company_vat: company_vat,
company_number: company_number,
type: type,
address_1: address_1,
address_2: address_2,
postcode: postcode,
state: state,
phone: phone,
phone_cc: phone_cc,
document_type: document_type,
document_nr: document_nr,
notes: notes,
lang: lang,
custom_1: custom_1,
custom_2: custom_2,
custom_3: custom_3,
custom_4: custom_4,
custom_5: custom_5,
custom_6: custom_6,
custom_7: custom_7,
custom_8: custom_8,
custom_9: custom_9,
custom_10: custom_10
});
// Changer le mot de passe
export const changeClientPassword = (current_password, new_password, confirm_password) =>
apiCall(`${BASE_URL}/api/client/profile/change_password`, {current_password:current_password, new_password:new_password, confirm_password:confirm_password});
// ==========================================
// GESTION DES ABONNEMENTS
// ==========================================
// Souscription
// Resiliation
// Suppression
export const deleteWrongOrder = (order_id) =>
apiCall(`${BASE_URL}/api/client/order/delete`, { id: order_id });
// Promotion (Surclassement)
// Lister
export const getUpgrades = (order_id) =>
apiCall(`${BASE_URL}/api/client/order/upgradables`, { id: order_id });
// Demotion (Declassement)
// Lister les abonnements du client
export const getClientOrders = () =>
apiCall(`${BASE_URL}/api/client/order/get_list`);
// Lister les produits disponibles
export const getProductList = () =>
apiCall(`${BASE_URL}/api/guest/product/get_list`);
// Recuperer les details d'un abonnement
export const getOrderDetails = (order_id) =>
apiCall(`${BASE_URL}/api/client/order/service`, { id: order_id });
// Recuperer les details secrets d'un abonnement
export const getServiceDetails = (orderId) =>
apiCall(`${BASE_URL}/api/client/order/get`, 'POST', { id: orderId });
// ==========================================
// GESTION DU PANIER
// ==========================================
// Ajouter au panier
export const addToCart = (productId, period, additionalData = {}) =>
apiCall(`${BASE_URL}/api/guest/cart/add_item`, 'POST', {
id: productId,
period: period,
...additionalData // Les 3 petits points "étalent" le contenu de l'objet
});
// Checkout
export const checkoutCart = () =>
apiCall(`${BASE_URL}/api/client/cart/checkout`, 'POST');
// ==========================================
// GESTION DES FACTURES
// ==========================================
// Creation
// Suppression
// Modification
// Lister
export const getInvoiceList = () =>
apiCall(`${BASE_URL}/api/client/invoice/get_list`, 'GET');
// Lire
export const getInvoiceDetails = (InvoiceHash) =>
apiCall(`${BASE_URL}/api/client/invoice/get`, 'GET');
// ==========================================
// GESTION DES TICKETS
// ==========================================
// Creation
export const createTicket = (subject, message, helpdesk_id) => export const createTicket = (subject, message, helpdesk_id) =>
apiCall(`${BASE_URL}/api/client/support/ticket_create`, 'POST', { apiCall(`${BASE_URL}/api/client/support/ticket_create`, 'POST', {
support_helpdesk_id: helpdesk_id, support_helpdesk_id: helpdesk_id,
subject: subject, subject: subject,
content: message content: message
}); });
// Suppression
// Lister les tickets // Répond à un ticket existant
export const getClientTickets = () =>
apiCall(`${BASE_URL}/api/client/support/ticket_get_list`, 'GET');
// Lister les helpdesks
export const getHelpdesks = () =>
apiCall(`${BASE_URL}/api/client/support/helpdesk_get_pairs`, 'GET');
// Lire
export const getTicketDetails = (ticketId) =>
apiCall(`${BASE_URL}/api/client/support/ticket_get`, 'POST', { id: ticketId });
// Repondre
export const replyTicket = (ticketId, message) => export const replyTicket = (ticketId, message) =>
apiCall(`${BASE_URL}/api/client/support/ticket_reply`, 'POST', { apiCall(`${BASE_URL}/api/client/support/ticket_reply`, 'POST', {
id: ticketId, id: ticketId,
content: message content: message
}); });
// Récupère la liste dynamique des départements (Helpdesks) configurés sur FOSSBilling
export const getHelpdesks = () =>
apiCall(`${BASE_URL}/api/client/support/helpdesk_get_pairs`, 'GET');
// ==========================================
// ROUTES FACTURATION & REÇUS
// ==========================================
// Récupère l'historique des factures via notre script Custom PHP
export const getInvoicesHistory = (clientId) =>
apiCall(`${CUSTOM_API_BASE_URL}/custom_api/nexus_subscription.php`, 'POST', {
action: 'get_invoices',
client_id: clientId
});
// (Optionnel) FOSSBilling Native : Récupère les détails complets d'une facture
export const getInvoiceDetails = (invoiceHash) =>
apiCall(`${BASE_URL}/api/client/invoice/get`, 'POST', { hash: invoiceHash });
// Annule un abonnement (Gère automatiquement la règle des 14 jours côté serveur)
export const cancelSubscription = (clientId, orderId) =>
apiCall(`${CUSTOM_API_BASE_URL}/custom_api/nexus_subscription.php`, 'POST', {
action: 'cancel_order',
client_id: clientId,
order_id: orderId
});
-346
View File
@@ -1,346 +0,0 @@
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '';
const CUSTOM_API_BASE_URL = import.meta.env.VITE_CUSTOM_API_BASE_URL || '';
// Le moteur de requête
const apiCall = async (endpoint, param2 = 'GET', param3 = null) => {
let method = 'GET';
let body = null;
// DÉTECTION DE SIGNATURE (Le bouclier anti-crash)
if (typeof param2 === 'string') {
// Cas 1 : On a bien envoyé (URL, "POST", {données})
method = param2.toUpperCase();
body = param3;
} else if (typeof param2 === 'object' && param2 !== null) {
// Cas 2 : L'ancienne méthode a envoyé (URL, {données})
// On redirige l'objet vers le body, et on force en POST
body = param2;
method = (typeof param3 === 'string') ? param3.toUpperCase() : 'POST';
}
// 1. Récupération du sésame
const token = localStorage.getItem('token');
// 2. Préparation de l'enveloppe
const headers = {
'Content-Type': 'application/json',
'Accept': 'application/json'
};
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
// 3. Configuration finale garantie sans objets égarés
const options = {
method: method,
headers: headers,
credentials: 'include'
};
if (body) {
options.body = JSON.stringify(body);
}
try {
const response = await fetch(endpoint, options);
if (response.status === 401 || response.status === 403) {
throw new Error("Accès refusé. Session expirée ou non valide.");
}
const data = await response.json();
// Gestion des erreurs internes de l'API
if (data.error) {
throw new Error(data.error.message || "Erreur renvoyée par le serveur de facturation.");
}
return data.result || data;
} catch (error) {
console.error(`[API FAIL] ${method} ${endpoint} :`, error);
throw error;
}
};
// Vide le panier (Action PUBLIQUE : on passe par l'API Guest)
export const resetCart = async () => {
try {
const cart = await apiCall(`${BASE_URL}/api/guest/cart/get`);
if (cart && cart.items && cart.items.length > 0) {
for (const item of cart.items) {
await apiCall(`${BASE_URL}/api/guest/cart/remove_item`, { id: item.id });
}
}
} catch (err) {
console.warn("Nettoyage du panier ignoré :", err);
}
};
// ==========================================
// ROUTES PERSONNALISÉES (CUSTOM API)
// ==========================================
export const registerUnifiedClient = async (email, username, password, firstName, lastName) => {
try {
// Utilisation des backticks ici aussi !
const response = await fetch(`${CUSTOM_API_BASE_URL}/custom_api/signup.php`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: email,
username: username,
password: password,
first_name: firstName,
last_name: lastName
})
});
const data = await response.json();
if (data.error) throw new Error(data.error.message);
return data.result;
} catch (err) {
console.error("Erreur lors de l'inscription unifiée :", err);
throw new Error("Échec de l'inscription. Veuillez réessayer plus tard.");
}
}
// #######################################################################################
// ==========================================
// GESTION DES ACCES
// ==========================================
// Connexion
export const loginClient = (email, password) =>
apiCall(`${BASE_URL}/api/guest/client/login`, { email, password });
// Deconnexion
export const logoutClient = () =>
apiCall(`${BASE_URL}/api/client/profile/logout`);
// ==========================================
// GESTION DU CLIENT
// ==========================================
// Creer un nouveau profil client
export const createNewClient = (
email,
first_name,
last_name,
password,
password_confirm,
type,
custom_1) =>
apiCall(`${BASE_URL}/api/guest/client/create`, {
email: email,
first_name: first_name,
password: password,
password_confirm: password_confirm,
last_name: last_name,
type: type,
custom_1: custom_1
});
// Recuperer les informations du client
export const getClient = () =>
apiCall(`${BASE_URL}/api/client/profile/get`);
// Update les informations du client
export const updateClient = (email, last_name, aid, gender, country, city, birthday, company, company_vat, company_number, type, address_1, address_2, postcode, state, phone, phone_cc, document_type, document_nr, notes, lang, custom_1, custom_2, custom_3, custom_4, custom_5, custom_6, custom_7, custom_8, custom_9, custom_10) =>
apiCall(`${BASE_URL}/api/client/profile/update`, {
email: email,
last_name: last_name,
aid: aid,
gender: gender,
country: country,
city: city,
birthday: birthday,
company: company,
company_vat: company_vat,
company_number: company_number,
type: type,
address_1: address_1,
address_2: address_2,
postcode: postcode,
state: state,
phone: phone,
phone_cc: phone_cc,
document_type: document_type,
document_nr: document_nr,
notes: notes,
lang: lang,
custom_1: custom_1,
custom_2: custom_2,
custom_3: custom_3,
custom_4: custom_4,
custom_5: custom_5,
custom_6: custom_6,
custom_7: custom_7,
custom_8: custom_8,
custom_9: custom_9,
custom_10: custom_10
});
// Changer le mot de passe
export const changeClientPassword = (current_password, new_password) =>
apiCall(`${BASE_URL}/api/client/profile/change_password`, {
current_password: current_password,
new_password: new_password,
confirm_password: new_password
});
// ==========================================
// GESTION DES ABONNEMENTS
// ==========================================
// Souscription
// Resiliation
// Action : Demande d'annulation (S'occupe de vérifier les 14 jours côté serveur)
export const cancelOrder = (orderId) => {
return apiCall(`${CUSTOM_API_BASE_URL}/custom_api/cancel_subscription.php`, 'POST', {
order_id: orderId
});
};
// Suppression
export const deleteOrder = (order_id) =>
apiCall(`${BASE_URL}/api/client/order/delete`, { id: order_id });
// Promotion (Surclassement)
// Lister
export const getUpgrades = (order_id) =>
apiCall(`${BASE_URL}/api/client/order/upgradables`, { id: order_id });
// Demotion (Declassement)
// Lister les abonnements du client
export const getOrdersList = () =>
apiCall(`${BASE_URL}/api/client/order/get_list`);
// Lister les produits disponibles
export const getProductList = () =>
apiCall(`${BASE_URL}/api/guest/product/get_list`);
// Recuperer les details d'un abonnement
export const getOrderDetails = (order_id) =>
apiCall(`${BASE_URL}/api/client/order/service`, { id: order_id });
// Recuperer les details secrets d'un abonnement
export const getServiceDetails = (orderId) =>
apiCall(`${BASE_URL}/api/client/order/get`, { id: orderId });
// ==========================================
// GESTION DU PANIER
// ==========================================
// Ajouter au panier
export const addToCart = (productId, period, additionalData = {}) =>
apiCall(`${BASE_URL}/api/guest/cart/add_item`, {
id: productId,
period: period,
...additionalData // Les 3 petits points "étalent" le contenu de l'objet
});
// Checkout
export const checkoutCart = () =>
apiCall(`${BASE_URL}/api/client/cart/checkout`, 'POST');
// ==========================================
// GESTION DES SERVICES D'HEBERGEMENT
// ==========================================
// Modification du username
export const updateHostingUsername = (order_id, username) =>
apiCall(`${BASE_URL}/api/client/servicehosting/change_username`, {
order_id: order_id,
username: username
});
// Modification du mot de passe
export const updateHostingPassword = (order_id, new_password) =>
apiCall(`${BASE_URL}/api/client/servicehosting/change_password`, {
order_id: order_id,
password: new_password,
password_confirm: new_password
});
// Modification du domaine principal
export const updateHostingDomain = (order_id, new_domain) =>
apiCall(`${BASE_URL}/api/client/servicehosting/change_domain`, {
order_id: order_id,
domain: new_domain
});
// Login URL (SSO)
export const getHostingLoginUrl = (order_id) =>
apiCall(`${BASE_URL}/api/client/servicehosting/get_login_url`, {
order_id: order_id
});
// Modification du plan d'hébergement
export const updateHostingPlan = (order_id, new_plan_id) =>
apiCall(`${BASE_URL}/api/admin/servicehosting/change_plan`, {
order_id: order_id,
plan_id: new_plan_id
});
// ==========================================
// GESTION DES FACTURES
// ==========================================
// Creation
// Suppression
// Modification
// Lister
export const getInvoiceList = (client_id) =>
apiCall(`${BASE_URL}/api/client/invoice/get_list`, { client_id: client_id });
// Lire
export const getInvoiceDetails = (invoiceHash) =>
apiCall(`${BASE_URL}/api/client/invoice/get`, { hash: invoiceHash });
// ==========================================
// GESTION DES TICKETS
// ==========================================
// Creation
export const createTicket = (subject, message, helpdesk_id) =>
apiCall(`${BASE_URL}/api/client/support/ticket_create`, {
support_helpdesk_id: helpdesk_id,
subject: subject,
content: message
});
// Suppression
// Lister les tickets
export const getClientTickets = () =>
apiCall(`${BASE_URL}/api/client/support/ticket_get_list`);
// Lister les helpdesks
export const getHelpdesks = () =>
apiCall(`${BASE_URL}/api/client/support/helpdesk_get_pairs`);
// Lire
export const getTicketDetails = (ticketId) =>
apiCall(`${BASE_URL}/api/client/support/ticket_get`, { id: ticketId });
// Repondre
export const replyTicket = (ticketId, message) =>
apiCall(`${BASE_URL}/api/client/support/ticket_reply`, {
id: ticketId,
content: message
});