configuration du store
Deploy Nexus Portal to HestiaCP (FTP) / build-and-deploy (push) Successful in 31s
Deploy Nexus Portal to HestiaCP (FTP) / build-and-deploy (push) Successful in 31s
This commit is contained in:
Generated
+1175
-5
File diff suppressed because it is too large
Load Diff
@@ -13,6 +13,7 @@
|
||||
"lucide-react": "^1.3.0",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-router-dom": "^7.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
+320
-97
@@ -1,135 +1,358 @@
|
||||
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, 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 [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`;
|
||||
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 };
|
||||
}
|
||||
|
||||
return 'Prix sur demande';
|
||||
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="w-full max-w-7xl p-6 mx-auto">
|
||||
<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' : ''}`}>
|
||||
|
||||
<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>
|
||||
{/* 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>
|
||||
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
<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>
|
||||
|
||||
{/* 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>
|
||||
{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>
|
||||
|
||||
{/* 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>
|
||||
) : (
|
||||
{/* BLOC DES PETITES LIGNES BANCAIRES */}
|
||||
<div className="mt-3 min-h-[44px] flex flex-col justify-end">
|
||||
{!priceData.isOnce && (
|
||||
<>
|
||||
<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>
|
||||
{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>
|
||||
|
||||
{/* Bouton d'action */}
|
||||
<button
|
||||
disabled={!priceData.isAvailable}
|
||||
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)]"
|
||||
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 [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 <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('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" />;
|
||||
};
|
||||
|
||||
// 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 (
|
||||
<div className="w-full max-w-7xl p-6 mx-auto">
|
||||
<header className="mb-12 text-center pt-8">
|
||||
<h1 className="text-4xl md:text-5xl font-black 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 text-lg">
|
||||
Déployez de nouvelles instances et étendez votre infrastructure. Facturation flexible.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{isLoading && <div className="flex justify-center text-cyan-400 my-20"><Loader className="w-10 h-10 animate-spin" /></div>}
|
||||
{error && <div className="flex justify-center text-red-400 my-20"><AlertCircle className="w-10 h-10 mr-2" /> {error}</div>}
|
||||
|
||||
{!isLoading && !error && sortedCategoryNames.map((categoryName) => (
|
||||
<CategorySection
|
||||
key={categoryName}
|
||||
categoryName={categoryName}
|
||||
products={groupedProducts[categoryName]}
|
||||
getCategoryIcon={getCategoryIcon}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user