// web/wizard-v2.jsx — Canonical wizard driven by item.composer_profile + category.wizard_template
// [GOAL LONG-TERM 2026-05-16] Mirror mobile pattern : 4 templates (sandwich / tacos / custom / simple)
// + 'custom' reads item.composer_profile.steps[] hardcoded (mirror DB shape) for Bols + Frites.
// Reuses existing web UI (lc-wiz-* styles, allergens panel, recap, live preview).

const { useState: wvS, useEffect: wvE } = React;

// ============================================================================
// buildSteps(item) — produces the list of step descriptors for this item
// Each step: { id, kind ('radio'|'multi'|'recap'), title, sub, required?, max?, options }
// Options : { id, name, desc?, price, icon?, allergens?, popular?, savings?, tag? }
// ============================================================================
function buildSteps(item) {
  if (!item) return [];
  const M = window.LC && window.LC.menu;
  if (!M) return [];

  const cat = M.findCategory(item.category_id);
  const template = item.wizard_template || (cat && cat.wizard_template) || 'simple';
  const steps = [];

  // Helper to map pool entries to wizard options
  // [GOAL-SYNC 2026-07-08 intégration] sauce_default (ex : Cayenne « Sauce fromagère maison »)
  //   pré-sélectionnée dans le step Sauce (contrat §5) — comparaison tolérante au préfixe
  //   « Sauce  » (pool = « Fromagère maison »), même normalisation que api.js norm().
  const normSauceName = (n) => String(n || '').toLowerCase().replace(/^sauce\s+/, '').replace(/\s+/g, ' ').trim();
  const sauceOptions = (excludeLocked) => M.sauces
    .filter(s => !excludeLocked || s.name !== excludeLocked)
    .map(s => ({ id: s.id, name: s.name, price: 0, image: s.image, icon: s.is_spicy ? '🌶' : '', tag: s.is_spicy ? '🌶' : '',
      dflt: !!(item.sauce_default && normSauceName(s.name) === normSauceName(item.sauce_default)) }))
    // [OWNER 2026-07-20] la sauce défaut PRÉ-SÉLECTIONNÉE (ex : Cayenne « Fromagère maison ») doit
    //   être EN HAUT de la liste — sinon le client ne la voit pas (enfouie idx 10), croit devoir
    //   en choisir une → 2 sauces sélectionnées → +0,50 facturé par erreur (« toutes les sauces facturées »).
    .sort((a, b) => (b.dflt ? 1 : 0) - (a.dflt ? 1 : 0));
  // [WEB-WIREUP 2026-06-26] Bol sauces = backend attr8 (only 2). Offering the 12 generic sauces
  // collapsed 11/12 choices silently onto "Fromagère maison". Use the real 2 (names matched).
  const bolSauceOptions = () => (M.bolSauces || []).map(s => ({ id: s.id, name: s.name, price: 0, image: s.image, icon: s.is_spicy ? '🌶' : '', tag: s.is_spicy ? '🌶' : '' }));
  const meatOptions = () => M.meats.map(m => ({ id: m.id, name: m.name, price: 0, image: m.image, icon: m.emoji }));
  // [WEB-SYNC-CAISSE 2026-06-26 L2] viande supplémentaire : mêmes viandes, +2,50€ chacune.
  const extraMeatOptions = () => M.meats.map(m => ({ id: m.id, name: m.name, price: M.extraMeatPrice, image: m.image, icon: m.emoji }));
  // [WEB-SYNC-CAISSE 2026-06-26 L4] choix du pain (Pain / Galette), gratuit.
  // [PAIN-FIX 2026-07-09] Choix pain/galette = choix SIMPLE (icône, pas de photo produit).
  //   Avant : photo « sandwich » vs « galette » → l'owner croyait choisir un produit, pas un pain.
  const painOptions = () => M.pains.map(p => ({ id: p.id, name: p.name, price: 0, icon: /galette/i.test(p.name) ? '🌯' : '🥖', dflt: !!p.is_default }));
  // [PARITE-BORNE 2026-07-19 · D1/D3] Crudités PAR ITEM (même logique que suppOptions) : miroir backend.
  //   · Cornichon (galette_only) → cat 2 seulement (backend 23/24 ; absent 22/26/38).
  //   · Oignons cuits (kid_excluded) → masqué sur Menu Enfant (cat 11 ; backend 106 = Salade/Tomate/Oignon).
  //   Résultat : galette=5 crudités, sandwich/tacos/burger=4, menu enfant chicken burger=3 — == borne.
  const cruditeOptions = (item) => {
    const isGalette = item && Number(item.category_id) === 2;
    const isKid = item && Number(item.category_id) === 11;
    return M.crudites
      .filter(c => (!c.galette_only || isGalette) && !(c.kid_excluded && isKid))
      .map(c => ({ id: c.id, name: c.name, price: 0, image: c.image, dflt: !!c.default }));
  };
  // [MASSIVE-LOGIC HEAL 2026-05-17 P0] suppOptions reads allergens DIRECTLY from
  // SUPPLEMENTS pool (now populated with FIC 1169/2011 disclosure per heal). Previously
  // hardcoded `allergens: []` silently dropped lactose/oeuf disclosure.
  // [DROP-FIX 2026-07-19] Parité borne : ne proposer QUE les extras réellement disponibles pour
  //   l'item. Le pool `M.supplements` reflète désormais EXACTEMENT les 9 suppléments backend réels
  //   (« Boule gratinée » retirée — data/menu.js — car ce n'est pas un ItemExtra mais un produit
  //   standalone). Le filtre par-item conserve `galette_excluded` : « Boursin » est absent des
  //   galettes côté backend (vérifié item/details 23,24) → jamais proposé/chiffré sur une galette,
  //   donc jamais droppé. `galette_only` reste supporté (no-op aujourd'hui, future-proof).
  //   isGalette = catégorie 2 (data cat id:2 slug:'galette').
  const suppOptions = (item) => {
    const isGalette = item && Number(item.category_id) === 2;
    return M.supplements
      .filter(s => (!s.galette_only || isGalette) && !(s.galette_excluded && isGalette))
      .map(s => ({
        id: s.id, name: s.name, price: s.price, image: s.image, icon: '+', allergens: s.allergens || [],
      }));
  };
  // [WEB-SYNC-CAISSE 2026-06-26 F1/L1] 9 suppléments bol + Option Gratiné.
  // [PARITE-BORNE 2026-07-19 · D2] Le gratiné est offert sur les DEUX bols (backend 41 & 45) : le flag
  //   `riz_only` a été retiré de la donnée (menu.js) → le filtre ci-dessous est désormais un no-op
  //   conservé pour compat/future-proof ; la disponibilité suit item.has_gratine (true sur les 2 bols).
  const suppBolsOptions = (allowGratine) => M.supplementsBols
    .filter(s => allowGratine || !s.riz_only)
    .map(s => ({
      id: s.id, name: s.name, price: s.price, image: s.image,
      icon: s.id === 'sb-gratine' ? '🧀' : '+',
      popular: s.id === 'sb-gratine',
    }));
  const drinkOptions = () => M.formuleDrinks.map(d => ({ id: d.id, name: d.name, price: 0, image: d.image, icon: d.emoji }));
  const drinkAddonOptions = () => [
    { id: '__none', name: 'Aucune boisson', price: 0, icon: '🚫', dflt: true },
    ...M.formuleDrinks.map(d => ({ id: d.id, name: d.name, price: M.priceForDrinkAddon(d.id), image: d.image, icon: d.emoji })),
  ];
  const fritesStyleOptions = () => M.fritesStyles.map(fs => ({
    id: fs.id === null ? '__nature' : fs.id, name: fs.name, price: fs.price, image: fs.image, icon: fs.emoji,
    dflt: !!fs.is_default,
  }));

  switch (template) {
    case 'sandwich':
    case 'tacos':
    // [WEB-WIREUP 2026-06-26] Burgers (cat 4) share this composition path: fixed meat
    // (viandes:0 → no meat step), no pain choice, but sauce (REQUIRED attr5 backend),
    // crudités, suppléments, viande supplémentaire, menu. Previously 'burger' fell through
    // to the simple/DirectAddView default → no steps at all, sauce silently defaulted.
    case 'burger': {
      // [WEB-SYNC-CAISSE 2026-06-26 L4] Pain / Galette — sandwichs uniquement (has_pain_choice).
      if (item.has_pain_choice) {
        steps.push({
          id: 'pain', kind: 'radio', title: 'Pain ou galette ?',
          sub: 'Choisis ta base', required: true,
          options: painOptions(), defaultValue: 'pain-classique',
        });
      }
      // Viandes step — N inclus (item.viande_count) + viande(s) EN PLUS fusionnées ici.
      // [COMPOSITION-VIANDE 2026-07-15] Owner : N viandes incluses GRATUITES (choisir exactement
      //   N) ; au-delà = +2,50 € chacune, signalé par un bandeau non-bloquant en HAUT de l'étape
      //   dès qu'on dépasse N (extraFrom). Plus d'étape « Viande supplémentaire ? » séparée.
      //   Les N premières partent en variations attr 1/2 (gratuites), le surplus en ItemExtra
      //   'Viande supplémentaire' @2,50 (api.js + computeWizardTotal, par COMPTE au-delà de N).
      const n = item.viande_count || item.viandes || 0;
      if (n > 0) {
        const extraAllowed = item.has_extra_meat ? 3 : 0;
        steps.push({
          id: 'viandes', kind: 'multi', title: `Choisis ${n} viande${n > 1 ? 's' : ''}`,
          sub: extraAllowed
            ? `${n} viande${n > 1 ? 's' : ''} incluse${n > 1 ? 's' : ''} · viande en plus +2,50 € chacune`
            : 'Sélection obligatoire',
          required: true, min: n, max: n + extraAllowed,
          extraFrom: extraAllowed ? n : undefined,
          extraPrice: extraAllowed ? M.extraMeatPrice : undefined,
          options: meatOptions(),
        });
      }
      // [OWNER 2026-07-28 · GOAL WEB — ANNULE WEB-EXTRAMEAT-N0 2026-07-16] Compositions FIXES
      // (viande_count=0 : Cayenne, Suprême, burgers) : vérification borne RÉELLE 2026-07-28
      // (KioskWizardComponent.shouldShowStep + kioskExtrasPartition group_label='supplement'
      // filtré) → la borne n'affiche AUCUNE étape viande : la viande est INCLUSE dans la
      // composition, « Viande supplémentaire » y est inaccessible. L'étape web « Viande en
      // plus ? » facturée dès la 1ʳᵉ sélection était une fausse parité = la plainte owner
      // « je choisis une viande, je paie direct ». Parité vraie = pas d'étape viande ici.
      // Sauce — either locked or free choice
      if (item.sauce_locked) {
        // Display-only: locked sauce shown in recap, no step (already included in name)
      } else if (item.has_sauce) {
        steps.push({
          // [COMPOSITION-SAUCE 2026-07-15] 1ère sauce incluse gratuite ; chaque sauce en plus
          //   +0,50€ (parité borne). min:1 (obligatoire) ; max ouvert. Bandeau info non-bloquant
          //   affiché dès la 2ème sélection. La 1ère part en variation attr5 gratuite, les
          //   suivantes en ItemExtra 'Sauce supplémentaire' @0,50 (api.js).
          id: 'sauce', kind: 'multi', title: 'Sauce',
          sub: '1ʳᵉ sauce incluse · sauce en plus +0,50 €', required: true, min: 1, max: (item.sauce_max || 4),
          // [OWNER 2026-07-20] extraFrom:1 → la 1ʳᵉ sauce affiche « Incluse » (gratuite), les
          //   suivantes « +0,50 » (bandeau + badge par option). Parité borne, tue « tout facturé ».
          extraFrom: 1, extraPrice: M.supplementSaucePrice,
          options: sauceOptions(),
        });
      }
      // Crudités — toggle list
      if (item.has_crudites) {
        steps.push({
          id: 'crudites', kind: 'multi', title: 'Crudités',
          sub: 'Tu peux retirer ce que tu ne veux pas',
          options: cruditeOptions(item),
        });
      }
      // Suppléments — optional
      if (item.has_supplements !== false) {
        steps.push({
          id: 'supplements', kind: 'multi', title: 'Suppléments gourmands',
          sub: 'Optionnel · ajoute ce que tu veux', max: 6,
          options: suppOptions(item),
        });
      }
      // [COMPOSITION-VIANDE 2026-07-15] Étape « Viande supplémentaire ? » séparée SUPPRIMÉE :
      //   la viande en plus est désormais choisie directement dans l'étape « viandes » ci-dessus
      //   (min:N, max:N+2, bandeau +2,50 au dépassement). Voir computeWizardTotal + api.js.
      // Menu addon — full / frites / boisson / none
      if (item.has_menu_addon) {
        steps.push({
          id: 'menu', kind: 'radio', title: 'Faire un menu ?',
          sub: 'Ajoute frites et/ou boisson', required: true,
          // [GOAL-SYNC-HEAL 2026-07-08] Deltas = addon backend `menu_component` (SSOT PricingService,
          //   prouvé e2e borne) : full +2,50 € · frites +1,90 € · boisson +1,90 € (G-PRIX owner 2026-07-22).
          //   frites/boisson corrigés 2,00 → 1,50 / 1,00 (étaient FAUX vs backend).
          // [FIX 2026-07-14 offre mensongère] `savings` RETIRÉ : Menu complet (+2,50) = Frites
          //   (1,50) + Boisson (1,00) = MÊME prix → économie réelle 0 €, le badge « −1,50 € »
          //   mentait. La borne (SdV) n'affiche AUCUNE économie → alignement.
          options: [
            { id: 'full',    name: 'Menu complet',   desc: 'Frites + Boisson', price: 2.50, icon: '🍽', popular: true },
            { id: 'frites',  name: 'Ajouter Frites', desc: 'Frites uniquement', price: 1.90, icon: '🍟' },
            { id: 'boisson', name: 'Ajouter Boisson', desc: 'Boisson uniquement', price: 1.90, icon: '🥤' },
            { id: 'none',    name: 'Sans formule',    desc: 'Juste le plat',     price: 0,    icon: '🚫' },
          ],
        });
        // Cascade drink/frites_style added dynamically by active filter in WizardFlow
      }
      break;
    }
    case 'bol': {
      // [WEB-SYNC-CAISSE 2026-06-26 L1] Bols : viande (1), sauce, suppléments BOL (9 + gratiné riz),
      //   boisson optionnelle (prix catalogue), viande supplémentaire. PAS de formule menu.
      const n = item.viande_count || item.viandes || 1;
      if (n > 0) {
        steps.push({
          id: 'viandes', kind: 'multi', title: `Choisis ${n} viande${n > 1 ? 's' : ''}`,
          sub: 'Sélection obligatoire', required: true, min: n, max: n,
          options: meatOptions(),
        });
      }
      if (item.has_sauce) {
        steps.push({
          id: 'sauce', kind: 'multi', title: 'Sauce',
          // [WEB-WIREUP 2026-06-26] Bol = attr8 « Sauce bol » : 2 sauces, single-select.
          sub: '1 sauce incluse', required: true, min: 1, max: 1,
          options: bolSauceOptions(),
        });
      }
      steps.push({
        id: 'bol_supplements', kind: 'multi', title: 'Suppléments du bol',
        sub: item.has_gratine ? 'Optionnel · suppléments +0,90 € · gratiné +2,00 €' : 'Optionnel · +0,90 € chacun',
        max: 6, options: suppBolsOptions(!!item.has_gratine),
      });
      steps.push({
        id: 'bol_drink', kind: 'radio', title: 'Une boisson ?',
        sub: 'Optionnel · prix catalogue',
        options: drinkAddonOptions(), defaultValue: '__none',
      });
      if (item.has_extra_meat) {
        steps.push({
          id: 'viande_extra', kind: 'multi', title: 'Viande supplémentaire ?',
          sub: 'Optionnel · +2,50 € par viande', max: 2,
          options: extraMeatOptions(),
        });
      }
      break;
    }
    case 'custom': {
      // Composer profile drives — read from item.composer_profile.steps[]
      const profile = item.composer_profile;
      if (profile && profile.steps) {
        profile.steps.forEach(s => {
          if (s.step_key === 'sauce') {
            steps.push({
              id: 'sauce', kind: 'radio', title: s.label, sub: s.default_choice_id ? 'Pré-sélectionnée : tu peux changer' : 'Choisis ta sauce', required: true,
              options: s.choices.map(c => ({ id: c.id, name: c.name, price: c.price || 0, image: c.image, icon: c.is_spicy ? '🌶' : '', popular: c.is_default })),
              defaultValue: s.default_choice_id,
            });
          } else if (s.step_key === 'bol_supplements') {
            steps.push({
              id: 'bol_supplements', kind: 'multi', title: s.label,
              sub: 'Optionnel · personnalise ton bol', max: s.max_select,
              options: suppBolsOptions(),
            });
          } else if (s.step_key === 'bol_drink') {
            steps.push({
              id: 'bol_drink', kind: 'radio', title: s.label,
              sub: 'Optionnel · ajoute une boisson (prix catalogue)',
              options: drinkAddonOptions(),
              defaultValue: '__none',
            });
          } else if (s.step_key === 'frites_style') {
            steps.push({
              id: 'frites_style', kind: 'radio', title: s.label,
              sub: 'Nature par défaut · upgrades payants', required: true,
              options: fritesStyleOptions(),
              defaultValue: '__nature',
            });
          }
        });
      }
      break;
    }
    case 'simple':
    default:
      // Direct-add — no steps, the WizardFlow will render a simple qty stepper
      break;
  }

  if (steps.length > 0) steps.push({ id: 'recap', kind: 'recap', title: 'Récap', sub: 'Vérifie ta commande' });
  return steps;
}

