add hosting plans via fossbilling
Deploy Nexus Portal to HestiaCP (FTP) / build-and-deploy (push) Successful in 33s

This commit is contained in:
2026-06-12 23:25:17 +02:00
parent 33c8babdd2
commit 48793034b1
8 changed files with 676 additions and 156 deletions
+135
View File
@@ -0,0 +1,135 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { getProductList } from '../../services/api';
import { Server, Database, Cloud, Globe, ShoppingCart, Loader, AlertCircle, Check } from 'lucide-react';
export default function Store() {
const navigate = useNavigate();
const [products, setProducts] = useState([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
// Récupération du catalogue
useEffect(() => {
const fetchCatalog = async () => {
try {
const data = await getProductList();
// FOSSBilling renvoie les produits dans data.list
setProducts(data.list || []);
} catch (err) {
setError("Impossible de contacter le serveur d'approvisionnement.");
} finally {
setIsLoading(false);
}
};
fetchCatalog();
}, []);
// Radar visuel : Associe une icône selon le nom de la catégorie ou du produit
const getCategoryIcon = (text) => {
const t = text.toLowerCase();
if (t.includes('vps') || t.includes('serveur')) return <Server className="w-8 h-8 text-cyan-400" />;
if (t.includes('cloud') || t.includes('nextcloud')) return <Cloud className="w-8 h-8 text-blue-400" />;
if (t.includes('db') || t.includes('base')) return <Database className="w-8 h-8 text-purple-400" />;
return <Globe className="w-8 h-8 text-emerald-400" />;
};
// Extracteur de prix : L'API FOSSBilling a une structure de prix assez profonde
const extractMonthlyPrice = (pricing) => {
if (pricing?.type === 'free') return 'Gratuit';
if (pricing?.type === 'once') return `${pricing.once.price} € (Une fois)`;
// CORRECTION ICI : Utilisation des crochets pour la clé numérique '1'
if (pricing?.type === 'recurrent' && pricing.recurrent['1']) {
return `${pricing.recurrent['1'].price} € / mois`;
}
return 'Prix sur demande';
};
return (
<div className="w-full max-w-7xl p-6 mx-auto">
<header className="mb-12 text-center">
<h1 className="text-4xl font-bold text-white tracking-widest mb-4">
CATALOGUE <span className="text-cyan-400">NEXUS</span>
</h1>
<p className="text-gray-400 max-w-2xl mx-auto">
Déployez de nouvelles instances et étendez votre infrastructure en quelques secondes. Provisionnement automatisé 24/7.
</p>
</header>
{/* GESTION DES ERREURS & CHARGEMENT */}
{isLoading && (
<div className="flex justify-center items-center space-x-3 text-cyan-400 my-20">
<Loader className="w-8 h-8 animate-spin" />
<span className="text-lg tracking-widest">TÉLÉCHARGEMENT DU CATALOGUE...</span>
</div>
)}
{error && (
<div className="flex justify-center my-20">
<div className="flex items-center space-x-3 text-red-400 bg-red-400/10 border border-red-400 p-6 rounded-lg max-w-lg">
<AlertCircle className="w-8 h-8 flex-shrink-0" />
<span>{error}</span>
</div>
</div>
)}
{/* GRILLE DU CATALOGUE */}
{!isLoading && !error && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
{products.map((product) => (
<div
key={product.id}
className="bg-gray-900 border border-gray-800 rounded-2xl overflow-hidden hover:border-cyan-400/50 transition-all duration-300 group flex flex-col relative"
>
{/* En-tête de la carte */}
<div className="p-8 border-b border-gray-800 relative overflow-hidden">
<div className="absolute top-0 right-0 p-8 opacity-10 group-hover:scale-110 group-hover:opacity-20 transition-all duration-500">
{getCategoryIcon(product.category || product.title)}
</div>
<h3 className="text-2xl font-bold text-white mb-2 relative z-10">
{product.title}
</h3>
<div className="text-cyan-400 font-mono text-xl relative z-10">
{extractMonthlyPrice(product.pricing)}
</div>
</div>
{/* Description du produit */}
<div className="p-8 flex-grow flex flex-col justify-between">
{/* FOSSBilling permet de mettre du HTML dans la description.
Ici, on triche un peu en simulant des puces pour le design,
mais tu pourras utiliser dangerouslySetInnerHTML si tu as mis du vrai HTML. */}
<div className="text-gray-400 text-sm mb-8 space-y-3">
{product.description ? (
<p>{product.description}</p>
) : (
<>
<div className="flex items-center space-x-2"><Check className="w-4 h-4 text-cyan-400"/> <span>Provisionnement instantané</span></div>
<div className="flex items-center space-x-2"><Check className="w-4 h-4 text-cyan-400"/> <span>Support technique 24/7</span></div>
<div className="flex items-center space-x-2"><Check className="w-4 h-4 text-cyan-400"/> <span>SLA 99.9%</span></div>
</>
)}
</div>
{/* Bouton d'action */}
<button
onClick={() => navigate(`/checkout/${product.id}`)}
className="w-full bg-cyan-400/10 hover:bg-cyan-400 text-cyan-400 hover:text-gray-900 border border-cyan-400 py-3 rounded-lg font-bold tracking-widest transition-all flex justify-center items-center space-x-2 group-hover:shadow-[0_0_20px_rgba(34,211,238,0.2)]"
>
<ShoppingCart className="w-5 h-5" />
<span>COMMANDER</span>
</button>
</div>
</div>
))}
</div>
)}
</div>
);
}