separate components

This commit is contained in:
LathanDevers
2026-06-25 09:16:47 +02:00
parent cb89b521ef
commit 323667e835
25 changed files with 921 additions and 1572 deletions
+55
View File
@@ -0,0 +1,55 @@
// src/components/store/CategorySection.jsx
import { useState } from 'react';
import { ChevronDown } from 'lucide-react';
import ProductCard from './ProductCard';
import { parsePeriod } from './StoreHelpers';
export default function 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 => {
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>
);
}
+112
View File
@@ -0,0 +1,112 @@
// src/components/store/ProductCard.jsx
import { useNavigate } from 'react-router-dom';
import ReactMarkdown from 'react-markdown';
import { ShoppingCart, CheckCircle2 } from 'lucide-react';
import { parsePeriod, getCategoryBadge } from './StoreHelpers';
export default function ProductCard({ product, selectedPeriod, categoryName }) {
const navigate = useNavigate();
const getPricingData = () => {
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);
const currentYearlyCost = currentPrice * currentPeriodInfo.factorToYear;
const currentMonthlyEquivalent = currentYearlyCost / 12;
let savingsPercent = 0;
let originalPrice = null;
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);
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), suffix: '/ mois',
originalPrice, savingsPercent, billingPrice: currentPrice.toFixed(2),
billingPhrase: currentPeriodInfo.billingPhrase, 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' : ''}`}>
<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>
{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">
<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>
<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}?period=${selectedPeriod}`)} 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>
);
}
+31
View File
@@ -0,0 +1,31 @@
// src/components/store/StoreHelpers.js
export 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}` };
};
export 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';
};