// ============================================================================
// activeSteps + cascade — handles formule menu inserting drink/frites_style
// ============================================================================
function getActiveSteps(steps, state) {
  const result = [];
  for (const s of steps) {
    result.push(s);
    // Insert cascade drink + frites_style after MENU step based on choice
    if (s.id === 'menu' && state.menu && state.menu !== 'none') {
      const M = window.LC.menu;
      if (state.menu === 'full' || state.menu === 'boisson') {
        result.push({
          id: 'cascade_drink', kind: 'radio', title: 'Choix de la boisson',
          sub: 'Incluse dans ta formule', required: true,
          options: M.formuleDrinks.map(d => ({ id: d.id, name: d.name, price: 0, image: d.image, icon: d.emoji })),
        });
      }
      if (state.menu === 'full' || state.menu === 'frites') {
        result.push({
          id: 'cascade_frites_style', kind: 'radio', title: 'Style de frites',
          sub: 'Nature par défaut · upgrades payants', required: true,
          // [WIZ-04 HEAL 2026-06-04] Parity with buildSteps 'frites_style' (line 132):
          // default to '__nature' so the required radio is pre-satisfied like the standalone variant.
          defaultValue: '__nature',
          // [TERRAIN-HEAL 2026-07-16 · WEB-CASCADE-FRITES-BADGE] En formule MENU, le style de frites
          // est un bundle FLAT backend (aucun surcoût par upgrade — cf. computeWizardTotal l.334-339
          // qui ne le facture volontairement PAS pour la parité caisse). Le badge affichait pourtant
          // +1,00/+2,00 € (fs.price) = affiché≠facturé (mensonge prix). On force price:0 ici → pas de
          // badge, display == ce qui est réellement facturé. (Le prix par-upgrade reste sur le produit
          // Frites STANDALONE, lui bien facturé via state.frites_style.)
          options: M.fritesStyles.map(fs => ({
            id: fs.id === null ? '__nature' : fs.id, name: fs.name, price: 0, image: fs.image, icon: fs.emoji, dflt: !!fs.is_default,
          })),
        });
        // [ULTRA-FRONTENDS HEAL 2026-05-18 P1] cascade_frites_sauce parity with mobile
        // (computeActiveSteps line 144 — mobile pushes STEP.FRITES_SAUCE alongside FRITES_STYLE).
        // Web previously omitted this step: users could pick frites style but skip sauce.
        result.push({
          id: 'cascade_frites_sauce', kind: 'multi', title: 'Sauce pour les frites',
          // [OWNER 2026-07-28 · GOAL WEB — annule R3 2026-07-11] Multi comme la sauce sandwich :
          // 1ʳᵉ incluse, chaque sauce en plus +0,50 € (parité borne : KioskStepMenuComponent
          // frites_sauce lit « Sauce supplémentaire » du parent, 1st-free). Facturé via
          // fritesSauceIds (menu.js priceFor l.561) + ItemExtra 'Sauce supplémentaire' (api.js).
          sub: '1ʳᵉ sauce incluse · sauce en plus +0,50 €', required: true, min: 1, max: 4,
          extraFrom: 1, extraPrice: M.supplementSaucePrice,
          options: M.sauces.map(s => ({
            id: s.id, name: s.name, price: 0, image: s.image, icon: s.is_spicy ? '🌶' : '', tag: s.is_spicy ? '🌶' : '',
          })),
        });
      }
    }
  }
  return result;
}

