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 (
{isLoading &&
}
{error &&
}
{!isLoading && !error && sortedCategoryNames.map((categoryName) => (
))}
);
}