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>
  );
}

Avis

Bundle Builder — React Ecommerce Section — Incubator