import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import ReactMarkdown from 'react-markdown'; import { getProductList } from '../../services/api'; import { Server, Database, Cloud, Globe, ShoppingCart, Loader, AlertCircle, ChevronDown, CheckCircle2 } from 'lucide-react'; // ========================================== // 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 (
{/* ENCART ACRO-BADGE */}
{getCategoryBadge(categoryName)}
{/* Badge d'économie */} {priceData.savingsPercent > 0 && priceData.isAvailable && (
ÉCONOMIE {priceData.savingsPercent}%
)}

{product.title}

{priceData.isAvailable ? (
{/* PRIX PRINCIPAL (Toujours ramené au mois) */}
{priceData.displayPrice} € {priceData.suffix}
{/* BLOC DES PETITES LIGNES BANCAIRES */}
{!priceData.isOnce && ( <> {priceData.originalPrice ? (
Au lieu de {priceData.originalPrice} € / mois
) : (
Tarif de base équivalent
)}
Facturé {priceData.billingPrice} € {priceData.billingPhrase}
)} {priceData.isOnce && (
Paiement unique
)}
) : (
Non disponible pour cette durée.
)}
    , li: ({node, ...props}) =>
  • {props.children}
  • , p: ({node, ...props}) =>

    , strong: ({node, ...props}) => }} > {product.description || "Aucune description technique."}

); }; // ========================================== // 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 (
{getCategoryIcon(categoryName)}

{categoryName}

{products.length} instance{products.length > 1 ? 's' : ''} disponible{products.length > 1 ? 's' : ''}
{sortedPeriods.length > 0 && (
Facturation :
)}
{products.map((product) => ( ))}
); }; // ========================================== // 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" }; useEffect(() => { const fetchCatalog = async () => { try { const data = await getProductList(); 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] = []; acc[catName].push(product); return acc; }, {}); setGroupedProducts(grid); } catch (err) { setError("Impossible de contacter le serveur d'approvisionnement."); } finally { setIsLoading(false); } }; fetchCatalog(); }, []); const getCategoryIcon = (text) => { const t = text.toLowerCase(); if (t.includes('vps') || t.includes('serveur')) return ; if (t.includes('cloud') || t.includes('nextcloud')) return ; if (t.includes('data') || t.includes('db') || t.includes('base')) return ; return ; }; // 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; }); return (

CATALOGUE NEXUS

Déployez de nouvelles instances et étendez votre infrastructure. Facturation flexible.

{isLoading &&
} {error &&
{error}
} {!isLoading && !error && sortedCategoryNames.map((categoryName) => ( ))}
); }