// ============================================================================
// computeTotal — uses window.LC.menu.priceFor mirror mobile
// ============================================================================
function computeWizardTotal(item, state) {
  if (!window.LC || !window.LC.menu) return item.price;
  const M = window.LC.menu;
  // Map wizard state → priceFor opts
  const sauceIds = Array.isArray(state.sauce) ? state.sauce : (state.sauce ? [state.sauce] : []);
  const supplementIds = Array.isArray(state.supplements) ? state.supplements : [];
  const bolSupplementIds = Array.isArray(state.bol_supplements) ? state.bol_supplements : [];
  // [WEB-SYNC-CAISSE 2026-06-26 L2] viandes supplémentaires sélectionnées (+2,50€ chacune).
  // [COMPOSITION-VIANDE 2026-07-15] Fusion : le surplus de l'étape « viandes » au-delà du nombre
  //   INCLUS (item.viande_count) compte aussi comme viande en plus. On combine avec un éventuel
  //   state.viande_extra résiduel (bols, étape séparée non fusionnée). priceFor price par COMPTE.
  const includedMeatN = item.viande_count || item.viandes || 0;
  const viandesArr = Array.isArray(state.viandes) ? state.viandes : [];
  const legacyExtraMeat = Array.isArray(state.viande_extra) ? state.viande_extra : [];
  const extraMeatIds = legacyExtraMeat.concat(viandesArr.slice(includedMeatN));
  const bolDrinkRaw = state.bol_drink;
  const bolDrinkId = bolDrinkRaw && bolDrinkRaw !== '__none' ? bolDrinkRaw : null;
  const formuleId = state.menu === 'full' ? 'f-menu' : state.menu === 'frites' ? 'f-frites' : state.menu === 'boisson' ? 'f-boisson' : null;
  // [WEB-WIREUP 2026-06-26] Price the frites STYLE only for a STANDALONE frites item
  // (state.frites_style). The menu-formule cascade (state.cascade_frites_style /
  // cascade_frites_sauce) is a FLAT bundle on the backend (menu addon = fixed +2,50€, no
  // per-upgrade charge) — so the web must NOT add an upcharge there or its total would exceed
  // what the caisse charges. The chosen style/drink is relayed to the kitchen via the line
  // instruction (api.js resolveLine), not priced.
  const fritesStyleRaw = state.frites_style;
  const fritesStyleId = fritesStyleRaw === '__nature' || !fritesStyleRaw ? null : fritesStyleRaw;
  // [OWNER 2026-07-28] Sauces frites du menu : 1ʳᵉ incluse, +0,50 € chacune au-delà
  // (priceFor l.561 : (n-1)×0,50) — parité borne, miroir de la sauce sandwich.
  const fritesSauceIds = Array.isArray(state.cascade_frites_sauce) ? state.cascade_frites_sauce : [];
  return M.priceFor(item, {
    sauceIds, supplementIds, bolSupplementIds, extraMeatIds, bolDrinkId, formuleId,
    fritesStyleId: fritesStyleRaw !== undefined ? fritesStyleId : undefined,
    fritesSauceIds,
    qty: 1,
  });
}

