Refactor/ui components #1
+25
@@ -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*
|
||||
@@ -0,0 +1,49 @@
|
||||
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>;
|
||||
}
|
||||
};
|
||||
|
||||
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 : {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 ></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-[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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { Server, Database, Cloud, Globe, ExternalLink, Play, Loader } from 'lucide-react';
|
||||
|
||||
export default function 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-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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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-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">
|
||||
<div className="flex items-baseline space-x-2">
|
||||
<span className="text-4xl font-black text-cyan-400">{priceData.displayPrice} €</span>
|
||||
<span className="text-gray-500">{priceData.suffix}</span>
|
||||
</div>
|
||||
<div className="mt-3 min-h-[44px] flex flex-col justify-end">
|
||||
{!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>
|
||||
);
|
||||
}
|
||||
@@ -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,60 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Plus, Lock } 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="text-gray-400 hover:text-white transition text-xl">✖</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>
|
||||
);
|
||||
}
|
||||
@@ -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() };
|
||||
}
|
||||
};
|
||||
@@ -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,80 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { Send, Loader } 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="text-gray-400 hover:text-white transition text-xl bg-black/50 p-2 rounded-lg">✖</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
+39
-183
@@ -1,63 +1,8 @@
|
||||
// src/layouts/AppLayout.jsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Outlet, NavLink, useNavigate } from 'react-router-dom';
|
||||
import { getClientTickets } from '../services/api';
|
||||
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() {
|
||||
const navigate = useNavigate();
|
||||
const [showLogoutModal, setShowLogoutModal] = useState(false);
|
||||
@@ -67,187 +12,98 @@ export default function AppLayout() {
|
||||
try {
|
||||
const data = await getClientTickets();
|
||||
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;
|
||||
setUnreadCount(unread);
|
||||
}
|
||||
} catch (err) {
|
||||
// Erreur silencieuse : on ne pollue pas l'écran si le réseau saute
|
||||
}
|
||||
} catch (err) {}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
checkUnreadTickets(); // Premier check immédiat
|
||||
const intervalId = setInterval(checkUnreadTickets, 120000); // Check toutes les 2 minutes
|
||||
checkUnreadTickets();
|
||||
const intervalId = setInterval(checkUnreadTickets, 120000);
|
||||
return () => clearInterval(intervalId);
|
||||
}, []);
|
||||
|
||||
// Ouvre simplement le modal
|
||||
const handleLogoutClick = () => {
|
||||
setShowLogoutModal(true);
|
||||
};
|
||||
|
||||
// Exécute la vraie déconnexion
|
||||
const confirmLogout = () => {
|
||||
// Ici, tu pourras effacer les tokens (ex: localStorage.removeItem('token'))
|
||||
setShowLogoutModal(false);
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
// --- DESIGN SYSTEM DU BUNKER ---
|
||||
const sidebarStyle = {
|
||||
width: '260px',
|
||||
backgroundColor: '#121212', // Gris blindage
|
||||
borderRight: '1px solid #222222',
|
||||
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'
|
||||
});
|
||||
// 🌟 La fonction magique Tailwind pour les liens du menu
|
||||
const navLinkClass = ({ isActive }) =>
|
||||
`block px-6 py-4 no-underline border-l-4 transition-all uppercase tracking-widest text-sm font-mono ${
|
||||
isActive
|
||||
? 'text-cyan-400 bg-cyan-400/5 border-cyan-400'
|
||||
: 'text-[#888] border-transparent hover:text-gray-300 hover:bg-white/5'
|
||||
}`;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'block' }}>
|
||||
<div className="flex min-h-screen bg-[#050505] text-[#E0E0E0] font-sans">
|
||||
|
||||
{/* ========================================== */}
|
||||
{/* BARRE LATÉRALE (SIDEBAR) */}
|
||||
{/* ========================================== */}
|
||||
<aside style={sidebarStyle}>
|
||||
{/* SIDEBAR */}
|
||||
<aside className="w-65 bg-[#121212] border-r border-[#222222] h-screen flex flex-col fixed top-0 left-0">
|
||||
|
||||
{/* En-tête de la Sidebar (Logo / Marque) */}
|
||||
<div style={{ padding: '30px 25px', borderBottom: '1px solid #222' }}>
|
||||
<h1 style={{
|
||||
color: '#FFF',
|
||||
margin: 0,
|
||||
fontSize: '1.8rem',
|
||||
letterSpacing: '3px',
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
gap: '8px'
|
||||
}}>
|
||||
<div className="px-6 py-8 border-b border-[#222]">
|
||||
<h1 className="text-white m-0 text-3xl font-black tracking-widest flex items-baseline gap-2">
|
||||
NEXUS
|
||||
<span style={{
|
||||
color: '#00E5FF',
|
||||
fontSize: '0.7rem',
|
||||
letterSpacing: '1px',
|
||||
fontWeight: 'normal',
|
||||
}}>
|
||||
by GISE
|
||||
</span>
|
||||
<span className="text-cyan-400 text-[10px] tracking-widest font-normal">by GISE</span>
|
||||
</h1>
|
||||
<p style={{ color: '#555', fontSize: '0.7rem', margin: '8px 0 0 0' }}>
|
||||
CONSOLE D'INFRASTRUCTURE v1.0
|
||||
<p className="text-[#555] text-[10px] font-mono tracking-wider mt-2 uppercase">
|
||||
Console d'infrastructure v1.0
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Menu de Navigation */}
|
||||
<nav style={{ flex: 1, marginTop: '20px' }}>
|
||||
<div style={{ padding: '0 25px', marginBottom: '10px', color: '#444', fontSize: '0.7rem', fontWeight: 'bold' }}>
|
||||
<nav className="flex-1 mt-6">
|
||||
<div className="px-6 mb-2 text-[#444] text-[10px] font-bold font-mono tracking-widest">
|
||||
// SUPERVISION
|
||||
</div>
|
||||
<NavLink style={getNavLinkStyle} to="/dashboard">Tableau de bord</NavLink>
|
||||
<NavLink style={getNavLinkStyle} to="/services">Inventaire Réseau</NavLink>
|
||||
<NavLink className={navLinkClass} to="/dashboard">Tableau de bord</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
|
||||
</div>
|
||||
{/*<NavLink style={getNavLinkStyle} to="/billing" locked>Facturation</NavLink>*/}
|
||||
<NavLink style={getNavLinkStyle} to="/support">
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', width: '100%' }}>
|
||||
<NavLink className={navLinkClass} to="/support">
|
||||
<div className="flex justify-between items-center w-full">
|
||||
<span>Tickets Support</span>
|
||||
|
||||
{/* LE BADGE DE NOTIFICATION */}
|
||||
{unreadCount > 0 && (
|
||||
<span style={{
|
||||
backgroundColor: '#00E5FF',
|
||||
color: '#000',
|
||||
fontSize: '0.65rem',
|
||||
fontWeight: 'bold',
|
||||
padding: '2px 8px',
|
||||
borderRadius: '12px',
|
||||
boxShadow: '0 0 10px rgba(0,229,255,0.6)'
|
||||
}}>
|
||||
<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">
|
||||
{unreadCount} NEW
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</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={{
|
||||
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'
|
||||
}}>
|
||||
<div className="flex justify-between items-center px-6 py-4 text-[#444] border-l-4 border-transparent uppercase tracking-widest text-sm font-mono cursor-not-allowed select-none">
|
||||
<span>Facturation</span>
|
||||
<span className="text-[9px] text-[#333] border border-[#333] px-1.5 py-0.5 rounded font-bold">WIP</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center px-6 py-4 text-[#444] border-l-4 border-transparent uppercase tracking-widest text-sm font-mono cursor-not-allowed select-none">
|
||||
<span>Profil & Sécurité</span>
|
||||
<span 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>
|
||||
</nav>
|
||||
|
||||
{/* Bas de la Sidebar (Déconnexion) */}
|
||||
<div style={{ padding: '20px', borderTop: '1px solid #222' }}>
|
||||
<div className="p-5 border-t border-[#222]">
|
||||
<button
|
||||
onClick={handleLogoutClick}
|
||||
style={{
|
||||
width: '100%', padding: '12px', backgroundColor: 'transparent',
|
||||
color: '#ff003c', border: '1px solid #ff003c', cursor: 'pointer',
|
||||
fontFamily: 'monospace', textTransform: 'uppercase'
|
||||
}}
|
||||
onClick={() => setShowLogoutModal(true)}
|
||||
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"
|
||||
>
|
||||
[ Déconnexion ]
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* ========================================== */}
|
||||
{/* ZONE DE CONTENU DYNAMIQUE */}
|
||||
{/* ========================================== */}
|
||||
<main style={mainContentStyle}>
|
||||
{/* Le composant <Outlet /> injecte le contenu de la page demandée sans recharger la sidebar */}
|
||||
{/* ZONE DE CONTENU */}
|
||||
<main className="ml-65 p-10 flex-1 min-h-screen">
|
||||
<Outlet context={{ refreshNavbarTickets: checkUnreadTickets }} />
|
||||
</main>
|
||||
|
||||
{/* MODAL DE DÉCONNEXION */}
|
||||
<ConfirmLogoutModal
|
||||
isOpen={showLogoutModal}
|
||||
onClose={() => setShowLogoutModal(false)}
|
||||
onConfirm={confirmLogout}
|
||||
/>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,35 +1,28 @@
|
||||
// src/layouts/PublicLayout.jsx
|
||||
import { Outlet, Link } from 'react-router-dom';
|
||||
|
||||
export default function PublicLayout() {
|
||||
return (
|
||||
<div style={{ backgroundColor: '#121212', color: '#E0E0E0', minHeight: '100vh', fontFamily: 'monospace' }}>
|
||||
<nav style={{ padding: '20px', borderBottom: '1px solid #242424', display: 'flex', gap: '15px', alignItems: 'center' }}>
|
||||
<Link to="/" style={{ display: 'flex', alignItems: 'baseline', gap: '6px', textDecoration: 'none' }}>
|
||||
<span style={{ color: '#FFF', fontSize: '1.2rem', letterSpacing: '2px', fontWeight: 'bold' }}>NEXUS</span>
|
||||
<span style={{ color: '#00E5FF', fontSize: '0.65rem', letterSpacing: '1px', fontWeight: 'normal'}}>by GISE</span>
|
||||
<div className="bg-[#121212] text-[#E0E0E0] min-h-screen font-mono">
|
||||
<nav className="p-5 border-b border-[#242424] flex gap-4 items-center">
|
||||
<Link to="/" className="flex items-baseline gap-1.5 no-underline">
|
||||
<span className="text-white text-xl tracking-[2px] font-bold">NEXUS</span>
|
||||
<span className="text-cyan-400 text-[10px] tracking-[1px]">by GISE</span>
|
||||
</Link>
|
||||
<Link to="/offres" style={{ color: '#E0E0E0', textDecoration: 'none', marginLeft: '20px' }}>Catalogue</Link>
|
||||
|
||||
{/* AJOUT DU BOUTON D'INSCRIPTION */}
|
||||
<Link to="/register" style={{ color: '#A0A0A0', textDecoration: 'none', marginLeft: 'auto', fontSize: '0.9rem' }}>
|
||||
<Link to="/offres" className="text-[#E0E0E0] no-underline ml-5 hover:text-cyan-400 transition-colors">
|
||||
Catalogue
|
||||
</Link>
|
||||
|
||||
<Link to="/register" className="text-[#A0A0A0] no-underline ml-auto text-sm hover:text-white transition-colors">
|
||||
Créer un compte
|
||||
</Link>
|
||||
|
||||
<Link to="/login" style={{
|
||||
color: '#000',
|
||||
backgroundColor: '#00E5FF',
|
||||
padding: '6px 12px',
|
||||
textDecoration: 'none',
|
||||
fontWeight: 'bold',
|
||||
fontSize: '0.9rem'
|
||||
}}>
|
||||
<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">
|
||||
Connexion
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* C'est ici que les pages (Accueil, Login, etc.) vont s'afficher */}
|
||||
<main style={{ padding: '20px' }}>
|
||||
<main className="p-5">
|
||||
<Outlet />
|
||||
</main>
|
||||
</div>
|
||||
|
||||
+12
-64
@@ -1,7 +1,11 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { getClientOrders } from '../../services/api';
|
||||
import { Server, Database, Cloud, Globe, Plus, AlertCircle, Loader } from 'lucide-react';
|
||||
import { AlertCircle, Loader } from 'lucide-react';
|
||||
|
||||
// Importation des composants isolés
|
||||
import DashboardServiceCard from '../../components/dashboard/DashboardServiceCard';
|
||||
import NewServiceCard from '../../components/dashboard/NewServiceCard';
|
||||
|
||||
export default function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
@@ -21,16 +25,12 @@ export default function Dashboard() {
|
||||
const title = (order.title || '').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 =
|
||||
type === 'domain' ||
|
||||
title.startsWith('domain ') ||
|
||||
title.startsWith('domaine ') ||
|
||||
title.startsWith('enregistrement ');
|
||||
|
||||
// On retourne true (on affiche) uniquement si ce n'est pas un produit fantôme
|
||||
return !isGhostProduct;
|
||||
});
|
||||
|
||||
@@ -48,25 +48,6 @@ export default function Dashboard() {
|
||||
fetchInventory();
|
||||
}, []);
|
||||
|
||||
// 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>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-6xl p-6 mx-auto">
|
||||
|
||||
@@ -94,50 +75,17 @@ export default function Dashboard() {
|
||||
{!isLoading && !error && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
|
||||
|
||||
{/* Boucle sur les services FOSSBilling */}
|
||||
{/* Boucle sur les composants isolés */}
|
||||
{orders.map((order) => (
|
||||
<div
|
||||
<DashboardServiceCard
|
||||
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"
|
||||
onClick={() => navigate(`/services/${order.id}`)} // Redirection future vers les détails
|
||||
>
|
||||
<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 ></span>
|
||||
</div>
|
||||
</div>
|
||||
order={order}
|
||||
onClick={() => navigate(`/services/${order.id}`)}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* 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>
|
||||
{/* Le composant carte d'ajout */}
|
||||
<NewServiceCard onClick={() => navigate('/store')} />
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
+34
-591
@@ -1,496 +1,39 @@
|
||||
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 { 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 } from 'lucide-react';
|
||||
|
||||
// ============================================================================
|
||||
// COMPOSANT 0 : MODAL DE NOTIFICATION (Remplace les alert() natifs)
|
||||
// ============================================================================
|
||||
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">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
// Importation de tes nouveaux composants modulaires !
|
||||
import NotificationModal from '../../components/ui/NotificationModal';
|
||||
import InstanceCard from '../../components/services/InstanceCard';
|
||||
import VpsDeployer from '../../components/services/VpsDeployer';
|
||||
import VpsManager from '../../components/services/VpsManager';
|
||||
import WebManager from '../../components/services/WebManager';
|
||||
|
||||
// ============================================================================
|
||||
// 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() {
|
||||
const [services, setServices] = useState([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [newVpcName, setNewVpcName] = useState("");
|
||||
const [isConnecting, setIsConnecting] = useState(null);
|
||||
const [ssoVault, setSsoVault] = useState(null);
|
||||
const [activeServiceModal, setActiveServiceModal] = useState(null);
|
||||
const [customAlert, setCustomAlert] = useState(null);
|
||||
|
||||
const { vpcs, createVPC, deleteVPC, assignToVPC, removeFromVPC } = useVPC();
|
||||
|
||||
const triggerAlert = (title, message, type = "info") => {
|
||||
setCustomAlert({ title, message, type });
|
||||
};
|
||||
const triggerAlert = (title, message, type = "info") => setCustomAlert({ title, message, type });
|
||||
|
||||
const fetchServices = useCallback(async () => {
|
||||
try {
|
||||
const data = await getMyServices();
|
||||
if (data.list && data.list.length > 0) {
|
||||
const detailedServices = await Promise.all(
|
||||
data.list.map(async (order) => {
|
||||
let hDetails = null;
|
||||
if (order.status === 'active' && (order.plugin === 'hosting' || (order.title || '').toLowerCase().includes('vps'))) {
|
||||
try { hDetails = await getHostingServiceDetails(order.id); }
|
||||
catch (e) { console.warn(`Détails inaccessibles pour ${order.id}:`, e); }
|
||||
}
|
||||
return { ...order, hostingDetails: hDetails };
|
||||
})
|
||||
);
|
||||
const detailedServices = await Promise.all(data.list.map(async (order) => {
|
||||
let hDetails = null;
|
||||
if (order.status === 'active' && (order.plugin === 'hosting' || (order.title || '').toLowerCase().includes('vps'))) {
|
||||
try { hDetails = await getHostingServiceDetails(order.id); } catch (e) {}
|
||||
}
|
||||
return { ...order, hostingDetails: hDetails };
|
||||
}));
|
||||
|
||||
const filteredServices = detailedServices.filter(s => {
|
||||
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');
|
||||
return (s.status === 'active' || s.status === 'pending_setup') && !isGhostProduct;
|
||||
});
|
||||
|
||||
setServices(filteredServices);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message || "Impossible de charger la télémétrie des services.");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
} catch (err) { setError(err.message || "Impossible de charger la télémétrie."); }
|
||||
finally { setIsLoading(false); }
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchServices();
|
||||
}, [fetchServices]);
|
||||
useEffect(() => { fetchServices(); }, [fetchServices]);
|
||||
|
||||
const handleOpenConsole = async (service) => {
|
||||
const titleLower = (service.title || '').toLowerCase();
|
||||
@@ -520,7 +57,6 @@ export default function Services() {
|
||||
const isWeb = !isVPS && !isCloud && !isDB;
|
||||
|
||||
setIsConnecting(service.id);
|
||||
|
||||
try {
|
||||
if (isCloud) {
|
||||
const titleMatch = (service.title || '').match(/(?: for | pour )(.+)$/i);
|
||||
@@ -532,53 +68,33 @@ export default function Services() {
|
||||
const freshDetails = await getHostingServiceDetails(service.id);
|
||||
setActiveServiceModal({ type: isVPS ? 'vps' : 'web', data: service, details: freshDetails });
|
||||
}
|
||||
} catch (err) {
|
||||
triggerAlert("Erreur d'Orchestration", "Échec du protocole : " + err.message, "error");
|
||||
} finally {
|
||||
setIsConnecting(null);
|
||||
}
|
||||
} catch (err) { triggerAlert("Erreur", err.message, "error"); }
|
||||
finally { setIsConnecting(null); }
|
||||
};
|
||||
|
||||
const assignedServiceIds = vpcs.flatMap(vpc => vpc.services);
|
||||
const freeServices = services.filter(s => !assignedServiceIds.includes(s.id));
|
||||
|
||||
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>
|
||||
<header>
|
||||
<h1 className="text-3xl font-black text-white tracking-wider"> {/* Typographie Dashboard */}
|
||||
TERMINAL <span className="text-cyan-400">NEXUS</span>
|
||||
</h1>
|
||||
<h1 className="text-3xl font-black text-white tracking-wider">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>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
<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
|
||||
</button>
|
||||
</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 du réseau et des instances en cours...</span>
|
||||
</div>
|
||||
)}
|
||||
{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>}
|
||||
{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>}
|
||||
|
||||
{/* 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 && (
|
||||
<>
|
||||
<div className="space-y-8 mb-12">
|
||||
@@ -588,118 +104,45 @@ export default function Services() {
|
||||
<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 items-center space-x-3 text-white">
|
||||
<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>
|
||||
<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>
|
||||
</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">
|
||||
<Trash2 className="w-4 h-4 mr-1" /> Démanteler VPC
|
||||
</button>
|
||||
<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>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{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">
|
||||
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}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{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>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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 D'INSTANCES LIBRES
|
||||
</h2>
|
||||
<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>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{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. 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}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
{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>
|
||||
</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 && (
|
||||
<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="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">
|
||||
{activeServiceModal.type === 'vps' ? (
|
||||
<><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</>
|
||||
)}
|
||||
{activeServiceModal.type === 'vps' ? <><Server className="w-6 h-6 text-cyan-400" /> GESTION VPS</> : <><Globe className="w-6 h-6 text-emerald-400" /> GESTION WEB</>}
|
||||
</h3>
|
||||
<button onClick={() => setActiveServiceModal(null)} className="text-gray-400 hover:text-white transition text-xl">✖</button>
|
||||
</div>
|
||||
|
||||
{activeServiceModal.type === 'vps' ? (
|
||||
activeServiceModal.details?.ip && activeServiceModal.details.ip !== '127.0.0.1' ? (
|
||||
<VpsManager details={activeServiceModal.details} orderId={activeServiceModal.data.id} onRefresh={fetchServices} onAlert={triggerAlert} />
|
||||
) : (
|
||||
<VpsDeployer serviceId={activeServiceModal.data.id} onDeploySuccess={() => { setActiveServiceModal(null); fetchServices(); }} onAlert={triggerAlert} />
|
||||
)
|
||||
activeServiceModal.details?.ip && activeServiceModal.details.ip !== '127.0.0.1' ? <VpsManager details={activeServiceModal.details} orderId={activeServiceModal.data.id} onRefresh={fetchServices} onAlert={triggerAlert} /> : <VpsDeployer serviceId={activeServiceModal.data.id} onDeploySuccess={() => { setActiveServiceModal(null); fetchServices(); }} onAlert={triggerAlert} />
|
||||
) : (
|
||||
<WebManager details={activeServiceModal.details} orderId={activeServiceModal.data.id} onRefresh={fetchServices} onOpenSso={setSsoVault} onAlert={triggerAlert} />
|
||||
<WebManager details={activeServiceModal.details} orderId={activeServiceModal.data.id} onRefresh={fetchServices} onAlert={triggerAlert} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* VRAI MODAL DE NOTIFICATION REACT SURCHARGE */}
|
||||
<NotificationModal notification={customAlert} onClose={() => setCustomAlert(null)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
+5
-283
@@ -1,288 +1,16 @@
|
||||
// src/pages/app/Store.jsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Server, Database, Cloud, Globe, Loader, AlertCircle } from 'lucide-react';
|
||||
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() {
|
||||
const [groupedProducts, setGroupedProducts] = useState({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
|
||||
// ALIGNEMENT PARFAIT SUR TES CATÉGORIES FOSSBILLING
|
||||
const CATEGORY_ORDER = [
|
||||
"Web Hosting",
|
||||
"VPS",
|
||||
"Database",
|
||||
"Cloud"
|
||||
];
|
||||
|
||||
const CATEGORY_MAP = {
|
||||
1: "Web Hosting",
|
||||
4: "VPS",
|
||||
3: "Database",
|
||||
2: "Cloud"
|
||||
};
|
||||
const CATEGORY_ORDER = ["Web Hosting", "VPS", "Database", "Cloud"];
|
||||
const CATEGORY_MAP = { 1: "Web Hosting", 4: "VPS", 3: "Database", 2: "Cloud" };
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCatalog = async () => {
|
||||
@@ -291,10 +19,7 @@ export default function Store() {
|
||||
const products = data.list || [];
|
||||
|
||||
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;
|
||||
|
||||
// TRADUCTION : On cherche le nom dans notre dictionnaire. Si inconnu -> Autres.
|
||||
const catName = CATEGORY_MAP[catId] || 'Autres Services';
|
||||
|
||||
if (!acc[catName]) acc[catName] = [];
|
||||
@@ -320,14 +45,11 @@ export default function Store() {
|
||||
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) => {
|
||||
let indexA = CATEGORY_ORDER.indexOf(a);
|
||||
let indexB = CATEGORY_ORDER.indexOf(b);
|
||||
|
||||
if (indexA === -1) indexA = 999;
|
||||
if (indexB === -1) indexB = 999;
|
||||
|
||||
return indexA - indexB;
|
||||
});
|
||||
|
||||
|
||||
+53
-292
@@ -1,77 +1,38 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
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 { LifeBuoy, Plus, MessageSquare, Clock, CheckCircle2, Send, AlertCircle, Loader, Lock } from 'lucide-react';
|
||||
import { getClientTickets, createTicket, getHelpdesks, getTicketDetails, replyTicket } from '../../services/api';
|
||||
import { Plus, AlertCircle, Loader } from 'lucide-react';
|
||||
|
||||
// ============================================================================
|
||||
// COMPOSANT 0 : MODAL DE NOTIFICATION (Design System)
|
||||
// ============================================================================
|
||||
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">
|
||||
<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>
|
||||
);
|
||||
};
|
||||
// Importation des composants isolés
|
||||
import NotificationModal from '../../components/ui/NotificationModal';
|
||||
import TicketCard from '../../components/support/TicketCard';
|
||||
import CreateTicketModal from '../../components/support/CreateTicketModal';
|
||||
import TicketThreadModal from '../../components/support/TicketThreadModal';
|
||||
|
||||
// ============================================================================
|
||||
// COMPOSANT PRINCIPAL : SUPPORT TICKETS
|
||||
// ============================================================================
|
||||
export default function Support() {
|
||||
const { refreshNavbarTickets } = useOutletContext();
|
||||
|
||||
const [tickets, setTickets] = useState([]);
|
||||
const [helpdesks, setHelpdesks] = useState([]);
|
||||
const [defaultHelpdesk, setDefaultHelpdesk] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isLoadingConversation, setIsLoadingConversation] = useState(false); // Loader spécifique pour le fil de discussion
|
||||
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 [isCreatingTicket, setIsCreatingTicket] = useState(false);
|
||||
const [activeTicket, setActiveTicket] = useState(null);
|
||||
const [isLoadingConversation, setIsLoadingConversation] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const messagesEndRef = useRef(null);
|
||||
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 });
|
||||
|
||||
const triggerAlert = (title, message, type = "info") => {
|
||||
setCustomAlert({ title, message, type });
|
||||
};
|
||||
|
||||
// Chargement synchrone des tickets et des helpdesks
|
||||
const loadSupportData = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const ticketsData = await getClientTickets();
|
||||
if (ticketsData && ticketsData.list) {
|
||||
setTickets(ticketsData.list.map(tkt => ({
|
||||
id: `TKT-${tkt.id}`,
|
||||
db_id: tkt.id,
|
||||
subject: tkt.subject,
|
||||
id: `TKT-${tkt.id}`, db_id: tkt.id, subject: tkt.subject,
|
||||
department: tkt.helpdesk?.name || 'Support Technique',
|
||||
status: ['open', 'closed', 'on_hold'].includes(tkt.status) ? tkt.status : 'pending',
|
||||
lastUpdate: tkt.updated_at || tkt.created_at || 'Récemment',
|
||||
@@ -83,112 +44,68 @@ export default function Support() {
|
||||
if (hdeskPairs) {
|
||||
const formattedDesks = Object.entries(hdeskPairs).map(([id, name]) => ({ id, name }));
|
||||
setHelpdesks(formattedDesks);
|
||||
|
||||
const nexusDesk = formattedDesks.find(hd => hd.name.toLowerCase().includes('nexus'));
|
||||
if (nexusDesk) {
|
||||
setSelectedHelpdesk(nexusDesk.id);
|
||||
} else if (formattedDesks.length > 0) {
|
||||
setSelectedHelpdesk(formattedDesks[0].id);
|
||||
}
|
||||
if (nexusDesk) setDefaultHelpdesk(nexusDesk.id);
|
||||
else if (formattedDesks.length > 0) setDefaultHelpdesk(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(() => {
|
||||
loadSupportData();
|
||||
}, [loadSupportData]);
|
||||
useEffect(() => { loadSupportData(); }, [loadSupportData]);
|
||||
|
||||
// ACTION CLIC : Charger les messages du ticket depuis FOSSBilling
|
||||
// Ouvre le fil de discussion
|
||||
const handleOpenTicket = async (ticket) => {
|
||||
try {
|
||||
setIsLoadingConversation(true);
|
||||
// On ouvre immédiatement le modal avec une liste de messages vide pour la fluidité
|
||||
setActiveTicket({ ...ticket, messages: [] });
|
||||
|
||||
const details = await getTicketDetails(ticket.db_id);
|
||||
|
||||
if (details && details.messages) {
|
||||
// FOSSBilling renvoie l'auteur dans msg.author.role ('client', 'staff', 'admin')
|
||||
const formattedMessages = details.messages.map(msg => ({
|
||||
sender: msg.author?.role === 'client' ? 'client' : 'staff',
|
||||
text: msg.content,
|
||||
date: msg.created_at || 'Récemment'
|
||||
text: msg.content, date: msg.created_at || 'Récemment'
|
||||
}));
|
||||
|
||||
// Injection dynamique des messages dans le modal actif
|
||||
setActiveTicket(prev => prev ? { ...prev, messages: formattedMessages } : null);
|
||||
}
|
||||
} catch (err) {
|
||||
triggerAlert("Erreur Réseau", "Impossible de récupérer l'historique : " + err.message, "error");
|
||||
} finally {
|
||||
setIsLoadingConversation(false);
|
||||
}
|
||||
} catch (err) { triggerAlert("Erreur", "Impossible de récupérer l'historique : " + err.message, "error"); }
|
||||
finally { setIsLoadingConversation(false); }
|
||||
};
|
||||
|
||||
// ACTION RÉPONSE : Envoyer un message dans le thread actuel
|
||||
const handleReplySubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!replyMessage.trim() || !activeTicket) return;
|
||||
|
||||
// Soumet une réponse au fil de discussion
|
||||
const handleReplySubmit = async (replyText) => {
|
||||
if (!activeTicket) return;
|
||||
try {
|
||||
await replyTicket(activeTicket.db_id, replyMessage);
|
||||
await replyTicket(activeTicket.db_id, replyText);
|
||||
loadSupportData();
|
||||
setReplyMessage(''); // Nettoyer l'input
|
||||
|
||||
// Rechargement instantané du fil de discussion pour afficher le message soumis
|
||||
const details = await getTicketDetails(activeTicket.db_id);
|
||||
if (details && details.messages) {
|
||||
const formattedMessages = details.messages.map(msg => ({
|
||||
sender: msg.author?.role === 'client' ? 'client' : 'staff',
|
||||
text: msg.content,
|
||||
date: msg.created_at || 'Récemment'
|
||||
text: msg.content, date: msg.created_at || 'Récemment'
|
||||
}));
|
||||
setActiveTicket(prev => prev ? { ...prev, messages: formattedMessages } : null);
|
||||
}
|
||||
if (refreshNavbarTickets) refreshNavbarTickets();
|
||||
} catch (err) {
|
||||
triggerAlert("Échec d'envoi", "Votre réponse n'a pas pu être transmise : " + err.message, "error");
|
||||
}
|
||||
} catch (err) { triggerAlert("Échec d'envoi", "Votre réponse n'a pas pu être transmise : " + err.message, "error"); }
|
||||
};
|
||||
|
||||
// Soumission du nouveau ticket
|
||||
const handleCreateTicket = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!subject.trim() || !message.trim() || !selectedHelpdesk) return;
|
||||
|
||||
// Crée un nouveau ticket
|
||||
const handleCreateTicket = async (subject, message, targetHelpdesk) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await createTicket(subject, message, selectedHelpdesk);
|
||||
await createTicket(subject, message, targetHelpdesk);
|
||||
setIsCreatingTicket(false);
|
||||
setSubject('');
|
||||
setMessage('');
|
||||
loadSupportData();
|
||||
if (refreshNavbarTickets) refreshNavbarTickets();
|
||||
triggerAlert("Ticket Ouvert", "Votre demande a bien été enregistrée sur le Service Desk.", "success");
|
||||
} catch (err) {
|
||||
triggerAlert("Échec", "Erreur lors de la création du ticket : " + err.message, "error");
|
||||
} 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() };
|
||||
}
|
||||
} catch (err) { triggerAlert("Échec", "Erreur lors de la création du ticket : " + err.message, "error"); }
|
||||
finally { setIsSubmitting(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
<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>
|
||||
</header>
|
||||
</div>
|
||||
|
||||
<button
|
||||
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)]"
|
||||
@@ -207,22 +123,9 @@ export default function Support() {
|
||||
</button>
|
||||
</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 && <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>}
|
||||
|
||||
{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 && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{tickets.length === 0 ? (
|
||||
@@ -230,171 +133,29 @@ export default function Support() {
|
||||
Aucun ticket de support actif. L'infrastructure est nominale.
|
||||
</div>
|
||||
) : (
|
||||
tickets.map(ticket => {
|
||||
const conf = getStatusConfig(ticket.status);
|
||||
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>
|
||||
)
|
||||
})
|
||||
tickets.map(ticket => (
|
||||
<TicketCard key={ticket.id} ticket={ticket} onClick={() => handleOpenTicket(ticket)} />
|
||||
))
|
||||
)}
|
||||
</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>
|
||||
<CreateTicketModal
|
||||
isOpen={isCreatingTicket}
|
||||
onClose={() => setIsCreatingTicket(false)}
|
||||
onSubmit={handleCreateTicket}
|
||||
helpdesks={helpdesks}
|
||||
defaultHelpdesk={defaultHelpdesk}
|
||||
isSubmitting={isSubmitting}
|
||||
/>
|
||||
|
||||
<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>
|
||||
)}
|
||||
<TicketThreadModal
|
||||
ticket={activeTicket}
|
||||
onClose={() => setActiveTicket(null)}
|
||||
onReply={handleReplySubmit}
|
||||
isLoadingConversation={isLoadingConversation}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
|
||||
{/* 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 */}
|
||||
<NotificationModal notification={customAlert} onClose={() => setCustomAlert(null)} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -2,68 +2,28 @@ import { Link } from 'react-router-dom';
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div style={{ textAlign: 'center', marginTop: '12vh' }}>
|
||||
|
||||
<h1 style={{
|
||||
color: '#FFF',
|
||||
fontSize: '4rem',
|
||||
letterSpacing: '6px',
|
||||
display: 'flex',
|
||||
alignItems: 'baseline',
|
||||
justifyContent: 'center',
|
||||
gap: '12px',
|
||||
margin: '0 0 20px 0'
|
||||
}}>
|
||||
<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">
|
||||
NEXUS
|
||||
<span style={{
|
||||
color: '#00E5FF',
|
||||
fontSize: '1.4rem',
|
||||
letterSpacing: '2px',
|
||||
fontWeight: 'normal'
|
||||
}}>
|
||||
<span className="text-cyan-400 text-xl md:text-2xl tracking-[2px] font-normal">
|
||||
by GISE
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<h2 style={{
|
||||
color: '#FFFFFF',
|
||||
fontSize: '1.5rem',
|
||||
letterSpacing: '2px',
|
||||
textTransform: 'uppercase',
|
||||
marginBottom: '20px',
|
||||
fontWeight: 'normal'
|
||||
}}>
|
||||
<h2 className="text-white text-xl md:text-2xl tracking-[2px] uppercase mb-5 font-normal">
|
||||
Bienvenue dans l'infrastructure
|
||||
</h2>
|
||||
|
||||
<p style={{
|
||||
color: '#A0A0A0',
|
||||
fontSize: '1.2rem',
|
||||
maxWidth: '600px',
|
||||
margin: '0 auto',
|
||||
lineHeight: '1.6'
|
||||
}}>
|
||||
<p className="text-[#A0A0A0] text-lg max-w-150 mx-auto leading-relaxed">
|
||||
Hébergement web, instances VPS et Stockage Cloud en Belgique.
|
||||
<br />Propulsé par une architecture bare-metal locale.
|
||||
</p>
|
||||
|
||||
{/* LE BOUTON D'ACTION PRINCIPAL REDIRIGE VERS /REGISTER */}
|
||||
<div style={{ marginTop: '50px' }}>
|
||||
<Link to="/register" style={{
|
||||
display: 'inline-block',
|
||||
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)',
|
||||
}}>
|
||||
<div className="mt-12">
|
||||
<Link
|
||||
to="/register"
|
||||
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"
|
||||
>
|
||||
Démarrer le déploiement
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
+19
-49
@@ -15,23 +15,9 @@ export default function Login() {
|
||||
setLoading(true);
|
||||
|
||||
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);
|
||||
|
||||
// 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');
|
||||
|
||||
// 3. AUTORISATION ET REDIRECTION
|
||||
// Maintenant que le token est en poche, ProtectedRoute nous laissera passer !
|
||||
navigate('/dashboard');
|
||||
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} 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 (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '10vh', padding: '0 20px' }}>
|
||||
<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: '5px', fontSize: '1.5rem', textAlign: 'center' }}>
|
||||
<div className="flex justify-center mt-[10vh] px-5">
|
||||
<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)]">
|
||||
<h2 className="text-white uppercase mb-1 text-2xl text-center font-sans font-bold">
|
||||
Connexion au Nexus
|
||||
</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 ]
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<div style={{
|
||||
color: '#ff003c', border: '1px solid #ff003c', backgroundColor: 'rgba(255, 0, 60, 0.1)',
|
||||
padding: '10px', marginBottom: '20px', fontFamily: 'monospace', fontSize: '0.85rem'
|
||||
}}>
|
||||
<div className="text-[#ff003c] border border-[#ff003c] bg-[#ff003c]/10 p-3 mb-5 font-mono text-sm">
|
||||
[ ALERTE ] : {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleLogin}>
|
||||
<label style={{ display: 'block', color: '#888', marginBottom: '5px', fontSize: '0.8rem' }}>IDENTIFIANT (E-MAIL)</label>
|
||||
<form onSubmit={handleLogin} className="font-mono">
|
||||
<label className="block text-[#888] mb-1 text-xs tracking-widest uppercase">Identifiant (E-mail)</label>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
style={inputStyle}
|
||||
required
|
||||
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' }}>CLÉ D'ACCÈS SÉCURISÉE</label>
|
||||
<label className="block text-[#888] mb-1 text-xs tracking-widest uppercase">Clé d'accès sécurisée</label>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
style={inputStyle}
|
||||
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 ? 'VÉRIFICATION...' : 'OUVRIR LE SAS'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style={{ marginTop: '20px', textAlign: 'center', fontSize: '0.85rem' }}>
|
||||
<Link to="/register" style={{ color: '#888', textDecoration: 'none' }}>
|
||||
Aucun accès réseau ? <span style={{ color: '#00E5FF' }}>S'enregistrer ></span>
|
||||
<div className="mt-5 text-center text-sm font-mono text-[#888]">
|
||||
Aucun accès réseau ?{' '}
|
||||
<Link to="/register" className="text-cyan-400 hover:text-cyan-300 transition-colors no-underline">
|
||||
S'enregistrer >
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
+35
-115
@@ -1,50 +1,8 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, Link } from 'react-router-dom';
|
||||
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() {
|
||||
const [firstName, setFirstName] = useState('');
|
||||
const [lastName, setLastName] = useState('');
|
||||
@@ -54,7 +12,7 @@ export default function Register() {
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
||||
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 handleRegister = async (e) => {
|
||||
@@ -62,119 +20,81 @@ export default function Register() {
|
||||
setCustomAlert(null);
|
||||
|
||||
const passwordPolicy = /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[\W_]).{8,}$/;
|
||||
|
||||
// VÉRIFICATIONS FRONTEND (Affiche le Modal d'Erreur)
|
||||
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." });
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
const validUsername = /^[a-zA-Z0-9]{3,12}$/.test(username);
|
||||
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)." });
|
||||
if (!/^[a-zA-Z0-9]{3,12}$/.test(username)) {
|
||||
setCustomAlert({ type: 'error', title: 'Identifiant invalide', message: "Le nom d'utilisateur doit contenir uniquement des lettres ou chiffres (entre 3 et 12 caractères)." });
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
// API BACKEND
|
||||
await registerUnifiedClient(email, username, password, firstName, lastName);
|
||||
|
||||
// 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."
|
||||
});
|
||||
|
||||
setCustomAlert({ type: 'success', title: 'PROVISIONNEMENT RÉUSSI', message: "Vos comptes FOSSBilling, HestiaCP et Nextcloud ont été initialisés.\n\nVous pouvez maintenant vous connecter." });
|
||||
} catch (err) {
|
||||
// ERREUR API (Affiche le Modal d'Erreur API)
|
||||
setCustomAlert({ type: 'error', title: 'Échec du Déploiement', message: err.message || "Échec de l'initialisation de l'infrastructure." });
|
||||
setCustomAlert({ type: 'error', title: 'Échec du Déploiement', message: err.message || "Échec de l'initialisation." });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Fermeture du Modal : Redirige vers le Login SI c'était un succès
|
||||
const handleCloseModal = () => {
|
||||
if (customAlert?.type === 'success') {
|
||||
navigate('/login');
|
||||
} 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'
|
||||
if (customAlert?.type === 'success') navigate('/login');
|
||||
else setCustomAlert(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', marginTop: '5vh', padding: '0 20px' }}>
|
||||
<div style={{
|
||||
width: '100%', maxWidth: '500px', 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: '5px', fontSize: '1.5rem' }}>
|
||||
<div className="flex justify-center mt-[5vh] px-5">
|
||||
<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)]">
|
||||
<h2 className="text-white uppercase mb-1 text-2xl font-sans font-bold">
|
||||
Créer un accès réseau
|
||||
</h2>
|
||||
<p style={{ color: '#888', fontFamily: 'monospace', fontSize: '0.85rem', marginBottom: '25px' }}>
|
||||
[ INITIALISATION DU PROVISIONNEMENT TRIPLE EN CASCADE ]
|
||||
<p className="text-[#888] font-mono text-sm mb-6">
|
||||
[ INITIALISATION DU PROVISIONNEMENT TRIPLE ]
|
||||
</p>
|
||||
|
||||
<form onSubmit={handleRegister}>
|
||||
<div style={{ display: 'flex', gap: '15px' }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<label style={{ display: 'block', color: '#888', marginBottom: '5px', fontSize: '0.8rem' }}>PRÉNOM</label>
|
||||
<input type="text" value={firstName} onChange={(e) => setFirstName(e.target.value)} style={inputStyle} required placeholder="John" />
|
||||
<form onSubmit={handleRegister} className="font-mono">
|
||||
<div className="flex gap-4">
|
||||
<div className="flex-1">
|
||||
<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)} 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 style={{ flex: 1 }}>
|
||||
<label style={{ display: 'block', color: '#888', marginBottom: '5px', fontSize: '0.8rem' }}>NOM</label>
|
||||
<input type="text" value={lastName} onChange={(e) => setLastName(e.target.value)} style={inputStyle} required placeholder="Doe" />
|
||||
<div className="flex-1">
|
||||
<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)} 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>
|
||||
|
||||
<label style={{ display: 'block', color: '#888', marginBottom: '5px', fontSize: '0.8rem' }}>ADRESSE E-MAIL (IDENTIFIANT FACTURATION)</label>
|
||||
<input type="email" value={email} onChange={(e) => setEmail(e.target.value)} style={inputStyle} required placeholder="john.doe@gise.be" />
|
||||
<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)} 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>
|
||||
<input type="text" value={username} onChange={(e) => setUsername(e.target.value)} style={inputStyle} required placeholder="ex: jdoe42" />
|
||||
<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)} 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>
|
||||
<input type="password" value={password} onChange={(e) => setPassword(e.target.value)} style={inputStyle} required placeholder="••••••••" />
|
||||
<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)} 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>
|
||||
<input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} style={inputStyle} required placeholder="••••••••" />
|
||||
<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)} 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'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div style={{ marginTop: '20px', textAlign: 'center', fontSize: '0.85rem' }}>
|
||||
<Link to="/login" style={{ color: '#888', textDecoration: 'none' }}>
|
||||
Déjà enregistré ? <span style={{ color: '#00E5FF' }}>Se connecter au terminal ></span>
|
||||
<div className="mt-5 text-center text-sm font-mono text-[#888]">
|
||||
Déjà enregistré ?{' '}
|
||||
<Link to="/login" className="text-cyan-400 hover:text-cyan-300 transition-colors no-underline">
|
||||
Se connecter au terminal >
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Affichage du Modal par-dessus le formulaire */}
|
||||
<NotificationModal notification={customAlert} onClose={handleCloseModal} />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user