separate components
This commit is contained in:
@@ -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-[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>
|
||||
);
|
||||
}
|
||||
@@ -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,90 @@
|
||||
import { useState } from 'react';
|
||||
import { Folder, ExternalLink, Play, Maximize2, Lock } 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 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 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>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<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-[400px]">
|
||||
<iframe src={terminalUrl} className="w-full h-full border-none" title="Terminal" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { useState } from 'react';
|
||||
import { Globe, ExternalLink, Lock, Loader } from 'lucide-react';
|
||||
import { resetHostingPassword } from '../../services/api';
|
||||
|
||||
export default function 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 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);
|
||||
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é.</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">
|
||||
<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 Site</span><ExternalLink className="w-4 h-4 text-gray-600 group-hover:text-emerald-400" />
|
||||
</button>
|
||||
<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" /> SSO...</span> : <><span className="font-mono text-emerald-400 text-sm flex items-center gap-2"><Lock className="w-4 h-4" /> Panel</span><ExternalLink className="w-4 h-4 text-emerald-600 group-hover:text-emerald-400" /></>}
|
||||
</button>
|
||||
</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-[160px] 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-[120px]">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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user