// ============================================================================
// WizardFlow — main shell, drives steps + state
// ============================================================================
function WizardFlow({ open, item, onClose, onAdd }) {
  const baseSteps = item ? buildSteps(item) : [];
  const [state, setState] = wvS({});
  const [idx, setIdx] = wvS(0);
  const [error, setError] = wvS(null);

  // Reset state on open + initialize defaults
  wvE(() => {
    if (!open || !item) return;
    setIdx(0); setError(null);
    const init = {};
    baseSteps.forEach(s => {
      if (s.kind === 'multi') {
        // Pre-select defaults (e.g. crudites all on)
        init[s.id] = (s.options || []).filter(o => o.dflt).map(o => o.id);
      } else if (s.kind === 'radio') {
        if (s.defaultValue !== undefined) init[s.id] = s.defaultValue;
        else if (s.options && s.options.find(o => o.dflt)) init[s.id] = s.options.find(o => o.dflt).id;
      }
    });
    setState(init);
  }, [open, item && item.id]);

  // [WIZ-04 HEAL 2026-06-04] Seed defaults for radios inserted DYNAMICALLY by getActiveSteps
  // (cascade_drink / cascade_frites_style) — the init effect above only iterates baseSteps,
  // so a dynamically-inserted required radio (e.g. cascade_frites_style → '__nature') would
  // otherwise never receive its default and block the required-radio gate.
  wvE(() => {
    if (!open || !item) return;
    const act = getActiveSteps(baseSteps, state);
    const seed = {};
    act.forEach(s => {
      if (s.kind === 'radio' && state[s.id] === undefined) {
        if (s.defaultValue !== undefined) seed[s.id] = s.defaultValue;
        else if (s.options && s.options.find(o => o.dflt)) seed[s.id] = s.options.find(o => o.dflt).id;
      }
    });
    if (Object.keys(seed).length) setState(s => ({ ...s, ...seed }));
  }, [open, item && item.id, state.menu]);

  if (!item) return null;

  // If simple template (no wizard steps), render direct-add view
  if (baseSteps.length === 0) {
    return <DirectAddView open={open} item={item} onClose={onClose} onAdd={onAdd}/>;
  }

  const active = getActiveSteps(baseSteps, state);
  const safeIdx = Math.min(idx, active.length - 1);
  const step = active[safeIdx];

  // Validation per step
  const canAdvance = (() => {
    if (!step) return false;
    if (step.kind === 'recap') return true;
    if (step.kind === 'radio') {
      if (!step.required) return true;
      return state[step.id] !== undefined && state[step.id] !== null;
    }
    if (step.kind === 'multi') {
      const arr = state[step.id] || [];
      if (step.required && step.min && arr.length < step.min) return false;
      return true;
    }
    return true;
  })();

  // Aggregate allergens across selected items
  const allergens = new Set();
  if (item.allergens) item.allergens.forEach(a => allergens.add(a));
  for (const s of baseSteps) {
    if (s.kind === 'radio' && state[s.id]) {
      const o = (s.options || []).find(o => o.id === state[s.id]);
      (o && o.allergens || []).forEach(a => allergens.add(a));
    } else if (s.kind === 'multi' && Array.isArray(state[s.id])) {
      for (const id of state[s.id]) {
        const o = (s.options || []).find(o => o.id === id);
        (o && o.allergens || []).forEach(a => allergens.add(a));
      }
    }
  }
  const allergensList = Array.from(allergens);
  const total = computeWizardTotal(item, state);
  const fmt = (n) => n.toFixed(2).replace('.', ',') + ' €';

  // [WIZ-02 + WIZ-03 HEAL 2026-06-04] Build a short human-readable customization summary
  // (names, not "N options") AND a count that excludes free DEFAULT selections so they
  // don't inflate the badge. A selection "counts" when it is a real customization:
  //   - has an upcharge (price > 0), OR
  //   - is a non-default choice the user actively picked (radio differing from its default;
  //     any multi pick that is not a pre-checked default option).
  // Sentinels ('__none' / '__nature') and pre-selected free defaults are skipped.
  const buildCustomSummary = () => {
    const parts = [];
    let count = 0;
    for (const s of active) {
      if (s.kind === 'recap') continue;
      const defRadio = s.defaultValue !== undefined
        ? s.defaultValue
        : (s.options && (s.options.find(o => o.dflt) || {}).id);
      if (s.kind === 'radio' && state[s.id] !== undefined && state[s.id] !== null) {
        const id = state[s.id];
        if (id === '__none' || id === '__nature') continue; // explicit "none" sentinels
        const o = (s.options || []).find(o => o.id === id);
        if (!o) continue;
        const isDefault = id === defRadio;
        const isFree = !(o.price > 0);
        if (isDefault && isFree) continue; // free default → not a real customization
        parts.push(o.name);
        count++;
      } else if (s.kind === 'multi' && Array.isArray(state[s.id])) {
        for (const id of state[s.id]) {
          const o = (s.options || []).find(o => o.id === id);
          if (!o) continue;
          const isDefault = !!o.dflt;
          const isFree = !(o.price > 0);
          if (isDefault && isFree) continue; // pre-checked free default (e.g. crudités) → skip
          parts.push(o.name);
          count++;
        }
      }
    }
    let summary = parts.join(', ');
    if (summary.length > 60) summary = summary.slice(0, 57).replace(/,\s*$/, '') + '…';
    return { summary, count };
  };

  const set = (sid, val) => {
    setError(null);
    if (step.kind === 'radio') {
      setState(s => ({ ...s, [sid]: val }));
      setTimeout(() => { if (safeIdx < active.length - 1) setIdx(i => Math.min(active.length - 1, i + 1)); }, 200);
    } else {
      setState(s => {
        const cur = Array.isArray(s[sid]) ? s[sid] : [];
        const isOn = cur.includes(val);
        // [TERRAIN-HEAL 2026-07-16 · WEB-WIZ-MULTI-MAX1] Étape multi à max===1 : cliquer une AUTRE
        // option REMPLACE la sélection (comme un radio) au lieu d'afficher « Maximum 1 » et de bloquer.
        // Avant : l'utilisateur pouvait choisir mais pas CHANGER son choix sans dé-sélectionner d'abord
        // (= la faute de logique owner « choisir qu'une seule / ça bloque »).
        if (!isOn && step.max === 1) {
          return { ...s, [sid]: [val] };
        }
        if (!isOn && step.max && cur.length >= step.max) {
          setError(`Maximum ${step.max} sélections`);
          return s;
        }
        const nextArr = isOn ? cur.filter(x => x !== val) : [...cur, val];
        return { ...s, [sid]: nextArr };
      });
    }
  };

  return (
    <WebModal open={open} onClose={onClose} full>
      <div className="lc-wiz">
        <div className="lc-wiz-main">
          <div className="lc-wiz-header">
            {/* [T2-09 HEAL 2026-06-05 WCAG 4.1.3] announce step change (eyebrow+title) politely.
                Plain block wrapper — .lc-wiz-header is block flow so stacking is unchanged, and we
                avoid display:contents which can drop the live region from the a11y tree. */}
            <div role="status" aria-live="polite">
              <div className="lc-wiz-eyebrow">
                <b>{safeIdx + 1}</b> / {active.length} · {item.name.toUpperCase()}
                {step.required && <span style={{ marginLeft: 8, color: 'var(--red-text)' }}>OBLIGATOIRE</span>}
              </div>
              <h2 className="lc-wiz-title">{step.title}</h2>
            </div>
            <div className="lc-wiz-sub">{step.sub}</div>
            <div className="lc-wiz-progress">
              {active.map((_, i) => <span key={i} className={i < safeIdx ? 'done' : i === safeIdx ? 'current' : ''}/>)}
            </div>
          </div>
          <div className="lc-wiz-body">
            {/* [T2-09 HEAL 2026-06-05 WCAG 4.1.3] over-select validation error announced assertively */}
            {error && (
              <div role="alert" aria-live="assertive" style={{ marginBottom: 14, padding: '10px 14px', background: 'rgba(215,38,56,0.08)', border: '1px solid rgba(215,38,56,0.24)', borderRadius: 12, color: 'var(--red-text)', fontSize: 12.5, fontWeight: 700, display: 'flex', alignItems: 'center', gap: 8 }}>
                <WC_I.close s={14}/> {error}
              </div>
            )}
            {step.kind === 'radio' && (
              <div className="lc-wiz-options">
                {step.options.map(opt => {
                  const on = state[step.id] === opt.id;
                  return (
                    <button key={opt.id} className={'lc-wiz-choice' + (on ? ' is-on' : '')} aria-pressed={on} onClick={() => set(step.id, opt.id)} style={{ position: 'relative' }}>
                      {opt.popular && <span style={{ position: 'absolute', top: -8, right: 12, background: 'var(--orange-text)', color: '#fff', padding: '3px 8px', borderRadius: 999, fontSize: 9.5, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase' }}>★ Populaire</span>}
                      {opt.image
                        ? <div className="lc-wiz-choice-thumb"><img src={opt.image} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 'inherit' }} onError={(e) => { e.target.style.display = 'none'; if (opt.icon) e.target.parentNode.textContent = opt.icon; }}/></div>
                        : (opt.icon && <div className="lc-wiz-choice-thumb">{opt.icon}</div>)}
                      <div className="lc-wiz-choice-body">
                        <div className="lc-wiz-choice-name">{opt.name}{opt.tag && <span style={{ marginLeft: 6 }}>{opt.tag}</span>}</div>
                        {opt.desc && <div className="lc-wiz-choice-desc">{opt.desc}</div>}
                      </div>
                      {opt.savings && <span className="lc-wiz-savings">−{fmt(opt.savings)}</span>}
                      {opt.price > 0 && <div className="lc-wiz-choice-price">+{fmt(opt.price)}</div>}
                      <div className="lc-wiz-choice-mark"><i/></div>
                    </button>
                  );
                })}
              </div>
            )}
            {step.kind === 'multi' && (
              <>
                {/* [COMPOSITION-VIANDE 2026-07-15] Bandeau non-bloquant en HAUT : n'apparaît que
                    lorsqu'on dépasse le nombre INCLUS (extraFrom) — owner « un petit message en
                    haut viande. En plus ça va être 2,50 € », pas d'alerte modale. */}
                {step.extraFrom != null && (state[step.id] || []).length > step.extraFrom && (
                  <div role="status" aria-live="polite" style={{ marginBottom: 12, padding: '9px 13px', background: 'rgba(244,80,30,0.09)', border: '1px solid rgba(244,80,30,0.28)', borderRadius: 12, color: 'var(--orange-text)', fontSize: 12.5, fontWeight: 700, display: 'flex', alignItems: 'center', gap: 8 }}>
                    <span>{step.id === 'sauce' || step.id === 'cascade_frites_sauce' ? '🥫' : '🍖'}</span>
                    {step.id === 'sauce' || step.id === 'cascade_frites_sauce' ? 'Sauce' : 'Viande'} en plus : +{fmt(step.extraPrice)} chacune
                    {' '}(× {(state[step.id] || []).length - step.extraFrom})
                  </div>
                )}
                {(step.max || step.min) && (
                  <div style={{ marginBottom: 12, fontSize: 12, color: 'var(--gray-3)' }}>
                    {step.min ? <span>Min : <b>{step.min}</b> · </span> : null}
                    Sélection : <b>{(state[step.id] || []).length}</b>{step.max ? ` / ${step.max}` : ''}
                  </div>
                )}
                <div className="lc-wiz-options">
                  {step.options.map(opt => {
                    const sel = state[step.id] || [];
                    const on = sel.includes(opt.id);
                    // [OWNER 2026-07-20] Badge par option : « Incluse » (gratuit, ≤ extraFrom) vs « +prix »
                    //   (extra au-delà) — le client VOIT ce qui est inclus → plus de « tout facturé ».
                    const selIdx = sel.indexOf(opt.id);
                    const showIncl = on && step.extraFrom != null && selIdx > -1 && selIdx < step.extraFrom;
                    const showExtra = on && step.extraFrom != null && step.extraPrice != null && selIdx >= step.extraFrom;
                    return (
                      <button key={opt.id} className={'lc-wiz-choice' + (on ? ' is-on' : '')} aria-pressed={on} onClick={() => set(step.id, opt.id)} style={{ position: 'relative' }}>
                        {opt.popular && <span style={{ position: 'absolute', top: -8, right: 12, background: 'var(--orange-text)', color: '#fff', padding: '3px 8px', borderRadius: 999, fontSize: 9.5, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase' }}>★ Populaire</span>}
                        {opt.image
                        ? <div className="lc-wiz-choice-thumb"><img src={opt.image} alt="" style={{ width: '100%', height: '100%', objectFit: 'cover', borderRadius: 'inherit' }} onError={(e) => { e.target.style.display = 'none'; if (opt.icon) e.target.parentNode.textContent = opt.icon; }}/></div>
                        : (opt.icon && <div className="lc-wiz-choice-thumb">{opt.icon}</div>)}
                        <div className="lc-wiz-choice-body">
                          <div className="lc-wiz-choice-name">{opt.name}{opt.tag && <span style={{ marginLeft: 6 }}>{opt.tag}</span>}</div>
                        </div>
                        {showIncl && <div className="lc-wiz-choice-price" style={{ background: 'rgba(46,125,50,0.12)', color: '#2e7d32' }}>Inclus{step.id === 'sauce' ? 'e' : ''}</div>}
                        {showExtra && <div className="lc-wiz-choice-price">+{fmt(step.extraPrice)}</div>}
                        {!showIncl && !showExtra && opt.price > 0 && <div className="lc-wiz-choice-price">+{fmt(opt.price)}</div>}
                        <div className="lc-wiz-choice-mark lc-wiz-choice-check"><WC_I.check s={12}/></div>
                      </button>
                    );
                  })}
                </div>
              </>
            )}
            {step.kind === 'recap' && (
              <div style={{ padding: 4 }}>
                {/* [OWNER 2026-07-28] Récap visuel : photo produit + nom en tête (fin des « Étape N »). */}
                <div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 16 }}>
                  <div style={{ width: 84, height: 84, borderRadius: 14, overflow: 'hidden', background: 'var(--cream)', display: 'flex', alignItems: 'center', justifyContent: 'center', flexShrink: 0 }}>
                    {item.image
                      ? <img src={item.image} alt={item.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} onError={(e) => { e.target.style.display = 'none'; e.target.parentNode.textContent = item.emoji || '🍽'; }}/>
                      : <span style={{ fontSize: 44 }}>{item.emoji || '🍽'}</span>}
                  </div>
                  <div>
                    <div style={{ fontFamily: 'var(--font-display)', fontSize: 22, textTransform: 'uppercase', lineHeight: 1.1 }}>{item.name}</div>
                    <div style={{ fontSize: 12, color: 'var(--gray-3)', marginTop: 4 }}>Ta personnalisation</div>
                  </div>
                </div>
                {item.sauce_locked && (
                  <div style={{ marginBottom: 14, padding: '12px 14px', background: 'rgba(255,90,31,0.08)', border: '1px solid rgba(255,90,31,0.24)', borderRadius: 12, fontSize: 13, fontWeight: 600 }}>
                    🌶 Sauce {item.sauce_locked} maison (incluse)
                  </div>
                )}
                {item.bol_meat_fixed && (
                  <div style={{ marginBottom: 10, padding: '10px 14px', background: 'var(--cream)', borderRadius: 10, fontSize: 12, color: 'var(--gray-4)' }}>
                    <b>Viande :</b> {item.bol_meat_fixed} · <b>Base :</b> {item.slug.indexOf('bowl-frites-') === 0 ? 'Frites' : 'Riz basmati'}
                  </div>
                )}
                {/* [OWNER 2026-07-28] Bandeau « Allergènes détectés » retiré du flux (copy anxiogène).
                    L'info allergènes reste accessible : lien discret ci-dessous + legal/allergens.html (INCO). */}
                {active.filter(s => s.kind !== 'recap').map((s, i) => {
                  let val = '—'; let p = 0;
                  if (s.kind === 'radio' && state[s.id]) {
                    const o = (s.options || []).find(o => o.id === state[s.id]);
                    val = o ? o.name : '—'; p = (o && o.price) || 0;
                  }
                  if (s.kind === 'multi') {
                    const ids = state[s.id] || [];
                    val = ids.length ? ids.map(id => ((s.options || []).find(o => o.id === id) || {}).name).filter(Boolean).join(', ') : 'Aucun';
                    p = ids.reduce((sum, id) => sum + (((s.options || []).find(o => o.id === id) || {}).price || 0), 0);
                  }
                  // [TERRAIN-HEAL 2026-07-16 · WEB-WIZ-MEAT-RECAP] Étape viandes : les viandes au-delà du
                  // nombre INCLUS (extraFrom) coûtent +2,50 € chacune. Le récap sommait les prix d'option
                  // (0 pour meatOptions) → affichait « Inclus » alors que le total facture le surplus →
                  // ligne récap ≠ total. On reflète le vrai surcoût du surplus.
                  if (s.id === 'viandes' && s.extraFrom != null && s.extraPrice) {
                    const surplus = Math.max(0, (state[s.id] || []).length - s.extraFrom);
                    p = surplus * s.extraPrice;
                  }
                  // [TERRAIN-HEAL 2026-07-16 · WEB-WIZ-MEAT-RECAP] idem sauce : 1ère incluse, chaque
                  // sauce en plus +0,50 € (= computeWizardTotal). Le récap affichait « Inclus ».
                  if (s.id === 'sauce') {
                    const M = (window.LC && window.LC.menu) || {};
                    const nb = (state[s.id] || []).length;
                    if (nb > 1) p = (nb - 1) * (M.supplementSaucePrice || 0.5);
                  }
                  // [TERRAIN-HEAL 2026-07-16 · WEB-BORNE-FRITES-CASCADE] Les étapes cascade_* (boisson +
                  // style/sauce de frites DANS une formule menu) font partie du BUNDLE forfaitaire menu
                  // (+2,50 € flat) — computeWizardTotal ne les facture PAS séparément. Le récap affichait
                  // pourtant le prix d'upgrade (ex. Cheddar +1,00) → ligne récap ≠ total. On force « Inclus ».
                  if (s.id && s.id.indexOf('cascade_') === 0) p = 0;
                  // [OWNER 2026-07-28] EXCEPTION : sauces frites en plus (1ʳᵉ incluse puis +0,50)
                  // sont bien facturées (priceFor l.561) → le récap doit refléter le surcoût réel.
                  if (s.id === 'cascade_frites_sauce') {
                    const M2 = (window.LC && window.LC.menu) || {};
                    const nbf = (state[s.id] || []).length;
                    p = nbf > 1 ? (nbf - 1) * (M2.supplementSaucePrice || 0.5) : 0;
                  }
                  return (
                    <div key={s.id} style={{ display: 'flex', alignItems: 'flex-start', padding: '14px 4px', borderBottom: '1px solid var(--gray-1)' }}>
                      <div style={{ flex: 1 }}>
                        <div style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.14em', textTransform: 'uppercase', color: 'var(--gray-3)' }}>{s.title.replace('Choisis ta ', '').replace('Choisis ton ', '').replace('Choisis ', '').replace(/^./, c => c.toUpperCase())}</div>
                        <div style={{ fontWeight: 700, marginTop: 4, fontSize: 14 }}>{val}</div>
                      </div>
                      <div style={{ textAlign: 'right', display: 'flex', flexDirection: 'column', alignItems: 'flex-end', gap: 6 }}>
                        {p > 0 ? <span className="lc-wiz-choice-price">+{fmt(p)}</span> : <span style={{ fontSize: 11, color: 'var(--gray-3)' }}>Inclus</span>}
                        <button onClick={() => setIdx(Math.max(0, active.findIndex(a => a.id === s.id)))} style={{ background: 'transparent', border: 0, color: 'var(--orange)', fontSize: 10.5, fontWeight: 700, letterSpacing: '0.08em', textTransform: 'uppercase', cursor: 'pointer' }}>
                          Modifier
                        </button>
                      </div>
                    </div>
                  );
                })}
              </div>
            )}
          </div>
          <div className="lc-wiz-footer">
            <div className="lc-wiz-footer-left">
              <button className="lc-wiz-foot-back" aria-label={safeIdx > 0 ? 'Étape précédente' : 'Fermer'} onClick={() => safeIdx > 0 ? setIdx(i => i - 1) : onClose()}><WC_I.back s={14}/></button>
              <div style={{ fontSize: 12, color: 'var(--gray-3)' }}>{safeIdx + 1} / {active.length}</div>
            </div>
            <button
              className="lc-wiz-foot-next"
              disabled={!canAdvance}
              style={{ background: step.kind === 'recap' ? 'var(--orange)' : 'var(--ink)' }}
              onClick={() => { if (step.kind === 'recap') { const cs = buildCustomSummary(); onAdd({ item, state, total, summary: cs.summary, optionCount: cs.count }); onClose(); } else { setIdx(i => Math.min(active.length - 1, i + 1)); } }}
            >
              {step.kind === 'recap' ? 'Ajouter au panier' : (safeIdx === active.length - 2 ? 'Voir récap' : 'Continuer')}
              <span className="lc-wiz-foot-next-total">{fmt(total)}</span>
            </button>
          </div>
        </div>
        {/* Right pane on desktop — live preview */}
        <div className="lc-wiz-preview">
          <div className="lc-wiz-preview-thumb">
            {item.image
              ? <img src={item.image} alt={item.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }}/>
              : <span>{item.emoji}</span>}
          </div>
          <div className="lc-wiz-preview-body">
            <div className="lc-eyebrow" style={{ marginBottom: 6 }}>// Aperçu live</div>
            <div className="lc-wiz-preview-name">{item.name}</div>
            <div style={{ marginTop: 14 }}>
              {item.sauce_locked && <div className="lc-wiz-preview-line"><span className="lc-wiz-preview-line-lbl">Sauce</span><span className="lc-wiz-preview-line-val">{item.sauce_locked} (incluse)</span></div>}
              {active.filter(s => s.kind !== 'recap').map(s => {
                let val = '—';
                if (s.kind === 'radio' && state[s.id]) val = ((s.options || []).find(o => o.id === state[s.id]) || {}).name || '—';
                if (s.kind === 'multi') val = (state[s.id] || []).length ? (state[s.id] || []).map(id => ((s.options || []).find(o => o.id === id) || {}).name).filter(Boolean).join(', ') : 'Aucun';
                return (
                  <div key={s.id} className="lc-wiz-preview-line">
                    <span className="lc-wiz-preview-line-lbl">{s.title.replace('Choisis ta ', '').replace('Choisis ton ', '').replace('Choisis ', '').replace('Faire un ', '').replace('Tu veux un ', '').replace(/^./, c => c.toUpperCase())}</span>
                    <span className="lc-wiz-preview-line-val">{val}</span>
                  </div>
                );
              })}
            </div>
            {/* [OWNER 2026-07-28] Encadré allergènes retiré de l'aperçu — lien discret conservé (INCO). */}
            <div style={{ marginTop: 14, fontSize: 11 }}>
              <a href="legal/allergens.html" target="_blank" rel="noopener" style={{ color: 'var(--gray-3)', textDecoration: 'underline' }}>Infos allergènes</a>
            </div>
            <div className="lc-wiz-preview-total">
              <span className="lc-wiz-preview-total-lbl">Total</span>
              <span className="lc-wiz-preview-total-num">{fmt(total)}</span>
            </div>
            <div style={{ marginTop: 12, padding: '8px 12px', background: 'rgba(31,166,83,0.08)', border: '1px solid rgba(31,166,83,0.2)', borderRadius: 10, fontSize: 11, color: 'var(--gray-4)', display: 'flex', alignItems: 'center', gap: 8 }}>
              <span style={{ color: 'var(--green)' }}>🎁</span>
              <span>+<b style={{ color: 'var(--ink)' }}>{(window.LC?.loyalty?.earnPoints ? window.LC.loyalty.earnPoints(total) : Math.floor(total))} pts</b> Pepper Club</span>
            </div>
          </div>
        </div>
      </div>
    </WebModal>
  );
}

// ============================================================================
// DirectAddView — simple template (Suppléments / Desserts / Boissons / Menu enfant)
// ============================================================================
function DirectAddView({ open, item, onClose, onAdd }) {
  const [qty, setQty] = wvS(1);
  wvE(() => { if (open) setQty(1); }, [open, item && item.id]);
  if (!item) return null;
  const fmt = (n) => n.toFixed(2).replace('.', ',') + ' €';
  const total = item.price * qty;
  return (
    <WebModal open={open} onClose={onClose}>
      <div style={{ padding: 24, maxWidth: 460 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 14, marginBottom: 16 }}>
          {item.image
            ? <img src={item.image} alt={item.name} style={{ width: 80, height: 80, objectFit: 'cover', borderRadius: 12 }}/>
            : <span style={{ fontSize: 50 }}>{item.emoji}</span>}
          <div>
            <h2 style={{ margin: 0, fontFamily: 'var(--font-display)', fontSize: 26, textTransform: 'uppercase' }}>{item.name}</h2>
            <div style={{ color: 'var(--gray-4)', fontSize: 13, marginTop: 4 }}>{item.description}</div>
          </div>
        </div>
        {/* [OWNER 2026-07-28] Encadré ⚠ retiré — lien discret INCO à la place. */}
        <div style={{ fontSize: 11, marginBottom: 14 }}>
          <a href="legal/allergens.html" target="_blank" rel="noopener" style={{ color: 'var(--gray-3)', textDecoration: 'underline' }}>Infos allergènes</a>
        </div>
        <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 18px', background: 'var(--ink)', borderRadius: 16, marginBottom: 16 }}>
          <span style={{ color: '#fff', fontWeight: 700, fontSize: 13, letterSpacing: '0.08em', textTransform: 'uppercase' }}>Quantité</span>
          <div style={{ display: 'flex', alignItems: 'center', gap: 14 }}>
            <button onClick={() => setQty(q => Math.max(1, q - 1))} aria-label="Diminuer" style={{ width: 32, height: 32, borderRadius: 999, background: 'rgba(255,255,255,0.15)', border: 0, color: '#fff', cursor: 'pointer' }}>−</button>
            <span style={{ color: 'var(--orange)', fontFamily: 'var(--font-display)', fontSize: 24, minWidth: 24, textAlign: 'center' }}>{qty}</span>
            <button onClick={() => setQty(q => q + 1)} aria-label="Augmenter" style={{ width: 32, height: 32, borderRadius: 999, background: 'var(--orange)', border: 0, color: '#fff', cursor: 'pointer' }}>+</button>
          </div>
        </div>
        {/* [WIZ-01 HEAL 2026-06-04] Pass UNIT price (item.price) — App's onAdd multiplies by qty.
            Passing the qty-baked `total` here caused quantity² in the cart (price=unit×qty, qty=qty).
            `total` stays local for the button label only. */}
        <button className="lc-btn lc-btn--orange" style={{ width: '100%' }} onClick={() => { onAdd({ item, state: { qty }, total: item.price }); onClose(); }}>
          Ajouter au panier · {fmt(total)}
        </button>
      </div>
    </WebModal>
  );
}

window.WizardFlow = WizardFlow;
window.buildWizardSteps = buildSteps;        // expose for E2E tests
window.computeWizardTotal = computeWizardTotal; // expose for E2E tests
