Retour au catalogue
Bundle Builder
Constructeur de bundle interactif : selection d'articles par categorie, prix total en temps reel.
ecommercecomplex Both Responsive a11y
playfulelegantecommercegrid
Theme
"use client";
import { useState } from "react";
import { motion, AnimatePresence } from "framer-motion";
import { Package, Check, ShoppingBag, Sparkles } from "lucide-react";
interface BundleItem {
id: string;
name: string;
price: number;
}
interface Category {
name: string;
items: BundleItem[];
}
interface EcommerceBundleBuilderProps {
title?: string;
subtitle?: string;
discount?: number;
categories?: Category[];
ctaLabel?: string;
}
const E: [number, number, number, number] = [0.16, 1, 0.3, 1];
export default function EcommerceBundleBuilder({
title = "Composez votre Kit",
subtitle = "",
discount = 20,
categories = [],
ctaLabel = "Ajouter au panier",
}: EcommerceBundleBuilderProps) {
const [selected, setSelected] = useState<Record<string, string>>({});
const total = categories.reduce((sum, cat) => {
const pick = cat.items.find((i) => i.id === selected[cat.name]);
return sum + (pick?.price ?? 0);
}, 0);
const discounted = total * (1 - discount / 100);
const allSelected = categories.every((c) => selected[c.name]);
return (
<section style={{ padding: "var(--section-padding-y) 0", background: "var(--color-background)" }}>
<div style={{ maxWidth: "var(--container-max-width)", margin: "0 auto", padding: "0 var(--container-padding-x)" }}>
<motion.div initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.6, ease: E }} style={{ textAlign: "center", marginBottom: "3rem" }}>
<div style={{ display: "inline-flex", alignItems: "center", gap: "0.5rem", padding: "0.5rem 1rem", borderRadius: "var(--radius-full)", background: "var(--color-accent-subtle)", marginBottom: "1rem" }}>
<Package size={14} style={{ color: "var(--color-accent)" }} />
<span style={{ fontSize: "0.8125rem", fontWeight: 600, color: "var(--color-accent)", fontFamily: "var(--font-sans)" }}>-{discount}%</span>
</div>
<h2 style={{ fontFamily: "var(--font-serif)", fontSize: "clamp(1.75rem, 3vw, 2.5rem)", fontWeight: 600, color: "var(--color-foreground)", marginBottom: "0.75rem" }}>{title}</h2>
{subtitle && <p style={{ fontSize: "1rem", color: "var(--color-foreground-muted)", fontFamily: "var(--font-sans)" }}>{subtitle}</p>}
</motion.div>
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fit, minmax(280px, 1fr))", gap: "1.5rem", marginBottom: "2.5rem" }}>
{categories.map((cat, ci) => (
<motion.div key={cat.name} initial={{ opacity: 0, y: 20 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.5, delay: ci * 0.1, ease: E }} style={{ borderRadius: "var(--radius-xl)", border: "1px solid var(--color-border)", padding: "1.5rem", background: "var(--color-background-card)" }}>
<p style={{ fontSize: "0.75rem", fontWeight: 700, textTransform: "uppercase", letterSpacing: "0.06em", color: "var(--color-foreground-muted)", fontFamily: "var(--font-sans)", marginBottom: "1rem" }}>Etape {ci + 1} — {cat.name}</p>
<div style={{ display: "flex", flexDirection: "column", gap: "0.5rem" }}>
{cat.items.map((item) => {
const active = selected[cat.name] === item.id;
return (
<button
key={item.id}
onClick={() => setSelected((s) => ({ ...s, [cat.name]: item.id }))}
style={{
display: "flex", alignItems: "center", justifyContent: "space-between",
padding: "0.875rem 1rem", borderRadius: "var(--radius-lg)",
border: active ? "2px solid var(--color-accent)" : "1px solid var(--color-border)",
background: active ? "var(--color-accent-subtle)" : "var(--color-background)",
cursor: "pointer", transition: "all 0.2s",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: "0.75rem" }}>
<div style={{ width: "1.25rem", height: "1.25rem", borderRadius: "var(--radius-full)", border: active ? "none" : "1px solid var(--color-border)", background: active ? "var(--color-accent)" : "transparent", display: "flex", alignItems: "center", justifyContent: "center" }}>
{active && <Check size={12} style={{ color: "var(--color-background)" }} />}
</div>
<span style={{ fontSize: "0.875rem", fontWeight: active ? 600 : 400, color: "var(--color-foreground)", fontFamily: "var(--font-sans)" }}>{item.name}</span>
</div>
<span style={{ fontSize: "0.875rem", fontWeight: 600, color: "var(--color-foreground)", fontFamily: "var(--font-sans)" }}>{item.price}\u20ac</span>
</button>
);
})}
</div>
</motion.div>
))}
</div>
{/* Total bar */}
<motion.div initial={{ opacity: 0, y: 16 }} whileInView={{ opacity: 1, y: 0 }} viewport={{ once: true }} transition={{ duration: 0.5, delay: 0.2, ease: E }} style={{ display: "flex", flexWrap: "wrap", alignItems: "center", justifyContent: "space-between", gap: "1rem", padding: "1.5rem 2rem", borderRadius: "var(--radius-xl)", background: "var(--color-background-alt)", border: "1px solid var(--color-border)" }}>
<div>
<AnimatePresence mode="wait">
<motion.div key={total} initial={{ opacity: 0, y: 8 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -8 }} style={{ display: "flex", alignItems: "baseline", gap: "0.75rem" }}>
{total > 0 && <span style={{ fontSize: "0.9375rem", textDecoration: "line-through", color: "var(--color-foreground-light)", fontFamily: "var(--font-sans)" }}>{total.toFixed(2)}\u20ac</span>}
<span style={{ fontSize: "1.5rem", fontWeight: 800, color: "var(--color-foreground)", fontFamily: "var(--font-sans)" }}>{discounted.toFixed(2)}\u20ac</span>
{total > 0 && (
<span style={{ display: "inline-flex", alignItems: "center", gap: "0.25rem", padding: "0.25rem 0.5rem", borderRadius: "var(--radius-full)", background: "var(--color-accent-subtle)", fontSize: "0.75rem", fontWeight: 600, color: "var(--color-accent)", fontFamily: "var(--font-sans)" }}>
<Sparkles size={12} /> Economie {(total - discounted).toFixed(2)}\u20ac
</span>
)}
</motion.div>
</AnimatePresence>
</div>
<button
disabled={!allSelected}
style={{
display: "flex", alignItems: "center", gap: "0.5rem",
padding: "0.875rem 2rem", borderRadius: "var(--radius-full)",
background: allSelected ? "var(--color-accent)" : "var(--color-border)",
color: allSelected ? "var(--color-background)" : "var(--color-foreground-muted)",
border: "none", fontWeight: 600, fontSize: "0.9375rem",
cursor: allSelected ? "pointer" : "not-allowed", fontFamily: "var(--font-sans)",
opacity: allSelected ? 1 : 0.6,
}}
>
<ShoppingBag size={16} /> {ctaLabel}
</button>
</motion.div>
</div>
</section>
);
}