configuration du store
Deploy Nexus Portal to HestiaCP (FTP) / build-and-deploy (push) Successful in 31s

This commit is contained in:
2026-06-15 11:24:52 +02:00
parent 10c7cfc7b0
commit 0863b0a161
3 changed files with 1494 additions and 100 deletions
+1175 -5
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -13,6 +13,7 @@
"lucide-react": "^1.3.0", "lucide-react": "^1.3.0",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
"react-markdown": "^10.1.0",
"react-router-dom": "^7.16.0" "react-router-dom": "^7.16.0"
}, },
"devDependencies": { "devDependencies": {
+318 -95
View File
@@ -1,135 +1,358 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import ReactMarkdown from 'react-markdown';
import { getProductList } from '../../services/api'; import { getProductList } from '../../services/api';
import { Server, Database, Cloud, Globe, ShoppingCart, Loader, AlertCircle, Check } from 'lucide-react'; import { Server, Database, Cloud, Globe, ShoppingCart, Loader, AlertCircle, ChevronDown, CheckCircle2 } from 'lucide-react';
export default function Store() { // ==========================================
// 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 navigate = useNavigate();
const [products, setProducts] = useState([]);
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}`)}
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 [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null); const [error, setError] = useState(null);
// Récupération du catalogue // 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"
};
useEffect(() => { useEffect(() => {
const fetchCatalog = async () => { const fetchCatalog = async () => {
try { try {
const data = await getProductList(); const data = await getProductList();
// FOSSBilling renvoie les produits dans data.list const products = data.list || [];
setProducts(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] = [];
acc[catName].push(product);
return acc;
}, {});
setGroupedProducts(grid);
} catch (err) { } catch (err) {
setError("Impossible de contacter le serveur d'approvisionnement."); setError("Impossible de contacter le serveur d'approvisionnement.");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}; };
fetchCatalog(); fetchCatalog();
}, []); }, []);
// Radar visuel : Associe une icône selon le nom de la catégorie ou du produit
const getCategoryIcon = (text) => { const getCategoryIcon = (text) => {
const t = text.toLowerCase(); const t = text.toLowerCase();
if (t.includes('vps') || t.includes('serveur')) return <Server className="w-8 h-8 text-cyan-400" />; 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('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" />; if (t.includes('data') || 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" />; return <Globe className="w-8 h-8 text-emerald-400" />;
}; };
// Extracteur de prix : L'API FOSSBilling a une structure de prix assez profonde // MOTEUR DE TRI SANS RISK DE CRASH INDICE
const extractMonthlyPrice = (pricing) => { const sortedCategoryNames = Object.keys(groupedProducts).sort((a, b) => {
if (pricing?.type === 'free') return 'Gratuit'; let indexA = CATEGORY_ORDER.indexOf(a);
if (pricing?.type === 'once') return `${pricing.once.price} € (Une fois)`; let indexB = CATEGORY_ORDER.indexOf(b);
// CORRECTION ICI : Utilisation des crochets pour la clé numérique '1' if (indexA === -1) indexA = 999;
if (pricing?.type === 'recurrent' && pricing.recurrent['1']) { if (indexB === -1) indexB = 999;
return `${pricing.recurrent['1'].price} € / mois`;
} return indexA - indexB;
});
return 'Prix sur demande';
};
return ( return (
<div className="w-full max-w-7xl p-6 mx-auto"> <div className="w-full max-w-7xl p-6 mx-auto">
<header className="mb-12 text-center pt-8">
<header className="mb-12 text-center"> <h1 className="text-4xl md:text-5xl font-black text-white tracking-widest mb-4">
<h1 className="text-4xl font-bold text-white tracking-widest mb-4">
CATALOGUE <span className="text-cyan-400">NEXUS</span> CATALOGUE <span className="text-cyan-400">NEXUS</span>
</h1> </h1>
<p className="text-gray-400 max-w-2xl mx-auto"> <p className="text-gray-400 max-w-2xl mx-auto text-lg">
Déployez de nouvelles instances et étendez votre infrastructure en quelques secondes. Provisionnement automatisé 24/7. Déployez de nouvelles instances et étendez votre infrastructure. Facturation flexible.
</p> </p>
</header> </header>
{/* GESTION DES ERREURS & CHARGEMENT */} {isLoading && <div className="flex justify-center text-cyan-400 my-20"><Loader className="w-10 h-10 animate-spin" /></div>}
{isLoading && ( {error && <div className="flex justify-center text-red-400 my-20"><AlertCircle className="w-10 h-10 mr-2" /> {error}</div>}
<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 && ( {!isLoading && !error && sortedCategoryNames.map((categoryName) => (
<div className="flex justify-center my-20"> <CategorySection
<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"> key={categoryName}
<AlertCircle className="w-8 h-8 flex-shrink-0" /> categoryName={categoryName}
<span>{error}</span> products={groupedProducts[categoryName]}
</div> getCategoryIcon={getCategoryIcon}
</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> </div>
); );
} }