10 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
LathanDevers c29047e6fa 1Y=annuel etc
Deploy Nexus Portal to HestiaCP (FTP) / build-and-deploy (push) Successful in 13s
2026-06-25 14:08:47 +02:00
maximus 1872a3c2fa Merge pull request 'Refactor/ui components' (#1) from refactor/ui-components into master
Deploy Nexus Portal to HestiaCP (FTP) / build-and-deploy (push) Successful in 13s
Reviewed-on: maximus/portail-gise#1
2026-06-25 11:09:11 +02:00
LathanDevers 2aac1b0775 some corrections 2026-06-25 11:04:46 +02:00
LathanDevers d83b7ad4e7 upgrade design 2026-06-25 10:50:49 +02:00
LathanDevers 323667e835 separate components 2026-06-25 09:16:47 +02:00
35 changed files with 2266 additions and 2034 deletions
+25
View File
@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*api-keys*
+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
@@ -4,7 +4,7 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>GISE Nexus | Portail d'Infrastructure Centralisée</title> <title>NEXUS by gise | Portail d'Infrastructure Centralisée</title>
<meta name="description" content="Accédez au portail GISE Nexus. Pilotez votre hébergement web, vos instances serveurs et votre stockage Cloud depuis une console unifiée."> <meta name="description" content="Accédez au portail GISE Nexus. Pilotez votre hébergement web, vos instances serveurs et votre stockage Cloud depuis une console unifiée.">
</head> </head>
<body> <body>
+2
View File
@@ -20,6 +20,7 @@ import Store from './pages/app/Store';
import Checkout from './pages/app/Checkout'; import Checkout from './pages/app/Checkout';
import Services from './pages/app/Services'; import Services from './pages/app/Services';
import Support from './pages/app/Support'; import Support from './pages/app/Support';
import BillingHistory from './pages/app/BillingHistory';
export default function App() { export default function App() {
return ( return (
@@ -49,6 +50,7 @@ export default function App() {
<Route path="/checkout/:productId" element={<Checkout />} /> <Route path="/checkout/:productId" element={<Checkout />} />
<Route path="/services" element={<Services />} /> <Route path="/services" element={<Services />} />
<Route path="/support" element={<Support />} /> <Route path="/support" element={<Support />} />
<Route path="/facturation" element={<BillingHistory />} />
</Route> </Route>
</Route> </Route>
@@ -0,0 +1,64 @@
import { Server, Database, Cloud, Globe } from 'lucide-react';
export default function DashboardServiceCard({ order, onClick }) {
// Fonction Radar : Détecte le type de service selon son nom pour afficher la bonne icône
const getServiceIcon = (title) => {
const t = title.toLowerCase();
if (t.includes('vps') || t.includes('serveur')) return <Server className="w-10 h-10 text-cyan-400" />;
if (t.includes('cloud') || t.includes('nextcloud')) return <Cloud className="w-10 h-10 text-blue-400" />;
if (t.includes('db') || t.includes('base') || t.includes('sql')) return <Database className="w-10 h-10 text-purple-400" />;
return <Globe className="w-10 h-10 text-emerald-400" />; // Par défaut : Web / Hestia
};
// Fonction d'état : Formate le statut du service
const getStatusBadge = (status) => {
switch (status) {
case 'active': return <span className="px-2 py-1 text-xs text-green-400 bg-green-400/10 border border-green-400/20 rounded">ACTIF</span>;
case 'pending_setup': return <span className="px-2 py-1 text-xs text-orange-400 bg-orange-400/10 border border-orange-400/20 rounded">EN PRÉPARATION</span>;
case 'suspended': return <span className="px-2 py-1 text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded">SUSPENDU</span>;
default: return <span className="px-2 py-1 text-xs text-gray-400 bg-gray-400/10 border border-gray-400/20 rounded">{status.toUpperCase()}</span>;
}
};
// Fonction utilitaire pour traduire les cycles de FOSSBilling
const formatPeriod = (periodCode) => {
const periods = {
'1W': 'Hebdomadaire',
'1M': 'Mensuel',
'3M': 'Trimestriel',
'6M': 'Semestriel',
'1Y': 'Annuel',
'2Y': 'Tous les 2 ans',
'3Y': 'Tous les 3 ans'
};
// Si le code est inconnu, on affiche le code brut par sécurité, sinon on traduit
return periods[periodCode] || periodCode;
};
return (
<div
className="bg-gray-900 border border-gray-800 p-6 rounded-xl hover:border-cyan-400/50 transition-colors group cursor-pointer flex flex-col justify-between shadow-lg"
onClick={onClick}
>
<div>
<div className="flex justify-between items-start mb-4">
<div className="p-3 bg-black/50 rounded-lg group-hover:scale-110 transition-transform">
{getServiceIcon(order.title)}
</div>
{getStatusBadge(order.status)}
</div>
<h3 className="text-lg font-semibold text-white truncate" title={order.title}>
{order.title}
</h3>
<p className="text-sm text-gray-500 mt-1">
Facturation : <span className="text-cyan-400 font-medium">{formatPeriod(order.period)}</span>
</p>
</div>
<div className="mt-6 pt-4 border-t border-gray-800 flex justify-between items-center text-sm">
<span className="text-gray-400">ID Réseau: #{order.id}</span>
<span className="text-cyan-400 group-hover:underline">Gérer &gt;</span>
</div>
</div>
);
}
@@ -0,0 +1,20 @@
import { Plus } from 'lucide-react';
export default function NewServiceCard({ onClick }) {
return (
<div
onClick={onClick}
className="bg-transparent border-2 border-dashed border-gray-700 hover:border-cyan-400 p-6 rounded-xl transition-colors cursor-pointer flex flex-col items-center justify-center text-center group min-h-50"
>
<div className="p-3 bg-gray-800/50 rounded-full group-hover:bg-cyan-400/20 transition-colors mb-4">
<Plus className="w-8 h-8 text-gray-400 group-hover:text-cyan-400" />
</div>
<h3 className="text-lg font-semibold text-white group-hover:text-cyan-400">
Demander une accréditation
</h3>
<p className="text-sm text-gray-500 mt-2">
Déployer un nouveau serveur Web, VPS, DB ou Cloud.
</p>
</div>
);
}
@@ -0,0 +1,498 @@
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 <span className="text-gray-500 italic">Aucune spécification disponible.</span>;
return text.split('\n').map((line, index) => {
const cleanLine = line.trim();
if (cleanLine.startsWith('* ') || cleanLine.startsWith('- ')) {
return (
<div key={index} className="flex items-center gap-3 text-sm font-semibold text-white my-2">
<div className="shrink-0 w-5 h-5 rounded-full border border-cyan-500/40 flex items-center justify-center bg-cyan-950/10">
<Check className="w-3 h-3 text-cyan-400 stroke-3" />
</div>
<span>{parseBold(cleanLine.substring(2))}</span>
</div>
);
}
return <p key={index} className="text-gray-400 text-xs my-1">{parseBold(cleanLine)}</p>;
});
};
const parseBold = (text) => text.split('**').map((part, i) => i % 2 === 1 ? <strong key={i} className="text-white font-bold">{part}</strong> : part);
const getProductCardMetrics = (pricing, period) => {
if (!pricing || !pricing.recurrent) {
return { displayPrice: "0.00", billingPrice: "0.00", oldDisplayPrice: "0.00", saving: 0, isAvailable: false };
}
const recurrent = pricing.recurrent;
const priceW = recurrent['1W']?.price ? parseFloat(recurrent['1W'].price) : 0;
const priceM = recurrent['1M']?.price ? parseFloat(recurrent['1M'].price) : 0;
const priceY = recurrent['1Y']?.price ? parseFloat(recurrent['1Y'].price) : 0;
let billingPrice = 0, oldBillingPrice = 0, savingPercent = 0, isAvailable = true;
let displayPrice = 0, oldDisplayPrice = 0;
if (period === '1W') {
billingPrice = priceW; oldBillingPrice = priceW;
displayPrice = priceW * 4.333; oldDisplayPrice = displayPrice;
if (!priceW) isAvailable = false;
} else if (period === '1M') {
billingPrice = priceM; oldBillingPrice = priceW > 0 ? priceW * 4.333 : priceM;
displayPrice = priceM; oldDisplayPrice = oldBillingPrice;
if (!priceM) isAvailable = false;
} else if (period === '1Y') {
billingPrice = priceY; oldBillingPrice = priceW > 0 ? priceW * 52 : (priceM > 0 ? priceM * 12 : priceY);
displayPrice = priceY / 12; oldDisplayPrice = oldBillingPrice / 12;
if (!priceY) isAvailable = false;
}
savingPercent = (oldBillingPrice > billingPrice && billingPrice > 0) ? Math.round(((oldBillingPrice - billingPrice) / oldBillingPrice) * 100) : 0;
return { displayPrice: displayPrice.toFixed(2), billingPrice: billingPrice.toFixed(2), oldDisplayPrice: oldDisplayPrice.toFixed(2), saving: savingPercent, isAvailable };
};
const getPlanBadge = (index) => {
if (index === 0) return { label: "ESSENTIEL", color: "text-gray-400 bg-gray-900 border-gray-700" };
if (index === 1) return { label: "PLUS POPULAIRE", color: "text-cyan-300 bg-cyan-950 border-cyan-800 shadow-[0_0_10px_rgba(6,182,212,0.3)]" };
return { label: "PRO", color: "text-amber-400 bg-amber-950 border-amber-800 shadow-[0_0_10px_rgba(245,158,11,0.2)]" };
};
useEffect(() => {
const fetchCatalog = async () => {
setIsCatalogLoading(true);
try {
const data = await getProductList();
const rawProducts = data.list || data.catalog || (Array.isArray(data) ? data : []);
const webProducts = rawProducts.filter(p => p.product_category_id === 1);
if (webProducts.length > 0) {
setCatalog(webProducts);
const current = webProducts.find(p => currentPlanName.toLowerCase() === p.title.toLowerCase());
if (current) setSelectedPlan(current);
} else {
console.warn("Aucun produit de type 'Web Service' n'a été trouvé.");
setCatalog([]);
}
} catch (err) {
console.error("Erreur API Catalogue:", err);
} finally {
setIsCatalogLoading(false);
}
};
fetchCatalog();
}, [currentPlanName]);
const isNoChange = selectedPlan && (currentPlanId === selectedPlan.id) && (currentBillingPeriod === billingPeriod);
// ==========================================
// 🧠 LOGIQUE UPGRADE / DOWNGRADE & PRORATA
// ==========================================
let migrationLabel = "Valider la modification";
let buttonColor = "bg-cyan-500 hover:bg-cyan-400 shadow-[0_0_20px_rgba(6,182,212,0.2)]";
let ActionIcon = null;
let finalInvoicePrice = 0;
let isProrataApplied = false;
let isRefund = false;
if (selectedPlan && !isNoChange) {
const currentPrice = parseFloat(order.price) || 0;
let currentMonthly = currentPrice;
if (currentBillingPeriod === '1W') currentMonthly = currentPrice * 4.333;
if (currentBillingPeriod === '1M') currentMonthly = currentPrice;
if (currentBillingPeriod === '1Y') currentMonthly = currentPrice / 12;
const selectedMetrics = getProductCardMetrics(selectedPlan.pricing, billingPeriod);
const selectedMonthly = parseFloat(selectedMetrics.displayPrice);
const p1 = parseFloat(order.price) || 0;
const p2 = parseFloat(selectedMetrics.billingPrice) || 0;
let m = 30;
if (currentBillingPeriod === '1Y') m = 365;
if (currentBillingPeriod === '1M') m = 30;
if (currentBillingPeriod === '1W') m = 7;
const expiresAt = new Date(order.expires_at);
const now = new Date();
let remainingDays = 0;
if (!isNaN(expiresAt)) {
remainingDays = Math.max(0, Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24)));
}
if (remainingDays > 0 && remainingDays <= m) {
const n = m - remainingDays; // Jours écoulés
finalInvoicePrice = p2 - p1 - (n * (p1 - p2) / m);
isProrataApplied = true;
} else {
finalInvoicePrice = p2;
}
isRefund = finalInvoicePrice < -0.01;
// --- 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 = <RefreshCcw className="w-4 h-4" />;
} else if (!isSamePlan) {
// Le pack change, on compare la hiérarchie absolue
if (parseFloat(baseSelectedPrice) > parseFloat(baseCurrentPrice)) {
migrationLabel = `Valider l'Upgrade`;
buttonColor = "bg-emerald-500 hover:bg-emerald-400 text-black shadow-[0_0_20px_rgba(16,185,129,0.2)]";
ActionIcon = <TrendingUp className="w-4 h-4" />;
} else {
migrationLabel = "Valider le Downgrade";
buttonColor = "bg-amber-500 hover:bg-amber-400 text-black shadow-[0_0_20px_rgba(245,158,11,0.2)]";
ActionIcon = <TrendingDown className="w-4 h-4" />;
}
}
}
// ==========================================
const handleMigration = async () => {
setLoading(true);
// 🎯 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.")) {
try {
const response = await cancelSubscription(user.id, order.id);
// 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);
}
}
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 (
<div className="flex flex-col w-full max-h-[95vh] md:h-auto md:max-h-[90vh] max-w-6xl mx-auto text-white">
<div className="shrink-0 space-y-4 mb-4 pr-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="bg-[#090f1c] border border-gray-800 p-3.5 rounded-xl flex items-center gap-3">
<div className="p-2 bg-gray-950 rounded-lg text-gray-400"><CreditCard className="w-4 h-4" /></div>
<div>
<p className="text-[10px] uppercase font-bold text-gray-500 tracking-wider">Abonnement Actuel</p>
<p className="text-sm font-bold text-white">{currentPlanName}</p>
</div>
</div>
<div className="bg-[#090f1c] border border-gray-800 p-3.5 rounded-xl flex items-center gap-3">
<div className="p-2 bg-gray-950 rounded-lg text-cyan-400"><Globe className="w-4 h-4" /></div>
<div className="truncate">
<p className="text-[10px] uppercase font-bold text-gray-500 tracking-wider">Espace Domaine</p>
<p className="text-sm font-mono text-cyan-400 truncate">{extractedDomain}</p>
</div>
</div>
</div>
<div className="bg-[#090f1c] border border-gray-800 p-4 rounded-xl flex flex-col md:flex-row items-start md:items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-gray-950 rounded-lg text-emerald-400 border border-gray-800/50">
<Calendar className="w-4 h-4" />
</div>
<div>
<p className="text-[10px] uppercase font-bold text-gray-500 tracking-wider">Prochain Renouvellement</p>
<p className="text-sm font-medium text-gray-300">
{order.expires_at ? order.expires_at : "Date non définie"}
<span className="text-gray-600 mx-2">|</span>
<span className="font-mono text-white font-bold">{order.price || '0.00'} {order.currency || '€'}</span>
<span className="text-[10px] text-gray-500 ml-1 uppercase">/ {order.period === '1W' ? 'semaine' : order.period === '1Y' ? 'an' : 'mois'}</span>
</p>
</div>
</div>
<a
href={`/facturation`}
className="text-xs font-bold text-cyan-400 hover:text-cyan-300 transition-colors flex items-center gap-1.5 bg-cyan-950/20 px-4 py-2 rounded-lg border border-cyan-900/30"
>
Voir les factures
</a>
</div>
<div className="bg-[#090f1c] p-2 rounded-xl border border-gray-800 flex items-center justify-between">
<span className="text-xs font-bold text-gray-400 ml-3 flex items-center gap-2">
<ToggleLeft className="w-4 h-4 text-cyan-400" /> Options de renouvellement
</span>
<div className="flex space-x-1">
{[
{ id: '1W', label: 'Semaine' },
{ id: '1M', label: 'Mois' },
{ id: '1Y', label: 'Année' }
].map((p) => (
<button
key={p.id}
onClick={() => setBillingPeriod(p.id)}
disabled={loading || cancelLoading}
className={`px-5 py-2 rounded-lg text-xs font-black tracking-wide transition-all ${billingPeriod === p.id ? 'bg-cyan-500 text-black shadow-lg shadow-cyan-500/20' : 'text-gray-400 hover:text-white hover:bg-gray-900'} disabled:opacity-50`}
>
{p.label}
</button>
))}
</div>
</div>
</div>
<div className="grow pr-2 custom-scrollbar">
{isCatalogLoading ? (
<div className="h-full flex flex-col items-center justify-center text-cyan-400">
<Loader className="w-8 h-8 animate-spin mx-auto mb-2" />
<p className="text-xs font-mono text-gray-500">Synchronisation des tarifs...</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-3 gap-5 pt-3 pb-4">
{catalog.map((plan, index) => {
const metrics = getProductCardMetrics(plan.pricing, billingPeriod);
const isSelected = selectedPlan?.id === plan.id;
const isCurrentActive = currentPlanId === plan.id && currentBillingPeriod === billingPeriod;
const topBadge = getPlanBadge(index);
return (
<div
key={plan.id}
onClick={() => !loading && !cancelLoading && metrics.isAvailable && setSelectedPlan(plan)}
className={`relative h-full p-6 rounded-2xl border transition-all duration-300 flex flex-col justify-between bg-[#0d1527] mt-3
${!metrics.isAvailable || loading || cancelLoading ? 'opacity-50 grayscale cursor-not-allowed border-gray-900' : 'cursor-pointer'}
${isSelected && metrics.isAvailable ? 'border-cyan-500 shadow-[0_0_20px_rgba(6,182,212,0.15)] scale-[1.02] z-10' : 'border-gray-800/80 hover:border-gray-700'}
${isCurrentActive ? 'ring-1 ring-gray-700' : ''}`}
>
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<span className={`text-[9px] font-black tracking-widest px-3 py-1 rounded-full border ${topBadge.color}`}>
{topBadge.label}
</span>
</div>
<div className="flex justify-between items-center w-full mb-5 mt-2">
<span className="text-[9px] font-black tracking-widest text-white bg-[#090f1c] px-2.5 py-1 rounded border border-gray-800 uppercase">
{plan.category?.title || "WEB"}
</span>
{metrics.saving > 0 && metrics.isAvailable && (
<span className="text-[10px] font-bold text-[#00df89] bg-[#00df89]/10 border border-[#00df89]/20 px-3 py-0.5 rounded-full uppercase tracking-wide">
Économie {metrics.saving}%
</span>
)}
</div>
<div className="mb-5 shrink-0">
<h4 className="font-bold text-xl text-white tracking-tight mb-3 flex items-center justify-between">
{plan.title}
{isCurrentActive && <span className="text-[9px] font-bold text-orange-500 uppercase tracking-widest bg-orange-900 px-2 py-0.5 rounded border border-orange-800">Actif</span>}
</h4>
{metrics.isAvailable ? (
<>
<div className="flex items-baseline gap-1.5">
<span className="text-3xl font-black text-cyan-400 tracking-tighter">{metrics.displayPrice} </span>
<span className="text-xs text-gray-400 font-medium">/ mois</span>
</div>
{metrics.saving > 0 && (
<p className="text-xs text-gray-500 line-through mt-1">
Au lieu de {metrics.oldDisplayPrice} / mois
</p>
)}
<div className="mt-3.5 inline-block bg-[#0e2238] border border-cyan-950/40 text-cyan-400 text-[9px] font-black tracking-widest uppercase px-3 py-1.5 rounded-md">
Facturé {metrics.billingPrice} par {billingPeriod === '1W' ? 'semaine' : billingPeriod === '1M' ? 'mois' : 'an'}
</div>
</>
) : (
<div className="py-2">
<span className="text-lg font-bold text-gray-500">Non disponible</span>
<p className="text-[10px] text-gray-600 mt-1 uppercase tracking-wider">Pour ce cycle</p>
</div>
)}
</div>
<div className="border-t border-gray-800/60 pt-4 grow space-y-1.5">
{renderMarkdownFeatures(plan.description)}
</div>
</div>
);
})}
</div>
)}
</div>
<div className="shrink-0 mt-auto pt-4 border-t border-gray-800/40 space-y-3">
{/* 🎯 NOUVEAU : AFFICHAGE DYNAMIQUE (PRORATA ET REMBOURSEMENT) */}
{selectedPlan && !isNoChange && (
<div className={`border p-3 rounded-xl mb-3 flex items-center justify-between transition-colors
${isRefund ? 'bg-amber-950/20 border-amber-900/50' : 'bg-[#0e2238] border-cyan-900/50'}`}
>
<div>
<p className={`text-xs font-bold uppercase tracking-widest ${isRefund ? 'text-amber-400' : 'text-cyan-400'}`}>
{isRefund
? "Remboursement estimé"
: finalInvoicePrice > 0.01
? "Montant à régler aujourd'hui"
: "Aucun frais immédiat"}
</p>
{isProrataApplied && <p className="text-[10px] text-gray-500 mt-0.5">Calculé au prorata des jours restants</p>}
</div>
<div className="text-right">
<span className={`text-xl font-black font-mono ${isRefund ? 'text-amber-400' : 'text-white'}`}>
{isRefund ? "-" : ""}{Math.abs(finalInvoicePrice).toFixed(2)}
</span>
</div>
</div>
)}
{selectedPlan && (
<button
onClick={handleMigration}
disabled={loading || cancelLoading || isNoChange}
className={`w-full py-4 rounded-xl font-black uppercase text-xs tracking-widest transition-all duration-300 flex items-center justify-center gap-3
${isNoChange
? 'bg-gray-900 text-gray-500 border border-gray-800 cursor-not-allowed shadow-none'
: buttonColor
}`}
>
{loading ? <Loader className="w-4 h-4 animate-spin" /> : (!isNoChange && ActionIcon)}
{loading
? 'Traitement en cours...'
: isNoChange
? 'Abonnement actuel (Aucune modification)'
: migrationLabel
}
</button>
)}
<button
onClick={handleCancel}
disabled={loading || cancelLoading}
className="w-full flex items-center justify-center gap-2 text-red-400/80 hover:text-red-400 border border-red-950/30 bg-red-950/5 hover:bg-red-950/15 p-3 rounded-xl text-xs font-bold uppercase tracking-wider transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
{cancelLoading ? <Loader className="w-4 h-4 animate-spin" /> : <ShieldAlert className="w-4 h-4" />}
{cancelLoading ? 'Résiliation en cours...' : 'Résilier l\'abonnement réseau'}
</button>
</div>
</div>
);
}
+136
View File
@@ -0,0 +1,136 @@
import React, { useState, useEffect } from 'react';
import { Server, Database, Cloud, Globe, ExternalLink, Play, Loader } from 'lucide-react';
import { getHostingServiceDetails } from '../../services/api';
export default function InstanceCard({ service, vpcs, onAssignVpc, onRemoveVpc, onOpenConsole, isConnecting }) {
// 1. États pour les détails techniques chargés via API
const [serviceDetails, setServiceDetails] = useState(null);
const [isLoadingDetails, setIsLoadingDetails] = useState(true);
// 2. Chargement des détails au montage du composant
useEffect(() => {
let isMounted = true;
const fetchDetails = async () => {
try {
const data = await getHostingServiceDetails(service.id);
if (isMounted) {
setServiceDetails(data);
}
} catch (error) {
console.error("Erreur lors du chargement des détails du service:", error);
} finally {
if (isMounted) setIsLoadingDetails(false);
}
};
fetchDetails();
return () => { isMounted = false; };
}, [service.id]);
// 3. Logique d'affichage (Titre, Type de service)
const titleLower = (service.title || '').toLowerCase();
const isVPS = titleLower.includes('vps') || titleLower.includes('compute');
const isCloud = titleLower.includes('cloud') || titleLower.includes('nextcloud');
const isDB = titleLower.includes('db') || titleLower.includes('base') || titleLower.includes('sql');
const isWeb = !isVPS && !isCloud && !isDB;
// 4. Extraction intelligente du domaine et de l'IP
// Priorité : API > Titre de la commande
const rawTitle = service.title || '';
const titleMatch = rawTitle.match(/(?: for | pour )(.+)$/i);
const domainFromTitle = titleMatch ? titleMatch[1].trim() : null;
const displayDomain = serviceDetails?.domain || serviceDetails?.config?.domain || domainFromTitle || null;
const displayIP = serviceDetails?.ip || serviceDetails?.config?.ip || null;
// Logique des boutons
let buttonText = "CONSOLE D'ADMINISTRATION";
let ButtonIcon = ExternalLink;
let buttonStyle = "bg-cyan-400/10 hover:bg-cyan-400 text-cyan-400 hover:text-gray-900 border-cyan-400";
if (isVPS) {
if (displayIP) { buttonText = "GÉRER L'INSTANCE"; ButtonIcon = ExternalLink; }
else { buttonText = "INITIALISATION"; ButtonIcon = Play; buttonStyle = "bg-yellow-500/10 text-yellow-500 hover:bg-yellow-500 hover:text-gray-900 border-yellow-500/50"; }
} else if (isWeb) { buttonText = "GÉRER L'HÉBERGEMENT"; ButtonIcon = Globe; buttonStyle = "bg-emerald-400/10 hover:bg-emerald-400 text-emerald-400 hover:text-gray-900 border-emerald-400"; }
else if (isCloud) { buttonText = "ACCÉDER AU CLOUD"; buttonStyle = "bg-blue-400/10 hover:bg-blue-400 text-blue-400 hover:text-gray-900 border-blue-400"; }
else if (isDB) { buttonText = "PHPMYADMIN / CLUSTER"; ButtonIcon = Database; buttonStyle = "bg-purple-400/10 hover:bg-purple-400 text-purple-400 hover:text-gray-900 border-purple-400"; }
const getServiceIcon = () => {
if (isVPS) return <Server className="w-8 h-8 text-cyan-400" />;
if (isCloud) return <Cloud className="w-8 h-8 text-blue-400" />;
if (isDB) return <Database className="w-8 h-8 text-purple-400" />;
return <Globe className="w-8 h-8 text-emerald-400" />;
};
const baseTitle = rawTitle.split(/(?: for | pour )/i)[0].trim();
const shortTitle = baseTitle.split(' ').slice(0, 2).join(' ');
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>
<div className="flex justify-between items-start mb-4">
<div className="p-2 bg-black/40 rounded-lg">{getServiceIcon()}</div>
{service.status === 'active' ? (
<span className="bg-emerald-500/10 text-emerald-400 px-2 py-1 rounded text-xs border border-emerald-500/20 font-bold tracking-widest">
{isVPS && !displayIP ? 'AWAITING INIT' : 'ONLINE'}
</span>
) : (
<span className="bg-orange-500/10 text-orange-400 px-2 py-1 rounded text-xs border border-orange-500/20 animate-pulse">DEPLOYING</span>
)}
</div>
<h3 className="font-bold text-white text-lg truncate" title={baseTitle}>{shortTitle}</h3>
{/* Zone Domaine & IP */}
<div className="flex flex-col gap-1 mt-2 mb-4 h-12 justify-center">
{isLoadingDetails ? (
<div className="flex items-center text-gray-500 text-xs gap-2">
<Loader className="w-3 h-3 animate-spin" /> Chargement...
</div>
) : (
<>
{displayDomain ? (
<p className={`${isVPS ? 'text-cyan-400' : 'text-emerald-400'} text-xs font-mono truncate`} title={displayDomain}>
{displayDomain}
</p>
) : (
<p className="text-gray-600 text-xs font-mono italic">Aucun domaine</p>
)}
{displayIP && (
<p className="text-gray-500 text-[10px] font-mono mt-0.5" title={`IP: ${displayIP}`}>
IP: {displayIP}
</p>
)}
</>
)}
</div>
</div>
<div className="mt-auto space-y-3">
<div className="flex items-center justify-between text-sm border-t border-gray-800 pt-3">
<span className="text-gray-500 text-xs">Projet VPC:</span>
<select
onChange={(e) => { const val = e.target.value; if (val === "free") onRemoveVpc(service.id); else if (val) onAssignVpc(service.id, val); }}
className="bg-black border border-gray-700 text-gray-300 rounded px-2 py-1 outline-none focus:border-cyan-400 text-xs w-32.5"
defaultValue={vpcs.find(v => v.services.includes(service.id))?.id || "free"}
>
<option value="free">-- Libre --</option>
{vpcs.map(vpc => ( <option key={vpc.id} value={vpc.id}>{vpc.name}</option> ))}
</select>
</div>
<button
onClick={() => onOpenConsole(service)}
disabled={isConnecting === service.id || service.status !== 'active'}
className={`w-full flex items-center justify-center space-x-2 py-2.5 rounded-lg font-bold text-sm tracking-wide transition-all disabled:opacity-50 border ${buttonStyle}`}
>
{isConnecting === service.id ? (
<><Loader className="w-4 h-4 animate-spin" /><span>CONNEXION...</span></>
) : (
<><ButtonIcon className="w-4 h-4" /><span>{buttonText}</span></>
)}
</button>
</div>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import { useState } from 'react';
export default function VpsDeployer({ serviceId, onDeploySuccess, onAlert }) {
const [domain, setDomain] = useState('');
const [password, setPassword] = useState('');
const [isDeploying, setIsDeploying] = useState(false);
const handleDeploy = async () => {
if (password.length < 8) { onAlert("Sécurité", "Le mot de passe doit faire au moins 8 caractères.", "error"); return; }
setIsDeploying(true);
try {
const response = await fetch('https://web.gise.be/custom_api/proxmox_create_vps.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ service_id: serviceId, domain: domain, password: password }) });
const data = await response.json();
if (data.status === 'success') { onDeploySuccess({ ip: data.ip, domain: data.domain, password: password }); }
else { onAlert("Échec", "Erreur de provisionnement : " + (data.message || 'Inconnue'), "error"); }
} catch (err) { onAlert("Erreur Réseau", "Erreur de communication avec l'API Proxmox Gateway.", "error"); }
finally { setIsDeploying(false); }
};
return (
<div>
<div className="bg-yellow-900/20 border border-yellow-600/30 p-4 rounded mb-6 text-yellow-500/80 text-sm">
<span className="font-bold text-yellow-500">PHASE D'INITIALISATION REQUISE</span><br />
La facturation est activée. Veuillez définir un mot de passe Root pour lancer la création de l'instance.
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Hostname personnalisé</label>
<input type="text" value={domain} onChange={(e) => setDomain(e.target.value.toLowerCase())} placeholder="Optionnel" className="w-full bg-gray-950 border border-gray-800 text-white p-3 rounded font-mono outline-none focus:border-cyan-500 transition-colors" />
</div>
<div>
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Mot de passe Root</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="••••••••" className="w-full bg-gray-950 border border-gray-800 text-white p-3 rounded font-mono outline-none focus:border-cyan-500 transition-colors" />
</div>
</div>
<button onClick={handleDeploy} disabled={isDeploying} className={`mt-6 w-full px-6 py-3 rounded font-bold font-mono tracking-widest uppercase transition ${isDeploying ? 'bg-gray-800 text-gray-500 cursor-not-allowed' : 'bg-cyan-600 hover:bg-cyan-500 text-white shadow-lg shadow-cyan-500/20'}`}>
{isDeploying ? 'Fabrication sur le Métal...' : 'Lancer l\'Instanciation'}
</button>
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { useState } from 'react';
import { Folder, ExternalLink, Play, Maximize2, Lock, Copy, Check } from 'lucide-react';
export default function VpsManager({ details, orderId, onRefresh, onAlert }) {
const [showTerminal, setShowTerminal] = useState(false);
const [isEditingDomain, setIsEditingDomain] = useState(false);
const [newDomain, setNewDomain] = useState(details.domain);
const [isUpdating, setIsUpdating] = useState(false);
const [sslStatus, setSslStatus] = useState(null);
const [copiedField, setCopiedField] = useState(null);
const isInternal = details.domain && details.domain.endsWith('.gise.be');
const fileManagerUrl = isInternal ? `https://file-${details.domain}` : `http://${details.domain}:8080`;
const terminalUrl = isInternal ? `https://terminal-${details.domain}` : `http://${details.domain}:8081`;
const handleCopy = (text, field) => {
navigator.clipboard.writeText(text);
setCopiedField(field);
setTimeout(() => setCopiedField(null), 2000);
};
const handleUpdateDomain = async () => {
if (!newDomain || newDomain === details.domain) return;
setIsUpdating(true);
try {
const response = await fetch('https://web.gise.be/custom_api/update_service_domain.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ order_id: orderId, new_domain: newDomain }) });
const data = await response.json();
if (data.status === 'success') { setIsEditingDomain(false); if (onRefresh) onRefresh(); onAlert("Succès", "Domaine VPS mis à jour !", "success"); }
else onAlert("Erreur", data.error, "error");
} catch (err) { onAlert("Erreur", "Erreur de connexion avec l'API.", "error"); } finally { setIsUpdating(false); }
};
const handleGenerateSSL = async () => {
setSslStatus('loading');
try {
const response = await fetch('https://web.gise.be/custom_api/generate_custom_ssl.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ domain: details.domain }) });
const data = await response.json();
if (data.status === 'success') { setSslStatus('success'); onAlert("SSL Activé", "Certificat SSL généré !", "success"); }
else { setSslStatus('error'); onAlert("Erreur DNS", "Vérifiez votre pointage.", "error"); }
} catch (e) { setSslStatus('error'); }
};
return (
<div>
{!showTerminal ? (
<>
<div className="mb-6 bg-gray-950 border border-gray-800 rounded p-4">
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-2">Domaine de l'instance VPS</label>
{!isEditingDomain ? (
<div className="flex justify-between items-center">
<span className="font-mono text-xl text-green-400 font-bold flex items-center gap-2">
<span className="h-3 w-3 rounded-full bg-green-500 shadow-[0_0_10px_#22c55e]"></span>{details.domain}
</span>
<button onClick={() => setIsEditingDomain(true)} className="text-xs border border-gray-700 hover:border-cyan-500 text-gray-400 hover:text-cyan-400 px-3 py-1.5 rounded transition">MODIFIER</button>
</div>
) : (
<div className="flex gap-2">
<input type="text" value={newDomain} onChange={(e) => setNewDomain(e.target.value.toLowerCase())} className="flex-1 bg-black border border-gray-700 rounded px-3 py-1.5 text-white font-mono text-sm focus:border-cyan-500 outline-none" disabled={isUpdating} />
<button onClick={handleUpdateDomain} disabled={isUpdating} className="bg-cyan-500 text-gray-900 px-4 py-1.5 rounded text-xs font-bold hover:bg-cyan-400">{isUpdating ? 'SYNC...' : 'SAUVEGARDER'}</button>
<button onClick={() => setIsEditingDomain(false)} className="bg-gray-800 text-white px-3 py-1.5 rounded text-xs hover:bg-gray-700">ANNULER</button>
</div>
)}
{!isInternal && (
<div className="mt-4 border-t border-gray-900 pt-3 flex justify-between items-center">
<p className="text-[11px] text-gray-500">Domaine externe détecté.</p>
<button onClick={handleGenerateSSL} disabled={sslStatus === 'loading'} className="text-xs bg-purple-600/20 hover:bg-purple-600 text-purple-400 hover:text-white border border-purple-500/30 px-3 py-1 rounded transition flex items-center gap-1">
{sslStatus === 'loading' ? 'GÉNÉRATION...' : <><Lock className="w-3 h-3" /> GÉNÉRER SSL</>}
</button>
</div>
)}
</div>
{/* ENCART DES IDENTIFIANTS */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div className="bg-black/60 border border-gray-800 rounded p-3">
<span className="text-gray-500 font-bold uppercase tracking-wider mb-2 text-[10px] flex items-center gap-1">
<Folder className="w-3 h-3" /> Accès Explorateur
</span>
<div className="flex justify-between items-center mb-1">
<span className="text-xs font-mono text-gray-400">User: <strong className="text-cyan-400">root</strong></span>
<button onClick={() => handleCopy('root', 'fileUser')} className="text-gray-500 hover:text-cyan-400 p-1">
{copiedField === 'fileUser' ? <Check className="w-3 h-3 text-cyan-400" /> : <Copy className="w-3 h-3" />}
</button>
</div>
<div className="flex justify-between items-center">
<span className="text-xs font-mono text-gray-400">Pass: <strong className="text-cyan-400">NexusGise2026!</strong></span>
<button onClick={() => handleCopy('NexusGise2026!', 'filePass')} className="text-gray-500 hover:text-cyan-400 p-1">
{copiedField === 'filePass' ? <Check className="w-3 h-3 text-cyan-400" /> : <Copy className="w-3 h-3" />}
</button>
</div>
</div>
<div className="bg-black/60 border border-gray-800 rounded p-3">
<span className="text-gray-500 font-bold uppercase tracking-wider mb-2 text-[10px] flex items-center gap-1">
<Play className="w-3 h-3" /> Accès Console / SSH
</span>
<div className="flex justify-between items-center mb-1">
<span className="text-xs font-mono text-gray-400">User: <strong className="text-cyan-400">root</strong></span>
<button onClick={() => handleCopy('root', 'sshUser')} className="text-gray-500 hover:text-cyan-400 p-1">
{copiedField === 'sshUser' ? <Check className="w-3 h-3 text-cyan-400" /> : <Copy className="w-3 h-3" />}
</button>
</div>
<div className="flex justify-between items-center">
<span className="text-xs font-mono text-gray-400">Pass: <span className="text-gray-600 italic">Défini à l'installation</span></span>
</div>
</div>
</div>
{/* BOUTONS D'ACTIONS */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-2">
<button onClick={() => window.open(fileManagerUrl, '_blank')} className="bg-gray-950 hover:bg-gray-900 border border-gray-800 hover:border-cyan-500/50 rounded p-3 flex justify-between items-center transition group">
<span className="font-mono text-gray-300 text-sm flex items-center gap-2"><Folder className="w-4 h-4 text-cyan-500" /> Explorateur</span><ExternalLink className="w-4 h-4 text-gray-600 group-hover:text-cyan-400" />
</button>
<button onClick={() => setShowTerminal(true)} className="bg-cyan-900/20 hover:bg-cyan-900/40 border border-cyan-800/50 hover:border-cyan-500 rounded p-3 flex justify-between items-center transition group">
<span className="font-mono text-cyan-400 text-sm flex items-center gap-2"><span>{">_"}</span> Terminal</span><Play className="w-4 h-4 text-cyan-600 group-hover:text-cyan-400" />
</button>
</div>
</>
) : (
<div>
<div className="flex justify-between items-center mb-4 border-b border-gray-800 pb-2">
<span className="text-cyan-400 font-mono text-sm tracking-widest">TERMINAL ({details.domain})</span>
<div className="flex items-center space-x-2">
<button onClick={() => { window.open(terminalUrl, '_blank'); setShowTerminal(false); }} className="text-xs bg-cyan-600/30 text-cyan-400 hover:bg-cyan-600 hover:text-white border border-cyan-500/30 px-3 py-1 rounded transition flex items-center gap-1"><Maximize2 className="w-3 h-3" /> Plein Écran </button>
<button onClick={() => setShowTerminal(false)} className="text-xs bg-red-900/30 text-white px-3 py-1 rounded">Fermer</button>
</div>
</div>
<div className="bg-black border border-gray-800 rounded h-100">
<iframe src={terminalUrl} className="w-full h-full border-none" title="Terminal" />
</div>
</div>
)}
</div>
);
}
+134
View File
@@ -0,0 +1,134 @@
import { useState } from 'react';
import { Globe, ExternalLink, Lock, Loader, Copy, Check } from 'lucide-react';
import { resetHostingPassword } from '../../services/api';
export default function WebManager({ details, orderId, onRefresh, onAlert }) {
const [isEditingDomain, setIsEditingDomain] = useState(false);
const [newDomain, setNewDomain] = useState(details.domain);
const [isUpdating, setIsUpdating] = useState(false);
const [sslStatus, setSslStatus] = useState(null);
// Nouveaux états pour gérer les accès localement
const [isGeneratingSso, setIsGeneratingSso] = useState(false);
const [ssoData, setSsoData] = useState(null);
const [copiedField, setCopiedField] = useState(null);
const isCustomDomain = details.domain && !details.domain.endsWith('.gise.be');
const siteUrl = (isCustomDomain && sslStatus !== 'success') ? `http://${details.domain}` : `https://${details.domain}`;
// Fonction utilitaire pour copier
const handleCopy = (text, field) => {
navigator.clipboard.writeText(text);
setCopiedField(field);
setTimeout(() => setCopiedField(null), 2000);
};
const handleUpdateDomain = async () => {
if (!newDomain || newDomain === details.domain) return;
setIsUpdating(true);
try {
const response = await fetch('https://web.gise.be/custom_api/update_service_domain.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ order_id: orderId, new_domain: newDomain }) });
const data = await response.json();
if (data.status === 'success') { setIsEditingDomain(false); if (onRefresh) onRefresh(); onAlert("Succès", "Domaine mis à jour !", "success"); }
else onAlert("Erreur", data.error, "error");
} catch (err) { onAlert("Erreur", "Erreur API.", "error"); } finally { setIsUpdating(false); }
};
const handleGenerateSSL = async () => {
setSslStatus('loading');
try {
const response = await fetch('https://web.gise.be/custom_api/generate_custom_ssl.php', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ domain: details.domain }) });
const data = await response.json();
if (data.status === 'success') { setSslStatus('success'); onAlert("SSL Validé", "Certificat SSL généré !", "success"); }
else { setSslStatus('error'); onAlert("Erreur DNS", "Vérifiez vos DNS.", "error"); }
} catch (e) { setSslStatus('error'); }
};
const handleOpenPanel = async () => {
setIsGeneratingSso(true);
try {
const secureHash = Math.random().toString(36).slice(-8) + Math.random().toString(36).slice(-4).toUpperCase();
const rollingPassword = `Nx${secureHash}`;
await resetHostingPassword(orderId, rollingPassword);
setSsoData({ username: details.username, password: rollingPassword, url: 'https://panel.gise.be/login/' });
} catch (err) {
onAlert("Erreur Métal", "Erreur lors de la génération du SSO HestiaCP.", "error");
} finally {
setIsGeneratingSso(false);
}
};
return (
<div>
<div className="mb-6 bg-gray-950 border border-gray-800 rounded p-4">
<label className="block text-xs uppercase tracking-widest text-emerald-500 mb-2">Domaine du Site Web</label>
{!isEditingDomain ? (
<div className="flex justify-between items-center">
<span className="font-mono text-xl text-emerald-400 font-bold flex items-center gap-2">
<span className="h-3 w-3 rounded-full bg-emerald-500 shadow-[0_0_10px_#10b981]"></span>{details.domain}
</span>
<button onClick={() => setIsEditingDomain(true)} className="text-xs border border-gray-700 hover:border-emerald-500 text-gray-400 hover:text-emerald-400 px-3 py-1.5 rounded transition">MODIFIER</button>
</div>
) : (
<div className="flex gap-2">
<input type="text" value={newDomain} onChange={(e) => setNewDomain(e.target.value.toLowerCase())} className="flex-1 bg-black border border-gray-700 rounded px-3 py-1.5 text-white font-mono text-sm focus:border-emerald-500 outline-none" disabled={isUpdating} />
<button onClick={handleUpdateDomain} disabled={isUpdating} className="bg-emerald-500 text-gray-900 px-4 py-1.5 rounded text-xs font-bold hover:bg-emerald-400">{isUpdating ? 'SYNC...' : 'SAUVEGARDER'}</button>
<button onClick={() => setIsEditingDomain(false)} className="bg-gray-800 text-white px-3 py-1.5 rounded text-xs hover:bg-gray-700">ANNULER</button>
</div>
)}
{isCustomDomain && (
<div className="mt-4 border-t border-gray-900 pt-3 flex justify-between items-center">
<p className="text-[11px] text-gray-500">Domaine personnalisé détecté.</p>
<button onClick={handleGenerateSSL} disabled={sslStatus === 'loading'} className="text-xs bg-purple-600/20 hover:bg-purple-600 text-purple-400 hover:text-white border border-purple-500/30 px-3 py-1 rounded transition flex items-center gap-1">
{sslStatus === 'loading' ? 'GÉNÉRATION...' : <><Lock className="w-3 h-3" /> GÉNÉRER SSL</>}
</button>
</div>
)}
</div>
<div className="grid grid-cols-1 gap-6 mb-6">
<button onClick={() => window.open(siteUrl, '_blank')} className="bg-gray-950 hover:bg-gray-900 border border-gray-800 hover:border-emerald-500/50 rounded p-3 flex justify-between items-center transition group">
<span className="font-mono text-gray-300 text-sm flex items-center gap-2"><Globe className="w-4 h-4 text-emerald-500" /> Ouvrir le Site</span><ExternalLink className="w-4 h-4 text-gray-600 group-hover:text-emerald-400" />
</button>
{/* ZONE DES ACCÈS PANEL */}
{!ssoData ? (
<button onClick={handleOpenPanel} disabled={isGeneratingSso} className="bg-emerald-900/20 hover:bg-emerald-900/40 border border-emerald-800/50 hover:border-emerald-500 rounded p-3 flex justify-between items-center transition group disabled:opacity-50">
{isGeneratingSso ? (
<span className="font-mono text-emerald-400 text-sm flex items-center gap-2"><Loader className="w-4 h-4 animate-spin" /> GÉNÉRATION DU MOT DE PASSE...</span>
) : (
<><span className="font-mono text-emerald-400 text-sm flex items-center gap-2"><Lock className="w-4 h-4" /> Obtenir les accès Panel</span><ExternalLink className="w-4 h-4 text-emerald-600 group-hover:text-emerald-400" /></>
)}
</button>
) : (
<div className="bg-emerald-900/10 border border-emerald-500/30 rounded p-4">
<p className="text-[11px] text-emerald-400/80 uppercase tracking-widest mb-3 flex items-center gap-2">
<Lock className="w-3 h-3" /> Accès Panel Généré
</p>
<div className="space-y-2 mb-4">
<div className="flex justify-between items-center bg-black/50 border border-gray-800 rounded p-2">
<span className="text-gray-400 text-xs font-mono">User: <strong className="text-white ml-1">{ssoData.username}</strong></span>
<button onClick={() => handleCopy(ssoData.username, 'webUser')} className="text-emerald-400 hover:text-emerald-300 transition-colors p-1">
{copiedField === 'webUser' ? <Check className="w-4 h-4 text-emerald-500" /> : <Copy className="w-4 h-4" />}
</button>
</div>
<div className="flex justify-between items-center bg-black/50 border border-gray-800 rounded p-2">
<span className="text-gray-400 text-xs font-mono">Pass: <strong className="text-emerald-400 ml-1">{ssoData.password}</strong></span>
<button onClick={() => handleCopy(ssoData.password, 'webPass')} className="text-emerald-400 hover:text-emerald-300 transition-colors p-1">
{copiedField === 'webPass' ? <Check className="w-4 h-4 text-emerald-500" /> : <Copy className="w-4 h-4" />}
</button>
</div>
</div>
<button onClick={() => window.open(ssoData.url, '_blank')} className="w-full bg-emerald-500 hover:bg-emerald-400 text-black font-bold font-mono tracking-widest uppercase py-2.5 rounded transition shadow-[0_0_15px_rgba(16,185,129,0.2)]">
Ouvrir le Panel
</button>
</div>
)}
</div>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
// src/components/store/CategorySection.jsx
import { useState } from 'react';
import { ChevronDown } from 'lucide-react';
import ProductCard from './ProductCard';
import { parsePeriod } from './StoreHelpers';
export default function CategorySection({ categoryName, products, getCategoryIcon }) {
const availablePeriods = new Set();
products.forEach(p => {
if (p.pricing?.type === 'recurrent' && p.pricing.recurrent) {
Object.keys(p.pricing.recurrent).forEach(period => {
const periodData = p.pricing.recurrent[period];
if (periodData.enabled == 1 || periodData.enabled === true) availablePeriods.add(period);
});
}
});
const sortedPeriods = Array.from(availablePeriods).sort((a, b) => parsePeriod(a).weight - parsePeriod(b).weight);
const defaultPeriod = sortedPeriods.includes('1M') ? '1M' : sortedPeriods[0];
const [sectionPeriod, setSectionPeriod] = useState(defaultPeriod);
return (
<section className="mb-16 bg-black/20 p-6 rounded-3xl border border-gray-800/50">
<div className="flex flex-col md:flex-row md:items-center justify-between mb-8 pb-6 border-b border-gray-800 space-y-4 md:space-y-0">
<div className="flex items-center space-x-4">
<div className="p-3 bg-gray-900 rounded-xl border border-gray-800 shadow-[0_0_15px_rgba(0,0,0,0.5)]">
{getCategoryIcon(categoryName)}
</div>
<div>
<h2 className="text-3xl font-black text-white tracking-wider">{categoryName}</h2>
<div className="text-gray-500 text-sm mt-1">{products.length} instance{products.length > 1 ? 's' : ''} disponible{products.length > 1 ? 's' : ''}</div>
</div>
</div>
{sortedPeriods.length > 0 && (
<div className="flex items-center space-x-3 bg-gray-900 p-2 rounded-xl border border-gray-800">
<span className="text-sm font-medium text-gray-400 pl-2">Facturation :</span>
<div className="relative">
<select value={sectionPeriod} onChange={(e) => setSectionPeriod(e.target.value)} className="appearance-none bg-black border border-gray-700 text-cyan-400 font-bold py-2 pl-4 pr-10 rounded-lg outline-none focus:border-cyan-400 transition-colors cursor-pointer hover:bg-gray-950">
{sortedPeriods.map(p => ( <option key={p} value={p}>{parsePeriod(p).label}</option> ))}
</select>
<ChevronDown className="absolute right-3 top-2.5 w-5 h-5 text-cyan-400 pointer-events-none" />
</div>
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{products.map((product) => (
<ProductCard key={product.id} product={product} selectedPeriod={sectionPeriod} categoryName={categoryName} />
))}
</div>
</section>
);
}
+112
View File
@@ -0,0 +1,112 @@
// src/components/store/ProductCard.jsx
import { useNavigate } from 'react-router-dom';
import ReactMarkdown from 'react-markdown';
import { ShoppingCart, CheckCircle2 } from 'lucide-react';
import { parsePeriod, getCategoryBadge } from './StoreHelpers';
export default function ProductCard({ product, selectedPeriod, categoryName }) {
const navigate = useNavigate();
const getPricingData = () => {
if (product.pricing?.type !== 'recurrent' || !product.pricing?.recurrent) {
const oncePrice = product.pricing?.once?.price ? parseFloat(product.pricing.once.price).toFixed(2) : '0.00';
return { isAvailable: true, displayPrice: oncePrice, suffix: '(Une fois)', originalPrice: null, savingsPercent: 0, isOnce: true };
}
const recurrentPrices = product.pricing.recurrent;
const availablePeriods = Object.keys(recurrentPrices).filter(
period => recurrentPrices[period].enabled == 1 || recurrentPrices[period].enabled === true
);
if (!recurrentPrices[selectedPeriod] || !availablePeriods.includes(selectedPeriod)) return { isAvailable: false };
const currentPrice = parseFloat(recurrentPrices[selectedPeriod].price);
const currentPeriodInfo = parsePeriod(selectedPeriod);
const currentYearlyCost = currentPrice * currentPeriodInfo.factorToYear;
const currentMonthlyEquivalent = currentYearlyCost / 12;
let savingsPercent = 0;
let originalPrice = null;
const sortedAvailablePeriods = [...availablePeriods].sort((a, b) => parsePeriod(a).weight - parsePeriod(b).weight);
const basePeriodCode = sortedAvailablePeriods[0];
if (basePeriodCode !== selectedPeriod) {
const basePrice = parseFloat(recurrentPrices[basePeriodCode].price);
const basePeriodInfo = parsePeriod(basePeriodCode);
const baseYearlyCost = basePrice * basePeriodInfo.factorToYear;
const baseMonthlyEquivalent = baseYearlyCost / 12;
if (baseYearlyCost > currentYearlyCost) {
savingsPercent = Math.round((1 - (currentYearlyCost / baseYearlyCost)) * 100);
originalPrice = baseMonthlyEquivalent.toFixed(2);
}
}
return {
isAvailable: true, displayPrice: currentMonthlyEquivalent.toFixed(2), suffix: '/ mois',
originalPrice, savingsPercent, billingPrice: currentPrice.toFixed(2),
billingPhrase: currentPeriodInfo.billingPhrase, isOnce: false
};
};
const priceData = getPricingData();
return (
<div className={`bg-gray-900 border border-gray-800 rounded-2xl overflow-hidden hover:border-cyan-400/50 transition-all duration-300 flex flex-col relative group ${!priceData.isAvailable ? 'opacity-50 grayscale' : ''}`}>
<div className="absolute top-4 left-4 z-20">
<span className="bg-black/60 backdrop-blur-sm text-gray-300 text-xs font-black px-3 py-1 rounded border border-gray-800 tracking-widest shadow-sm">
{getCategoryBadge(categoryName)}
</span>
</div>
{priceData.savingsPercent > 0 && priceData.isAvailable && (
<div className="absolute top-4 right-4 z-20 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-bold px-3 py-1 rounded-full animate-pulse shadow-[0_0_15px_rgba(16,185,129,0.2)]">
ÉCONOMIE {priceData.savingsPercent}%
</div>
)}
<div className="p-8 pt-14 border-b border-gray-800 relative bg-linear-to-b from-gray-800/30 to-transparent min-h-47.5 flex flex-col">
<h3 className="text-2xl font-bold text-white mb-4 relative z-10">{product.title}</h3>
{priceData.isAvailable ? (
<div className="grow flex flex-col justify-end">
<div className="flex items-baseline space-x-2">
<span className="text-4xl font-black text-cyan-400">{priceData.displayPrice} </span>
<span className="text-gray-500">{priceData.suffix}</span>
</div>
<div className="mt-3 min-h-11 flex flex-col justify-end">
{!priceData.isOnce && (
<>
{priceData.originalPrice ? (
<div className="text-sm text-gray-500">Au lieu de <span className="line-through">{priceData.originalPrice} </span> / mois</div>
) : (
<div className="text-sm text-gray-600 italic">Tarif de base équivalent</div>
)}
<div className="text-xs text-cyan-500 mt-1 font-semibold uppercase tracking-wider bg-cyan-500/10 inline-block px-2 py-1 rounded w-max">
Facturé {priceData.billingPrice} {priceData.billingPhrase}
</div>
</>
)}
{priceData.isOnce && <div className="text-sm text-gray-500">Paiement unique</div>}
</div>
</div>
) : (
<div className="text-red-400 font-medium mt-auto">Non disponible pour cette durée.</div>
)}
</div>
<div className="p-8 grow flex flex-col justify-between">
<div className="text-gray-400 text-sm mb-8 space-y-3 prose prose-invert max-w-none">
<ReactMarkdown components={{ ul: ({node, ...props}) => <ul className="space-y-2" {...props} />, li: ({node, ...props}) => <li className="flex items-start space-x-2"><CheckCircle2 className="w-4 h-4 text-cyan-400 mt-0.5 shrink-0"/> <span>{props.children}</span></li>, p: ({node, ...props}) => <p className="mb-2 text-gray-300" {...props} />, strong: ({node, ...props}) => <strong className="text-white font-semibold" {...props} /> }}>
{product.description || "Aucune description technique."}
</ReactMarkdown>
</div>
<button disabled={!priceData.isAvailable} onClick={() => navigate(`/checkout/${product.id}?period=${selectedPeriod}`)} className={`w-full py-3 rounded-lg font-bold tracking-widest transition-all flex justify-center items-center space-x-2 ${priceData.isAvailable ? "bg-cyan-400/10 hover:bg-cyan-400 text-cyan-400 hover:text-gray-900 border border-cyan-400 group-hover:shadow-[0_0_20px_rgba(34,211,238,0.2)]" : "bg-gray-800 text-gray-600 border border-gray-800 cursor-not-allowed"}`}>
<ShoppingCart className="w-5 h-5" /><span>COMMANDER</span>
</button>
</div>
</div>
);
}
+31
View File
@@ -0,0 +1,31 @@
// src/components/store/StoreHelpers.js
export const parsePeriod = (code) => {
if (!code) return { label: '', weight: 0, factorToYear: 1, billingPhrase: '' };
const value = parseInt(code);
if (code.includes('W')) return {
label: `${value} Semaine${value > 1 ? 's' : ''}`,
weight: value * 7, factorToYear: 52 / value,
billingPhrase: value === 1 ? 'par semaine' : `toutes les ${value} semaines`
};
if (code.includes('M')) return {
label: `${value} Mois`,
weight: value * 30, factorToYear: 12 / value,
billingPhrase: value === 1 ? 'par mois' : `tous les ${value} mois`
};
if (code.includes('Y')) return {
label: `${value} An${value > 1 ? 's' : ''}`,
weight: value * 365, factorToYear: 1 / value,
billingPhrase: value === 1 ? 'par an' : `tous les ${value} ans`
};
return { label: code, weight: 999, factorToYear: 1, billingPhrase: `pour ${code}` };
};
export const getCategoryBadge = (categoryName) => {
const t = (categoryName || '').toLowerCase();
if (t.includes('web') || t.includes('hosting')) return 'WEB';
if (t.includes('vps')) return 'VPS';
if (t.includes('data') || t.includes('db')) return 'DB';
if (t.includes('cloud')) return 'CLOUD';
return 'SRV';
};
@@ -0,0 +1,62 @@
import { useState, useEffect } from 'react';
import { Plus, Lock, X } from 'lucide-react';
export default function CreateTicketModal({ isOpen, onClose, onSubmit, helpdesks, defaultHelpdesk, isSubmitting }) {
const [subject, setSubject] = useState('');
const [message, setMessage] = useState('');
const [selectedHelpdesk, setSelectedHelpdesk] = useState('');
// Réinitialisation du formulaire à chaque ouverture
useEffect(() => {
if (isOpen) {
setSubject('');
setMessage('');
setSelectedHelpdesk(defaultHelpdesk);
}
}, [isOpen, defaultHelpdesk]);
if (!isOpen) return null;
const handleSubmit = (e) => {
e.preventDefault();
if (!subject.trim() || !message.trim() || !selectedHelpdesk) return;
onSubmit(subject, message, selectedHelpdesk);
};
return (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-cyan-500/30 rounded-lg shadow-2xl max-w-lg w-full p-6 text-gray-200">
<div className="flex justify-between items-center mb-6 border-b border-gray-800 pb-4">
<h3 className="text-xl font-bold text-white tracking-wider flex items-center gap-2">
<Plus className="w-5 h-5 text-cyan-400" /> OUVRIR UNE REQUÊTE
</h3>
<button onClick={onClose} className="p-2 text-gray-500 hover:text-white bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Sujet de la demande</label>
<input type="text" value={subject} onChange={(e) => setSubject(e.target.value)} required placeholder="Ex: Problème d'accès sur l'API" className="w-full bg-black border border-gray-800 text-white p-3 rounded text-sm focus:border-cyan-500 outline-none transition" />
</div>
<div>
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Département (Cible Réseau)</label>
<div className="w-full bg-gray-950 border border-gray-800 text-gray-500 p-3 rounded text-sm font-mono flex items-center justify-between select-none">
<span>{helpdesks.find(h => h.id === selectedHelpdesk)?.name || 'Service Desk Nexus'}</span>
<Lock className="w-4 h-4 text-gray-700" />
</div>
<p className="text-[10px] text-gray-600 mt-1">Routage automatique vers les ingénieurs d'infrastructure.</p>
</div>
<div>
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Description détaillée</label>
<textarea value={message} onChange={(e) => setMessage(e.target.value)} required rows="4" placeholder="Décrivez votre problème technique ici..." className="w-full bg-black border border-gray-800 text-white p-3 rounded text-sm focus:border-cyan-500 outline-none transition resize-none"></textarea>
</div>
<button type="submit" disabled={isSubmitting} className="w-full bg-cyan-600 hover:bg-cyan-500 text-white font-bold font-mono tracking-widest uppercase py-3 rounded mt-2 transition shadow-lg shadow-cyan-500/20 disabled:opacity-50">
{isSubmitting ? 'CHIFFREMENT ET TRANSMISSION...' : 'TRANSMETTRE AU SUPPORT NEXUS'}
</button>
</form>
</div>
</div>
);
}
+11
View File
@@ -0,0 +1,11 @@
import { MessageSquare, Clock, CheckCircle2, AlertCircle } from 'lucide-react';
export const getStatusConfig = (status) => {
switch (status) {
case 'open': return { color: 'text-cyan-400', bg: 'bg-cyan-500/10', border: 'border-cyan-500/20', label: 'SUPPORT', icon: <MessageSquare className="w-3 h-3 mr-1" /> };
case 'on_hold': return { color: 'text-yellow-400', bg: 'bg-yellow-500/10', border: 'border-yellow-500/20', label: 'RÉPONSE REÇUE', icon: <AlertCircle className="w-3 h-3 mr-1" /> };
case 'pending': return { color: 'text-orange-400', bg: 'bg-orange-500/10', border: 'border-orange-500/20', label: 'EN ATTENTE', icon: <Clock className="w-3 h-3 mr-1" /> };
case 'closed': return { color: 'text-gray-400', bg: 'bg-gray-500/10', border: 'border-gray-500/20', label: 'RÉSOLU', icon: <CheckCircle2 className="w-3 h-3 mr-1" /> };
default: return { color: 'text-gray-400', bg: 'bg-gray-800', border: 'border-gray-700', label: status.toUpperCase() };
}
};
+36
View File
@@ -0,0 +1,36 @@
import { getStatusConfig } from './SupportHelpers';
export default function TicketCard({ ticket, onClick }) {
const conf = getStatusConfig(ticket.status);
return (
<div
onClick={onClick}
className="bg-gray-900 border border-gray-800 rounded-xl p-5 hover:border-cyan-400/50 hover:bg-gray-800/50 transition-all cursor-pointer group flex flex-col justify-between min-h-40 shadow-lg"
>
<div>
<div className="flex justify-between items-start mb-3">
<div className="flex items-center gap-3">
<span className="text-gray-500 font-mono text-xs">{ticket.id}</span>
{ticket.status === 'on_hold' && (
<span className="flex h-2.5 w-2.5 relative" title="Nouveau message en attente de lecture">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-cyan-500 shadow-[0_0_8px_#00E5FF]"></span>
</span>
)}
</div>
<span className={`flex items-center px-2 py-1 rounded text-[10px] font-bold border tracking-wider ${conf.color} ${conf.bg} ${conf.border}`}>
{conf.icon} {conf.label}
</span>
</div>
<h3 className="text-white font-bold text-lg mb-1 group-hover:text-cyan-400 transition-colors line-clamp-2">
{ticket.subject}
</h3>
</div>
<div className="flex justify-between items-center text-xs text-gray-500 font-mono border-t border-gray-800 pt-3 mt-4">
<span>Département: {ticket.department}</span>
<span className="truncate max-w-30">MàJ: {ticket.lastUpdate}</span>
</div>
</div>
);
}
@@ -0,0 +1,82 @@
import { useState, useEffect, useRef } from 'react';
import { Send, Loader, X } from 'lucide-react';
export default function TicketThreadModal({ ticket, onClose, onReply, isLoadingConversation }) {
const [replyMessage, setReplyMessage] = useState('');
const messagesEndRef = useRef(null);
// Défilement automatique
useEffect(() => {
if (ticket && ticket.messages && !isLoadingConversation) {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}
}, [ticket?.messages, isLoadingConversation]);
if (!ticket) return null;
const handleReplySubmit = (e) => {
e.preventDefault();
if (!replyMessage.trim()) return;
onReply(replyMessage);
setReplyMessage(''); // Reset auto du champ
};
return (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-2 sm:p-4">
<div className="bg-gray-950 border border-gray-800 rounded-lg shadow-2xl max-w-3xl w-full h-[85vh] flex flex-col">
<div className="bg-gray-900 border-b border-gray-800 p-4 sm:p-6 flex justify-between items-start rounded-t-lg">
<div>
<div className="flex items-center gap-3 mb-1">
<span className="text-cyan-500 font-mono text-sm">{ticket.id}</span>
<span className="bg-gray-800 text-gray-300 text-[10px] uppercase font-bold tracking-wider px-2 py-0.5 rounded border border-gray-700">{ticket.department}</span>
</div>
<h3 className="text-xl font-bold text-white line-clamp-1">{ticket.subject}</h3>
</div>
<button onClick={onClose} className="p-2 text-gray-500 hover:text-white bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-6 scrollbar-thin scrollbar-thumb-gray-800">
{isLoadingConversation ? (
<div className="flex flex-col items-center justify-center h-full text-cyan-400 gap-2 font-mono text-xs">
<Loader className="w-6 h-6 animate-spin" /><span>Téléchargement des paquets de discussion sécurisés...</span>
</div>
) : ticket.messages.length === 0 ? (
<div className="text-center text-gray-600 font-mono text-xs pt-10">Aucun message trouvé dans ce fil.</div>
) : (
ticket.messages.map((msg, idx) => (
<div key={idx} className={`flex flex-col ${msg.sender === 'client' ? 'items-end' : 'items-start'}`}>
<div className="flex items-baseline gap-2 mb-1 px-1">
<span className={`text-[10px] font-bold uppercase tracking-widest ${msg.sender === 'client' ? 'text-gray-500' : 'text-cyan-400'}`}>
{msg.sender === 'client' ? 'VOUS' : 'INGÉNIEUR GISE'}
</span>
<span className="text-[10px] text-gray-600 font-mono">{msg.date}</span>
</div>
<div className={`p-4 rounded-xl max-w-[85%] text-sm leading-relaxed ${msg.sender === 'client' ? 'bg-gray-800 text-gray-200 rounded-tr-none' : 'bg-cyan-900/20 border border-cyan-800/30 text-cyan-50 rounded-tl-none'}`}>
{msg.text}
</div>
</div>
))
)}
<div ref={messagesEndRef} />
</div>
{ticket.status !== 'closed' ? (
<form onSubmit={handleReplySubmit} className="bg-gray-900 border-t border-gray-800 p-4 rounded-b-lg">
<div className="flex gap-2">
<textarea rows="2" value={replyMessage} onChange={(e) => setReplyMessage(e.target.value)} required placeholder="Taper une réponse sécurisée..." className="flex-1 bg-black border border-gray-800 text-white p-3 rounded-lg text-sm focus:border-cyan-500 outline-none transition resize-none"></textarea>
<button type="submit" className="bg-cyan-600 hover:bg-cyan-500 text-white px-6 rounded-lg font-bold font-mono tracking-widest transition flex flex-col items-center justify-center gap-1 shadow-lg shadow-cyan-500/20">
<Send className="w-4 h-4" /><span className="text-[10px]">ENVOYER</span>
</button>
</div>
</form>
) : (
<div className="bg-gray-900 border-t border-gray-800 p-4 rounded-b-lg text-center font-mono text-sm text-gray-500">
[ CE TICKET EST VERROUILLÉ ET ARCHIVÉ ]
</div>
)}
</div>
</div>
);
}
+32
View File
@@ -0,0 +1,32 @@
export default function ConfirmLogoutModal({ isOpen, onClose, onConfirm }) {
if (!isOpen) return null;
return (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.8)', backdropFilter: 'blur(4px)',
zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center'
}}>
<div style={{
backgroundColor: '#1A1A1A', border: '1px solid #ff003c',
boxShadow: '0 10px 30px rgba(255, 0, 60, 0.2)', borderRadius: '8px',
maxWidth: '400px', width: '100%', padding: '24px', color: '#FFF', fontFamily: 'monospace'
}}>
<h4 style={{ fontSize: '1.1rem', fontWeight: 'bold', letterSpacing: '1px', marginBottom: '10px', color: '#ff003c', textTransform: 'uppercase' }}>
DÉCONNEXION DU TERMINAL
</h4>
<p style={{ fontSize: '0.9rem', color: '#AAA', marginBottom: '24px', lineHeight: '1.5' }}>
Êtes-vous sûr de vouloir fermer la session sécurisée et quitter l'environnement cloud ?
</p>
<div style={{ display: 'flex', gap: '10px' }}>
<button onClick={onClose} style={{ flex: 1, padding: '10px', backgroundColor: '#333', color: '#FFF', border: 'none', borderRadius: '4px', fontFamily: 'monospace', fontWeight: 'bold', cursor: 'pointer', letterSpacing: '1px' }}>
ANNULER
</button>
<button onClick={onConfirm} style={{ flex: 1, padding: '10px', backgroundColor: '#ff003c', color: '#FFF', border: 'none', borderRadius: '4px', fontFamily: 'monospace', fontWeight: 'bold', cursor: 'pointer', letterSpacing: '1px' }}>
SE DÉCONNECTER
</button>
</div>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
export default function NotificationModal({ notification, onClose }) {
if (!notification) return null;
const isError = notification.type === 'error';
return (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[100] flex items-center justify-center p-4">
<div className={`bg-gray-900 border ${isError ? 'border-red-500/50 shadow-red-500/10' : 'border-cyan-500/50 shadow-cyan-500/10'} rounded-lg shadow-2xl max-w-sm w-full p-6 text-gray-200`}>
<h4 className={`text-lg font-mono font-bold tracking-wider mb-2 ${isError ? 'text-red-400' : 'text-cyan-400'}`}>
{notification.title.toUpperCase()}
</h4>
<p className="text-sm text-gray-400 mb-6">{notification.message}</p>
<button onClick={onClose} className={`w-full font-mono py-2 rounded text-xs font-bold transition ${isError ? 'bg-red-600 hover:bg-red-500 text-white' : 'bg-cyan-500 hover:bg-cyan-400 text-gray-950'}`}>
COMPRIS
</button>
</div>
</div>
);
}
+37 -183
View File
@@ -1,63 +1,9 @@
// src/layouts/AppLayout.jsx
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 !
// ============================================================================
// COMPOSANT : MODAL DE CONFIRMATION DE DÉCONNEXION
// ============================================================================
const ConfirmLogoutModal = ({ isOpen, onClose, onConfirm }) => {
if (!isOpen) return null;
return (
<div style={{
position: 'fixed', top: 0, left: 0, right: 0, bottom: 0,
backgroundColor: 'rgba(0,0,0,0.8)', backdropFilter: 'blur(4px)',
zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center'
}}>
<div style={{
backgroundColor: '#1A1A1A',
border: '1px solid #ff003c',
boxShadow: '0 10px 30px rgba(255, 0, 60, 0.2)',
borderRadius: '8px',
maxWidth: '400px',
width: '100%',
padding: '24px',
color: '#FFF',
fontFamily: 'monospace'
}}>
<h4 style={{ fontSize: '1.1rem', fontWeight: 'bold', letterSpacing: '1px', marginBottom: '10px', color: '#ff003c', textTransform: 'uppercase' }}>
DÉCONNEXION DU TERMINAL
</h4>
<p style={{ fontSize: '0.9rem', color: '#AAA', marginBottom: '24px', lineHeight: '1.5' }}>
Êtes-vous sûr de vouloir fermer la session sécurisée et quitter l'environnement cloud ?
</p>
<div style={{ display: 'flex', gap: '10px' }}>
<button onClick={onClose} style={{
flex: 1, padding: '10px',
backgroundColor: '#333', color: '#FFF',
border: 'none', borderRadius: '4px',
fontFamily: 'monospace', fontWeight: 'bold', cursor: 'pointer', letterSpacing: '1px'
}}>
ANNULER
</button>
<button onClick={onConfirm} style={{
flex: 1, padding: '10px',
backgroundColor: '#ff003c', color: '#FFF',
border: 'none', borderRadius: '4px',
fontFamily: 'monospace', fontWeight: 'bold', cursor: 'pointer', letterSpacing: '1px'
}}>
SE DÉCONNECTER
</button>
</div>
</div>
</div>
);
};
// ============================================================================
// COMPOSANT PRINCIPAL : LAYOUT DU PORTAIL
// ============================================================================
export default function AppLayout() { export default function AppLayout() {
const navigate = useNavigate(); const navigate = useNavigate();
const [showLogoutModal, setShowLogoutModal] = useState(false); const [showLogoutModal, setShowLogoutModal] = useState(false);
@@ -67,187 +13,95 @@ export default function AppLayout() {
try { try {
const data = await getClientTickets(); const data = await getClientTickets();
if (data && data.list) { if (data && data.list) {
// On cherche les tickets qui ont le statut 'on_hold'
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) { }
// Erreur silencieuse : on ne pollue pas l'écran si le réseau saute
}
}; };
useEffect(() => { useEffect(() => {
checkUnreadTickets(); // Premier check immédiat checkUnreadTickets();
const intervalId = setInterval(checkUnreadTickets, 120000); // Check toutes les 2 minutes const intervalId = setInterval(checkUnreadTickets, 120000);
return () => clearInterval(intervalId); return () => clearInterval(intervalId);
}, []); }, []);
// Ouvre simplement le modal
const handleLogoutClick = () => {
setShowLogoutModal(true);
};
// Exécute la vraie déconnexion
const confirmLogout = () => { const confirmLogout = () => {
// Ici, tu pourras effacer les tokens (ex: localStorage.removeItem('token'))
setShowLogoutModal(false); setShowLogoutModal(false);
navigate('/login'); navigate('/login');
}; };
// --- DESIGN SYSTEM DU BUNKER --- // 🌟 La fonction magique Tailwind pour les liens du menu
const sidebarStyle = { const navLinkClass = ({ isActive }) =>
width: '260px', `block px-6 py-4 no-underline border-l-4 transition-all uppercase tracking-widest text-sm font-mono ${isActive
backgroundColor: '#121212', // Gris blindage ? 'text-cyan-400 bg-cyan-400/5 border-cyan-400'
borderRight: '1px solid #222222', : 'text-[#888] border-transparent hover:text-gray-300 hover:bg-white/5'
height: '100vh', }`;
display: 'flex',
flexDirection: 'column',
position: 'fixed',
top: 0,
left: 0,
fontFamily: 'monospace'
};
const mainContentStyle = {
marginLeft: '260px', // Laisse la place à la sidebar
minHeight: '100vh',
backgroundColor: '#050505', // Fond abyssal
color: '#E0E0E0',
padding: '40px'
};
// Fonction pour styliser le lien actif (en Cyan)
const getNavLinkStyle = ({ isActive }) => ({
display: 'block',
padding: '15px 25px',
color: isActive ? '#00E5FF' : '#888',
backgroundColor: isActive ? 'rgba(0, 229, 255, 0.05)' : 'transparent',
textDecoration: 'none',
borderLeft: isActive ? '4px solid #00E5FF' : '4px solid transparent',
transition: 'all 0.2s',
textTransform: 'uppercase',
letterSpacing: '1px',
fontSize: '0.9rem'
});
return ( return (
<div style={{ display: 'block' }}> <div className="flex min-h-screen bg-[#050505] text-[#E0E0E0] font-sans">
{/* ========================================== */} {/* SIDEBAR */}
{/* BARRE LATÉRALE (SIDEBAR) */} <aside className="w-65 bg-[#121212] border-r border-[#222222] h-screen flex flex-col fixed top-0 left-0">
{/* ========================================== */}
<aside style={sidebarStyle}>
{/* En-tête de la Sidebar (Logo / Marque) */} <div className="px-6 py-8 border-b border-[#222]">
<div style={{ padding: '30px 25px', borderBottom: '1px solid #222' }}> <h1 className="text-white m-0 text-3xl font-black tracking-widest flex items-baseline gap-2">
<h1 style={{
color: '#FFF',
margin: 0,
fontSize: '1.8rem',
letterSpacing: '3px',
display: 'flex',
alignItems: 'baseline',
gap: '8px'
}}>
NEXUS NEXUS
<span style={{ <span className="text-cyan-400 text-[10px] tracking-widest font-normal">by GISE</span>
color: '#00E5FF',
fontSize: '0.7rem',
letterSpacing: '1px',
fontWeight: 'normal',
}}>
by GISE
</span>
</h1> </h1>
<p style={{ color: '#555', fontSize: '0.7rem', margin: '8px 0 0 0' }}> <p className="text-[#555] text-[10px] font-mono tracking-wider mt-2 uppercase">
CONSOLE D'INFRASTRUCTURE v1.0 Console d'infrastructure v1.0
</p> </p>
</div> </div>
{/* Menu de Navigation */} <nav className="flex-1 mt-6">
<nav style={{ flex: 1, marginTop: '20px' }}> <div className="px-6 mb-2 text-[#444] text-[10px] font-bold font-mono tracking-widest">
<div style={{ padding: '0 25px', marginBottom: '10px', color: '#444', fontSize: '0.7rem', fontWeight: 'bold' }}>
// SUPERVISION // SUPERVISION
</div> </div>
<NavLink style={getNavLinkStyle} to="/dashboard">Tableau de bord</NavLink> <NavLink className={navLinkClass} to="/dashboard">Tableau de bord</NavLink>
<NavLink style={getNavLinkStyle} to="/services">Inventaire Réseau</NavLink> <NavLink className={navLinkClass} to="/services">Inventaire Réseau</NavLink>
<div style={{ padding: '0 25px', marginTop: '25px', marginBottom: '10px', color: '#444', fontSize: '0.7rem', fontWeight: 'bold' }}> <div className="px-6 mt-8 mb-2 text-[#444] text-[10px] font-bold font-mono tracking-widest">
// GESTION // GESTION
</div> </div>
{/*<NavLink style={getNavLinkStyle} to="/billing" locked>Facturation</NavLink>*/} <NavLink className={navLinkClass} to="/support">
<NavLink style={getNavLinkStyle} to="/support"> <div className="flex justify-between items-center w-full">
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
<span>Tickets Support</span> <span>Tickets Support</span>
{/* LE BADGE DE NOTIFICATION */}
{unreadCount > 0 && ( {unreadCount > 0 && (
<span style={{ <span className="bg-cyan-400 text-black text-[10px] font-bold px-2 py-0.5 rounded-full shadow-[0_0_10px_rgba(0,229,255,0.6)] animate-pulse">
backgroundColor: '#00E5FF',
color: '#000',
fontSize: '0.65rem',
fontWeight: 'bold',
padding: '2px 8px',
borderRadius: '12px',
boxShadow: '0 0 10px rgba(0,229,255,0.6)'
}}>
{unreadCount} NEW {unreadCount} NEW
</span> </span>
)} )}
</div> </div>
</NavLink> </NavLink>
{/*<NavLink style={getNavLinkStyle} to="/settings">Profil & Sécurité</NavLink>*/}
{/* === MODULES VERROUILLÉS (WORK IN PROGRESS) === */}
<div style={{
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '15px 25px', color: '#444', borderLeft: '4px solid transparent',
textTransform: 'uppercase', letterSpacing: '1px', fontSize: '0.9rem',
cursor: 'not-allowed', userSelect: 'none'
}}>
<span>Facturation</span>
<span style={{ fontSize: '0.6rem', color: '#333', border: '1px solid #333', padding: '2px 5px', borderRadius: '3px', fontWeight: 'bold' }}>PROCHAINEMENT</span>
</div>
<div style={{ <NavLink className={navLinkClass} to="/facturation">Facturation</NavLink>
display: 'flex', justifyContent: 'space-between', alignItems: 'center',
padding: '15px 25px', color: '#444', borderLeft: '4px solid transparent', <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">
textTransform: 'uppercase', letterSpacing: '1px', fontSize: '0.9rem',
cursor: 'not-allowed', userSelect: 'none'
}}>
<span>Profil & Sécurité</span> <span>Profil & Sécurité</span>
<span style={{ fontSize: '0.6rem', color: '#333', border: '1px solid #333', padding: '2px 5px', borderRadius: '3px', fontWeight: 'bold' }}>PROCHAINEMENT</span> <span className="text-[9px] text-[#333] border border-[#333] px-1.5 py-0.5 rounded font-bold">WIP</span>
</div> </div>
</nav> </nav>
{/* Bas de la Sidebar (Déconnexion) */} <div className="p-5 border-t border-[#222]">
<div style={{ padding: '20px', borderTop: '1px solid #222' }}>
<button <button
onClick={handleLogoutClick} onClick={() => setShowLogoutModal(true)}
style={{ className="w-full p-3 bg-transparent text-[#ff003c] hover:bg-[#ff003c]/10 border border-[#ff003c] cursor-pointer font-mono text-sm tracking-widest uppercase transition-colors rounded-sm"
width: '100%', padding: '12px', backgroundColor: 'transparent',
color: '#ff003c', border: '1px solid #ff003c', cursor: 'pointer',
fontFamily: 'monospace', textTransform: 'uppercase'
}}
> >
[ Déconnexion ] [ Déconnexion ]
</button> </button>
</div> </div>
</aside> </aside>
{/* ========================================== */} {/* ZONE DE CONTENU */}
{/* ZONE DE CONTENU DYNAMIQUE */} <main className="ml-65 p-10 flex-1 min-h-screen">
{/* ========================================== */}
<main style={mainContentStyle}>
{/* Le composant <Outlet /> injecte le contenu de la page demandée sans recharger la sidebar */}
<Outlet context={{ refreshNavbarTickets: checkUnreadTickets }} /> <Outlet context={{ refreshNavbarTickets: checkUnreadTickets }} />
</main> </main>
{/* MODAL DE DÉCONNEXION */}
<ConfirmLogoutModal <ConfirmLogoutModal
isOpen={showLogoutModal} isOpen={showLogoutModal}
onClose={() => setShowLogoutModal(false)} onClose={() => setShowLogoutModal(false)}
onConfirm={confirmLogout} onConfirm={confirmLogout}
/> />
</div> </div>
); );
} }
+12 -19
View File
@@ -1,35 +1,28 @@
// src/layouts/PublicLayout.jsx
import { Outlet, Link } from 'react-router-dom'; import { Outlet, Link } from 'react-router-dom';
export default function PublicLayout() { export default function PublicLayout() {
return ( return (
<div style={{ backgroundColor: '#121212', color: '#E0E0E0', minHeight: '100vh', fontFamily: 'monospace' }}> <div className="bg-[#121212] text-[#E0E0E0] min-h-screen font-mono">
<nav style={{ padding: '20px', borderBottom: '1px solid #242424', display: 'flex', gap: '15px', alignItems: 'center' }}> <nav className="p-5 border-b border-[#242424] flex gap-4 items-center">
<Link to="/" style={{ display: 'flex', alignItems: 'baseline', gap: '6px', textDecoration: 'none' }}> <Link to="/" className="flex items-baseline gap-1.5 no-underline">
<span style={{ color: '#FFF', fontSize: '1.2rem', letterSpacing: '2px', fontWeight: 'bold' }}>NEXUS</span> <span className="text-white text-xl tracking-[2px] font-bold">NEXUS</span>
<span style={{ color: '#00E5FF', fontSize: '0.65rem', letterSpacing: '1px', fontWeight: 'normal'}}>by GISE</span> <span className="text-cyan-400 text-[10px] tracking-[1px]">by GISE</span>
</Link> </Link>
<Link to="/offres" style={{ color: '#E0E0E0', textDecoration: 'none', marginLeft: '20px' }}>Catalogue</Link>
{/* AJOUT DU BOUTON D'INSCRIPTION */} <Link to="/offres" className="text-[#E0E0E0] no-underline ml-5 hover:text-cyan-400 transition-colors">
<Link to="/register" style={{ color: '#A0A0A0', textDecoration: 'none', marginLeft: 'auto', fontSize: '0.9rem' }}> Catalogue
</Link>
<Link to="/register" className="text-[#A0A0A0] no-underline ml-auto text-sm hover:text-white transition-colors">
Créer un compte Créer un compte
</Link> </Link>
<Link to="/login" style={{ <Link to="/login" className="text-black bg-cyan-400 hover:bg-cyan-300 transition-colors px-3 py-1.5 no-underline font-bold text-sm">
color: '#000',
backgroundColor: '#00E5FF',
padding: '6px 12px',
textDecoration: 'none',
fontWeight: 'bold',
fontSize: '0.9rem'
}}>
Connexion Connexion
</Link> </Link>
</nav> </nav>
{/* C'est ici que les pages (Accueil, Login, etc.) vont s'afficher */} <main className="p-5">
<main style={{ padding: '20px' }}>
<Outlet /> <Outlet />
</main> </main>
</div> </div>
+338
View File
@@ -0,0 +1,338 @@
import { useState, useEffect } from 'react';
import { FileText, Download, CreditCard, Clock, CheckCircle2, XCircle, AlertCircle, Loader, X, Receipt, Trash2, ArrowRight, RefreshCcw } from 'lucide-react';
import NotificationModal from '../../components/ui/NotificationModal'; // Vérifie ton chemin
import { getClientProfile, getInvoicesHistory } from '../../services/api'; // Vérifie ton chemin
export default function BillingHistory() {
const [invoices, setInvoices] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
const [customAlert, setCustomAlert] = useState(null);
// NOUVEL ÉTAT : La facture actuellement ouverte dans la modale
const [selectedInvoice, setSelectedInvoice] = useState(null);
const triggerAlert = (title, message, type) => {
setCustomAlert({ title, message, type });
};
useEffect(() => {
const fetchBillingData = async () => {
setIsLoading(true);
setError(null);
try {
const profileData = await getClientProfile();
if (!profileData || !profileData.id) {
throw new Error("Impossible de vérifier votre identité ou session expirée.");
}
const invoicesData = await getInvoicesHistory(profileData.id);
setInvoices(invoicesData.data || []);
} catch (err) {
setError(err.message || "Problème de connexion avec le serveur.");
} finally {
setIsLoading(false);
}
};
fetchBillingData();
}, []);
const getStatusBadge = (status, total = 0) => {
const isNegative = parseFloat(total) < 0;
switch (status?.toLowerCase()) {
case 'paid':
if (isNegative) {
return { label: 'REMBOURSÉE', css: 'bg-blue-500/10 text-blue-400 border-blue-500/20', icon: <RefreshCcw className="w-3.5 h-3.5" /> };
}
return { label: 'PAYÉE', css: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/20', icon: <CheckCircle2 className="w-3.5 h-3.5" /> };
case 'refunded':
// NOUVEAU STATUT : REMBOURSÉE
case 'unpaid':
// SI IMPAYÉE MAIS NÉGATIVE = EN COURS DE TRAITEMENT
if (isNegative) {
return { label: 'REMBOURSEMENT EN COURS', css: 'bg-amber-500/10 text-amber-400 border-amber-500/20', icon: <Clock className="w-3.5 h-3.5" /> };
}
return { label: 'EN ATTENTE', css: 'bg-amber-500/10 text-amber-400 border-amber-500/20', icon: <Clock className="w-3.5 h-3.5" /> };
case 'canceled':
return { label: 'ANNULÉE', css: 'bg-gray-500/10 text-gray-400 border-gray-500/20', icon: <XCircle className="w-3.5 h-3.5" /> };
default:
return { label: status?.toUpperCase() || 'INCONNU', css: 'bg-blue-500/10 text-blue-400 border-blue-500/20', icon: <AlertCircle className="w-3.5 h-3.5" /> };
}
};
const formatDate = (dateString) => {
if (!dateString) return '--/--/----';
return new Date(dateString).toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' });
};
// Fonctions vides pour la future logique
const handlePayInvoice = (invoiceId) => {
triggerAlert("Paiement initié", `Logique de paiement Stripe/PayPal à venir pour la facture #${invoiceId}.`, "info");
};
const handleCancelInvoice = (invoiceId) => {
triggerAlert("Annulation", `Logique d'annulation à venir pour la facture #${invoiceId}.`, "info");
};
return (
<div className="w-full max-w-6xl p-6 mx-auto relative">
<header className="mb-8">
<h1 className="text-3xl font-black text-white tracking-wider">HISTORIQUE DE <span className="text-cyan-400">FACTURATION</span></h1>
<p className="text-gray-400 mt-2">Consultez vos reçus, payez vos transactions en attente et gérez vos factures.</p>
</header>
{isLoading && (
<div className="flex items-center space-x-3 text-cyan-400 mb-6">
<Loader className="w-6 h-6 animate-spin" />
<span>Synchronisation avec le registre comptable...</span>
</div>
)}
{error && (
<div className="flex items-center space-x-3 text-red-400 bg-red-400/10 border border-red-400 p-4 rounded-lg mb-6">
<AlertCircle className="w-6 h-6" />
<span>{error}</span>
</div>
)}
{!isLoading && !error && (
<div className="bg-[#090f1c] border border-gray-800 rounded-lg overflow-hidden shadow-xl">
{invoices.length === 0 ? (
<div className="text-center py-12 px-4">
<FileText className="w-10 h-10 mx-auto text-gray-600 mb-3" />
<p className="text-base font-bold text-gray-400 tracking-wider uppercase">Aucune facture</p>
<p className="text-sm text-gray-600 mt-1">Vos transactions apparaîtront ici dès qu'elles seront générées.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="bg-gray-900 border-b border-gray-800 text-gray-500 text-[10px] uppercase font-black tracking-widest">
<th className="py-4 px-6">Référence</th>
<th className="py-4 px-6">Date d'émission</th>
<th className="py-4 px-6">Statut</th>
<th className="py-4 px-6 text-right">Montant</th>
<th className="py-4 px-6 text-center">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-800/40 text-sm">
{invoices.map((invoice) => {
const badge = getStatusBadge(invoice.status, invoice.total);
return (
<tr key={invoice.id} className="hover:bg-gray-800/20 transition-colors">
<td className="py-5 px-6 font-mono text-xs font-bold text-gray-300">
{invoice.invoice_number}
</td>
<td className="py-5 px-6 text-gray-400 text-xs">
{formatDate(invoice.created_at)}
</td>
<td className="py-5 px-6">
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-black tracking-widest border ${badge.css}`}>
{badge.icon}
{badge.label}
</span>
</td>
<td className="py-5 px-6 text-right font-mono font-bold text-white tracking-wider">
{parseFloat(invoice.total).toFixed(2)} {invoice.currency || '€'}
</td>
<td className="py-5 px-6">
<div className="flex items-center justify-center gap-3">
{invoice.status?.toLowerCase() === 'unpaid' && parseFloat(invoice.total) >= 0 ? (
// 🎯 Facture positive et impayée : BOUTON PAYER
<button
onClick={() => setSelectedInvoice(invoice)}
className="flex items-center gap-1.5 px-4 py-2 bg-amber-500 hover:bg-amber-400 text-black text-[10px] font-black uppercase tracking-widest rounded transition-all shadow-[0_0_15px_rgba(245,158,11,0.2)]"
>
<CreditCard className="w-3.5 h-3.5" /> PAYER
</button>
) : (
// 🎯 Facture payée, annulée, ou NÉGATIVE (Remboursement) : BOUTON DÉTAILS UNIQUEMENT
<button
onClick={() => setSelectedInvoice(invoice)}
className="flex items-center gap-1.5 px-4 py-2 bg-gray-900 hover:bg-gray-800 text-gray-300 hover:text-cyan-400 border border-gray-700 hover:border-cyan-900 text-[10px] font-black uppercase tracking-widest rounded transition-all"
>
<Receipt className="w-3.5 h-3.5" /> DÉTAILS
</button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
)}
{/* ========================================== */}
{/* LA MODALE DE DÉTAILS DE FACTURE */}
{selectedInvoice && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm">
<div className="bg-[#090f1c] border border-gray-800 shadow-2xl shadow-cyan-500/10 rounded-2xl w-full max-w-2xl flex flex-col max-h-[90vh] overflow-hidden">
{/* HEADER MODALE */}
<div className="flex items-center justify-between p-6 border-b border-gray-800 shrink-0">
<div className="flex items-center gap-4">
<div className="p-3 bg-cyan-950/30 text-cyan-400 rounded-xl border border-cyan-900/50">
<Receipt className="w-6 h-6" />
</div>
<div>
<h3 className="text-xl font-black text-white tracking-widest uppercase">
FACTURE <span className="text-cyan-400">{selectedInvoice.invoice_number}</span>
</h3>
<p className="text-xs text-gray-500 font-medium mt-1">
Émise le {formatDate(selectedInvoice.created_at)}
</p>
</div>
</div>
<button onClick={() => setSelectedInvoice(null)} className="p-2 text-gray-500 hover:text-white bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors">
<X className="w-5 h-5" />
</button>
</div>
{/* CORPS DE LA MODALE */}
<div className="p-6 overflow-y-auto grow custom-scrollbar space-y-6">
{/* Statut Badge */}
<div className="flex items-center justify-between p-4 bg-[#0d1527] rounded-xl border border-gray-800">
<span className="text-xs font-bold text-gray-400 uppercase tracking-wider">Statut du document</span>
<span className={`inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-black tracking-widest border ${getStatusBadge(selectedInvoice.status).css}`}>
{getStatusBadge(selectedInvoice.status).icon}
{getStatusBadge(selectedInvoice.status).label}
</span>
</div>
{/* Lignes de facture (Filtrées : uniquement les prix > 0) */}
<div>
<h4 className="text-xs font-bold text-gray-500 uppercase tracking-widest mb-3 border-b border-gray-800 pb-2">Détail des prestations</h4>
<div className="space-y-4">
{(() => {
// FILTRE : On ne garde que les items dont le prix est strictement supérieur à 0
const validItems = selectedInvoice.items
? selectedInvoice.items.filter(item => parseFloat(item.price) > 0)
: [];
if (validItems.length > 0) {
return validItems.map((item, idx) => (
<div key={idx} className="flex justify-between items-start text-sm">
<div className="pr-4">
<p className="text-white font-medium">{item.title}</p>
<p className="text-xs text-gray-500 mt-0.5">Quantité : {item.quantity}</p>
</div>
<div className="text-right font-mono font-bold text-gray-300 shrink-0">
{(parseFloat(item.price) * parseInt(item.quantity)).toFixed(2)} {selectedInvoice.currency || '€'}
</div>
</div>
));
} else {
// Fallback si la facture n'a que des items à 0€ ou aucun détail
return (
<div className="flex justify-between items-start text-sm">
<div>
<p className="text-white font-medium">Services d'infrastructure technique</p>
<p className="text-xs text-gray-500 mt-0.5">Détail inclus ou facturation sans frais complémentaires.</p>
</div>
<div className="text-right font-mono font-bold text-gray-300 shrink-0">
{parseFloat(selectedInvoice.total).toFixed(2)} {selectedInvoice.currency || ''}
</div>
</div>
);
}
})()}
</div>
</div>
{/* CALCULS (EXTRACTION TVA DEPUIS LE TTC) */}
{(() => {
// Le total récupéré de la BDD est considéré comme le PRIX FINAL (TTC)
const totalTTC = parseFloat(selectedInvoice.total) || 0;
const vatRate = 0.21; // TVA Belge à 21%
// Extraction du Hors Taxe : TTC / 1.21
const subtotalHT = totalTTC / (1 + vatRate);
// Le montant exact de la taxe
const vatAmount = totalTTC - subtotalHT;
return (
<div className="flex flex-col items-end pt-5 border-t border-gray-800">
<div className="flex justify-between w-full sm:w-[60%] text-sm mb-2">
<span className="text-gray-500">Sous-total (HT)</span>
<span className="text-gray-400 font-mono">{subtotalHT.toFixed(2)} {selectedInvoice.currency || ''}</span>
</div>
<div className="flex justify-between w-full sm:w-[60%] text-sm mb-4">
<span className="text-gray-500">TVA (21%)</span>
<span className="text-gray-400 font-mono">{vatAmount.toFixed(2)} {selectedInvoice.currency || ''}</span>
</div>
<div className="flex justify-between w-full sm:w-[60%] text-lg border-t border-gray-800 pt-3">
<span className="font-bold text-white uppercase tracking-wider">Total TTC</span>
<span className="font-black text-cyan-400 font-mono text-xl">{totalTTC.toFixed(2)} {selectedInvoice.currency || ''}</span>
</div>
</div>
);
})()}
</div>
{/* PIED DE LA MODALE (Actions) */}
<div className="p-6 border-t border-gray-800 bg-gray-900/50 shrink-0 flex flex-col sm:flex-row items-center justify-between gap-4">
{selectedInvoice.status?.toLowerCase() === 'unpaid' && parseFloat(selectedInvoice.total) >= 0 ? (
<>
{/* Cas standard : Facture positive en attente de paiement */}
<button
onClick={() => handleCancelInvoice(selectedInvoice.id)}
className="w-full sm:w-auto px-5 py-3 text-red-400 hover:text-white bg-red-500/10 hover:bg-red-500 border border-red-500/20 hover:border-red-500 text-xs font-bold uppercase tracking-wider rounded-xl transition-all flex items-center justify-center gap-2"
>
<Trash2 className="w-4 h-4" /> Annuler la facture
</button>
<button
onClick={() => handlePayInvoice(selectedInvoice.id)}
className="w-full sm:w-auto px-8 py-3 bg-amber-500 hover:bg-amber-400 text-black text-xs font-black uppercase tracking-widest rounded-xl transition-all shadow-[0_0_20px_rgba(245,158,11,0.2)] flex items-center justify-center gap-2"
>
<CreditCard className="w-4 h-4" /> Procéder au paiement <ArrowRight className="w-4 h-4" />
</button>
</>
) : (
<>
{/* Cas alternatif : Facture payée, annulée, ou NÉGATIVE (Remboursement) */}
<button onClick={() => setSelectedInvoice(null)} className="w-full sm:w-auto px-5 py-3 text-gray-400 hover:text-white bg-gray-900 hover:bg-gray-800 border border-gray-800 text-xs font-bold uppercase tracking-wider rounded-xl transition-all">
Fermer
</button>
{parseFloat(selectedInvoice.total) < 0 && selectedInvoice.status?.toLowerCase() === 'unpaid' && (
<div className="text-xs font-bold text-amber-400 bg-amber-500/5 border border-amber-500/10 px-4 py-2.5 rounded-xl uppercase tracking-wider animate-pulse">
⚡ Flux financier en cours de traitement
</div>
)}
{selectedInvoice.status?.toLowerCase() === 'refunded' && (
<div className="text-xs font-bold text-blue-400 bg-blue-500/5 border border-blue-500/10 px-4 py-2.5 rounded-xl uppercase tracking-wider">
✓ Fonds reversés au client
</div>
)}
{selectedInvoice.status?.toLowerCase() === 'paid' && (
<a
href={`/invoice/pdf/${selectedInvoice.id}`}
target="_blank" rel="noopener noreferrer"
className="w-full sm:w-auto px-8 py-3 bg-cyan-500 hover:bg-cyan-400 text-black text-xs font-black uppercase tracking-widest rounded-xl transition-all shadow-[0_0_20px_rgba(6,182,212,0.2)] flex items-center justify-center gap-2"
>
<Download className="w-4 h-4" /> Télécharger le reçu
</a>
)}
</>
)}
</div>
</div>
</div>
)}
<NotificationModal notification={customAlert} onClose={() => setCustomAlert(null)} />
</div>
);
}
+69 -71
View File
@@ -1,7 +1,13 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { getClientOrders } from '../../services/api'; import { getClientOrders } from '../../services/api';
import { Server, Database, Cloud, Globe, Plus, AlertCircle, Loader } from 'lucide-react'; import { AlertCircle, Loader, FileText, X } from 'lucide-react';
// Importation des composants
import DashboardServiceCard from '../../components/dashboard/DashboardServiceCard';
import NewServiceCard from '../../components/dashboard/NewServiceCard';
import NotificationModal from '../../components/ui/NotificationModal';
import SubscriptionManager from '../../components/dashboard/SubscriptionManager'; // 🌟 Le nouveau composant !
export default function Dashboard() { export default function Dashboard() {
const navigate = useNavigate(); const navigate = useNavigate();
@@ -9,9 +15,13 @@ export default function Dashboard() {
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Chargement des données à l'ouverture du Sas const [activeSubscriptionModal, setActiveSubscriptionModal] = useState(null);
useEffect(() => { const [customAlert, setCustomAlert] = useState(null);
const triggerAlert = (title, message, type = "info") => setCustomAlert({ title, message, type });
const fetchInventory = async () => { const fetchInventory = async () => {
setIsLoading(true);
try { try {
const data = await getClientOrders(); const data = await getClientOrders();
@@ -21,16 +31,12 @@ export default function Dashboard() {
const title = (order.title || '').toLowerCase(); const title = (order.title || '').toLowerCase();
const type = (order.type || '').toLowerCase(); const type = (order.type || '').toLowerCase();
// On masque la commande SI :
// 1. Le type technique est "domain"
// 2. OU le titre COMMENCE par "domain", "domaine" ou "enregistrement"
const isGhostProduct = const isGhostProduct =
type === 'domain' || type === 'domain' ||
title.startsWith('domain ') || title.startsWith('domain ') ||
title.startsWith('domaine ') || title.startsWith('domaine ') ||
title.startsWith('enregistrement '); title.startsWith('enregistrement ');
// On retourne true (on affiche) uniquement si ce n'est pas un produit fantôme
return !isGhostProduct; return !isGhostProduct;
}); });
@@ -45,41 +51,46 @@ export default function Dashboard() {
} }
}; };
useEffect(() => {
fetchInventory(); fetchInventory();
}, []); }, []);
// Fonction Radar : Détecte le type de service selon son nom pour afficher la bonne icône // 🌟 Plus besoin de charger les IPs, on ouvre juste la modale comptable !
const getServiceIcon = (title) => { const handleManageSubscription = (order) => {
const t = title.toLowerCase(); // 1. Détection du type de service
if (t.includes('vps') || t.includes('serveur')) return <Server className="w-10 h-10 text-cyan-400" />; const titleLower = (order.title || '').toLowerCase();
if (t.includes('cloud') || t.includes('nextcloud')) return <Cloud className="w-10 h-10 text-blue-400" />; const isVPS = titleLower.includes('vps') || titleLower.includes('compute');
if (t.includes('db') || t.includes('base') || t.includes('sql')) return <Database className="w-10 h-10 text-purple-400" />; const isCloud = titleLower.includes('cloud') || titleLower.includes('nextcloud');
return <Globe className="w-10 h-10 text-emerald-400" />; // Par défaut : Web / Hestia const isDB = titleLower.includes('db') || titleLower.includes('base') || titleLower.includes('sql');
};
// Fonction d'état : Formate le statut du service // C'est un hébergement web s'il ne correspond à aucun des cas ci-dessus
const getStatusBadge = (status) => { const isWeb = !isVPS && !isCloud && !isDB;
switch (status) {
case 'active': return <span className="px-2 py-1 text-xs text-green-400 bg-green-400/10 border border-green-400/20 rounded">ACTIF</span>; // 2. Logique de blocage
case 'pending_setup': return <span className="px-2 py-1 text-xs text-orange-400 bg-orange-400/10 border border-orange-400/20 rounded">EN PRÉPARATION</span>; if (isWeb) {
case 'suspended': return <span className="px-2 py-1 text-xs text-red-400 bg-red-400/10 border border-red-400/20 rounded">SUSPENDU</span>; // On ouvre la modale comptable uniquement pour le WEB
default: return <span className="px-2 py-1 text-xs text-gray-400 bg-gray-400/10 border border-gray-400/20 rounded">{status.toUpperCase()}</span>; 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 ( return (
<div className="w-full max-w-6xl p-6 mx-auto"> <div className="w-full max-w-6xl p-6 mx-auto relative">
<header className="mb-8"> <header className="mb-8">
<h1 className="text-3xl font-black text-white tracking-wider">TABLEAU DE <span className="text-cyan-400">BORD</span></h1> <h1 className="text-3xl font-black text-white tracking-wider">TABLEAU DE <span className="text-cyan-400">BORD</span></h1>
<p className="text-gray-400 mt-2">Aperçu de vos accréditations réseau et infrastructures.</p> <p className="text-gray-400 mt-2">Gestion financière et accréditations de vos infrastructures.</p>
</header> </header>
{/* GESTION DES ERREURS & CHARGEMENT */}
{isLoading && ( {isLoading && (
<div className="flex items-center space-x-3 text-cyan-400"> <div className="flex items-center space-x-3 text-cyan-400">
<Loader className="w-6 h-6 animate-spin" /> <Loader className="w-6 h-6 animate-spin" />
<span>Synchronisation avec l'orchestrateur en cours...</span> <span>Synchronisation avec le registre comptable...</span>
</div> </div>
)} )}
@@ -90,57 +101,44 @@ export default function Dashboard() {
</div> </div>
)} )}
{/* GRILLE DES SERVICES */}
{!isLoading && !error && ( {!isLoading && !error && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{/* Boucle sur les services FOSSBilling */}
{orders.map((order) => ( {orders.map((order) => (
<div <DashboardServiceCard
key={order.id} key={order.id}
className="bg-gray-900 border border-gray-800 p-6 rounded-xl hover:border-cyan-400/50 transition-colors group cursor-pointer flex flex-col justify-between" order={order}
onClick={() => navigate(`/services/${order.id}`)} // Redirection future vers les détails onClick={() => handleManageSubscription(order)}
> />
<div>
<div className="flex justify-between items-start mb-4">
<div className="p-3 bg-black/50 rounded-lg group-hover:scale-110 transition-transform">
{getServiceIcon(order.title)}
</div>
{getStatusBadge(order.status)}
</div>
<h3 className="text-lg font-semibold text-white truncate" title={order.title}>
{order.title}
</h3>
<p className="text-sm text-gray-500 mt-1">
Facturation : {order.period}
</p>
</div>
<div className="mt-6 pt-4 border-t border-gray-800 flex justify-between items-center text-sm">
<span className="text-gray-400">ID Réseau: #{order.id}</span>
<span className="text-cyan-400 group-hover:underline">Gérer &gt;</span>
</div>
</div>
))} ))}
<NewServiceCard onClick={() => navigate('/store')} />
{/* LA CARTE : OBTENIR UN NOUVEAU PRODUIT */}
<div
onClick={() => navigate('/store')} // Remplace /store par l'URL de ton catalogue
className="bg-transparent border-2 border-dashed border-gray-700 hover:border-cyan-400 p-6 rounded-xl transition-colors cursor-pointer flex flex-col items-center justify-center text-center group min-h-[200px]"
>
<div className="p-3 bg-gray-800/50 rounded-full group-hover:bg-cyan-400/20 transition-colors mb-4">
<Plus className="w-8 h-8 text-gray-400 group-hover:text-cyan-400" />
</div>
<h3 className="text-lg font-semibold text-white group-hover:text-cyan-400">
Demander une accréditation
</h3>
<p className="text-sm text-gray-500 mt-2">
Déployer un nouveau serveur Web, VPS ou Cloud.
</p>
</div>
</div> </div>
)} )}
{/* 🌟 LA MODALE DE GESTION D'ABONNEMENT */}
{activeSubscriptionModal && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-cyan-500/50 shadow-2xl shadow-cyan-500/10 rounded-lg max-w-xxl w-full p-6 text-gray-200">
<div className="flex justify-between items-center mb-6 border-b border-gray-800 pb-4">
<h3 className="text-xl font-bold text-white tracking-wider flex items-center gap-3">
<FileText className="w-6 h-6 text-cyan-400" />
GESTION DE L'ABONNEMENT
</h3>
<button onClick={() => setActiveSubscriptionModal(null)} className="p-2 text-gray-500 hover:text-white bg-gray-900 hover:bg-gray-800 rounded-lg transition-colors">
<X className="w-5 h-5" />
</button>
</div>
<SubscriptionManager
order={activeSubscriptionModal}
onClose={() => setActiveSubscriptionModal(null)}
onRefresh={fetchInventory}
onAlert={triggerAlert}
/>
</div>
</div>
)}
<NotificationModal notification={customAlert} onClose={() => setCustomAlert(null)} />
</div> </div>
); );
} }
+33 -588
View File
@@ -1,496 +1,39 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { getMyServices, getServiceDetails, getHostingServiceDetails, resetHostingPassword } from '../../services/api'; import { getMyServices, getHostingServiceDetails } from '../../services/api';
import { useVPC } from '../../services/useVPC'; import { useVPC } from '../../services/useVPC';
import { Server, Database, Cloud, Globe, Folder, Trash2, ExternalLink, Loader, Plus, Play, Lock, Maximize2, AlertCircle } from 'lucide-react'; // Ajout de AlertCircle import { Server, Globe, Folder, Trash2, Loader, Plus, AlertCircle, X } from 'lucide-react';
// ============================================================================ // Importation de tes nouveaux composants modulaires !
// COMPOSANT 0 : MODAL DE NOTIFICATION (Remplace les alert() natifs) import NotificationModal from '../../components/ui/NotificationModal';
// ============================================================================ import InstanceCard from '../../components/services/InstanceCard';
const NotificationModal = ({ notification, onClose }) => { import VpsDeployer from '../../components/services/VpsDeployer';
if (!notification) return null; import VpsManager from '../../components/services/VpsManager';
const isError = notification.type === 'error'; import WebManager from '../../components/services/WebManager';
return (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[100] flex items-center justify-center p-4">
<div className={`bg-gray-900 border ${isError ? 'border-red-500/50 shadow-red-500/10' : 'border-cyan-500/50 shadow-cyan-500/10'} rounded-lg shadow-2xl max-w-sm w-full p-6 text-gray-200`}>
<h4 className={`text-lg font-mono font-bold tracking-wider mb-2 ${isError ? 'text-red-400' : 'text-cyan-400'}`}>
{notification.title.toUpperCase()}
</h4>
<p className="text-sm text-gray-400 mb-6">{notification.message}</p>
<button onClick={onClose} className={`w-full font-mono py-2 rounded text-xs font-bold transition ${isError ? 'bg-red-600 hover:bg-red-500 text-white' : 'bg-cyan-500 hover:bg-cyan-400 text-gray-950'}`}>
COMPRIS
</button>
</div>
</div>
);
};
// ============================================================================
// COMPOSANT 1 : INSTANCE CARD (Mode PaaS Pur - IP Masquée)
// ============================================================================
const InstanceCard = ({ service, vpcs, onAssignVpc, onRemoveVpc, onOpenConsole, isConnecting }) => {
const titleLower = (service.title || '').toLowerCase();
const isVPS = titleLower.includes('vps') || titleLower.includes('compute');
const isCloud = titleLower.includes('cloud') || titleLower.includes('nextcloud');
const isDB = titleLower.includes('db') || titleLower.includes('base') || titleLower.includes('sql');
const isWeb = !isVPS && !isCloud && !isDB;
const hasIP = service.hostingDetails?.ip && service.hostingDetails.ip !== '127.0.0.1' && service.hostingDetails.ip !== '';
let buttonText = "CONSOLE D'ADMINISTRATION";
let ButtonIcon = ExternalLink;
let buttonStyle = "bg-cyan-400/10 hover:bg-cyan-400 text-cyan-400 hover:text-gray-900 border-cyan-400";
if (isVPS) {
if (hasIP) {
buttonText = "GÉRER L'INSTANCE";
ButtonIcon = ExternalLink;
} else {
buttonText = "INITIALISATION";
ButtonIcon = Play;
buttonStyle = "bg-yellow-500/10 text-yellow-500 hover:bg-yellow-500 hover:text-gray-900 border-yellow-500/50";
}
} else if (isWeb) {
buttonText = "GÉRER L'HÉBERGEMENT";
ButtonIcon = Globe;
buttonStyle = "bg-emerald-400/10 hover:bg-emerald-400 text-emerald-400 hover:text-gray-900 border-emerald-400";
} else if (isCloud) {
buttonText = "ACCÉDER AU CLOUD";
buttonStyle = "bg-blue-400/10 hover:bg-blue-400 text-blue-400 hover:text-gray-900 border-blue-400";
} else if (isDB) {
buttonText = "PHPMYADMIN / CLUSTER";
ButtonIcon = Database;
buttonStyle = "bg-purple-400/10 hover:bg-purple-400 text-purple-400 hover:text-gray-900 border-purple-400";
}
const getServiceIcon = () => {
if (isVPS) return <Server className="w-8 h-8 text-cyan-400" />;
if (isCloud) return <Cloud className="w-8 h-8 text-blue-400" />;
if (isDB) return <Database className="w-8 h-8 text-purple-400" />;
return <Globe className="w-8 h-8 text-emerald-400" />;
};
const baseTitle = (service.title || '').split(/(?: for | pour )/i)[0].trim();
const shortTitle = baseTitle.split(' ').slice(0, 2).join(' ');
const titleMatch = (service.title || '').match(/(?: for | pour )(.+)$/i);
const domainFromTitle = titleMatch ? titleMatch[1].trim() : null;
let displayDomain = service.hostingDetails?.domain && service.hostingDetails.domain !== '127.0.0.1'
? service.hostingDetails.domain
: domainFromTitle || service.domain;
if (!displayDomain || displayDomain === '127.0.0.1') displayDomain = null;
return (
<div className="bg-gray-900 border border-gray-800 rounded-xl p-5 flex flex-col justify-between hover:border-gray-700 transition-all shadow-lg">
<div>
<div className="flex justify-between items-start mb-4">
<div className="p-2 bg-black/40 rounded-lg">
{getServiceIcon()}
</div>
{service.status === 'active' ? (
<span className="bg-emerald-500/10 text-emerald-400 px-2 py-1 rounded text-xs border border-emerald-500/20 font-bold tracking-widest">
{isVPS && !hasIP ? 'AWAITING INIT' : 'ONLINE'}
</span>
) : (
<span className="bg-orange-500/10 text-orange-400 px-2 py-1 rounded text-xs border border-orange-500/20 animate-pulse">DEPLOYING</span>
)}
</div>
<h3 className="font-bold text-white text-lg truncate" title={baseTitle}>
{shortTitle}
</h3>
<div className="flex flex-col gap-1 mt-2 mb-4 h-8 justify-center">
{displayDomain ? (
<p className={`${isVPS ? 'text-cyan-400' : 'text-emerald-400'} text-xs font-mono truncate`} title={displayDomain}>
{displayDomain}
</p>
) : (
<p className="text-gray-600 text-xs font-mono italic">En attente de déploiement</p>
)}
</div>
</div>
<div className="mt-auto space-y-3">
<div className="flex items-center justify-between text-sm border-t border-gray-800 pt-3">
<span className="text-gray-500 text-xs">Projet VPC:</span>
<select
onChange={(e) => {
const val = e.target.value;
if (val === "free") onRemoveVpc(service.id);
else if (val) onAssignVpc(service.id, val);
}}
className="bg-black border border-gray-700 text-gray-300 rounded px-2 py-1 outline-none focus:border-cyan-400 text-xs w-[130px]"
defaultValue={vpcs.find(v => v.services.includes(service.id))?.id || "free"}
>
<option value="free">-- Libre --</option>
{vpcs.map(vpc => (
<option key={vpc.id} value={vpc.id}>{vpc.name}</option>
))}
</select>
</div>
<button
onClick={() => onOpenConsole(service)}
disabled={isConnecting === service.id || service.status !== 'active'}
className={`w-full flex items-center justify-center space-x-2 py-2.5 rounded-lg font-bold text-sm tracking-wide transition-all disabled:opacity-50 border ${buttonStyle}`}
>
{isConnecting === service.id ? (
<><Loader className="w-4 h-4 animate-spin" /><span>CONNEXION...</span></>
) : (
<><ButtonIcon className="w-4 h-4" /><span>{buttonText}</span></>
)}
</button>
</div>
</div>
);
};
// ============================================================================
// COMPOSANT 2 : VPS DEPLOYER (Instanciation)
// ============================================================================
const VpsDeployer = ({ serviceId, onDeploySuccess, onAlert }) => {
const [domain, setDomain] = useState('');
const [password, setPassword] = useState('');
const [isDeploying, setIsDeploying] = useState(false);
const handleDeploy = async () => {
if (password.length < 8) { onAlert("Sécurité", "Le mot de passe doit faire au moins 8 caractères.", "error"); return; }
setIsDeploying(true);
try {
const response = await fetch('https://web.gise.be/custom_api/proxmox_create_vps.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ service_id: serviceId, domain: domain, password: password })
});
const data = await response.json();
if (data.status === 'success') {
onDeploySuccess({ ip: data.ip, domain: data.domain, password: password });
} else {
onAlert("Échec", "Erreur de provisionnement : " + (data.message || 'Inconnue'), "error");
}
} catch (err) {
onAlert("Erreur Réseau", "Erreur de communication avec l'API Proxmox Gateway.", "error");
} finally {
setIsDeploying(false);
}
};
return (
<div>
<div className="bg-yellow-900/20 border border-yellow-600/30 p-4 rounded mb-6 text-yellow-500/80 text-sm">
<span className="font-bold text-yellow-500">PHASE D'INITIALISATION REQUISE</span><br />
La facturation est activée. Veuillez définir un mot de passe Root pour lancer la création de l'instance sur l'hyperviseur.
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Hostname personnalisé (Optionnel)</label>
<input type="text" value={domain} onChange={(e) => setDomain(e.target.value.toLowerCase())} placeholder="Laissez vide pour le défaut" className="w-full bg-gray-950 border border-gray-800 text-white p-3 rounded font-mono outline-none focus:border-cyan-500 transition-colors" />
</div>
<div>
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Mot de passe Root (Requis)</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="••••••••" className="w-full bg-gray-950 border border-gray-800 text-white p-3 rounded font-mono outline-none focus:border-cyan-500 transition-colors" />
</div>
</div>
<button onClick={handleDeploy} disabled={isDeploying} className={`mt-6 w-full px-6 py-3 rounded font-bold font-mono tracking-widest uppercase transition ${isDeploying ? 'bg-gray-800 text-gray-500 cursor-not-allowed' : 'bg-cyan-600 hover:bg-cyan-500 text-white shadow-lg shadow-cyan-500/20'}`}>
{isDeploying ? 'Fabrication sur le Métal en cours...' : 'Lancer l\'Instanciation'}
</button>
</div>
);
};
// ============================================================================
// COMPOSANT 3 : VPS MANAGER (Day-2 Operations)
// ============================================================================
const VpsManager = ({ details, orderId, onRefresh, onAlert }) => {
const [showTerminal, setShowTerminal] = useState(false);
const [isEditingDomain, setIsEditingDomain] = useState(false);
const [newDomain, setNewDomain] = useState(details.domain);
const [isUpdating, setIsUpdating] = useState(false);
const [sslStatus, setSslStatus] = useState(null);
const isInternal = details.domain && details.domain.endsWith('.gise.be');
const isCustomDomain = details.domain && !isInternal;
const fileManagerUrl = isInternal ? `https://file-${details.domain}` : `http://${details.domain}:8080`;
const terminalUrl = isInternal ? `https://terminal-${details.domain}` : `http://${details.domain}:8081`;
const handleUpdateDomain = async () => {
if (!newDomain || newDomain === details.domain) return;
setIsUpdating(true);
try {
const response = await fetch('https://web.gise.be/custom_api/update_service_domain.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ order_id: orderId, new_domain: newDomain })
});
const data = await response.json();
if (data.status === 'success') {
setIsEditingDomain(false);
if (onRefresh) onRefresh();
onAlert("Succès", "Domaine VPS mis à jour avec succès sur l'infrastructure !", "success");
} else onAlert("Erreur", data.error, "error");
} catch (err) {
onAlert("Erreur", "Erreur de connexion avec l'API.", "error");
} finally { setIsUpdating(false); }
};
const handleGenerateSSL = async () => {
setSslStatus('loading');
try {
const response = await fetch('https://web.gise.be/custom_api/generate_custom_ssl.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ domain: details.domain })
});
const data = await response.json();
if (data.status === 'success') {
setSslStatus('success');
onAlert("SSL Activé", "Certificat SSL Let's Encrypt généré et activé avec succès !", "success");
} else {
setSslStatus('error');
onAlert("Challenge DNS Échoué", "Vérifiez que votre domaine pointe bien vers notre IP publique.", "error");
}
} catch (e) { setSslStatus('error'); }
};
return (
<div>
{!showTerminal ? (
<>
<div className="mb-6 bg-gray-950 border border-gray-800 rounded p-4">
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-2">Domaine de l'instance VPS</label>
{!isEditingDomain ? (
<div className="flex justify-between items-center">
<span className="font-mono text-xl text-green-400 font-bold flex items-center gap-2">
<span className="h-3 w-3 rounded-full bg-green-500 shadow-[0_0_10px_#22c55e]"></span>
{details.domain}
</span>
<button onClick={() => setIsEditingDomain(true)} className="text-xs border border-gray-700 hover:border-cyan-500 text-gray-400 hover:text-cyan-400 px-3 py-1.5 rounded transition">MODIFIER</button>
</div>
) : (
<div className="flex gap-2">
<input type="text" value={newDomain} onChange={(e) => setNewDomain(e.target.value.toLowerCase())} className="flex-1 bg-black border border-gray-700 rounded px-3 py-1.5 text-white font-mono text-sm focus:border-cyan-500 outline-none" disabled={isUpdating} />
<button onClick={handleUpdateDomain} disabled={isUpdating} className="bg-cyan-500 text-gray-900 px-4 py-1.5 rounded text-xs font-bold hover:bg-cyan-400">
{isUpdating ? 'SYNC...' : 'SAUVEGARDER'}
</button>
<button onClick={() => setIsEditingDomain(false)} className="bg-gray-800 text-white px-3 py-1.5 rounded text-xs hover:bg-gray-700">ANNULER</button>
</div>
)}
{isCustomDomain && (
<div className="mt-4 border-t border-gray-900 pt-3 flex justify-between items-center">
<p className="text-[11px] text-gray-500">Domaine externe détecté. Sécurisez après pointage DNS.</p>
<button onClick={handleGenerateSSL} disabled={sslStatus === 'loading'} className="text-xs bg-purple-600/20 hover:bg-purple-600 text-purple-400 hover:text-white border border-purple-500/30 px-3 py-1 rounded transition flex items-center gap-1">
{sslStatus === 'loading' ? 'GÉNÉRATION...' : <><Lock className="w-3 h-3" /> GÉNÉRER SSL</>}
</button>
</div>
)}
<div className="mt-4 bg-black/60 border border-gray-900 rounded p-3 text-[11px] space-y-1">
<span className="text-gray-500 font-bold uppercase tracking-wider block mb-1 text-[10px]">Identifiants d'usine :</span>
<p className="text-gray-400 font-mono">Console SSH : <span className="text-cyan-400 font-bold">root</span> / <span className="text-gray-500 italic">Clé choisie à l'initialisation</span></p>
<p className="text-gray-400 font-mono">Explorateur : <span className="text-cyan-400 font-bold">root</span> / <span className="text-cyan-400 font-bold">NexusGise2026! (à changer)</span></p>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div>
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Explorateur de Fichiers</label>
<button onClick={() => window.open(fileManagerUrl, '_blank')} className="w-full bg-gray-950 hover:bg-gray-900 border border-gray-800 hover:border-cyan-500/50 rounded p-3 flex justify-between items-center transition group">
<span className="font-mono text-gray-300 text-sm flex items-center gap-2"><Folder className="w-4 h-4 text-cyan-500" /> Ouvrir l'explorateur</span>
<ExternalLink className="w-4 h-4 text-gray-600 group-hover:text-cyan-400" />
</button>
</div>
<div>
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Console Système</label>
<button onClick={() => setShowTerminal(true)} className="w-full bg-cyan-900/20 hover:bg-cyan-900/40 border border-cyan-800/50 hover:border-cyan-500 rounded p-3 flex justify-between items-center transition group">
<span className="font-mono text-cyan-400 text-sm flex items-center gap-2"><span>{">_"}</span> Ouvrir le Terminal</span>
<Play className="w-4 h-4 text-cyan-600 group-hover:text-cyan-400" />
</button>
</div>
</div>
</>
) : (
<div>
<div className="flex justify-between items-center mb-4 border-b border-gray-800 pb-2">
<span className="text-cyan-400 font-mono text-sm tracking-widest">NEXUS TERMINAL ({details.domain})</span>
<div className="flex items-center space-x-2">
<button onClick={() => { window.open(terminalUrl, '_blank'); setShowTerminal(false); }} className="text-xs bg-cyan-600/30 text-cyan-400 hover:bg-cyan-600 hover:text-white border border-cyan-500/30 px-3 py-1 rounded transition flex items-center gap-1">
<Maximize2 className="w-3 h-3" /> Plein Écran
</button>
<button onClick={() => setShowTerminal(false)} className="text-xs bg-red-900/30 text-white px-3 py-1 rounded">Fermer</button>
</div>
</div>
<div className="bg-black border border-gray-800 rounded h-[400px]">
<iframe src={terminalUrl} className="w-full h-full border-none" title="Terminal" />
</div>
</div>
)}
</div>
);
};
// ============================================================================
// COMPOSANT 4 : WEB MANAGER (HestiaCP & Day-2 Operations)
// ============================================================================
const WebManager = ({ details, orderId, onRefresh, onOpenSso, onAlert }) => {
const [isEditingDomain, setIsEditingDomain] = useState(false);
const [newDomain, setNewDomain] = useState(details.domain);
const [isUpdating, setIsUpdating] = useState(false);
const [sslStatus, setSslStatus] = useState(null);
const [isGeneratingSso, setIsGeneratingSso] = useState(false);
const isCustomDomain = details.domain && !details.domain.endsWith('.gise.be');
const siteUrl = (isCustomDomain && sslStatus !== 'success') ? `http://${details.domain}` : `https://${details.domain}`;
const handleUpdateDomain = async () => {
if (!newDomain || newDomain === details.domain) return;
setIsUpdating(true);
try {
const response = await fetch('https://web.gise.be/custom_api/update_service_domain.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ order_id: orderId, new_domain: newDomain })
});
const data = await response.json();
if (data.status === 'success') {
setIsEditingDomain(false);
if (onRefresh) onRefresh();
onAlert("Succès", "Domaine web mis à jour avec succès sur le serveur HestiaCP !", "success");
} else onAlert("Erreur", data.error, "error");
} catch (err) {
onAlert("Erreur", "Erreur de connexion avec l'API.", "error");
} finally { setIsUpdating(false); }
};
const handleGenerateSSL = async () => {
setSslStatus('loading');
try {
const response = await fetch('https://web.gise.be/custom_api/generate_custom_ssl.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({ domain: details.domain })
});
const data = await response.json();
if (data.status === 'success') {
setSslStatus('success');
onAlert("SSL Validé", "Certificat SSL Let's Encrypt généré !", "success");
} else {
setSslStatus('error');
onAlert("Erreur DNS", "Échec Let's Encrypt. Vérifiez vos DNS.", "error");
}
} catch (e) { setSslStatus('error'); }
};
const handleOpenPanel = async () => {
setIsGeneratingSso(true);
try {
const secureHash = Math.random().toString(36).slice(-8) + Math.random().toString(36).slice(-4).toUpperCase();
const rollingPassword = `Nx${secureHash}`;
await resetHostingPassword(orderId, rollingPassword);
onOpenSso({ username: details.username, password: rollingPassword, url: 'https://panel.gise.be/login/' });
} catch (err) {
onAlert("Erreur Métal", "Erreur lors de la génération du SSO HestiaCP.", "error");
} finally {
setIsGeneratingSso(false);
}
};
return (
<div>
<div className="mb-6 bg-gray-950 border border-gray-800 rounded p-4">
<label className="block text-xs uppercase tracking-widest text-emerald-500 mb-2">Domaine du Site Web</label>
{!isEditingDomain ? (
<div className="flex justify-between items-center">
<span className="font-mono text-xl text-emerald-400 font-bold flex items-center gap-2">
<span className="h-3 w-3 rounded-full bg-emerald-500 shadow-[0_0_10px_#10b981]"></span>
{details.domain}
</span>
<button onClick={() => setIsEditingDomain(true)} className="text-xs border border-gray-700 hover:border-emerald-500 text-gray-400 hover:text-emerald-400 px-3 py-1.5 rounded transition">MODIFIER</button>
</div>
) : (
<div className="flex gap-2">
<input type="text" value={newDomain} onChange={(e) => setNewDomain(e.target.value.toLowerCase())} className="flex-1 bg-black border border-gray-700 rounded px-3 py-1.5 text-white font-mono text-sm focus:border-emerald-500 outline-none" disabled={isUpdating} />
<button onClick={handleUpdateDomain} disabled={isUpdating} className="bg-emerald-500 text-gray-900 px-4 py-1.5 rounded text-xs font-bold hover:bg-emerald-400">
{isUpdating ? 'SYNC...' : 'SAUVEGARDER'}
</button>
<button onClick={() => setIsEditingDomain(false)} className="bg-gray-800 text-white px-3 py-1.5 rounded text-xs hover:bg-gray-700">ANNULER</button>
</div>
)}
{isCustomDomain && (
<div className="mt-4 border-t border-gray-900 pt-3 flex justify-between items-center">
<p className="text-[11px] text-gray-500">Domaine personnalisé détecté. Sécurisez après pointage DNS.</p>
<button onClick={handleGenerateSSL} disabled={sslStatus === 'loading'} className="text-xs bg-purple-600/20 hover:bg-purple-600 text-purple-400 hover:text-white border border-purple-500/30 px-3 py-1 rounded transition flex items-center gap-1">
{sslStatus === 'loading' ? 'GÉNÉRATION...' : <><Lock className="w-3 h-3" /> GÉNÉRER SSL</>}
</button>
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div>
<label className="block text-xs uppercase tracking-widest text-emerald-500 mb-1">Visiter le Site</label>
<button onClick={() => window.open(siteUrl, '_blank')} className="w-full bg-gray-950 hover:bg-gray-900 border border-gray-800 hover:border-emerald-500/50 rounded p-3 flex justify-between items-center transition group">
<span className="font-mono text-gray-300 text-sm flex items-center gap-2"><Globe className="w-4 h-4 text-emerald-500" /> Ouvrir dans le navigateur</span>
<ExternalLink className="w-4 h-4 text-gray-600 group-hover:text-emerald-400" />
</button>
</div>
<div>
<label className="block text-xs uppercase tracking-widest text-emerald-500 mb-1">Panel de Contrôle</label>
<button onClick={handleOpenPanel} disabled={isGeneratingSso} className="w-full bg-emerald-900/20 hover:bg-emerald-900/40 border border-emerald-800/50 hover:border-emerald-500 rounded p-3 flex justify-between items-center transition group disabled:opacity-50">
{isGeneratingSso ? (
<span className="font-mono text-emerald-400 text-sm flex items-center gap-2"><Loader className="w-4 h-4 animate-spin" /> GÉNÉRATION SSO...</span>
) : (
<>
<span className="font-mono text-emerald-400 text-sm flex items-center gap-2"><Lock className="w-4 h-4" /> Gérer l'hébergement</span>
<ExternalLink className="w-4 h-4 text-emerald-600 group-hover:text-emerald-400" />
</>
)}
</button>
</div>
</div>
</div>
);
};
// ============================================================================
// COMPOSANT PRINCIPAL : SERVICES
// ============================================================================
export default function Services() { export default function Services() {
const [services, setServices] = useState([]); const [services, setServices] = useState([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
const [newVpcName, setNewVpcName] = useState(""); const [newVpcName, setNewVpcName] = useState("");
const [isConnecting, setIsConnecting] = useState(null); const [isConnecting, setIsConnecting] = useState(null);
const [ssoVault, setSsoVault] = useState(null);
const [activeServiceModal, setActiveServiceModal] = useState(null); const [activeServiceModal, setActiveServiceModal] = useState(null);
const [customAlert, setCustomAlert] = useState(null); const [customAlert, setCustomAlert] = useState(null);
const { vpcs, createVPC, deleteVPC, assignToVPC, removeFromVPC } = useVPC(); const { vpcs, createVPC, deleteVPC, assignToVPC, removeFromVPC } = useVPC();
const triggerAlert = (title, message, type = "info") => { const triggerAlert = (title, message, type = "info") => setCustomAlert({ title, message, type });
setCustomAlert({ title, message, type });
};
const fetchServices = useCallback(async () => { const fetchServices = useCallback(async () => {
try { try {
const data = await getMyServices(); const data = await getMyServices();
if (data.list && data.list.length > 0) { if (data.list && data.list.length > 0) {
const detailedServices = await Promise.all( const detailedServices = await Promise.all(data.list.map(async (order) => {
data.list.map(async (order) => {
let hDetails = null; let hDetails = null;
if (order.status === 'active' && (order.plugin === 'hosting' || (order.title || '').toLowerCase().includes('vps'))) { if (order.status === 'active' && (order.plugin === 'hosting' || (order.title || '').toLowerCase().includes('vps'))) {
try { hDetails = await getHostingServiceDetails(order.id); } try { hDetails = await getHostingServiceDetails(order.id); } catch (e) { }
catch (e) { console.warn(`Détails inaccessibles pour ${order.id}:`, e); }
} }
return { ...order, hostingDetails: hDetails }; return { ...order, hostingDetails: hDetails };
}) }));
);
const filteredServices = detailedServices.filter(s => { const filteredServices = detailedServices.filter(s => {
const type = (s.type || '').toLowerCase(); const type = (s.type || '').toLowerCase();
@@ -498,19 +41,13 @@ export default function Services() {
const isGhostProduct = type === 'domain' || title.includes('domain') || title.includes('domaine') || title.includes('enregistrement'); const isGhostProduct = type === 'domain' || title.includes('domain') || title.includes('domaine') || title.includes('enregistrement');
return (s.status === 'active' || s.status === 'pending_setup') && !isGhostProduct; return (s.status === 'active' || s.status === 'pending_setup') && !isGhostProduct;
}); });
setServices(filteredServices); setServices(filteredServices);
} }
} catch (err) { } catch (err) { setError(err.message || "Impossible de charger la télémétrie."); }
setError(err.message || "Impossible de charger la télémétrie des services."); finally { setIsLoading(false); }
} finally {
setIsLoading(false);
}
}, []); }, []);
useEffect(() => { useEffect(() => { fetchServices(); }, [fetchServices]);
fetchServices();
}, [fetchServices]);
const handleOpenConsole = async (service) => { const handleOpenConsole = async (service) => {
const titleLower = (service.title || '').toLowerCase(); const titleLower = (service.title || '').toLowerCase();
@@ -520,7 +57,6 @@ export default function Services() {
const isWeb = !isVPS && !isCloud && !isDB; const isWeb = !isVPS && !isCloud && !isDB;
setIsConnecting(service.id); setIsConnecting(service.id);
try { try {
if (isCloud) { if (isCloud) {
const titleMatch = (service.title || '').match(/(?: for | pour )(.+)$/i); const titleMatch = (service.title || '').match(/(?: for | pour )(.+)$/i);
@@ -532,53 +68,33 @@ export default function Services() {
const freshDetails = await getHostingServiceDetails(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) { } catch (err) { triggerAlert("Erreur", err.message, "error"); }
triggerAlert("Erreur d'Orchestration", "Échec du protocole : " + err.message, "error"); finally { setIsConnecting(null); }
} finally {
setIsConnecting(null);
}
}; };
const assignedServiceIds = vpcs.flatMap(vpc => vpc.services); const assignedServiceIds = vpcs.flatMap(vpc => vpc.services);
const freeServices = services.filter(s => !assignedServiceIds.includes(s.id)); const freeServices = services.filter(s => !assignedServiceIds.includes(s.id));
return ( return (
<div className="w-full max-w-6xl p-6 mx-auto"> {/* Alignement avec le Dashboard */} <div className="w-full max-w-6xl p-6 mx-auto">
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-8 gap-4"> <div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-8 gap-4">
<div> <div>
<header> <header>
<h1 className="text-3xl font-black text-white tracking-wider"> {/* Typographie Dashboard */} <h1 className="text-3xl font-black text-white tracking-wider">TERMINAL <span className="text-cyan-400">NEXUS</span></h1>
TERMINAL <span className="text-cyan-400">NEXUS</span>
</h1>
<p className="text-gray-400 mt-2">Orchestration des environnements et des Virtual Private Clouds.</p> <p className="text-gray-400 mt-2">Orchestration des environnements et des Virtual Private Clouds.</p>
</header> </header>
</div> </div>
<div className="flex space-x-2 bg-gray-900 p-2 rounded-xl border border-gray-800"> <div className="flex space-x-2 bg-gray-900 p-2 rounded-xl border border-gray-800">
<input type="text" placeholder="Nom du nouveau VPC..." value={newVpcName} onChange={(e) => setNewVpcName(e.target.value)} className="bg-black border border-gray-800 rounded-lg px-4 py-2 text-sm text-white focus:outline-none focus:border-cyan-400 w-64" /> <input type="text" placeholder="Nom du nouveau VPC..." value={newVpcName} onChange={(e) => setNewVpcName(e.target.value)} className="bg-black border border-gray-800 rounded-lg px-4 py-2 text-sm text-white focus:outline-none focus:border-cyan-400 w-64" />
<button onClick={() => { createVPC(newVpcName); setNewVpcName(""); triggerAlert("VPC", "Nouveau groupe de routage VPC créé avec succès !", "success"); }} disabled={!newVpcName.trim()} className="bg-cyan-400 text-gray-900 px-4 py-2 rounded-lg font-bold text-sm disabled:opacity-50 hover:bg-cyan-300 flex items-center"> <button onClick={() => { createVPC(newVpcName); setNewVpcName(""); triggerAlert("VPC", "Nouveau groupe créé !", "success"); }} disabled={!newVpcName.trim()} className="bg-cyan-400 text-gray-900 px-4 py-2 rounded-lg font-bold text-sm disabled:opacity-50 hover:bg-cyan-300 flex items-center">
<Plus className="w-4 h-4 mr-1" /> CRÉER GROUPE <Plus className="w-4 h-4 mr-1" /> CRÉER GROUPE
</button> </button>
</div> </div>
</div> </div>
{/* GESTION DU CHARGEMENT IDENTIQUE AU DASHBOARD */} {isLoading && <div className="flex items-center space-x-3 text-cyan-400 mb-8"><Loader className="w-6 h-6 animate-spin" /><span>Analyse en cours...</span></div>}
{isLoading && ( {error && <div className="flex items-center space-x-3 text-red-400 bg-red-400/10 border border-red-400 p-4 rounded-lg mb-8"><AlertCircle className="w-6 h-6" /><span>{error}</span></div>}
<div className="flex items-center space-x-3 text-cyan-400 mb-8">
<Loader className="w-6 h-6 animate-spin" />
<span>Analyse du réseau et des instances en cours...</span>
</div>
)}
{/* GESTION DES ERREURS IDENTIQUE AU DASHBOARD */}
{error && (
<div className="flex items-center space-x-3 text-red-400 bg-red-400/10 border border-red-400 p-4 rounded-lg mb-8">
<AlertCircle className="w-6 h-6" />
<span>{error}</span>
</div>
)}
{/* CONTENU (Caché pendant le chargement pour rester propre) */}
{!isLoading && !error && ( {!isLoading && !error && (
<> <>
<div className="space-y-8 mb-12"> <div className="space-y-8 mb-12">
@@ -588,118 +104,47 @@ export default function Services() {
<div key={vpc.id} className="bg-gray-900/40 border border-gray-800 rounded-2xl p-6"> <div key={vpc.id} className="bg-gray-900/40 border border-gray-800 rounded-2xl p-6">
<div className="flex justify-between items-center mb-6 border-b border-gray-800 pb-4"> <div className="flex justify-between items-center mb-6 border-b border-gray-800 pb-4">
<div className="flex items-center space-x-3 text-white"> <div className="flex items-center space-x-3 text-white">
<Folder className="w-6 h-6 text-cyan-400" /> <Folder className="w-6 h-6 text-cyan-400" /><h2 className="text-xl font-bold tracking-wider">{vpc.name.toUpperCase()}</h2><span className="bg-gray-800 text-gray-400 px-2 py-0.5 rounded text-xs font-mono">{vpcServices.length} INSTANCES</span>
<h2 className="text-xl font-bold tracking-wider">{vpc.name.toUpperCase()}</h2>
<span className="bg-gray-800 text-gray-400 px-2 py-0.5 rounded text-xs font-mono">{vpcServices.length} INSTANCES</span>
</div> </div>
<button onClick={() => { deleteVPC(vpc.id); triggerAlert("VPC Démantelé", "Le groupe de routage a été dissous. Les instances ont été reversées dans le pool libre.", "info"); }} className="text-gray-500 hover:text-red-400 transition-colors flex items-center text-sm"> <button onClick={() => { deleteVPC(vpc.id); triggerAlert("VPC Démantelé", "Instances reversées.", "info"); }} className="text-gray-500 hover:text-red-400 transition-colors flex items-center text-sm"><Trash2 className="w-4 h-4 mr-1" /> Démanteler</button>
<Trash2 className="w-4 h-4 mr-1" /> Démanteler VPC
</button>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{vpcServices.length === 0 ? ( {vpcServices.length === 0 ? <div className="col-span-full text-gray-600 text-sm border border-dashed border-gray-800 rounded-xl p-6 text-center font-mono">Vide.</div> : vpcServices.map(service => <InstanceCard key={service.id} service={service} vpcs={vpcs} onAssignVpc={assignToVPC} onRemoveVpc={removeFromVPC} onOpenConsole={handleOpenConsole} isConnecting={isConnecting} />)}
<div className="col-span-full text-gray-600 text-sm border border-dashed border-gray-800 rounded-xl p-6 text-center font-mono">
Réseau virtuel vide. Assigner des instances depuis le pool libre.
</div>
) : (
vpcServices.map(service => (
<InstanceCard
key={service.id} service={service} vpcs={vpcs}
onAssignVpc={assignToVPC} onRemoveVpc={removeFromVPC}
onOpenConsole={handleOpenConsole} isConnecting={isConnecting}
/>
))
)}
</div> </div>
</div> </div>
); );
})} })}
</div> </div>
<div> <div>
<h2 className="text-lg font-bold text-gray-500 tracking-wider mb-6 flex items-center"> <h2 className="text-lg font-bold text-gray-500 tracking-wider mb-6 flex items-center"><Server className="w-5 h-5 mr-2" /> POOL LIBRE</h2>
<Server className="w-5 h-5 mr-2" /> POOL D'INSTANCES LIBRES
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{freeServices.length === 0 ? ( {freeServices.length === 0 ? <div className="col-span-full text-gray-600 text-sm border border-gray-900 bg-gray-900/20 rounded-xl p-6 text-center font-mono">Aucune instance libre.</div> : freeServices.map(service => <InstanceCard key={service.id} service={service} vpcs={vpcs} onAssignVpc={assignToVPC} onRemoveVpc={removeFromVPC} onOpenConsole={handleOpenConsole} isConnecting={isConnecting} />)}
<div className="col-span-full text-gray-600 text-sm border border-gray-900 bg-gray-900/20 rounded-xl p-6 text-center font-mono">
Aucune instance libre. Toutes vos accréditations sont assignées à des VPC.
</div>
) : (
freeServices.map(service => (
<InstanceCard
key={service.id} service={service} vpcs={vpcs}
onAssignVpc={assignToVPC} onRemoveVpc={removeFromVPC}
onOpenConsole={handleOpenConsole} isConnecting={isConnecting}
/>
))
)}
</div> </div>
</div> </div>
</> </>
)} )}
{/* MODAL 1 : HESTIACP SSO VAULT */}
{ssoVault && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[60] flex items-center justify-center p-4">
<div className="bg-gray-900 border border-emerald-500/50 rounded-lg shadow-2xl shadow-emerald-500/20 max-w-md w-full p-6 text-gray-200">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-bold text-emerald-400 font-mono tracking-wider">ACCÈS AUTORISÉ</h3>
<button onClick={() => setSsoVault(null)} className="text-gray-400 hover:text-white transition text-lg">✖</button>
</div>
<p className="text-sm text-gray-400 mb-6">Le pare-feu HestiaCP bloque les injections directes. Un mot de passe <strong>jetable</strong> a été généré. Copiez-le et connectez-vous.</p>
<div className="space-y-4 mb-8">
<div>
<label className="block text-xs uppercase tracking-widest text-emerald-500 mb-1">Utilisateur</label>
<div className="flex bg-gray-950 border border-gray-800 rounded p-3 justify-between items-center">
<span className="font-mono text-white">{ssoVault.username}</span>
<button onClick={() => navigator.clipboard.writeText(ssoVault.username)} className="text-xs bg-gray-800 hover:bg-gray-700 text-white px-3 py-1 rounded transition">Copier</button>
</div>
</div>
<div>
<label className="block text-xs uppercase tracking-widest text-emerald-500 mb-1">Clé Éphémère</label>
<div className="flex bg-gray-950 border border-gray-800 rounded p-3 justify-between items-center">
<span className="font-mono text-emerald-400">{ssoVault.password}</span>
<button onClick={() => navigator.clipboard.writeText(ssoVault.password)} className="text-xs bg-emerald-600 hover:bg-emerald-500 text-white px-3 py-1 rounded transition">Copier</button>
</div>
</div>
</div>
<a href={ssoVault.url} target="_blank" rel="noopener noreferrer" className="block w-full bg-emerald-500 hover:bg-emerald-400 text-gray-950 font-bold py-3 text-center rounded transition font-mono tracking-widest uppercase">
Ouvrir le Panel Web ↗
</a>
</div>
</div>
)}
{/* MODAL 2 : GESTIONNAIRE DE SERVICE GÉNÉRIQUE (VPS & WEB) */}
{activeServiceModal && ( {activeServiceModal && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4"> <div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className={`bg-gray-900 border rounded-lg shadow-2xl max-w-2xl w-full p-6 text-gray-200 ${activeServiceModal.type === 'vps' ? 'border-cyan-500/50 shadow-cyan-500/20' : 'border-emerald-500/50 shadow-emerald-500/20'}`}> <div className={`bg-gray-900 border rounded-lg shadow-2xl max-w-2xl w-full p-6 text-gray-200 ${activeServiceModal.type === 'vps' ? 'border-cyan-500/50 shadow-cyan-500/20' : 'border-emerald-500/50 shadow-emerald-500/20'}`}>
<div className="flex justify-between items-center mb-6 border-b border-gray-800 pb-4"> <div className="flex justify-between items-center mb-6 border-b border-gray-800 pb-4">
<h3 className="text-xl font-bold text-white tracking-wider flex items-center gap-3"> <h3 className="text-xl font-bold text-white tracking-wider flex items-center gap-3">
{activeServiceModal.type === 'vps' ? ( {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</>}
<><Server className="w-6 h-6 text-cyan-400" /> {activeServiceModal.details?.ip && activeServiceModal.details.ip !== '127.0.0.1' ? "GESTION DE L'INSTANCE" : "PHASE D'INITIALISATION"}</>
) : (
<><Globe className="w-6 h-6 text-emerald-400" /> GESTION DE L'HÉBERGEMENT 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' ? ( 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} />
<VpsManager details={activeServiceModal.details} orderId={activeServiceModal.data.id} onRefresh={fetchServices} onAlert={triggerAlert} />
) : ( ) : (
<VpsDeployer serviceId={activeServiceModal.data.id} onDeploySuccess={() => { setActiveServiceModal(null); fetchServices(); }} onAlert={triggerAlert} /> <WebManager details={activeServiceModal.details} orderId={activeServiceModal.data.id} onRefresh={fetchServices} onAlert={triggerAlert} />
)
) : (
<WebManager details={activeServiceModal.details} orderId={activeServiceModal.data.id} onRefresh={fetchServices} onOpenSso={setSsoVault} onAlert={triggerAlert} />
)} )}
</div> </div>
</div> </div>
)} )}
{/* VRAI MODAL DE NOTIFICATION REACT SURCHARGE */}
<NotificationModal notification={customAlert} onClose={() => setCustomAlert(null)} /> <NotificationModal notification={customAlert} onClose={() => setCustomAlert(null)} />
</div> </div>
); );
+5 -283
View File
@@ -1,288 +1,16 @@
// src/pages/app/Store.jsx
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { Server, Database, Cloud, Globe, Loader, AlertCircle } from 'lucide-react';
import ReactMarkdown from 'react-markdown';
import { getProductList } from '../../services/api'; import { getProductList } from '../../services/api';
import { Server, Database, Cloud, Globe, ShoppingCart, Loader, AlertCircle, ChevronDown, CheckCircle2 } from 'lucide-react'; import CategorySection from '../../components/store/CategorySection'; // L'import magique
// ==========================================
// MOTEUR TEMPOREL : Poids et Traductions
// ==========================================
const parsePeriod = (code) => {
if (!code) return { label: '', weight: 0, factorToYear: 1, billingPhrase: '' };
const value = parseInt(code);
if (code.includes('W')) return {
label: `${value} Semaine${value > 1 ? 's' : ''}`,
weight: value * 7,
factorToYear: 52 / value,
billingPhrase: value === 1 ? 'par semaine' : `toutes les ${value} semaines`
};
if (code.includes('M')) return {
label: `${value} Mois`,
weight: value * 30,
factorToYear: 12 / value,
billingPhrase: value === 1 ? 'par mois' : `tous les ${value} mois`
};
if (code.includes('Y')) return {
label: `${value} An${value > 1 ? 's' : ''}`,
weight: value * 365,
factorToYear: 1 / value,
billingPhrase: value === 1 ? 'par an' : `tous les ${value} ans`
};
return { label: code, weight: 999, factorToYear: 1, billingPhrase: `pour ${code}` };
};
// ==========================================
// GÉNÉRATEUR DE BADGES COURTS (Mis à jour pour l'anglais)
// ==========================================
const getCategoryBadge = (categoryName) => {
const t = (categoryName || '').toLowerCase();
if (t.includes('web') || t.includes('hosting')) return 'WEB';
if (t.includes('vps')) return 'VPS';
if (t.includes('data') || t.includes('db')) return 'DB';
if (t.includes('cloud')) return 'CLOUD';
return 'SRV';
};
// ==========================================
// SOUS-COMPOSANT : LA CARTE PRODUIT
// ==========================================
const ProductCard = ({ product, selectedPeriod, categoryName }) => {
const navigate = useNavigate();
const getPricingData = () => {
// 1. Gestion des produits payables une seule fois
if (product.pricing?.type !== 'recurrent' || !product.pricing?.recurrent) {
const oncePrice = product.pricing?.once?.price ? parseFloat(product.pricing.once.price).toFixed(2) : '0.00';
return { isAvailable: true, displayPrice: oncePrice, suffix: '(Une fois)', originalPrice: null, savingsPercent: 0, isOnce: true };
}
const recurrentPrices = product.pricing.recurrent;
const availablePeriods = Object.keys(recurrentPrices).filter(
period => recurrentPrices[period].enabled == 1 || recurrentPrices[period].enabled === true
);
if (!recurrentPrices[selectedPeriod] || !availablePeriods.includes(selectedPeriod)) {
return { isAvailable: false };
}
const currentPrice = parseFloat(recurrentPrices[selectedPeriod].price);
const currentPeriodInfo = parsePeriod(selectedPeriod);
// LA MAGIE : On convertit le prix de la période choisie en coût mensuel lissé
const currentYearlyCost = currentPrice * currentPeriodInfo.factorToYear;
const currentMonthlyEquivalent = currentYearlyCost / 12;
let savingsPercent = 0;
let originalPrice = null;
// On cherche le forfait le plus court (ex: 1W) pour s'en servir de base de comparaison
const sortedAvailablePeriods = [...availablePeriods].sort((a, b) => parsePeriod(a).weight - parsePeriod(b).weight);
const basePeriodCode = sortedAvailablePeriods[0];
if (basePeriodCode !== selectedPeriod) {
const basePrice = parseFloat(recurrentPrices[basePeriodCode].price);
const basePeriodInfo = parsePeriod(basePeriodCode);
// On calcule aussi le prix mensuel lissé de ce forfait de base
const baseYearlyCost = basePrice * basePeriodInfo.factorToYear;
const baseMonthlyEquivalent = baseYearlyCost / 12;
if (baseYearlyCost > currentYearlyCost) {
savingsPercent = Math.round((1 - (currentYearlyCost / baseYearlyCost)) * 100);
originalPrice = baseMonthlyEquivalent.toFixed(2);
}
}
return {
isAvailable: true,
displayPrice: currentMonthlyEquivalent.toFixed(2), // Le GROS texte principal
suffix: '/ mois',
originalPrice, // Le texte BARRÉ (null si on est sur la période de base)
savingsPercent,
billingPrice: currentPrice.toFixed(2), // Ce que la banque va vraiment prélever
billingPhrase: currentPeriodInfo.billingPhrase, // "par an", "par semaine", etc.
isOnce: false
};
};
const priceData = getPricingData();
return (
<div className={`bg-gray-900 border border-gray-800 rounded-2xl overflow-hidden hover:border-cyan-400/50 transition-all duration-300 flex flex-col relative group ${!priceData.isAvailable ? 'opacity-50 grayscale' : ''}`}>
{/* ENCART ACRO-BADGE */}
<div className="absolute top-4 left-4 z-20">
<span className="bg-black/60 backdrop-blur-sm text-gray-300 text-xs font-black px-3 py-1 rounded border border-gray-800 tracking-widest shadow-sm">
{getCategoryBadge(categoryName)}
</span>
</div>
{/* Badge d'économie */}
{priceData.savingsPercent > 0 && priceData.isAvailable && (
<div className="absolute top-4 right-4 z-20 bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 text-xs font-bold px-3 py-1 rounded-full animate-pulse shadow-[0_0_15px_rgba(16,185,129,0.2)]">
ÉCONOMIE {priceData.savingsPercent}%
</div>
)}
<div className="p-8 pt-14 border-b border-gray-800 relative bg-gradient-to-b from-gray-800/30 to-transparent min-h-[190px] flex flex-col">
<h3 className="text-2xl font-bold text-white mb-4 relative z-10">{product.title}</h3>
{priceData.isAvailable ? (
<div className="flex-grow flex flex-col justify-end">
{/* PRIX PRINCIPAL (Toujours ramené au mois) */}
<div className="flex items-baseline space-x-2">
<span className="text-4xl font-black text-cyan-400">{priceData.displayPrice} </span>
<span className="text-gray-500">{priceData.suffix}</span>
</div>
{/* BLOC DES PETITES LIGNES BANCAIRES */}
<div className="mt-3 min-h-[44px] flex flex-col justify-end">
{!priceData.isOnce && (
<>
{priceData.originalPrice ? (
<div className="text-sm text-gray-500">
Au lieu de <span className="line-through">{priceData.originalPrice} </span> / mois
</div>
) : (
<div className="text-sm text-gray-600 italic">
Tarif de base équivalent
</div>
)}
<div className="text-xs text-cyan-500 mt-1 font-semibold uppercase tracking-wider bg-cyan-500/10 inline-block px-2 py-1 rounded w-max">
Facturé {priceData.billingPrice} {priceData.billingPhrase}
</div>
</>
)}
{priceData.isOnce && (
<div className="text-sm text-gray-500">Paiement unique</div>
)}
</div>
</div>
) : (
<div className="text-red-400 font-medium mt-auto">Non disponible pour cette durée.</div>
)}
</div>
<div className="p-8 flex-grow flex flex-col justify-between">
<div className="text-gray-400 text-sm mb-8 space-y-3 prose prose-invert max-w-none">
<ReactMarkdown
components={{
ul: ({node, ...props}) => <ul className="space-y-2" {...props} />,
li: ({node, ...props}) => <li className="flex items-start space-x-2"><CheckCircle2 className="w-4 h-4 text-cyan-400 mt-0.5 flex-shrink-0"/> <span>{props.children}</span></li>,
p: ({node, ...props}) => <p className="mb-2 text-gray-300" {...props} />,
strong: ({node, ...props}) => <strong className="text-white font-semibold" {...props} />
}}
>
{product.description || "Aucune description technique."}
</ReactMarkdown>
</div>
<button
disabled={!priceData.isAvailable}
onClick={() => navigate(`/checkout/${product.id}?period=${selectedPeriod}`)}
className={`w-full py-3 rounded-lg font-bold tracking-widest transition-all flex justify-center items-center space-x-2
${priceData.isAvailable
? "bg-cyan-400/10 hover:bg-cyan-400 text-cyan-400 hover:text-gray-900 border border-cyan-400 group-hover:shadow-[0_0_20px_rgba(34,211,238,0.2)]"
: "bg-gray-800 text-gray-600 border border-gray-800 cursor-not-allowed"}`}
>
<ShoppingCart className="w-5 h-5" />
<span>COMMANDER</span>
</button>
</div>
</div>
);
};
// ==========================================
// SOUS-COMPOSANT : LA SECTION
// ==========================================
const CategorySection = ({ categoryName, products, getCategoryIcon }) => {
const availablePeriods = new Set();
products.forEach(p => {
if (p.pricing?.type === 'recurrent' && p.pricing.recurrent) {
Object.keys(p.pricing.recurrent).forEach(period => {
// FILTRE DE SÉCURITÉ : On ne garde que les périodes actives (enabled = 1 ou true)
const periodData = p.pricing.recurrent[period];
if (periodData.enabled == 1 || periodData.enabled === true) {
availablePeriods.add(period);
}
});
}
});
const sortedPeriods = Array.from(availablePeriods).sort((a, b) => parsePeriod(a).weight - parsePeriod(b).weight);
const defaultPeriod = sortedPeriods.includes('1M') ? '1M' : sortedPeriods[0];
const [sectionPeriod, setSectionPeriod] = useState(defaultPeriod);
return (
<section className="mb-16 bg-black/20 p-6 rounded-3xl border border-gray-800/50">
<div className="flex flex-col md:flex-row md:items-center justify-between mb-8 pb-6 border-b border-gray-800 space-y-4 md:space-y-0">
<div className="flex items-center space-x-4">
<div className="p-3 bg-gray-900 rounded-xl border border-gray-800 shadow-[0_0_15px_rgba(0,0,0,0.5)]">
{getCategoryIcon(categoryName)}
</div>
<div>
<h2 className="text-3xl font-black text-white tracking-wider">{categoryName}</h2>
<div className="text-gray-500 text-sm mt-1">
{products.length} instance{products.length > 1 ? 's' : ''} disponible{products.length > 1 ? 's' : ''}
</div>
</div>
</div>
{sortedPeriods.length > 0 && (
<div className="flex items-center space-x-3 bg-gray-900 p-2 rounded-xl border border-gray-800">
<span className="text-sm font-medium text-gray-400 pl-2">Facturation :</span>
<div className="relative">
<select
value={sectionPeriod}
onChange={(e) => setSectionPeriod(e.target.value)}
className="appearance-none bg-black border border-gray-700 text-cyan-400 font-bold py-2 pl-4 pr-10 rounded-lg outline-none focus:border-cyan-400 transition-colors cursor-pointer hover:bg-gray-950"
>
{sortedPeriods.map(p => (
<option key={p} value={p}>{parsePeriod(p).label}</option>
))}
</select>
<ChevronDown className="absolute right-3 top-2.5 w-5 h-5 text-cyan-400 pointer-events-none" />
</div>
</div>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{products.map((product) => (
<ProductCard
key={product.id}
product={product}
selectedPeriod={sectionPeriod}
categoryName={categoryName}
/>
))}
</div>
</section>
);
};
// ==========================================
// COMPOSANT PRINCIPAL : LE MAGASIN (STORE)
// ==========================================
export default function Store() { export default function Store() {
const [groupedProducts, setGroupedProducts] = useState({}); const [groupedProducts, setGroupedProducts] = useState({});
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
// ALIGNEMENT PARFAIT SUR TES CATÉGORIES FOSSBILLING const CATEGORY_ORDER = ["Web Hosting", "VPS", "Database", "Cloud"];
const CATEGORY_ORDER = [ const CATEGORY_MAP = { 1: "Web Hosting", 4: "VPS", 3: "Database", 2: "Cloud" };
"Web Hosting",
"VPS",
"Database",
"Cloud"
];
const CATEGORY_MAP = {
1: "Web Hosting",
4: "VPS",
3: "Database",
2: "Cloud"
};
useEffect(() => { useEffect(() => {
const fetchCatalog = async () => { const fetchCatalog = async () => {
@@ -291,10 +19,7 @@ export default function Store() {
const products = data.list || []; const products = data.list || [];
const grid = products.reduce((acc, product) => { const grid = products.reduce((acc, product) => {
// EXTRACTION DE L'ID : On lit le product_category_id envoyé par FOSSBilling
const catId = product.product_category_id; const catId = product.product_category_id;
// TRADUCTION : On cherche le nom dans notre dictionnaire. Si inconnu -> Autres.
const catName = CATEGORY_MAP[catId] || 'Autres Services'; const catName = CATEGORY_MAP[catId] || 'Autres Services';
if (!acc[catName]) acc[catName] = []; if (!acc[catName]) acc[catName] = [];
@@ -320,14 +45,11 @@ export default function Store() {
return <Globe className="w-8 h-8 text-emerald-400" />; return <Globe className="w-8 h-8 text-emerald-400" />;
}; };
// MOTEUR DE TRI SANS RISK DE CRASH INDICE
const sortedCategoryNames = Object.keys(groupedProducts).sort((a, b) => { const sortedCategoryNames = Object.keys(groupedProducts).sort((a, b) => {
let indexA = CATEGORY_ORDER.indexOf(a); let indexA = CATEGORY_ORDER.indexOf(a);
let indexB = CATEGORY_ORDER.indexOf(b); let indexB = CATEGORY_ORDER.indexOf(b);
if (indexA === -1) indexA = 999; if (indexA === -1) indexA = 999;
if (indexB === -1) indexB = 999; if (indexB === -1) indexB = 999;
return indexA - indexB; return indexA - indexB;
}); });
+54 -293
View File
@@ -1,77 +1,38 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { useOutletContext } from 'react-router-dom'; import { useOutletContext } from 'react-router-dom';
import { getClientTickets, createTicket, getHelpdesks, getTicketDetails, replyTicket } from '../../services/api'; // Ajout des routes de détails et réponses import { getClientTickets, createTicket, getHelpdesks, getTicketDetails, replyTicket } from '../../services/api';
import { LifeBuoy, Plus, MessageSquare, Clock, CheckCircle2, Send, AlertCircle, Loader, Lock } from 'lucide-react'; import { Plus, AlertCircle, Loader } from 'lucide-react';
// ============================================================================ // Importation des composants isolés
// COMPOSANT 0 : MODAL DE NOTIFICATION (Design System) import NotificationModal from '../../components/ui/NotificationModal';
// ============================================================================ import TicketCard from '../../components/support/TicketCard';
const NotificationModal = ({ notification, onClose }) => { import CreateTicketModal from '../../components/support/CreateTicketModal';
if (!notification) return null; import TicketThreadModal from '../../components/support/TicketThreadModal';
const isError = notification.type === 'error';
return (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[100] flex items-center justify-center p-4">
<div className={`bg-gray-900 border ${isError ? 'border-red-500/50 shadow-red-500/10' : 'border-cyan-500/50 shadow-cyan-500/10'} rounded-lg shadow-2xl max-w-sm w-full p-6 text-gray-200`}>
<h4 className={`text-lg font-mono font-bold tracking-wider mb-2 ${isError ? 'text-red-400' : 'text-cyan-400'}`}>
{notification.title.toUpperCase()}
</h4>
<p className="text-sm text-gray-400 mb-6">{notification.message}</p>
<button onClick={onClose} className={`w-full font-mono py-2 rounded text-xs font-bold transition ${isError ? 'bg-red-600 hover:bg-red-500 text-white' : 'bg-cyan-500 hover:bg-cyan-400 text-gray-950'}`}>
COMPRIS
</button>
</div>
</div>
);
};
// ============================================================================
// COMPOSANT PRINCIPAL : SUPPORT TICKETS
// ============================================================================
export default function Support() { export default function Support() {
const { refreshNavbarTickets } = useOutletContext(); const { refreshNavbarTickets } = useOutletContext();
const [tickets, setTickets] = useState([]); const [tickets, setTickets] = useState([]);
const [helpdesks, setHelpdesks] = useState([]); const [helpdesks, setHelpdesks] = useState([]);
const [defaultHelpdesk, setDefaultHelpdesk] = useState('');
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [isLoadingConversation, setIsLoadingConversation] = useState(false); // Loader spécifique pour le fil de discussion
const [error, setError] = useState(null); const [error, setError] = useState(null);
// États pour les formulaires
const [subject, setSubject] = useState('');
const [message, setMessage] = useState('');
const [replyMessage, setReplyMessage] = useState(''); // Stocke le texte de la réponse
const [selectedHelpdesk, setSelectedHelpdesk] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
// États pour les modaux
const [customAlert, setCustomAlert] = useState(null); const [customAlert, setCustomAlert] = useState(null);
const [isCreatingTicket, setIsCreatingTicket] = useState(false); const [isCreatingTicket, setIsCreatingTicket] = useState(false);
const [activeTicket, setActiveTicket] = useState(null); const [activeTicket, setActiveTicket] = useState(null);
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const messagesEndRef = useRef(null); const triggerAlert = (title, message, type = "info") => setCustomAlert({ title, message, type });
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
};
useEffect(() => {
if (activeTicket && activeTicket.messages && !isLoadingConversation) {
scrollToBottom();
}
}, [activeTicket?.messages, isLoadingConversation]);
const triggerAlert = (title, message, type = "info") => {
setCustomAlert({ title, message, type });
};
// Chargement synchrone des tickets et des helpdesks
const loadSupportData = useCallback(async () => { const loadSupportData = useCallback(async () => {
try { try {
setIsLoading(true); setIsLoading(true);
const ticketsData = await getClientTickets(); const ticketsData = await getClientTickets();
if (ticketsData && ticketsData.list) { if (ticketsData && ticketsData.list) {
setTickets(ticketsData.list.map(tkt => ({ setTickets(ticketsData.list.map(tkt => ({
id: `TKT-${tkt.id}`, id: `TKT-${tkt.id}`, db_id: tkt.id, subject: tkt.subject,
db_id: tkt.id,
subject: tkt.subject,
department: tkt.helpdesk?.name || 'Support Technique', department: tkt.helpdesk?.name || 'Support Technique',
status: ['open', 'closed', 'on_hold'].includes(tkt.status) ? tkt.status : 'pending', status: ['open', 'closed', 'on_hold'].includes(tkt.status) ? tkt.status : 'pending',
lastUpdate: tkt.updated_at || tkt.created_at || 'Récemment', lastUpdate: tkt.updated_at || tkt.created_at || 'Récemment',
@@ -83,112 +44,68 @@ export default function Support() {
if (hdeskPairs) { if (hdeskPairs) {
const formattedDesks = Object.entries(hdeskPairs).map(([id, name]) => ({ id, name })); const formattedDesks = Object.entries(hdeskPairs).map(([id, name]) => ({ id, name }));
setHelpdesks(formattedDesks); setHelpdesks(formattedDesks);
const nexusDesk = formattedDesks.find(hd => hd.name.toLowerCase().includes('nexus')); const nexusDesk = formattedDesks.find(hd => hd.name.toLowerCase().includes('nexus'));
if (nexusDesk) { if (nexusDesk) setDefaultHelpdesk(nexusDesk.id);
setSelectedHelpdesk(nexusDesk.id); else if (formattedDesks.length > 0) setDefaultHelpdesk(formattedDesks[0].id);
} else if (formattedDesks.length > 0) {
setSelectedHelpdesk(formattedDesks[0].id);
}
}
} catch (err) {
setError(err.message || "Impossible de synchroniser le centre de support.");
} finally {
setIsLoading(false);
} }
} catch (err) { setError(err.message || "Impossible de synchroniser le centre de support."); }
finally { setIsLoading(false); }
}, []); }, []);
useEffect(() => { useEffect(() => { loadSupportData(); }, [loadSupportData]);
loadSupportData();
}, [loadSupportData]);
// ACTION CLIC : Charger les messages du ticket depuis FOSSBilling // Ouvre le fil de discussion
const handleOpenTicket = async (ticket) => { const handleOpenTicket = async (ticket) => {
try { try {
setIsLoadingConversation(true); setIsLoadingConversation(true);
// On ouvre immédiatement le modal avec une liste de messages vide pour la fluidité
setActiveTicket({ ...ticket, messages: [] }); setActiveTicket({ ...ticket, messages: [] });
const details = await getTicketDetails(ticket.db_id); const details = await getTicketDetails(ticket.db_id);
if (details && details.messages) { if (details && details.messages) {
// FOSSBilling renvoie l'auteur dans msg.author.role ('client', 'staff', 'admin')
const formattedMessages = details.messages.map(msg => ({ const formattedMessages = details.messages.map(msg => ({
sender: msg.author?.role === 'client' ? 'client' : 'staff', sender: msg.author?.role === 'client' ? 'client' : 'staff',
text: msg.content, text: msg.content, date: msg.created_at || 'Récemment'
date: msg.created_at || 'Récemment'
})); }));
// Injection dynamique des messages dans le modal actif
setActiveTicket(prev => prev ? { ...prev, messages: formattedMessages } : null); setActiveTicket(prev => prev ? { ...prev, messages: formattedMessages } : null);
} }
} catch (err) { } catch (err) { triggerAlert("Erreur", "Impossible de récupérer l'historique : " + err.message, "error"); }
triggerAlert("Erreur Réseau", "Impossible de récupérer l'historique : " + err.message, "error"); finally { setIsLoadingConversation(false); }
} finally {
setIsLoadingConversation(false);
}
}; };
// ACTION RÉPONSE : Envoyer un message dans le thread actuel // Soumet une réponse au fil de discussion
const handleReplySubmit = async (e) => { const handleReplySubmit = async (replyText) => {
e.preventDefault(); if (!activeTicket) return;
if (!replyMessage.trim() || !activeTicket) return;
try { try {
await replyTicket(activeTicket.db_id, replyMessage); await replyTicket(activeTicket.db_id, replyText);
loadSupportData(); loadSupportData();
setReplyMessage(''); // Nettoyer l'input
// Rechargement instantané du fil de discussion pour afficher le message soumis
const details = await getTicketDetails(activeTicket.db_id); const details = await getTicketDetails(activeTicket.db_id);
if (details && details.messages) { if (details && details.messages) {
const formattedMessages = details.messages.map(msg => ({ const formattedMessages = details.messages.map(msg => ({
sender: msg.author?.role === 'client' ? 'client' : 'staff', sender: msg.author?.role === 'client' ? 'client' : 'staff',
text: msg.content, text: msg.content, date: msg.created_at || 'Récemment'
date: msg.created_at || 'Récemment'
})); }));
setActiveTicket(prev => prev ? { ...prev, messages: formattedMessages } : null); setActiveTicket(prev => prev ? { ...prev, messages: formattedMessages } : null);
} }
if (refreshNavbarTickets) refreshNavbarTickets(); if (refreshNavbarTickets) refreshNavbarTickets();
} catch (err) { } catch (err) { triggerAlert("Échec d'envoi", "Votre réponse n'a pas pu être transmise : " + err.message, "error"); }
triggerAlert("Échec d'envoi", "Votre réponse n'a pas pu être transmise : " + err.message, "error");
}
}; };
// Soumission du nouveau ticket // Crée un nouveau ticket
const handleCreateTicket = async (e) => { const handleCreateTicket = async (subject, message, targetHelpdesk) => {
e.preventDefault();
if (!subject.trim() || !message.trim() || !selectedHelpdesk) return;
setIsSubmitting(true); setIsSubmitting(true);
try { try {
await createTicket(subject, message, selectedHelpdesk); await createTicket(subject, message, targetHelpdesk);
setIsCreatingTicket(false); setIsCreatingTicket(false);
setSubject('');
setMessage('');
loadSupportData(); loadSupportData();
if (refreshNavbarTickets) refreshNavbarTickets(); if (refreshNavbarTickets) refreshNavbarTickets();
triggerAlert("Ticket Ouvert", "Votre demande a bien été enregistrée sur le Service Desk.", "success"); triggerAlert("Ticket Ouvert", "Votre demande a bien été enregistrée sur le Service Desk.", "success");
} catch (err) { } catch (err) { triggerAlert("Échec", "Erreur lors de la création du ticket : " + err.message, "error"); }
triggerAlert("Échec", "Erreur lors de la création du ticket : " + err.message, "error"); finally { setIsSubmitting(false); }
} finally {
setIsSubmitting(false);
}
};
const getStatusConfig = (status) => {
switch (status) {
case 'open': return { color: 'text-cyan-400', bg: 'bg-cyan-500/10', border: 'border-cyan-500/20', label: 'SUPPORT', icon: <MessageSquare className="w-3 h-3 mr-1" /> };
case 'on_hold': return { color: 'text-yellow-400', bg: 'bg-yellow-500/10', border: 'border-yellow-500/20', label: 'RÉPONSE REÇUE', icon: <AlertCircle className="w-3 h-3 mr-1" /> };
case 'pending': return { color: 'text-orange-400', bg: 'bg-orange-500/10', border: 'border-orange-500/20', label: 'EN ATTENTE', icon: <Clock className="w-3 h-3 mr-1" /> };
case 'closed': return { color: 'text-gray-400', bg: 'bg-gray-500/10', border: 'border-gray-500/20', label: 'RÉSOLU', icon: <CheckCircle2 className="w-3 h-3 mr-1" /> };
default: return { color: 'text-gray-400', bg: 'bg-gray-800', border: 'border-gray-700', label: status.toUpperCase() };
}
}; };
return ( return (
<div className="w-full max-w-6xl p-6 mx-auto"> <div className="w-full max-w-6xl p-6 mx-auto">
{/* HEADER */}
<div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-8 gap-4"> <div className="flex flex-col md:flex-row justify-between items-start md:items-end mb-8 gap-4">
<div> <div>
<header> <header>
@@ -198,7 +115,6 @@ export default function Support() {
<p className="text-gray-400 mt-2">Canal de communication sécurisé avec les ingénieurs GISE.</p> <p className="text-gray-400 mt-2">Canal de communication sécurisé avec les ingénieurs GISE.</p>
</header> </header>
</div> </div>
<button <button
onClick={() => setIsCreatingTicket(true)} onClick={() => setIsCreatingTicket(true)}
className="bg-cyan-500 hover:bg-cyan-400 text-gray-900 px-5 py-2.5 rounded-lg font-bold text-sm transition flex items-center font-mono tracking-widest shadow-[0_0_15px_rgba(0,229,255,0.2)]" className="bg-cyan-500 hover:bg-cyan-400 text-gray-900 px-5 py-2.5 rounded-lg font-bold text-sm transition flex items-center font-mono tracking-widest shadow-[0_0_15px_rgba(0,229,255,0.2)]"
@@ -207,22 +123,9 @@ export default function Support() {
</button> </button>
</div> </div>
{/* CHARGEMENT & ERREURS GLOBALES */} {isLoading && <div className="flex items-center space-x-3 text-cyan-400 mb-8 font-mono"><Loader className="w-6 h-6 animate-spin" /><span>Déchiffrement de la matrice de support...</span></div>}
{isLoading && ( {error && <div className="flex items-center space-x-3 text-red-400 bg-red-400/10 border border-red-400 p-4 rounded-lg mb-8"><AlertCircle className="w-6 h-6" /><span>{error}</span></div>}
<div className="flex items-center space-x-3 text-cyan-400 mb-8 font-mono">
<Loader className="w-6 h-6 animate-spin" />
<span>Déchiffrement de la matrice de support...</span>
</div>
)}
{error && (
<div className="flex items-center space-x-3 text-red-400 bg-red-400/10 border border-red-400 p-4 rounded-lg mb-8">
<AlertCircle className="w-6 h-6" />
<span>{error}</span>
</div>
)}
{/* GRILLE DES TICKETS */}
{!isLoading && !error && ( {!isLoading && !error && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{tickets.length === 0 ? ( {tickets.length === 0 ? (
@@ -230,171 +133,29 @@ export default function Support() {
Aucun ticket de support actif. L'infrastructure est nominale. Aucun ticket de support actif. L'infrastructure est nominale.
</div> </div>
) : ( ) : (
tickets.map(ticket => { tickets.map(ticket => (
const conf = getStatusConfig(ticket.status); <TicketCard key={ticket.id} ticket={ticket} onClick={() => handleOpenTicket(ticket)} />
return (
<div
key={ticket.id}
onClick={() => handleOpenTicket(ticket)} // Modification ici : Appel de la fonction de chargement au lieu du setter direct
className="bg-gray-900 border border-gray-800 rounded-xl p-5 hover:border-cyan-400/50 hover:bg-gray-800/50 transition-all cursor-pointer group flex flex-col justify-between min-h-[160px]"
>
<div>
<div className="flex justify-between items-start mb-3">
<div className="flex items-center gap-3">
<span className="text-gray-500 font-mono text-xs">{ticket.id}</span>
{/* LE RADAR : Pastille clignotante en cas de réponse */}
{ticket.status === 'on_hold' && (
<span className="flex h-2.5 w-2.5 relative" title="Nouveau message en attente de lecture">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-cyan-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2.5 w-2.5 bg-cyan-500 shadow-[0_0_8px_#00E5FF]"></span>
</span>
)}
</div>
<span className={`flex items-center px-2 py-1 rounded text-[10px] font-bold border tracking-wider ${conf.color} ${conf.bg} ${conf.border}`}>
{conf.icon} {conf.label}
</span>
</div>
<h3 className="text-white font-bold text-lg mb-1 group-hover:text-cyan-400 transition-colors line-clamp-2">
{ticket.subject}
</h3>
</div>
<div className="flex justify-between items-center text-xs text-gray-500 font-mono border-t border-gray-800 pt-3 mt-4">
<span>Département: {ticket.department}</span>
<span className="truncate max-w-[120px]">MàJ: {ticket.lastUpdate}</span>
</div>
</div>
)
})
)}
</div>
)}
{/* MODAL 1 : CRÉATION DE TICKET */}
{isCreatingTicket && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="bg-gray-900 border border-cyan-500/30 rounded-lg shadow-2xl max-w-lg w-full p-6 text-gray-200">
<div className="flex justify-between items-center mb-6 border-b border-gray-800 pb-4">
<h3 className="text-xl font-bold text-white tracking-wider flex items-center gap-2">
<Plus className="w-5 h-5 text-cyan-400" /> OUVRIR UNE REQUÊTE
</h3>
<button onClick={() => setIsCreatingTicket(false)} className="text-gray-400 hover:text-white transition text-xl">✖</button>
</div>
<form onSubmit={handleCreateTicket} className="space-y-4">
<div>
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Sujet de la demande</label>
<input type="text" value={subject} onChange={(e) => setSubject(e.target.value)} required placeholder="Ex: Problème d'accès sur l'API" className="w-full bg-black border border-gray-800 text-white p-3 rounded text-sm focus:border-cyan-500 outline-none transition" />
</div>
<div>
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">
Département (Cible Réseau)
</label>
{/* CHAMP VERROUILLÉ (Read-Only) */}
<div className="w-full bg-gray-950 border border-gray-800 text-gray-500 p-3 rounded text-sm font-mono flex items-center justify-between select-none">
<span>
{helpdesks.find(h => h.id === selectedHelpdesk)?.name || 'Service Desk Nexus'}
</span>
<Lock className="w-4 h-4 text-gray-700" />
</div>
<p className="text-[10px] text-gray-600 mt-1">
Routage automatique vers les ingénieurs d'infrastructure.
</p>
</div>
<div>
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Description détaillée</label>
<textarea value={message} onChange={(e) => setMessage(e.target.value)} required rows="4" placeholder="Décrivez votre problème technique ici..." className="w-full bg-black border border-gray-800 text-white p-3 rounded text-sm focus:border-cyan-500 outline-none transition resize-none"></textarea>
</div>
<button type="submit" disabled={isSubmitting} className="w-full bg-cyan-600 hover:bg-cyan-500 text-white font-bold font-mono tracking-widest uppercase py-3 rounded mt-2 transition shadow-lg shadow-cyan-500/20 disabled:opacity-50">
{isSubmitting ? 'CHIFFREMENT ET TRANSMISSION...' : 'TRANSMETTRE AU SUPPORT NEXUS'}
</button>
</form>
</div>
</div>
)}
{/* MODAL 2 : FIL DE DISCUSSION INTERACTIF (THREAD NATIVE) */}
{activeTicket && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-50 flex items-center justify-center p-2 sm:p-4">
<div className="bg-gray-950 border border-gray-800 rounded-lg shadow-2xl max-w-3xl w-full h-[85vh] flex flex-col">
{/* Thread Header */}
<div className="bg-gray-900 border-b border-gray-800 p-4 sm:p-6 flex justify-between items-start rounded-t-lg">
<div>
<div className="flex items-center gap-3 mb-1">
<span className="text-cyan-500 font-mono text-sm">{activeTicket.id}</span>
<span className="bg-gray-800 text-gray-300 text-[10px] uppercase font-bold tracking-wider px-2 py-0.5 rounded border border-gray-700">
{activeTicket.department}
</span>
</div>
<h3 className="text-xl font-bold text-white line-clamp-1">{activeTicket.subject}</h3>
</div>
<button onClick={() => setActiveTicket(null)} className="text-gray-400 hover:text-white transition text-xl bg-black/50 p-2 rounded-lg"></button>
</div>
{/* Thread Body (Messages avec état de chargement) */}
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-6 scrollbar-thin scrollbar-thumb-gray-800">
{isLoadingConversation ? (
<div className="flex flex-col items-center justify-center h-full text-cyan-400 gap-2 font-mono text-xs">
<Loader className="w-6 h-6 animate-spin" />
<span>Téléchargement des paquets de discussion sécurisés...</span>
</div>
) : activeTicket.messages.length === 0 ? (
<div className="text-center text-gray-600 font-mono text-xs pt-10">
Aucun message trouvé dans ce fil.
</div>
) : (
activeTicket.messages.map((msg, idx) => (
<div key={idx} className={`flex flex-col ${msg.sender === 'client' ? 'items-end' : 'items-start'}`}>
<div className="flex items-baseline gap-2 mb-1 px-1">
<span className={`text-[10px] font-bold uppercase tracking-widest ${msg.sender === 'client' ? 'text-gray-500' : 'text-cyan-400'}`}>
{msg.sender === 'client' ? 'VOUS' : 'INGÉNIEUR GISE'}
</span>
<span className="text-[10px] text-gray-600 font-mono">{msg.date}</span>
</div>
<div className={`p-4 rounded-xl max-w-[85%] text-sm leading-relaxed ${msg.sender === 'client'
? 'bg-gray-800 text-gray-200 rounded-tr-none'
: 'bg-cyan-900/20 border border-cyan-800/30 text-cyan-50 rounded-tl-none'
}`}>
{msg.text}
</div>
<div ref={messagesEndRef} />
</div>
)) ))
)} )}
</div> </div>
{/* Thread Footer (Formulaire de réponse branché) */}
{activeTicket.status !== 'closed' ? (
<form onSubmit={handleReplySubmit} className="bg-gray-900 border-t border-gray-800 p-4 rounded-b-lg">
<div className="flex gap-2">
<textarea
rows="2"
value={replyMessage}
onChange={(e) => setReplyMessage(e.target.value)}
required
placeholder="Taper une réponse sécurisée..."
className="flex-1 bg-black border border-gray-800 text-white p-3 rounded-lg text-sm focus:border-cyan-500 outline-none transition resize-none"
></textarea>
<button
type="submit"
className="bg-cyan-600 hover:bg-cyan-500 text-white px-6 rounded-lg font-bold font-mono tracking-widest transition flex flex-col items-center justify-center gap-1 shadow-lg shadow-cyan-500/20"
>
<Send className="w-4 h-4" />
<span className="text-[10px]">ENVOYER</span>
</button>
</div>
</form>
) : (
<div className="bg-gray-900 border-t border-gray-800 p-4 rounded-b-lg text-center font-mono text-sm text-gray-500">
[ CE TICKET EST VERROUILLÉ ET ARCHIVÉ ]
</div>
)}
</div>
</div>
)} )}
{/* VRAI MODAL DE NOTIFICATION */} <CreateTicketModal
isOpen={isCreatingTicket}
onClose={() => setIsCreatingTicket(false)}
onSubmit={handleCreateTicket}
helpdesks={helpdesks}
defaultHelpdesk={defaultHelpdesk}
isSubmitting={isSubmitting}
/>
<TicketThreadModal
ticket={activeTicket}
onClose={() => setActiveTicket(null)}
onReply={handleReplySubmit}
isLoadingConversation={isLoadingConversation}
/>
<NotificationModal notification={customAlert} onClose={() => setCustomAlert(null)} /> <NotificationModal notification={customAlert} onClose={() => setCustomAlert(null)} />
</div> </div>
); );
-110
View File
@@ -1,110 +0,0 @@
import React, { useState } from 'react';
const VpsDeployer = ({ serviceId }) => {
// Nouveaux états pour le Domaine et le Mot de passe
const [domain, setDomain] = useState('');
const [password, setPassword] = useState('');
const [isDeploying, setIsDeploying] = useState(false);
const [result, setResult] = useState(null);
const handleDeploy = async () => {
if (password.length < 8) {
alert("Le mot de passe doit faire au moins 8 caractères.");
return;
}
if (!domain.includes('.')) {
alert("Veuillez entrer un nom de domaine valide (ex: mon-projet.fr).");
return;
}
setIsDeploying(true);
try {
// L'appel vers ton Pont PHP avec les nouvelles données
const response = await fetch('https://web.gise.be/custom_api/proxmox_create_vps.php', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
service_id: serviceId, // L'ID du produit dans FOSSBilling
domain: domain, // Le domaine choisi par le client
password: password
})
});
const data = await response.json();
if (data.status === 'success') {
setResult(data);
} else {
alert("Erreur de déploiement : " + data.message);
}
} catch (err) {
alert("Erreur de connexion au serveur de provisionnement.");
} finally {
setIsDeploying(false);
}
};
return (
<div className="bg-gray-900 border border-cyan-500/30 p-6 rounded-lg text-gray-200 mt-4 shadow-xl">
<h3 className="text-xl font-mono text-cyan-400 mb-4">Enregistrement & Instanciation</h3>
{!result ? (
<div className="space-y-4">
<p className="text-sm text-gray-400">
Configuration système : <span className="text-white">Ubuntu 26.04 LTS (Docker Ready)</span>
</p>
<div>
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Nom de domaine (Hostname) :</label>
<input
type="text"
value={domain}
onChange={(e) => setDomain(e.target.value.toLowerCase())}
className="w-full bg-gray-950 border border-gray-700 text-white p-3 rounded font-mono focus:border-cyan-500 outline-none"
placeholder="ex: srv1.mon-domaine.com"
/>
</div>
<div>
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Mot de passe ROOT :</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-gray-950 border border-gray-700 text-white p-3 rounded font-mono focus:border-cyan-500 outline-none"
placeholder="Créez un mot de passe fort"
/>
</div>
<button
onClick={handleDeploy}
disabled={isDeploying}
className={`w-full py-3 mt-4 rounded font-bold font-mono tracking-widest uppercase transition ${isDeploying ? 'bg-gray-700 text-gray-400 cursor-not-allowed' : 'bg-cyan-600 hover:bg-cyan-500 text-white shadow-lg shadow-cyan-500/20'}`}
>
{isDeploying ? 'Synchronisation & Déploiement...' : 'Déployer le Serveur'}
</button>
</div>
) : (
<div className="space-y-4">
<div className="bg-green-900/20 border border-green-500/50 p-4 rounded text-green-400 text-center font-mono font-bold">
INSTANCE ENREGISTRÉE ET OPÉRATIONNELLE
</div>
<div className="flex justify-between bg-gray-950 p-3 rounded border border-gray-800">
<span className="text-gray-400">Domaine lié:</span>
<span className="text-white">{result.domain}</span>
</div>
<div className="flex justify-between bg-gray-950 p-3 rounded border border-gray-800">
<span className="text-gray-400">Adresse IP Allouée:</span>
<span className="text-cyan-400 font-bold">{result.ip}</span>
</div>
<p className="text-xs text-center text-gray-500 mt-4">
Ce serveur est maintenant rattaché à votre compte. Vous pouvez vous y connecter en SSH via l'IP ou le domaine (une fois les DNS propagés).
</p>
</div>
)}
</div>
);
};
export default VpsDeployer;
-112
View File
@@ -1,112 +0,0 @@
import React, { useState } from 'react';
// Ce composant reçoit les détails du service depuis ton API FOSSBilling habituelle
const VpsManager = ({ serviceDetails }) => {
const [isActionPending, setIsActionPending] = useState(false);
// Fonction pour envoyer des ordres d'alimentation à Proxmox (via ton API)
const handlePowerAction = async (action) => {
if (!window.confirm(`Êtes-vous sûr de vouloir ${action} ce VPS ?`)) return;
setIsActionPending(true);
try {
// Tu devras créer un petit fichier 'proxmox_power.php' plus tard pour gérer ça
alert(`Signal d'alimentation "${action}" envoyé à l'hyperviseur.`);
// const response = await fetch('https://web.gise.be/custom_api/proxmox_power.php', { ... })
} catch (error) {
alert("Erreur de communication avec l'infrastructure.");
} finally {
setIsActionPending(false);
}
};
// Si Proxmox n'a pas encore fini de configurer, on affiche un loader
if (!serviceDetails.ip) {
return (
<div className="bg-gray-900 border border-cyan-500/30 p-6 rounded-lg text-gray-200 mt-4 text-center">
<div className="animate-pulse flex flex-col items-center">
<div className="h-8 w-8 border-4 border-cyan-500 border-t-transparent rounded-full animate-spin mb-4"></div>
<p className="text-cyan-400 font-mono tracking-widest text-sm">PROVISIONNEMENT EN COURS...</p>
<p className="text-xs text-gray-500 mt-2">Votre serveur est en cours d'assemblage sur le VLAN 60. Cela prend environ 10 secondes.</p>
</div>
</div>
);
}
return (
<div className="bg-gray-900 border border-cyan-500/50 shadow-xl shadow-cyan-500/10 p-6 rounded-lg text-gray-200 mt-4">
<div className="flex justify-between items-center mb-6 border-b border-gray-800 pb-4">
<h3 className="text-xl font-mono text-white flex items-center gap-3">
<span className="h-3 w-3 rounded-full bg-green-500 shadow-[0_0_10px_#22c55e]"></span>
NEXUS COMPUTE INSTANCE
</h3>
<span className="bg-gray-950 border border-gray-700 px-3 py-1 rounded text-xs text-gray-400 font-mono">
ID: {serviceDetails.id}
</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
{/* BLOC RÉSEAU */}
<div className="space-y-4">
<div>
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Adresse IP (VLAN 60)</label>
<div className="bg-gray-950 border border-gray-800 rounded p-3 font-mono text-green-400 font-bold">
{serviceDetails.ip}
</div>
</div>
<div>
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Hostname Interne</label>
<div className="bg-gray-950 border border-gray-800 rounded p-3 font-mono text-gray-300 text-sm">
{serviceDetails.domain}
</div>
</div>
</div>
{/* BLOC ACCÈS */}
<div className="space-y-4">
<div>
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Utilisateur SSH</label>
<div className="bg-gray-950 border border-gray-800 rounded p-3 font-mono text-white">
root
</div>
</div>
<div>
<label className="block text-xs uppercase tracking-widest text-cyan-500 mb-1">Mot de passe Root</label>
<div className="flex bg-gray-950 border border-gray-800 rounded p-3 justify-between items-center">
<span className="font-mono text-gray-400 text-sm">
{serviceDetails.password || serviceDetails.pass}
</span>
<button
onClick={() => navigator.clipboard.writeText(serviceDetails.password || serviceDetails.pass)}
className="text-xs bg-gray-800 hover:bg-gray-700 text-white px-3 py-1 rounded transition"
>Copier</button>
</div>
</div>
</div>
</div>
{/* CONTRÔLES D'ALIMENTATION */}
<div className="bg-gray-950 rounded border border-gray-800 p-4">
<label className="block text-xs uppercase tracking-widest text-gray-500 mb-3">Contrôles d'Alimentation</label>
<div className="flex gap-3">
<button
onClick={() => handlePowerAction('redémarrer')}
disabled={isActionPending}
className="flex-1 bg-gray-800 hover:bg-gray-700 text-white text-sm py-2 rounded transition font-mono border border-gray-600"
>
↻ Reboot
</button>
<button
onClick={() => handlePowerAction('éteindre')}
disabled={isActionPending}
className="flex-1 bg-red-900/30 hover:bg-red-900/50 text-red-400 text-sm py-2 rounded transition font-mono border border-red-900/50"
>
Stop
</button>
</div>
</div>
</div>
);
};
export default VpsManager;
-34
View File
@@ -1,34 +0,0 @@
// composant VpsServiceDetail.jsx
import React, { useState } from 'react';
// Tu as déjà ces composants de la discussion précédente :
// import VpsDeployer from './VpsDeployer';
// import VpsManager from './VpsManager';
const VpsServiceDetail = ({ serviceDetails }) => {
// Le test logique : Si le service a une IP, il est déjà créé sur Proxmox.
const isProvisioned = serviceDetails.ip && serviceDetails.ip !== '';
return (
<div className="p-4">
<h2 className="text-2xl font-bold text-white mb-4">Gestion VPS</h2>
{isProvisioned ? (
// Étape 4 (après création) : Le tableau de bord
<VpsManager serviceDetails={serviceDetails} />
) : (
// Étape 3 (avant création) : Le formulaire de déploiement
<div>
<div className="bg-yellow-900/30 border border-yellow-600/50 p-4 rounded mb-6 text-yellow-500">
<p className="font-bold">Votre VPS est prêt à être instancié !</p>
<p className="text-sm">La facturation est activée. Veuillez configurer les accès initiaux pour lancer la création sur l'infrastructure (VLAN 60).</p>
</div>
{/* On passe le service_id au composant Deployer */}
<VpsDeployer serviceId={serviceDetails.id} />
</div>
)}
</div>
);
};
export default VpsServiceDetail;
+10 -50
View File
@@ -2,68 +2,28 @@ import { Link } from 'react-router-dom';
export default function Home() { export default function Home() {
return ( return (
<div style={{ textAlign: 'center', marginTop: '12vh' }}> <div className="text-center mt-[12vh]">
<h1 className="text-white text-5xl md:text-6xl tracking-[6px] flex items-baseline justify-center gap-3 mb-5 font-black">
<h1 style={{
color: '#FFF',
fontSize: '4rem',
letterSpacing: '6px',
display: 'flex',
alignItems: 'baseline',
justifyContent: 'center',
gap: '12px',
margin: '0 0 20px 0'
}}>
NEXUS NEXUS
<span style={{ <span className="text-cyan-400 text-xl md:text-2xl tracking-[2px] font-normal">
color: '#00E5FF',
fontSize: '1.4rem',
letterSpacing: '2px',
fontWeight: 'normal'
}}>
by GISE by GISE
</span> </span>
</h1> </h1>
<h2 style={{ <h2 className="text-white text-xl md:text-2xl tracking-[2px] uppercase mb-5 font-normal">
color: '#FFFFFF',
fontSize: '1.5rem',
letterSpacing: '2px',
textTransform: 'uppercase',
marginBottom: '20px',
fontWeight: 'normal'
}}>
Bienvenue dans l'infrastructure Bienvenue dans l'infrastructure
</h2> </h2>
<p style={{ <p className="text-[#A0A0A0] text-lg max-w-150 mx-auto leading-relaxed">
color: '#A0A0A0',
fontSize: '1.2rem',
maxWidth: '600px',
margin: '0 auto',
lineHeight: '1.6'
}}>
Hébergement web, instances VPS et Stockage Cloud en Belgique. Hébergement web, instances VPS et Stockage Cloud en Belgique.
<br />Propulsé par une architecture bare-metal locale. <br />Propulsé par une architecture bare-metal locale.
</p> </p>
{/* LE BOUTON D'ACTION PRINCIPAL REDIRIGE VERS /REGISTER */} <div className="mt-12">
<div style={{ marginTop: '50px' }}> <Link
<Link to="/register" style={{ to="/register"
display: 'inline-block', className="inline-block bg-transparent text-cyan-400 border border-cyan-400 px-6 py-3 font-mono text-base uppercase tracking-[1px] font-bold transition-all duration-300 shadow-[0_0_15px_rgba(0,229,255,0.1)] hover:bg-cyan-400/10 hover:shadow-[0_0_20px_rgba(0,229,255,0.3)] no-underline rounded-sm"
backgroundColor: 'transparent', >
color: '#00E5FF',
border: '1px solid #00E5FF',
padding: '12px 24px',
fontSize: '1rem',
fontFamily: 'monospace',
textDecoration: 'none',
textTransform: 'uppercase',
letterSpacing: '1px',
fontWeight: 'bold',
transition: 'all 0.3s ease',
boxShadow: '0 0 15px rgba(0, 229, 255, 0.1)',
}}>
Démarrer le déploiement Démarrer le déploiement
</Link> </Link>
</div> </div>
+19 -49
View File
@@ -15,23 +15,9 @@ export default function Login() {
setLoading(true); setLoading(true);
try { try {
// 1. APPEL À TON API BACKEND (FOSSBilling / PHP)
// Ici, tu mettras ton vrai 'fetch' vers ton serveur pour vérifier le mot de passe.
// Pour l'instant, on simule un délai réseau d'une seconde.
await new Promise(resolve => setTimeout(resolve, 1000));
// --- SIMULATION D'AUTHENTIFICATION ---
// (À remplacer par la vraie validation de ton serveur)
await loginClient(email, password); await loginClient(email, password);
// 2. LA CLÉ DU PROBLÈME EST ICI : L'ATTRIBUTION DU BADGE
// On sauvegarde le token (généralement renvoyé par ton API) dans le navigateur
localStorage.setItem('gise_token', 'secure_token_alphanumerique_factice'); localStorage.setItem('gise_token', 'secure_token_alphanumerique_factice');
// 3. AUTORISATION ET REDIRECTION
// Maintenant que le token est en poche, ProtectedRoute nous laissera passer !
navigate('/dashboard'); navigate('/dashboard');
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
} finally { } finally {
@@ -39,72 +25,56 @@ export default function Login() {
} }
}; };
// --- DESIGN SYSTEM "BUNKER" ---
const inputStyle = {
width: '100%', padding: '10px', marginBottom: '20px',
backgroundColor: '#1A1A1A', color: '#00E5FF',
border: '1px solid #333', fontFamily: 'monospace', outline: 'none',
boxSizing: 'border-box'
};
const buttonStyle = {
width: '100%', padding: '12px', backgroundColor: loading ? '#333' : '#00E5FF',
color: loading ? '#888' : '#000', border: 'none', cursor: loading ? 'not-allowed' : 'pointer',
fontFamily: 'monospace', fontWeight: 'bold', textTransform: 'uppercase', letterSpacing: '1px',
marginTop: '10px'
};
return ( return (
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '10vh', padding: '0 20px' }}> <div className="flex justify-center mt-[10vh] px-5">
<div style={{ <div className="w-full max-w-100 bg-[#242424] p-8 border-t-4 border-cyan-400 shadow-[0_10px_30px_rgba(0,0,0,0.5)]">
width: '100%', maxWidth: '400px', backgroundColor: '#242424', <h2 className="text-white uppercase mb-1 text-2xl text-center font-sans font-bold">
padding: '30px', borderTop: '4px solid #00E5FF', boxShadow: '0 10px 30px rgba(0,0,0,0.5)'
}}>
<h2 style={{ color: '#FFF', textTransform: 'uppercase', marginBottom: '5px', fontSize: '1.5rem', textAlign: 'center' }}>
Connexion au Nexus Connexion au Nexus
</h2> </h2>
<p style={{ color: '#888', fontFamily: 'monospace', fontSize: '0.85rem', marginBottom: '25px', textAlign: 'center' }}> <p className="text-[#888] font-mono text-sm mb-6 text-center">
[ IDENTIFICATION REQUISE ] [ IDENTIFICATION REQUISE ]
</p> </p>
{error && ( {error && (
<div style={{ <div className="text-[#ff003c] border border-[#ff003c] bg-[#ff003c]/10 p-3 mb-5 font-mono text-sm">
color: '#ff003c', border: '1px solid #ff003c', backgroundColor: 'rgba(255, 0, 60, 0.1)',
padding: '10px', marginBottom: '20px', fontFamily: 'monospace', fontSize: '0.85rem'
}}>
[ ALERTE ] : {error} [ ALERTE ] : {error}
</div> </div>
)} )}
<form onSubmit={handleLogin}> <form onSubmit={handleLogin} className="font-mono">
<label style={{ display: 'block', color: '#888', marginBottom: '5px', fontSize: '0.8rem' }}>IDENTIFIANT (E-MAIL)</label> <label className="block text-[#888] mb-1 text-xs tracking-widest uppercase">Identifiant (E-mail)</label>
<input <input
type="email" type="email"
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
style={inputStyle}
required required
placeholder="admin@gise.be" placeholder="admin@gise.be"
className="w-full p-3 mb-5 bg-[#1A1A1A] text-cyan-400 border border-[#333] outline-none focus:border-cyan-400 transition-colors"
/> />
<label style={{ display: 'block', color: '#888', marginBottom: '5px', fontSize: '0.8rem' }}>C D'ACCÈS SÉCURISÉE</label> <label className="block text-[#888] mb-1 text-xs tracking-widest uppercase">C d'accès sécurisée</label>
<input <input
type="password" type="password"
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
style={inputStyle}
required required
placeholder="••••••••" placeholder="••••••••"
className="w-full p-3 mb-5 bg-[#1A1A1A] text-cyan-400 border border-[#333] outline-none focus:border-cyan-400 transition-colors"
/> />
<button type="submit" style={buttonStyle} disabled={loading}> <button
type="submit"
disabled={loading}
className="w-full p-3 mt-2 font-bold uppercase tracking-widest transition-colors disabled:bg-[#333] disabled:text-[#888] disabled:cursor-not-allowed bg-cyan-400 hover:bg-cyan-300 text-black shadow-[0_0_15px_rgba(0,229,255,0.2)]"
>
{loading ? 'VÉRIFICATION...' : 'OUVRIR LE SAS'} {loading ? 'VÉRIFICATION...' : 'OUVRIR LE SAS'}
</button> </button>
</form> </form>
<div style={{ marginTop: '20px', textAlign: 'center', fontSize: '0.85rem' }}> <div className="mt-5 text-center text-sm font-mono text-[#888]">
<Link to="/register" style={{ color: '#888', textDecoration: 'none' }}> Aucun accès réseau ?{' '}
Aucun accès réseau ? <span style={{ color: '#00E5FF' }}>S'enregistrer &gt;</span> <Link to="/register" className="text-cyan-400 hover:text-cyan-300 transition-colors no-underline">
S'enregistrer &gt;
</Link> </Link>
</div> </div>
</div> </div>
+35 -115
View File
@@ -1,50 +1,8 @@
import { useState } from 'react'; import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { registerUnifiedClient } from '../../services/api'; import { registerUnifiedClient } from '../../services/api';
import NotificationModal from '../../components/ui/NotificationModal';
// ============================================================================
// COMPOSANT : MODAL DE NOTIFICATION (Succès / Erreur)
// ============================================================================
const NotificationModal = ({ notification, onClose }) => {
if (!notification) return null;
const isError = notification.type === 'error';
return (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm z-[100] flex items-center justify-center p-4" style={{ position: 'fixed', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: 'rgba(0,0,0,0.8)', backdropFilter: 'blur(4px)', zIndex: 100, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<div style={{
backgroundColor: '#1A1A1A',
border: `1px solid ${isError ? '#ff003c' : '#00E5FF'}`,
boxShadow: `0 10px 30px ${isError ? 'rgba(255, 0, 60, 0.2)' : 'rgba(0, 229, 255, 0.2)'}`,
borderRadius: '8px',
maxWidth: '400px',
width: '100%',
padding: '24px',
color: '#FFF',
fontFamily: 'monospace'
}}>
<h4 style={{ fontSize: '1.1rem', fontWeight: 'bold', letterSpacing: '1px', marginBottom: '10px', color: isError ? '#ff003c' : '#00E5FF' }}>
{notification.title.toUpperCase()}
</h4>
<p style={{ fontSize: '0.9rem', color: '#AAA', marginBottom: '24px', whiteSpace: 'pre-line', lineHeight: '1.5' }}>
{notification.message}
</p>
<button onClick={onClose} style={{
width: '100%', padding: '10px',
backgroundColor: isError ? '#ff003c' : '#00E5FF',
color: isError ? '#FFF' : '#000',
border: 'none', borderRadius: '4px',
fontFamily: 'monospace', fontWeight: 'bold', cursor: 'pointer', letterSpacing: '1px'
}}>
COMPRIS
</button>
</div>
</div>
);
};
// ============================================================================
// COMPOSANT PRINCIPAL : REGISTER
// ============================================================================
export default function Register() { export default function Register() {
const [firstName, setFirstName] = useState(''); const [firstName, setFirstName] = useState('');
const [lastName, setLastName] = useState(''); const [lastName, setLastName] = useState('');
@@ -54,7 +12,7 @@ export default function Register() {
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [customAlert, setCustomAlert] = useState(null); // Remplace l'état "error" et "alert()" const [customAlert, setCustomAlert] = useState(null);
const navigate = useNavigate(); const navigate = useNavigate();
const handleRegister = async (e) => { const handleRegister = async (e) => {
@@ -62,119 +20,81 @@ export default function Register() {
setCustomAlert(null); setCustomAlert(null);
const passwordPolicy = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,}$/; const passwordPolicy = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,}$/;
// VÉRIFICATIONS FRONTEND (Affiche le Modal d'Erreur)
if (!passwordPolicy.test(password)) { if (!passwordPolicy.test(password)) {
setCustomAlert({ type: 'error', title: 'Sécurité compromise', message: "Le mot de passe doit contenir 8 caractères min, une majuscule, une minuscule, un chiffre et un caractère spécial." }); setCustomAlert({ type: 'error', title: 'Sécurité compromise', message: "Le mot de passe doit contenir 8 caractères min, une majuscule, une minuscule, un chiffre et un caractère spécial." });
return; return;
} }
if (password !== confirmPassword) { if (password !== confirmPassword) {
setCustomAlert({ type: 'error', title: 'Erreur de saisie', message: "Les clés d'accès (mots de passe) ne correspondent pas." }); setCustomAlert({ type: 'error', title: 'Erreur de saisie', message: "Les clés d'accès ne correspondent pas." });
return; return;
} }
if (!/^[a-zA-Z0-9]{3,12}$/.test(username)) {
const validUsername = /^[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)." });
if (!validUsername) {
setCustomAlert({ type: 'error', title: 'Identifiant invalide', message: "Le nom d'utilisateur doit contenir uniquement des lettres ou chiffres (entre 3 et 12 caractères, sans espace)." });
return; return;
} }
setLoading(true); setLoading(true);
try { try {
// API BACKEND
await registerUnifiedClient(email, username, password, firstName, lastName); 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." });
// SUCCÈS (Affiche le Modal de Succès)
setCustomAlert({
type: 'success',
title: 'PROVISIONNEMENT RÉUSSI',
message: "Vos comptes FOSSBilling, HestiaCP et Nextcloud ont été initialisés.\n\nVous pouvez maintenant vous connecter à l'infrastructure."
});
} catch (err) { } catch (err) {
// ERREUR API (Affiche le Modal d'Erreur API) 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 de l'infrastructure." });
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; };
// Fermeture du Modal : Redirige vers le Login SI c'était un succès
const handleCloseModal = () => { const handleCloseModal = () => {
if (customAlert?.type === 'success') { if (customAlert?.type === 'success') navigate('/login');
navigate('/login'); else setCustomAlert(null);
} else {
setCustomAlert(null);
}
};
// Styles
const inputStyle = {
width: '100%', padding: '10px', marginBottom: '15px',
backgroundColor: '#1A1A1A', color: '#00E5FF',
border: '1px solid #333', fontFamily: 'monospace', outline: 'none',
boxSizing: 'border-box'
};
const buttonStyle = {
width: '100%', padding: '12px', backgroundColor: loading ? '#333' : '#00E5FF',
color: loading ? '#888' : '#000', border: 'none', cursor: loading ? 'not-allowed' : 'pointer',
fontFamily: 'monospace', fontWeight: 'bold', textTransform: 'uppercase', letterSpacing: '1px',
marginTop: '10px'
}; };
return ( return (
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '5vh', padding: '0 20px' }}> <div className="flex justify-center mt-[5vh] px-5">
<div style={{ <div className="w-full max-w-125 bg-[#242424] p-8 border-t-4 border-cyan-400 shadow-[0_10px_30px_rgba(0,0,0,0.5)]">
width: '100%', maxWidth: '500px', backgroundColor: '#242424', <h2 className="text-white uppercase mb-1 text-2xl font-sans font-bold">
padding: '30px', borderTop: '4px solid #00E5FF', boxShadow: '0 10px 30px rgba(0,0,0,0.5)'
}}>
<h2 style={{ color: '#FFF', textTransform: 'uppercase', marginBottom: '5px', fontSize: '1.5rem' }}>
Créer un accès réseau Créer un accès réseau
</h2> </h2>
<p style={{ color: '#888', fontFamily: 'monospace', fontSize: '0.85rem', marginBottom: '25px' }}> <p className="text-[#888] font-mono text-sm mb-6">
[ INITIALISATION DU PROVISIONNEMENT TRIPLE EN CASCADE ] [ INITIALISATION DU PROVISIONNEMENT TRIPLE ]
</p> </p>
<form onSubmit={handleRegister}> <form onSubmit={handleRegister} className="font-mono">
<div style={{ display: 'flex', gap: '15px' }}> <div className="flex gap-4">
<div style={{ flex: 1 }}> <div className="flex-1">
<label style={{ display: 'block', color: '#888', marginBottom: '5px', fontSize: '0.8rem' }}>PRÉNOM</label> <label className="block text-[#888] mb-1 text-xs tracking-widest uppercase">Prénom</label>
<input type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} style={inputStyle} required placeholder="John" /> <input type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} required placeholder="John" className="w-full p-3 mb-4 bg-[#1A1A1A] text-cyan-400 border border-[#333] outline-none focus:border-cyan-400 transition-colors" />
</div> </div>
<div style={{ flex: 1 }}> <div className="flex-1">
<label style={{ display: 'block', color: '#888', marginBottom: '5px', fontSize: '0.8rem' }}>NOM</label> <label className="block text-[#888] mb-1 text-xs tracking-widest uppercase">Nom</label>
<input type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} style={inputStyle} required placeholder="Doe" /> <input type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} required placeholder="Doe" className="w-full p-3 mb-4 bg-[#1A1A1A] text-cyan-400 border border-[#333] outline-none focus:border-cyan-400 transition-colors" />
</div> </div>
</div> </div>
<label style={{ display: 'block', color: '#888', marginBottom: '5px', fontSize: '0.8rem' }}>ADRESSE E-MAIL (IDENTIFIANT FACTURATION)</label> <label className="block text-[#888] mb-1 text-xs tracking-widest uppercase">E-mail (Facturation)</label>
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} style={inputStyle} required placeholder="john.doe@gise.be" /> <input type="email" value={email} onChange={(e) => setEmail(e.target.value)} required placeholder="john.doe@gise.be" className="w-full p-3 mb-4 bg-[#1A1A1A] text-cyan-400 border border-[#333] outline-none focus:border-cyan-400 transition-colors" />
<label style={{ display: 'block', color: '#888', marginBottom: '5px', fontSize: '0.8rem' }}>NOM D'UTILISATEUR SÉCURISÉ (ACCÈS HESTIA & CLOUD)</label> <label className="block text-[#888] mb-1 text-xs tracking-widest uppercase">Identifiant sécurisé (Cloud)</label>
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} style={inputStyle} required placeholder="ex: jdoe42" /> <input type="text" value={username} onChange={(e) => setUsername(e.target.value)} required placeholder="ex: jdoe42" className="w-full p-3 mb-4 bg-[#1A1A1A] text-cyan-400 border border-[#333] outline-none focus:border-cyan-400 transition-colors" />
<label style={{ display: 'block', color: '#888', marginBottom: '5px', fontSize: '0.8rem' }}>CLÉ D'ACCÈS (MOT DE PASSE UNIQUE)</label> <label className="block text-[#888] mb-1 text-xs tracking-widest uppercase">Clé d'accès unique</label>
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} style={inputStyle} required placeholder="••••••••" /> <input type="password" value={password} onChange={(e) => setPassword(e.target.value)} required placeholder="••••••••" className="w-full p-3 mb-4 bg-[#1A1A1A] text-cyan-400 border border-[#333] outline-none focus:border-cyan-400 transition-colors" />
<label style={{ display: 'block', color: '#888', marginBottom: '5px', fontSize: '0.8rem' }}>CONFIRMER LA CLÉ D'ACCÈS</label> <label className="block text-[#888] mb-1 text-xs tracking-widest uppercase">Confirmer la clé</label>
<input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} style={inputStyle} required placeholder="••••••••" /> <input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} required placeholder="••••••••" className="w-full p-3 mb-5 bg-[#1A1A1A] text-cyan-400 border border-[#333] outline-none focus:border-cyan-400 transition-colors" />
<button type="submit" style={buttonStyle} disabled={loading}> <button type="submit" disabled={loading} className="w-full p-3 mt-2 font-bold uppercase tracking-widest transition-colors disabled:bg-[#333] disabled:text-[#888] disabled:cursor-not-allowed bg-cyan-400 hover:bg-cyan-300 text-black shadow-[0_0_15px_rgba(0,229,255,0.2)]">
{loading ? 'DÉPLOIEMENT EN COURS...' : 'LANCER LE PROVISIONNEMENT'} {loading ? 'DÉPLOIEMENT EN COURS...' : 'LANCER LE PROVISIONNEMENT'}
</button> </button>
</form> </form>
<div style={{ marginTop: '20px', textAlign: 'center', fontSize: '0.85rem' }}> <div className="mt-5 text-center text-sm font-mono text-[#888]">
<Link to="/login" style={{ color: '#888', textDecoration: 'none' }}> Déjà enregistré ?{' '}
Déjà enregistré ? <span style={{ color: '#00E5FF' }}>Se connecter au terminal &gt;</span> <Link to="/login" className="text-cyan-400 hover:text-cyan-300 transition-colors no-underline">
Se connecter au terminal &gt;
</Link> </Link>
</div> </div>
</div> </div>
{/* Affichage du Modal par-dessus le formulaire */}
<NotificationModal notification={customAlert} onClose={handleCloseModal} /> <NotificationModal notification={customAlert} onClose={handleCloseModal} />
</div> </div>
); );
-90
View File
@@ -1,90 +0,0 @@
import { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { loginClient } from '../../services/api';
export default function Login() {
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState(null);
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const handleLogin = async (e) => {
e.preventDefault(); // Empêche la page de se rafraîchir
setError(null);
setLoading(true);
try {
// On envoie la requête à FOSSBilling
await loginClient(email, password);
// Si on arrive ici, le login est un succès !
// On redirige vers l'espace client sécurisé
navigate('/dashboard');
} catch (err) {
setError(err.message || "Accès refusé. Vérifiez vos identifiants.");
} finally {
setLoading(false);
}
};
// Styles CSS en variables pour garder le code lisible
const inputStyle = {
width: '100%', padding: '12px', marginBottom: '15px',
backgroundColor: '#1A1A1A', color: '#00E5FF',
border: '1px solid #333', fontFamily: 'monospace', outline: 'none'
};
const buttonStyle = {
width: '100%', padding: '12px', backgroundColor: loading ? '#333' : '#00E5FF',
color: loading ? '#888' : '#000', border: 'none', cursor: loading ? 'not-allowed' : 'pointer',
fontFamily: 'monospace', fontWeight: 'bold', textTransform: 'uppercase', letterSpacing: '1px'
};
return (
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '10vh' }}>
<div style={{
width: '100%', maxWidth: '400px', backgroundColor: '#242424',
padding: '30px', borderTop: '4px solid #00E5FF', boxShadow: '0 10px 30px rgba(0,0,0,0.5)'
}}>
<h2 style={{ color: '#FFF', textTransform: 'uppercase', marginBottom: '20px', fontSize: '1.5rem' }}>
Authentification
</h2>
{/* Zone d'erreur qui s'affiche si le mot de passe est faux */}
{error && (
<div style={{ backgroundColor: 'rgba(255, 68, 68, 0.1)', color: '#FF4444', padding: '10px', marginBottom: '15px', border: '1px solid #FF4444', fontSize: '0.9rem' }}>
[ ERREUR ] : {error}
</div>
)}
<form onSubmit={handleLogin}>
<label style={{ display: 'block', color: '#888', marginBottom: '5px', fontSize: '0.8rem' }}>IDENTIFIANT (EMAIL)</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
style={inputStyle}
required
placeholder="admin@gise.be"
/>
<label style={{ display: 'block', color: '#888', marginBottom: '5px', fontSize: '0.8rem' }}>CLÉ D'ACCÈS (MOT DE PASSE)</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
style={inputStyle}
required
placeholder="••••••••"
/>
<button type="submit" style={buttonStyle} disabled={loading}>
{loading ? 'VÉRIFICATION...' : 'INITIALISER LA CONNEXION'}
</button>
</form>
</div>
</div>
);
}
+23
View File
@@ -221,3 +221,26 @@ export const replyTicket = (ticketId, message) =>
// Récupère la liste dynamique des départements (Helpdesks) configurés sur FOSSBilling // Récupère la liste dynamique des départements (Helpdesks) configurés sur FOSSBilling
export const getHelpdesks = () => export const getHelpdesks = () =>
apiCall(`${BASE_URL}/api/client/support/helpdesk_get_pairs`, 'GET'); 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
});