/* Uleez — UI primitives. Exports to window. */
(function () {
  const { useState, useEffect, useRef } = React;
  const Icon = window.Icon;

  // ---------- Avatar ----------
  function Avatar({ name, initials, color, size = 40, square = false, src }) {
    const cls = color || (name ? window.KD.avColor(name) : 'av-slate');
    const baseStyle = { width: size, height: size };
    if (src) {
      return React.createElement('div', {
        className: `avatar ${square ? 'sq' : ''}`,
        style: baseStyle,
      }, React.createElement('img', { src, alt: name || '' }));
    }
    const ini = initials || (name ? window.KD.initials(name) : '?');
    return React.createElement('div', {
      className: `avatar ${square ? 'sq' : ''} ${cls}`,
      style: { ...baseStyle, fontSize: size * 0.38 },
    }, ini);
  }

  // ---------- Button ----------
  function Button({ variant = 'secondary', size, icon, iconRight, loading, children, className = '', onClick, disabled, style, title }) {
    const [locked, setLocked] = useState(false);
    const lockTimer = useRef(null);
    const prevLoading = useRef(loading);

    useEffect(() => {
      if (prevLoading.current && !loading) setLocked(false);
      prevLoading.current = loading;
    }, [loading]);

    useEffect(() => () => { if (lockTimer.current) clearTimeout(lockTimer.current); }, []);

    function handleClick(e) {
      if (!onClick) return; // form submit buttons — let the browser handle it
      if (locked || loading || disabled) return;
      setLocked(true);
      if (lockTimer.current) clearTimeout(lockTimer.current);
      lockTimer.current = setTimeout(() => setLocked(false), 5000);
      onClick(e);
    }

    const isDisabled = disabled || loading || (onClick ? locked : false);
    const sz = size === 'sm' ? 'btn-sm' : size === 'lg' ? 'btn-lg' : '';
    return React.createElement('button', {
      className: `btn btn-${variant} ${sz} ${className}`,
      onClick: handleClick, disabled: isDisabled, style, title
    },
      loading ? React.createElement(Icon, { name: 'loader', size: size === 'sm' ? 15 : 17, className: 'spin' })
              : (icon ? React.createElement(Icon, { name: icon, size: size === 'sm' ? 15 : 17 }) : null),
      children ? React.createElement('span', null, children) : null,
      iconRight ? React.createElement(Icon, { name: iconRight, size: size === 'sm' ? 15 : 17 }) : null,
    );
  }
  function IconButton({ name, size = 'md', className = '', iconSize, onClick, disabled, style, title }) {
    return React.createElement('button', { className: `btn btn-secondary btn-icon ${size === 'sm' ? 'sm' : ''} ${className}`, onClick, disabled, style, title },
      React.createElement(Icon, { name, size: iconSize || (size === 'sm' ? 16 : 18) }));
  }

  // ---------- Badge ----------
  function Badge({ cls = 'gray', children, dot = true, style }) {
    return React.createElement('span', { className: `badge ${cls}`, style },
      dot ? React.createElement('span', { className: 'bdot' }) : null, children);
  }
  /**
   * Pastille de statut. `map[value].label` est une CLÉ de traduction (voir data.jsx), résolue
   * ICI et non à la construction de la table : celle-ci est une constante de module évaluée au
   * chargement, alors que la langue peut changer sans rechargement de page.
   *
   * `useT()` abonne le composant aux changements de langue — sans lui, les pastilles déjà à
   * l'écran resteraient dans l'ancienne langue jusqu'au prochain rendu du parent.
   *
   * Repli : une valeur inconnue de la table s'affiche telle quelle, comme avant.
   */
  function StatusBadge({ map, value }) {
    const t = window.I18N ? I18N.useT() : ((k) => k);
    const m = map[value];
    if (!m) return React.createElement(Badge, { cls: 'gray' }, value);
    return React.createElement(Badge, { cls: m.cls }, t(m.label));
  }

  // ---------- Chip / tabs ----------
  function Chip({ active, count, alert, onClick, children }) {
    return React.createElement('button', { className: `chip ${active ? 'on' : ''}`, onClick },
      children,
      count != null ? React.createElement('span', { className: `ct ${alert ? 'alert' : ''}` }, count) : null);
  }

  // ---------- Inputs ----------
  function Field({ label, error, children, hint }) {
    return React.createElement('div', { className: 'field' },
      label ? React.createElement('label', null, label) : null,
      children,
      error ? React.createElement('div', { className: 'field-err' },
        React.createElement(Icon, { name: 'alert', size: 13 }), error) : null,
      hint && !error ? React.createElement('div', { className: 'muted', style: { fontSize: 12.5 } }, hint) : null,
    );
  }
  function Input({ lead, trail, error, className = '', ...rest }) {
    return React.createElement('div', { className: 'input-wrap' },
      lead ? React.createElement('span', { className: 'lead' }, React.createElement(Icon, { name: lead, size: 17 })) : null,
      React.createElement('input', { className: `input ${lead ? 'has-lead' : ''} ${trail ? 'has-trail' : ''} ${error ? 'err' : ''} ${className}`, ...rest }),
      trail || null,
    );
  }
  function Textarea({ error, className = '', ...rest }) {
    return React.createElement('textarea', { className: `textarea ${error ? 'err' : ''} ${className}`, ...rest });
  }
  function Select({ children, value, onChange, ...rest }) {
    return React.createElement('div', { className: 'select-wrap' },
      React.createElement('select', { className: 'select', value, onChange, ...rest }, children),
      React.createElement('span', { className: 'chev' }, React.createElement(Icon, { name: 'chevDown', size: 16 })));
  }
  function Checkbox({ checked, onChange }) {
    return React.createElement('button', { className: `checkbox ${checked ? 'on' : ''}`, onClick: (e) => { e.stopPropagation(); onChange && onChange(!checked); } },
      checked ? React.createElement(Icon, { name: 'check', size: 13, stroke: 3 }) : null);
  }

  // ---------- Card ----------
  function Card({ className = '', children, style, onClick, id }) {
    return React.createElement('div', { className: `card ${className}`, style, onClick, id }, children);
  }
  function CardHead({ title, sub, icon, right }) {
    return React.createElement('div', { className: 'card-head' },
      icon ? React.createElement('div', { className: 'sc-ico ' + (icon.color || 'green'), style: { width: 38, height: 38, borderRadius: 11 } },
        React.createElement(Icon, { name: icon.name, size: 19 })) : null,
      React.createElement('div', null,
        React.createElement('h3', null, title),
        sub ? React.createElement('div', { className: 'ch-sub' }, sub) : null),
      React.createElement('div', { className: 'spacer' }),
      right || null);
  }

  // ---------- StatCard ----------
  function StatCard({ icon, color = 'green', value, label, meta, trend, spark }) {
    return React.createElement('div', { className: 'statcard' },
      React.createElement('div', { className: 'sc-top' },
        React.createElement('div', { className: 'sc-ico ' + color }, React.createElement(Icon, { name: icon, size: 30 })),
        trend ? React.createElement('span', { className: 'sc-trend ' + (trend.dir || 'up') },
          React.createElement(Icon, { name: trend.dir === 'down' ? 'trendDown' : 'trendUp', size: 13, stroke: 2.5 }), trend.value) : null),
      React.createElement('div', { className: 'sc-val' }, value),
      React.createElement('div', { className: 'sc-label' }, label),
      meta ? React.createElement('div', { className: 'sc-meta' }, meta) : null,
      spark ? React.createElement('div', { className: 'sc-spark' }, React.createElement(Sparkline, { data: spark.data, color: spark.color, down: spark.down })) : null,
    );
  }

  // ---------- Sparkline ----------
  function Sparkline({ data, color = '#059669', down = false, w = 120, h = 34 }) {
    if (!data || !Array.isArray(data) || data.length < 2) {
      return React.createElement('div', { style: { width: '100%', height: h, background: '#f3f4f6', borderRadius: 4 } });
    }
    const validData = data.filter(v => typeof v === 'number' && !isNaN(v) && isFinite(v));
    if (validData.length < 2) {
      return React.createElement('div', { style: { width: '100%', height: h, background: '#f3f4f6', borderRadius: 4 } });
    }
    const max = Math.max(...validData), min = Math.min(...validData);
    const rng = max - min || 1;
    const pts = validData.map((v, i) => [(i / (validData.length - 1)) * w, h - ((v - min) / rng) * (h - 4) - 2]);
    const d = pts.map((p, i) => (i ? 'L' : 'M') + p[0].toFixed(1) + ' ' + p[1].toFixed(1)).join(' ');
    const area = d + ` L${w} ${h} L0 ${h} Z`;
    const id = 'sp' + Math.random().toString(36).slice(2, 7);
    return React.createElement('svg', { width: '100%', height: h, viewBox: `0 0 ${w} ${h}`, preserveAspectRatio: 'none' },
      React.createElement('defs', null, React.createElement('linearGradient', { id, x1: 0, y1: 0, x2: 0, y2: 1 },
        React.createElement('stop', { offset: '0%', stopColor: color, stopOpacity: 0.22 }),
        React.createElement('stop', { offset: '100%', stopColor: color, stopOpacity: 0 }))),
      React.createElement('path', { d: area, fill: `url(#${id})` }),
      React.createElement('path', { d, fill: 'none', stroke: color, strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round' }),
    );
  }

  // ---------- AreaChart ----------
  function AreaChart({ data, color = '#065f46', height = 220 }) {
    const [hover, setHover] = useState(null);
    if (!data || !Array.isArray(data) || data.length < 2) {
      return React.createElement('div', { style: { width: '100%', height, background: '#f3f4f6', borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#9ca3af' } }, window.I18N ? I18N.t('chart.noData') : 'Pas de données');
    }
    const w = 760, h = height, pl = 8, pr = 8, pt = 16, pb = 26;
    const iw = w - pl - pr, ih = h - pt - pb;
    const validValues = data.map(d => {
      const v = d.value;
      return (typeof v === 'number' && !isNaN(v) && isFinite(v)) ? v : 0;
    });
    const max = Math.max(...validValues) * 1.1 || 1;
    const x = (i) => pl + (data.length > 1 ? (i / (data.length - 1)) * iw : iw / 2);
    const y = (v) => pt + ih - (v / max) * ih;
    const line = data.map((d, i) => {
      const v = (typeof d.value === 'number' && !isNaN(d.value) && isFinite(d.value)) ? d.value : 0;
      return (i ? 'L' : 'M') + x(i).toFixed(1) + ' ' + y(v).toFixed(1);
    }).join(' ');
    const area = line + ` L${x(data.length - 1)} ${pt + ih} L${x(0)} ${pt + ih} Z`;
    return React.createElement('div', { style: { position: 'relative', width: '100%' } },
      React.createElement('svg', { width: '100%', height: h, viewBox: `0 0 ${w} ${h}`, preserveAspectRatio: 'none',
        onMouseMove: (e) => { const r = e.currentTarget.getBoundingClientRect(); const rel = (e.clientX - r.left) / r.width * w; const i = Math.round((rel - pl) / iw * (data.length - 1)); setHover(Math.max(0, Math.min(data.length - 1, i))); },
        onMouseLeave: () => setHover(null) },
        React.createElement('defs', null, React.createElement('linearGradient', { id: 'agrad', x1: 0, y1: 0, x2: 0, y2: 1 },
          React.createElement('stop', { offset: '0%', stopColor: color, stopOpacity: 0.18 }),
          React.createElement('stop', { offset: '100%', stopColor: color, stopOpacity: 0 }))),
        [0.25, 0.5, 0.75, 1].map((g, i) => React.createElement('line', { key: i, x1: pl, x2: w - pr, y1: pt + ih * g, y2: pt + ih * g, stroke: '#eef2f7', strokeWidth: 1 })),
        React.createElement('path', { d: area, fill: 'url(#agrad)' }),
        React.createElement('path', { d: line, fill: 'none', stroke: color, strokeWidth: 2.4, strokeLinecap: 'round', strokeLinejoin: 'round' }),
        hover != null ? React.createElement('g', null,
          React.createElement('line', { x1: x(hover), x2: x(hover), y1: pt, y2: pt + ih, stroke: color, strokeWidth: 1, strokeDasharray: '3 3', opacity: 0.4 }),
          React.createElement('circle', { cx: x(hover), cy: y(data[hover].value), r: 4.5, fill: '#fff', stroke: color, strokeWidth: 2.5 })) : null,
      ),
      hover != null ? React.createElement('div', { style: { position: 'absolute', left: `${x(hover) / w * 100}%`, top: 6, transform: 'translateX(-50%)', background: '#0f172a', color: '#fff', padding: '6px 10px', borderRadius: 8, fontSize: 12, fontWeight: 700, whiteSpace: 'nowrap', pointerEvents: 'none' } },
        `J${data[hover].d} · ` + window.KD.fmtDA(data[hover].value)) : null,
    );
  }

  function BarChart({ data, color = '#065f46', height = 220 }) {
    const [hover, setHover] = useState(null);
    if (!data || !Array.isArray(data) || data.length === 0) {
      return React.createElement('div', { style: { width: '100%', height, background: '#f3f4f6', borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#9ca3af' } }, window.I18N ? I18N.t('chart.noData') : 'Pas de données');
    }
    const w = 760, h = height, pl = 8, pr = 8, pt = 16, pb = 26;
    const iw = w - pl - pr, ih = h - pt - pb;
    const validValues = data.map(d => (typeof d.value === 'number' && !isNaN(d.value) && isFinite(d.value)) ? d.value : 0);
    const max = Math.max(...validValues) * 1.1 || 1;
    const n = data.length;
    const slot = iw / n;
    const barW = Math.max(4, slot * 0.55);
    const x = (i) => pl + i * slot + (slot - barW) / 2;
    const y = (v) => pt + ih - (v / max) * ih;
    return React.createElement('div', { style: { position: 'relative', width: '100%' } },
      React.createElement('svg', { width: '100%', height: h, viewBox: `0 0 ${w} ${h}`, preserveAspectRatio: 'none',
        onMouseLeave: () => setHover(null) },
        [0.25, 0.5, 0.75, 1].map((g, i) => React.createElement('line', { key: i, x1: pl, x2: w - pr, y1: pt + ih * g, y2: pt + ih * g, stroke: '#eef2f7', strokeWidth: 1 })),
        data.map((d, i) => {
          const v = validValues[i];
          return React.createElement('rect', {
            key: i,
            x: x(i), y: y(v), width: barW, height: Math.max(0, pt + ih - y(v)),
            rx: 3, fill: color, opacity: hover === i ? 1 : 0.78,
            onMouseEnter: () => setHover(i),
          });
        }),
      ),
      hover != null ? React.createElement('div', { style: { position: 'absolute', left: `${(x(hover) + barW / 2) / w * 100}%`, top: 6, transform: 'translateX(-50%)', background: '#0f172a', color: '#fff', padding: '6px 10px', borderRadius: 8, fontSize: 12, fontWeight: 700, whiteSpace: 'nowrap', pointerEvents: 'none' } },
        `${data[hover].d} · ` + window.KD.fmtDA(data[hover].value)) : null,
    );
  }

  // ---------- PieChart ----------
  function PieChart({ data, height = 230 }) {
    const [hover, setHover] = useState(null);
    const COLORS = ['#047857', '#3b82f6', '#f59e0b', '#7c3aed', '#f43f5e', '#06b6d4', '#ec4899'];
    if (!data || !Array.isArray(data) || data.length === 0) {
      return React.createElement('div', { style: { width: '100%', height, background: '#f3f4f6', borderRadius: 4, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#9ca3af' } }, window.I18N ? I18N.t('chart.noData') : 'Pas de données');
    }
    const total = data.reduce((s, d) => s + (typeof d.value === 'number' && isFinite(d.value) ? d.value : 0), 0) || 1;
    const size = Math.min(height, 200);
    const cx = size / 2, cy = size / 2, r = size / 2 - 10, ri = r * 0.52;
    let angle = -Math.PI / 2;
    const slices = data.map((d, i) => {
      const val = typeof d.value === 'number' && isFinite(d.value) ? d.value : 0;
      const frac = val / total;
      const start = angle;
      const end = angle + frac * 2 * Math.PI;
      angle = end;
      return { label: d.d || d.label || '', val, frac, start, end, color: COLORS[i % COLORS.length] };
    });
    const arc = (s, e, outerR, innerR, expand) => {
      const mid = (s + e) / 2;
      const dx = expand ? Math.cos(mid) * 5 : 0, dy = expand ? Math.sin(mid) * 5 : 0;
      const cos1 = Math.cos(s), sin1 = Math.sin(s);
      const cos2 = Math.cos(e), sin2 = Math.sin(e);
      const large = (e - s) > Math.PI ? 1 : 0;
      return [
        `M ${cx + dx + outerR * cos1} ${cy + dy + outerR * sin1}`,
        `A ${outerR} ${outerR} 0 ${large} 1 ${cx + dx + outerR * cos2} ${cy + dy + outerR * sin2}`,
        `L ${cx + dx + innerR * cos2} ${cy + dy + innerR * sin2}`,
        `A ${innerR} ${innerR} 0 ${large} 0 ${cx + dx + innerR * cos1} ${cy + dy + innerR * sin1}`,
        'Z',
      ].join(' ');
    };
    return React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: 20, width: '100%' } },
      React.createElement('svg', { width: size, height: size, style: { flexShrink: 0 } },
        slices.map((sl, i) =>
          React.createElement('path', {
            key: i,
            d: arc(sl.start, sl.end, r, ri, hover === i),
            fill: sl.color,
            opacity: hover != null && hover !== i ? 0.65 : 1,
            style: { cursor: 'pointer', transition: 'opacity .15s' },
            onMouseEnter: () => setHover(i),
            onMouseLeave: () => setHover(null),
          })
        ),
        React.createElement('circle', { cx, cy, r: ri - 2, fill: '#fff' }),
        hover != null
          ? React.createElement('text', { x: cx, y: cy - 8, textAnchor: 'middle', fontSize: 12, fontWeight: 700, fill: '#0f172a' }, window.KD ? window.KD.fmtNum(slices[hover].val) : slices[hover].val)
          : null,
        hover != null
          ? React.createElement('text', { x: cx, y: cy + 10, textAnchor: 'middle', fontSize: 10, fill: '#64748b' }, `${(slices[hover].frac * 100).toFixed(1)}%`)
          : null,
      ),
      React.createElement('div', { style: { flex: 1, display: 'flex', flexDirection: 'column', gap: 8 } },
        slices.map((sl, i) =>
          React.createElement('div', { key: i, style: { display: 'flex', alignItems: 'center', gap: 8, cursor: 'default', opacity: hover != null && hover !== i ? 0.55 : 1 },
            onMouseEnter: () => setHover(i), onMouseLeave: () => setHover(null) },
            React.createElement('div', { style: { width: 10, height: 10, borderRadius: 99, background: sl.color, flexShrink: 0 } }),
            React.createElement('div', { style: { flex: 1, fontSize: 12.5, fontWeight: 600, color: 'var(--muted)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, sl.label),
            React.createElement('div', { style: { fontSize: 12, fontWeight: 700, color: 'var(--ink)', flexShrink: 0 } },
              window.KD ? window.KD.fmtNum(sl.val) : sl.val),
          )
        )
      )
    );
  }

  // ---------- Modal ----------
  function Modal({ title, desc, icon, onClose, children, footer, wide }) {
    useEffect(() => {
      const fn = (e) => { if (e.key === 'Escape') onClose && onClose(); };
      window.addEventListener('keydown', fn); return () => window.removeEventListener('keydown', fn);
    }, []);
    return React.createElement('div', { className: 'modal-overlay', onMouseDown: (e) => { if (e.target === e.currentTarget) onClose && onClose(); } },
      React.createElement('div', { className: `modal ${wide ? 'wide' : ''}` },
        React.createElement('div', { className: 'modal-head' },
          icon ? React.createElement('div', { className: 'sc-ico ' + (icon.color || 'green'), style: { width: 40, height: 40, borderRadius: 11 } }, React.createElement(Icon, { name: icon.name, size: 20 })) : null,
          React.createElement('div', { className: 'mh-txt' },
            React.createElement('h3', null, title),
            desc ? React.createElement('p', null, desc) : null),
          React.createElement('button', { className: 'modal-x', onClick: onClose }, React.createElement(Icon, { name: 'x', size: 18 }))),
        children ? React.createElement('div', { className: 'modal-body' }, children) : null,
        footer ? React.createElement('div', { className: 'modal-foot' }, footer) : null,
      ));
  }

  // ---------- Toast ----------
  const ToastCtx = React.createContext(null);
  function ToastProvider({ children }) {
    const [toasts, setToasts] = useState([]);
    const push = (t) => {
      const id = Math.random().toString(36).slice(2);
      setToasts((s) => [...s, { id, ...t }]);
      setTimeout(() => setToasts((s) => s.filter((x) => x.id !== id)), t.duration || 4000);
    };
    const remove = (id) => setToasts((s) => s.filter((x) => x.id !== id));
    return React.createElement(ToastCtx.Provider, { value: push },
      children,
      React.createElement('div', { className: 'toast-stack' },
        toasts.map((t) => React.createElement('div', { key: t.id, className: 'toast ' + (t.variant || 'success') },
          React.createElement('div', { className: 't-ico' }, React.createElement(Icon, { name: t.variant === 'error' ? 'x' : t.variant === 'info' ? 'info' : 'check', size: 18, stroke: 2.5 })),
          React.createElement('div', { className: 't-txt' },
            React.createElement('div', { className: 'tt' }, t.title),
            t.desc ? React.createElement('div', { className: 'td' }, t.desc) : null),
          React.createElement('button', { className: 't-x', onClick: () => remove(t.id) }, React.createElement(Icon, { name: 'x', size: 15 }))))),
    );
  }
  const useToast = () => React.useContext(ToastCtx);

  // ---------- Vehicle photo placeholder ----------
  function VehiclePhoto({ vehicle, w = '100%', h = 48, radius = 10, tint, grayscale, className = '', label }) {
    const t = tint != null ? tint : (vehicle ? vehicle.tint : 0);
    const photos = vehicle && vehicle.photos || [];
    const cover = photos.find((p) => p.isCover) || photos[0];
    const photoUrl = cover ? window.API.resolveUrl(cover.url) : null;
    if (photoUrl) {
      return React.createElement('div', { className: `vthumb tint-${t} ${className}`, style: { width: w, height: h, borderRadius: radius, padding: 0, overflow: 'hidden' } },
        React.createElement('img', { src: photoUrl, alt: '', style: { width: '100%', height: '100%', objectFit: 'cover', display: 'block', filter: grayscale ? 'grayscale(1)' : 'none' } }),
        label ? React.createElement('div', { style: { position: 'absolute', bottom: 8, left: 10, fontSize: 11, fontWeight: 700, opacity: 0.85, color: '#fff', textShadow: '0 1px 3px rgba(0,0,0,.6)' } }, label) : null,
      );
    }
    return React.createElement('div', { className: `vthumb tint-${t} ${className}`, style: { width: w, height: h, borderRadius: radius, filter: grayscale ? 'grayscale(1)' : 'none' } },
      React.createElement(Icon, { name: 'car', size: Math.min(typeof h === 'number' ? h * 0.5 : 26, 64), stroke: 1.6 }),
      label ? React.createElement('div', { style: { position: 'absolute', bottom: 8, left: 10, fontSize: 11, fontWeight: 700, opacity: 0.85 } }, label) : null,
    );
  }

  // ---------- Skeleton ----------
  function Sk({ w = '100%', h = 14, r = 8, style }) {
    return React.createElement('div', { className: 'sk', style: { width: w, height: h, borderRadius: r, ...style } });
  }

  // ---------- Empty / Error states ----------
  function EmptyState({ variant = 'empty', icon, title, desc, action }) {
    return React.createElement('div', { className: 'estate ' + variant },
      React.createElement('div', { className: 'e-ico' }, React.createElement(Icon, { name: icon || (variant === 'err' ? 'alert' : variant === 'ok' ? 'check' : 'inbox'), size: 34 })),
      React.createElement('h3', null, title),
      desc ? React.createElement('p', null, desc) : null,
      action || null);
  }

  // ---------- Dropdown menu ----------
  //
  // Le menu est rendu dans un PORTAIL sur `document.body`, en `position: fixed`, aux
  // coordonnées du déclencheur.
  //
  // Pourquoi : il était `position: absolute` à l'intérieur du tableau, et `.tbl-wrap` porte
  // `overflow-x: auto`. Or, d'après la spec CSS, `overflow-y` ne peut pas rester `visible` quand
  // l'autre axe ne l'est pas — il devient `auto`, et le conteneur CLIPPE donc aussi
  // verticalement. Aucun `z-index` ne s'échappe d'un clip d'overflow : sur les dernières lignes
  // d'un tableau, on ne voyait plus que la bordure haute du menu. `.page { overflow: auto }` et
  // l'en-tête collant (`z-index: 2`) aggravaient encore le cas.
  //
  // C'est exactement le diagnostic déjà posé pour `ReactionBar` ci-dessous, et la même solution :
  // sortir du flux par un portail est le SEUL moyen fiable d'échapper au clip d'un ancêtre.
  function Dropdown({ trigger, items, align = 'right' }) {
    const [open, setOpen] = useState(false);
    const [pos, setPos] = useState(null);
    const anchorRef = useRef();
    const menuRef = useRef();
    const list = (items || []).filter(Boolean);

    useEffect(() => {
      if (!open) { setPos(null); return; }

      const place = () => {
        const el = anchorRef.current;
        if (!el) return;
        const r = el.getBoundingClientRect();
        const MENU_W = 188;
        // Hauteur réelle si le menu est déjà monté, estimation sinon.
        const h = (menuRef.current && menuRef.current.offsetHeight)
          || (list.length * 38 + 12);
        // Aligné sur le bord demandé du déclencheur, puis borné au viewport : près du bord
        // droit de l'écran, un menu aligné à droite débordait.
        let left = align === 'left' ? r.left : r.right - MENU_W;
        left = Math.min(Math.max(8, left), window.innerWidth - MENU_W - 8);
        // Sous le déclencheur, ou au-dessus s'il n'y a plus la place en bas — le cas des
        // dernières lignes d'un long tableau.
        let top = r.bottom + 6;
        if (top + h > window.innerHeight - 8) top = r.top - h - 6;
        // Puis borné au viewport dans les DEUX sens : sur un menu haut face à un déclencheur
        // proche d'un bord, le simple retournement ne suffit pas à le faire tenir.
        top = Math.min(Math.max(8, top), Math.max(8, window.innerHeight - h - 8));
        setPos((prev) => (prev && prev.left === left && prev.top === top ? prev : { left, top }));
      };
      place();

      const onDown = (e) => {
        if (menuRef.current && menuRef.current.contains(e.target)) return;
        if (anchorRef.current && anchorRef.current.contains(e.target)) return;
        setOpen(false);
      };
      const onKey = (e) => { if (e.key === 'Escape') setOpen(false); };
      // `fixed` ne suit pas un ancêtre qui défile : on ferme plutôt que de laisser dériver.
      const onMove = () => setOpen(false);
      document.addEventListener('mousedown', onDown);
      document.addEventListener('keydown', onKey);
      window.addEventListener('resize', onMove);
      window.addEventListener('scroll', onMove, true);
      return () => {
        document.removeEventListener('mousedown', onDown);
        document.removeEventListener('keydown', onKey);
        window.removeEventListener('resize', onMove);
        window.removeEventListener('scroll', onMove, true);
      };
    }, [open, align, list.length]);

    // Le menu monté, on repositionne une fois avec sa hauteur RÉELLE : l'estimation ci-dessus
    // suffit à décider haut/bas, pas à caler un menu au pixel près en bas d'écran.
    useEffect(() => {
      if (!open || !pos || !menuRef.current) return;
      const h = menuRef.current.offsetHeight;
      if (pos.top + h > window.innerHeight - 8) {
        const el = anchorRef.current;
        if (!el) return;
        const r = el.getBoundingClientRect();
        let top = r.top - h - 6;
        top = Math.min(Math.max(8, top), Math.max(8, window.innerHeight - h - 8));
        if (top !== pos.top) setPos({ left: pos.left, top });
      }
    }, [open, pos]);

    return React.createElement(React.Fragment, null,
      React.createElement('div', {
        ref: anchorRef,
        style: { display: 'inline-flex' },
        onClick: (e) => { e.stopPropagation(); setOpen((o) => !o); },
      }, trigger),
      open && pos ? ReactDOM.createPortal(
        React.createElement('div', {
          ref: menuRef,
          role: 'menu',
          // `stopPropagation` : le menu vit sur document.body, donc hors de la ligne de
          // tableau ; sans ça, un clic dedans ne déclencherait pas le onClick de la ligne
          // (c'est voulu) mais un clic sur le fond du menu remonterait au body.
          onClick: (e) => e.stopPropagation(),
          style: {
            position: 'fixed', left: pos.left, top: pos.top,
            background: 'var(--surface)', border: '1px solid var(--line)',
            borderRadius: 12, boxShadow: 'var(--sh-lg)', padding: 6,
            minWidth: 188, zIndex: 3000,
          },
        },
          list.map((it, i) => it.sep
            ? React.createElement('div', { key: i, style: { height: 1, background: 'var(--line)', margin: '5px 4px' } })
            : React.createElement('button', {
                key: i,
                type: 'button',
                onClick: (e) => { e.stopPropagation(); setOpen(false); it.onClick && it.onClick(); },
                style: { display: 'flex', alignItems: 'center', gap: 10, width: '100%', padding: '9px 11px', border: 'none', background: 'transparent', borderRadius: 8, fontSize: 13.5, fontWeight: 600, color: it.danger ? 'var(--rose)' : 'var(--ink-2)', textAlign: 'left', cursor: 'pointer' },
                onMouseEnter: (e) => e.currentTarget.style.background = it.danger ? 'var(--rose-bg)' : 'var(--slate-100)',
                onMouseLeave: (e) => e.currentTarget.style.background = 'transparent',
              },
              it.icon ? React.createElement(Icon, { name: it.icon, size: 16 }) : null, it.label))),
        document.body,
      ) : null,
    );
  }

  // ---------- ReactionBar — WhatsApp-style emoji picker ----------
  //
  // Purpose-built rather than reusing Dropdown, which was wrong here on five counts:
  //  1. Dropdown renders a VERTICAL 188px-wide menu; we want a compact horizontal row.
  //  2. It positions itself `absolute` inside `.bubble-actions`, which is anchored to the
  //     full-width `.bubble-row` — so on an incoming message the picker opened at the far
  //     right of the panel, nowhere near the bubble.
  //  3. `.thread-body` has `overflow-y: auto`, and per the CSS spec overflow-x cannot stay
  //     `visible` when the other axis isn't — so it becomes `auto` and CLIPS the menu. No
  //     z-index escapes an overflow clip; this is why the picker looked "hidden".
  //  4. `.bubble-actions` is `opacity:0; pointer-events:none` except on `.bubble-row:hover`,
  //     so the menu died as soon as the cursor left the row — which it had to, to reach a
  //     menu overflowing outside the row.
  //  5. The CSS meant to enlarge the emoji (`.bubble-actions .dropdown-menu button`) never
  //     matched: Dropdown emits no className at all.
  //
  // Fix: render in a PORTAL on document.body with fixed coordinates derived from the
  // trigger's rect. That is the only reliable way out of an ancestor's overflow clip.
  function ReactionBar({ emojis, active, onPick, anchorRef, open, onClose }) {
    const [pos, setPos] = useState(null);
    const barRef = useRef();

    // Callers pass inline arrows, so `onClose` has a new identity on every render. Keeping it
    // in the effect's dependency list would re-run the effect each render — and since the
    // effect calls setPos() with a fresh object, that is an infinite render loop. Route it
    // through a ref instead: the effect depends only on values that actually change.
    const onCloseRef = useRef(onClose);
    onCloseRef.current = onClose;
    const close = () => onCloseRef.current && onCloseRef.current();

    useEffect(() => {
      if (!open) { setPos(null); return; }

      const place = () => {
        const el = anchorRef && anchorRef.current;
        if (!el) return;
        const r = el.getBoundingClientRect();
        const BAR_W = emojis.length * 42 + 12;
        const BAR_H = 46;
        // Just under the bubble, aligned on its left edge, clamped to the viewport so it is
        // never cut off near the panel edges.
        const left = Math.min(Math.max(8, r.left), window.innerWidth - BAR_W - 8);
        let top = r.bottom + 6;
        if (top + BAR_H > window.innerHeight - 8) top = Math.max(8, r.top - BAR_H - 6);
        // Only update on a real move, so re-renders never feed themselves.
        setPos((prev) => (prev && prev.left === left && prev.top === top ? prev : { left, top }));
      };
      place();

      const onDown = (e) => {
        if (barRef.current && barRef.current.contains(e.target)) return;
        if (anchorRef && anchorRef.current && anchorRef.current.contains(e.target)) return;
        close();
      };
      const onKey = (e) => { if (e.key === 'Escape') close(); };
      const onMove = () => close();
      // Fixed positioning does not follow a scrolling ancestor, so close instead of drifting.
      document.addEventListener('mousedown', onDown);
      document.addEventListener('keydown', onKey);
      window.addEventListener('resize', onMove);
      window.addEventListener('scroll', onMove, true);
      return () => {
        document.removeEventListener('mousedown', onDown);
        document.removeEventListener('keydown', onKey);
        window.removeEventListener('resize', onMove);
        window.removeEventListener('scroll', onMove, true);
      };
    }, [open, anchorRef, emojis.length]);

    if (!open || !pos) return null;
    return ReactDOM.createPortal(
      React.createElement('div', {
        ref: barRef,
        className: 'reaction-bar',
        role: 'menu',
        style: { position: 'fixed', left: pos.left, top: pos.top, zIndex: 3000 },
      },
        // Emoji come from the server-side closed set (ALLOWED_REACTIONS) and are rendered as
        // text children — never innerHTML.
        emojis.map((e) => React.createElement('button', {
          key: e,
          type: 'button',
          className: 'reaction-bar-btn' + (active && active.indexOf(e) >= 0 ? ' on' : ''),
          title: window.I18N ? I18N.t(active && active.indexOf(e) >= 0 ? 'chat.removeReaction' : 'chat.react')
            : (active && active.indexOf(e) >= 0 ? 'Retirer ma réaction' : 'Réagir'),
          onClick: (ev) => { ev.stopPropagation(); close(); onPick(e); },
        }, e))),
      document.body,
    );
  }

  // ---------- ChatBubble — one message row, shared by every conversation screen ----------
  //
  // Lives here so BookingDetail (lessor2) and the admin support desk (admin2) render
  // identically. The lessor's own emergency-thread screen was removed in 0.9.6 (mobile now
  // owns that flow end to end) — this component no longer has a third consumer.
  //
  // `msg`: { id, from: 'me'|'them', text, location, attachment, replyTo, reactions, t }
  /** Aperçu (pictogramme, texte) du message cité, à partir de la copie DÉCHIFFRÉE locale.
   *
   *  Sur un fil chiffré le serveur ne peut RIEN prévisualiser — `quotePreview` renvoie null
   *  par construction, puisqu'il ne sait pas lire le message. La citation n'affichait donc
   *  qu'une étiquette au-dessus d'un cadre vide. Le fil local, lui, est déjà ouvert : c'est
   *  la seule source possible. Le média prime sur le texte, comme dans WhatsApp ou Telegram.
   */
  function quotePreview(t, replyTo, source) {
    if (!source) {
      const p = (replyTo && replyTo.preview) || '';
      return { icon: null, text: p || t('chat.quoteEncrypted') };
    }
    const att = source.attachment;
    if (att) {
      if (att.isAudio) return { icon: 'mic', text: t('chat.quoteVoice') };
      if (att.isImage) {
        const caption = (source.text || '').trim();
        return {
          icon: 'image',
          text: caption || t(att.groupId ? 'chat.quoteAlbum' : 'chat.quotePhoto'),
        };
      }
      return { icon: 'clipboard', text: att.name || t('chat.quoteFile') };
    }
    if (source.location) return { icon: 'pin', text: t('chat.quoteLocation') };
    if (source.undecryptable) return { icon: 'shield', text: t('chat.quoteEncrypted') };
    const body = (source.text || '').trim();
    if (body) return { icon: null, text: body };
    return { icon: null, text: (replyTo && replyTo.preview) || t('chat.quoteEncrypted') };
  }

  function ChatBubble({ msg, peerLabel = 'Client', onReply, onReact, quotedSource = null }) {
    const t = window.I18N ? I18N.useT() : ((k) => k);
    const [pickerOpen, setPickerOpen] = useState(false);
    const wrapRef = useRef();
    const m = msg;
    const mine = (m.reactions || []).filter((r) => r.mine).map((r) => r.emoji);

    return React.createElement('div', { className: `bubble-row ${m.from}` },
      React.createElement('div', { className: 'bubble-wrap', ref: wrapRef },
        m.replyTo ? (() => {
          const q = quotePreview(t, m.replyTo, quotedSource);
          return React.createElement('div', { className: 'bubble-quote' },
            React.createElement('div', { className: 'bq-body' },
              React.createElement('div', { className: 'bq-author' },
                m.replyTo.senderRole === 'lessor' ? t('chat.quoteYou') : peerLabel),
              React.createElement('div', { className: 'bq-text' },
                q.icon ? React.createElement(Icon, { name: q.icon, size: 13, className: 'bq-icon' }) : null,
                React.createElement('span', null, q.text))));
        })() : null,

        // An encrypted message this browser holds no key for. Shown explicitly rather than
        // as an empty bubble, so the gap is never mistaken for a message that was not sent.
        m.undecryptable
          ? React.createElement('div', { className: 'bubble', style: { fontStyle: 'italic', opacity: 0.75, display: 'flex', gap: 7, alignItems: 'center' } },
              React.createElement(Icon, { name: 'shield', size: 14 }),
              t('chat.encrypted'))
          : m.location
            ? React.createElement('div', { className: 'bubble' },
                React.createElement(LocationBubble, { location: m.location, label: t('chat.sharedLocation') }))
            : (m.text ? React.createElement('div', { className: 'bubble' }, m.text) : null),

        // `attachments` (plural) is set by groupAlbums when several photos were sent as one
        // message. ChatAlbum/ChatAttachment are bare globals from shell.jsx, resolved at
        // RENDER time — do not destructure them at module scope, shell.jsx loads after this.
        m.attachments && m.attachments.length > 1
          ? React.createElement(ChatAlbum, { attachments: m.attachments })
          : (m.attachment ? React.createElement(ChatAttachment, { attachment: m.attachment }) : null),

        m.reactions && m.reactions.length > 0
          ? React.createElement('div', { className: 'bubble-reactions' },
              m.reactions.map((r) => React.createElement('button', {
                key: r.emoji,
                className: `reaction ${r.mine ? 'mine' : ''}`,
                onClick: () => onReact && onReact(m, r.emoji),
                title: t(r.mine ? 'chat.removeReaction' : 'chat.react'),
              }, React.createElement('span', null, r.emoji),
                 r.count > 1 ? React.createElement('b', null, r.count) : null)))
          : null,

        React.createElement('div', { className: 'bubble-time' }, m.t),

        m.id ? React.createElement('div',
          // `.open` keeps the actions visible while the picker is up: the row's :hover alone
          // would hide them the moment the cursor moved down to the emoji bar.
          { className: 'bubble-actions' + (pickerOpen ? ' open' : '') },
          onReply ? React.createElement('button', {
            title: t('chat.reply'), type: 'button', onClick: () => onReply(m),
          }, React.createElement(Icon, { name: 'reply', size: 14 })) : null,
          onReact ? React.createElement('button', {
            title: t('chat.react'), type: 'button',
            onClick: (e) => { e.stopPropagation(); setPickerOpen((v) => !v); },
          }, React.createElement(Icon, { name: 'smile', size: 14 })) : null,
        ) : null,
      ),
      onReact ? React.createElement(ReactionBar, {
        emojis: API.support.REACTIONS,
        active: mine,
        anchorRef: wrapRef,
        open: pickerOpen,
        onClose: () => setPickerOpen(false),
        onPick: (emoji) => onReact(m, emoji),
      }) : null,
    );
  }

  // ─── WilayaMap — Leaflet + CartoDB Positron ────────────────────────────────
  const WILAYA_LATLON = {
    'Adrar':[27.87,-0.29],'Chlef':[36.17,1.33],'Laghouat':[33.80,2.86],
    'Oum El Bouaghi':[35.87,7.11],'Batna':[35.55,6.17],'Béjaïa':[36.75,5.07],
    'Biskra':[34.85,5.73],'Béchar':[31.62,-2.22],'Blida':[36.47,2.83],
    'Bouira':[36.37,3.90],'Tamanrasset':[22.78,5.52],'Tébessa':[35.40,8.12],
    'Tlemcen':[34.88,-1.32],'Tiaret':[35.37,1.33],'Tizi Ouzou':[36.72,4.05],
    'Alger':[36.74,3.06],'Djelfa':[34.67,3.25],'Jijel':[36.82,5.77],
    'Sétif':[36.19,5.41],'Saïda':[34.83,0.15],'Skikda':[36.88,6.91],
    'Sidi Bel Abbès':[35.20,-0.63],'Annaba':[36.90,7.77],'Guelma':[36.46,7.43],
    'Constantine':[36.37,6.61],'Médéa':[36.27,2.75],'Mostaganem':[35.93,0.09],
    "M'Sila":[35.70,4.54],'Mascara':[35.40,0.14],'Ouargla':[31.95,5.33],
    'Oran':[35.70,-0.63],'El Bayadh':[33.68,1.02],'Illizi':[26.48,8.48],
    'Bordj Bou Arréridj':[36.07,4.76],'Boumerdès':[36.76,3.48],'El Tarf':[36.77,8.31],
    'Tindouf':[27.67,-8.14],'Tissemsilt':[35.60,1.81],'El Oued':[33.37,6.86],
    'Khenchela':[35.43,7.14],'Souk Ahras':[36.28,7.95],'Tipaza':[36.59,2.45],
    'Mila':[36.45,6.26],'Aïn Defla':[36.27,1.97],"Naâma":[33.27,-0.32],
    'Aïn Témouchent':[35.30,-1.14],'Ghardaïa':[32.49,3.67],'Relizane':[35.73,0.56],
    // Wilayas created in 2019 — without these the dashboard map silently dropped them.
    'Timimoun':[29.26,0.23],'Bordj Badji Mokhtar':[21.33,0.95],'Ouled Djellal':[34.42,5.06],
    'Béni Abbès':[30.13,-2.17],'In Salah':[27.19,2.48],'In Guezzam':[19.57,5.77],
    'Touggourt':[33.11,6.06],'Djanet':[24.55,9.48],"El M'Ghair":[33.95,5.92],
    'El Meniaa':[30.58,2.88],
  };

  function WilayaMap({ data = [], height = 360, onWilayaClick }) {
    const containerRef = useRef(null);
    const mapRef       = useRef(null);
    const markersRef   = useRef([]);

    // init map once
    useEffect(() => {
      if (!window.L || !containerRef.current || mapRef.current) return;
      mapRef.current = window.L.map(containerRef.current, {
        center: [36.5, 3.0],
        zoom: 5,
        scrollWheelZoom: false,
        zoomControl: true,
        attributionControl: true,
      });
      // Tuile OSM standard, sans clé — CARTO (gratuit, sans clé lui aussi jusqu'ici) répond
      // maintenant 200 avec un filigrane « API key required » au lieu d'une tuile, d'où le
      // fond blanc. Même remède que côté mobile (kerya_tile_layer.dart), en amont depuis hier.
      // Le mode sombre n'est plus une seconde URL (OSM n'a qu'un style) mais un filtre CSS sur
      // .leaflet-tile-pane (styles.css), piloté par [data-theme] donc réactif en direct.
      window.L.tileLayer(
        'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
        { attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', maxZoom: 18 }
      ).addTo(mapRef.current);
      return () => { mapRef.current.remove(); mapRef.current = null; };
    }, []);

    // update markers when data changes
    useEffect(() => {
      if (!mapRef.current) return;
      markersRef.current.forEach(m => m.remove());
      markersRef.current = [];
      if (!data.length) return;

      const maxRev = Math.max(...data.map(d => d.totalRevenue || 0), 1);
      const fmt = n => n >= 1e6 ? (n/1e6).toFixed(1)+'M DA' : n >= 1000 ? (n/1000).toFixed(0)+'k DA' : n+' DA';

      const latLngs = [];
      data.forEach(d => {
        const latlng = WILAYA_LATLON[d.wilaya];
        if (!latlng) return;
        const r = Math.max(10, Math.min(40, Math.sqrt((d.totalRevenue || 0) / maxRev) * 36));
        const circle = window.L.circleMarker(latlng, {
          radius: r, fillColor: '#059669', color: '#fff',
          weight: 2, opacity: 1, fillOpacity: 0.82,
        });
        circle.bindPopup(
          `<div style="font-family:system-ui;min-width:140px;padding:2px 0">
            <div style="font-weight:700;font-size:14px;margin-bottom:3px">${d.wilaya}</div>
            <div style="color:#6b7280;font-size:12px">${d.lessorCount} loueur${d.lessorCount>1?'s':''}</div>
            <div style="font-weight:600;color:#059669;font-size:13px;margin-top:2px">${fmt(d.totalRevenue)}</div>
          </div>`,
          { maxWidth: 200 }
        );
        if (onWilayaClick) circle.on('click', () => onWilayaClick(d));
        circle.addTo(mapRef.current);
        markersRef.current.push(circle);
        latLngs.push(latlng);
      });
      // Fit the map to show all markers with activity
      if (latLngs.length > 0) {
        mapRef.current.fitBounds(latLngs, { padding: [30, 30], maxZoom: 8 });
      }
    }, [data]);

    return React.createElement('div', { style: { position: 'relative' } },
      React.createElement('div', { ref: containerRef, style: { width: '100%', height, borderRadius: 10, overflow: 'hidden' } }),
      React.createElement('div', { style: { display: 'flex', gap: 16, marginTop: 8, fontSize: 11, color: 'var(--muted)' } },
        React.createElement('span', { style: { display: 'flex', alignItems: 'center', gap: 5 } },
          React.createElement('svg', { width: 10, height: 10 }, React.createElement('circle', { cx: 5, cy: 5, r: 5, fill: '#059669', opacity: 0.82 })),
          'Taille = Chiffre d\'affaires')));
  }

  // ─── MapView — generic Leaflet map with labelled point markers ─────────────
  // Used for booking logistics (pickup / return locations). Plain OSM tiles, no key (CARTO's
  // anonymous free tier now returns a watermarked placeholder instead of a real tile).
  // `points`: [{ lat, lng, label, color }]. Auto-fits to show all valid points.
  function MapView({ points = [], height = 240 }) {
    const containerRef = useRef(null);
    const mapRef = useRef(null);
    const markersRef = useRef([]);

    useEffect(() => {
      if (!window.L || !containerRef.current || mapRef.current) return;
      mapRef.current = window.L.map(containerRef.current, {
        center: [36.5, 3.0], zoom: 6, scrollWheelZoom: false,
        zoomControl: true, attributionControl: true,
      });
      // Voir WilayaMap ci-dessus pour le contexte du changement CARTO → OSM.
      window.L.tileLayer(
        'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
        { attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors', maxZoom: 18 }
      ).addTo(mapRef.current);
      return () => { mapRef.current.remove(); mapRef.current = null; };
    }, []);

    useEffect(() => {
      if (!mapRef.current) return;
      markersRef.current.forEach(m => m.remove());
      markersRef.current = [];
      const valid = (points || []).filter(p => p && p.lat != null && p.lng != null && !isNaN(p.lat) && !isNaN(p.lng));
      if (!valid.length) return;
      const latLngs = [];
      valid.forEach(p => {
        const color = p.color || '#059669';
        const marker = window.L.circleMarker([p.lat, p.lng], {
          radius: 10, fillColor: color, color: '#fff', weight: 2, opacity: 1, fillOpacity: 0.9,
        });
        // SECURITY: labels carry user-controlled text (pickupLocation, message bodies).
        // bindPopup parses HTML, so build the node instead of interpolating a string.
        if (p.label) {
          const el = document.createElement('div');
          el.style.cssText = 'font-family:system-ui;font-weight:600;font-size:13px;padding:2px 0';
          el.textContent = String(p.label);
          marker.bindPopup(el, { maxWidth: 220 });
        }
        marker.addTo(mapRef.current);
        markersRef.current.push(marker);
        latLngs.push([p.lat, p.lng]);
      });
      if (latLngs.length === 1) {
        mapRef.current.setView(latLngs[0], 13);
      } else {
        mapRef.current.fitBounds(latLngs, { padding: [40, 40], maxZoom: 13 });
      }
    }, [JSON.stringify(points)]);

    return React.createElement('div', { ref: containerRef, style: { width: '100%', height, borderRadius: 10, overflow: 'hidden' } });
  }

  // ─── Shared location in a chat message ─────────────────────────────────────
  // The mobile app lets a renter share their live GPS position in a conversation.
  // The API returns it as `location: {lat, lng}` (support message serializer), but
  // older messages only carry a maps URL inside the body — hence the parse fallback.
  // Without this, the lessor just saw a raw URL as plain text.

  // Extract lat/lng from a maps URL pasted in a message body, or null.
  function parseBodyLocation(body) {
    if (!body) return null;
    const s = String(body);
    if (!/google\.com\/maps|maps\.apple\.com|geo:/i.test(s)) return null;
    const m = s.match(/(-?\d{1,3}\.\d{3,}),\s*(-?\d{1,3}\.\d{3,})/);
    if (!m) return null;
    const lat = Number(m[1]), lng = Number(m[2]);
    if (isNaN(lat) || isNaN(lng) || Math.abs(lat) > 90 || Math.abs(lng) > 180) return null;
    return { lat, lng };
  }

  // Structured location first, maps-URL fallback second.
  function messageLocation(m) {
    if (!m) return null;
    const loc = m.location;
    if (loc && loc.lat != null && loc.lng != null) {
      const lat = Number(loc.lat), lng = Number(loc.lng);
      if (!isNaN(lat) && !isNaN(lng)) return { lat, lng };
    }
    return parseBodyLocation(m.text || m.body);
  }

  // Mini map + "open in Google Maps" link, rendered in place of the raw URL.
  //
  // LARGEUR : `width` FLUIDE, plafonnée — et surtout pas les 240px fixes d'origine.
  // La bulle vit dans `.bubble` (max-width 72%), elle-même dans la colonne FIXE de 360px de
  // `.detail-grid` (BookingDetail) : le contenu disponible y vaut ~207px. Une largeur imposée
  // de 240px débordait donc systématiquement — rognée à droite par l'`overflow-x:hidden` de
  // `.thread-body`, et visiblement hors du cadre gris à gauche.
  // `minWidth: 0` est indispensable : sans lui, un enfant flex/grid refuse de passer sous sa
  // taille de contenu et le plafond ne sert à rien.
  function LocationBubble({ location, label }) {
    const t = window.I18N ? I18N.useT() : ((k) => k);
    if (!location) return null;
    const href = `https://www.google.com/maps/search/?api=1&query=${location.lat},${location.lng}`;
    return React.createElement('div', { style: { width: '100%', maxWidth: 240, minWidth: 0 } },
      React.createElement(MapView, {
        height: 140,
        points: [{ lat: location.lat, lng: location.lng, label: label || t('chat.sharedLocation'), color: '#e11d48' }],
      }),
      React.createElement('a', {
        href, target: '_blank', rel: 'noopener noreferrer',
        style: {
          display: 'inline-flex', alignItems: 'center', gap: 6, marginTop: 6,
          fontSize: 12.5, fontWeight: 700,
          // Le libellé est plus long que la bulle sur mobile : autoriser la coupe plutôt que
          // de repousser la largeur du parent.
          maxWidth: '100%', overflowWrap: 'anywhere',
        },
      },
        React.createElement(Icon, { name: 'pin', size: 14 }),
        t('chat.openInMaps')),
    );
  }

  /* ── Typing indicator ──────────────────────────────────────────────────────
     "Typing" means THE COMPOSER CONTAINS TEXT — not "a key was pressed recently".

     The old logic sent a single `typing:true`, then a `false` after 2 s of keyboard idle,
     while the receiver expired the bubble after 4 s. Composing a long message therefore made
     the bubble disappear mid-sentence, which is exactly what was reported.

     Now: one `true` immediately, a heartbeat every 3 s for as long as there IS text, and an
     immediate `false` when the field empties / the message is sent / the conversation changes
     / the tab is hidden. The receiver waits TYPING_EXPIRY_MS (8 s = over two heartbeats) so a
     single dropped ping never blinks the bubble. Server allows 20 pings / 10 s per socket —
     one every 3 s leaves plenty of headroom.

     Shared by the lessor booking thread, the lessor SOS inbox and the support desk: three
     copies of a timing rule is three chances to get it subtly different. */
  /* ── Albums ────────────────────────────────────────────────────────────────
     Several photos sent at once are stored as one ROW PER PHOTO sharing
     `attachment.groupId` — so each file keeps its own sealed blob and its own download
     route. This merges them back into a single bubble carrying `attachments[]`.

     The rules are IDENTICAL to `groupAlbums` in kerya_dz/lib/features/booking/presentation/
     widgets/chat_album.dart, and pinned by test/chat_album_test.dart there:
       1. walk the thread in its existing chronological order;
       2. a row joins the current group only if it has the same non-null groupId, the same
          sender AND is contiguous — a foreign row in between splits the album into two
          bubbles, which is what makes a spliced-in tile visible rather than blended into a
          legitimate mosaic;
       3. sort within a group by groupIndex, tie-break on time then id. Never on time alone:
          one multi-row INSERT writes the whole album, so every row can share a millisecond.

     Input/output are the SCREEN's bubble shape ({ id, from, text, attachment, … }), which is
     what all three chat surfaces already build. */
  function groupAlbums(messages) {
    const out = [];
    let current = null;

    const flush = () => {
      if (!current) return;
      if (current.rows.length > 1) {
        current.rows.sort((a, b) => {
          const ai = (a.attachment && a.attachment.groupIndex) || 0;
          const bi = (b.attachment && b.attachment.groupIndex) || 0;
          if (ai !== bi) return ai - bi;
          const at = new Date(a.createdAt || 0).getTime();
          const bt = new Date(b.createdAt || 0).getTime();
          if (at !== bt) return at - bt;
          return String(a.id || '').localeCompare(String(b.id || ''));
        });
        const head = current.rows[0];
        out.push(Object.assign({}, head, {
          attachments: current.rows.map((r) => r.attachment),
          attachment: null,
        }));
      } else {
        out.push(current.rows[0]);
      }
      current = null;
    };

    for (const m of messages || []) {
      const groupId = m && m.attachment && m.attachment.groupId;
      if (!groupId) { flush(); out.push(m); continue; }
      const sender = m.senderId || m.from;
      if (current && current.groupId === groupId && current.sender === sender) {
        current.rows.push(m);
        continue;
      }
      flush();
      current = { groupId, sender, rows: [m] };
    }
    flush();
    return out;
  }

  const TYPING_HEARTBEAT_MS = 3000;
  const TYPING_EXPIRY_MS = 8000;

  function createTypingSignal() {
    let sent = false;
    let timer = null;
    let current = null;

    function stop() {
      if (timer) { clearInterval(timer); timer = null; }
      if (sent && current) API.Realtime.sendTyping(current, false);
      sent = false;
    }

    // Call from the composer's onChange, and whenever the active conversation changes.
    function update(ticketId, hasText) {
      if (!ticketId || !hasText) { stop(); return; }
      if (current !== ticketId) { stop(); }
      current = ticketId;
      if (sent) return; // already beating for this conversation
      sent = true;
      API.Realtime.sendTyping(current, true);
      timer = setInterval(() => {
        if (current && sent) API.Realtime.sendTyping(current, true);
      }, TYPING_HEARTBEAT_MS);
    }

    // A hidden tab must not leave the peer staring at a bubble that will never stop.
    const onHidden = () => { if (document.hidden) stop(); };
    document.addEventListener('visibilitychange', onHidden);

    return {
      update,
      stop,
      dispose() { stop(); document.removeEventListener('visibilitychange', onHidden); },
    };
  }

  // ─── PeriodNavigator — l'ancre temporelle de tous les écrans d'administration ─────────
  //
  // Le back-office ne savait regarder que « maintenant ». Le backend, lui, sait déjà déplacer
  // la fenêtre : `periodBounds` et le paramètre `date` existent sur finance/summary,
  // finance/revenue, finance/subscriptions et activity. Il manquait la commande.
  //
  // Un seul composant pour les trois écrans (tableau de bord, activité globale, facturation),
  // et le MÊME langage visuel que le carrousel de l'écran Paiements du loueur (.pc-nav /
  // .pc-chip) : le geste est identique des deux côtés de la plateforme, et aucune règle CSS
  // n'est ajoutée.
  //
  // `anchor` est une chaîne AAAA-MM, ou null pour « période en cours ». Elle voyage dans le
  // hash de la route, donc elle vient de l'utilisateur : chaque écran la valide avant de
  // l'envoyer à l'API, et `periodBounds` retombe de toute façon sur le mois courant devant
  // une valeur inattendue.
  // Les noms de mois viennent d'`Intl`, pas d'un tableau français figé : ce libellé apparaît
  // sur tous les écrans financiers, et le coder en dur y bloquait la moitié de l'interface en
  // français même après bascule en anglais ou en arabe.
  //
  // `I18N.lang` et non la locale du navigateur : c'est la langue CHOISIE par le loueur qui
  // doit primer, pas celle de sa machine.
  function monthName(index) {
    const lang = window.I18N ? I18N.lang : 'fr';
    const locale = lang === 'ar' ? 'ar-DZ' : lang === 'en' ? 'en-GB' : 'fr-FR';
    const label = new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(2000, index, 1));
    return label.charAt(0).toUpperCase() + label.slice(1);
  }

  /** Abréviation localisée d'un mois, pour les pastilles étroites du sélecteur d'année.
   *
   *  `monthName(i).slice(0, 4)` ne convient pas : la troncature d'un nom long donne « Janu » en
   *  anglais et coupe un mot arabe au milieu. `Intl` connaît l'abréviation propre à chaque
   *  langue (« janv. », « Jan », « يناير »). */
  function monthShort(index) {
    const lang = window.I18N ? I18N.lang : 'fr';
    const locale = lang === 'ar' ? 'ar-DZ' : lang === 'en' ? 'en-GB' : 'fr-FR';
    const label = new Intl.DateTimeFormat(locale, { month: 'short' }).format(new Date(2000, index, 1));
    return label.charAt(0).toUpperCase() + label.slice(1);
  }

  /** AAAA-MM du mois en cours. */
  function currentAnchor() {
    const d = new Date();
    return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
  }

  /** Décale une ancre de `delta` mois. Toujours normalisée, jamais hors bornes de calendrier. */
  function shiftAnchor(anchor, delta) {
    const [y, m] = (anchor || currentAnchor()).split('-').map(Number);
    const d = new Date(y, m - 1 + delta, 1);
    return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
  }

  /** Libellé lisible d'une ancre. */
  function anchorLabel(anchor) {
    const [y, m] = (anchor || currentAnchor()).split('-').map(Number);
    return `${monthName(m - 1)} ${y}`;
  }

  /** Date ISO au MILIEU du mois visé — l'ancre que les endpoints attendent.
   *
   *  Le 15 et non le 1er : `periodBounds` travaille en heure d'Alger (+01:00 fixe), et une
   *  date du 1er à minuit local peut, depuis un navigateur à l'ouest, retomber sur le dernier
   *  jour du mois précédent. Le milieu du mois ne peut dériver d'aucun fuseau. C'est la même
   *  précaution que celle prise côté serveur sur l'ancrage des factures. */
  function anchorToISO(anchor) {
    if (!anchor) return undefined;
    if (!/^\d{4}-(0[1-9]|1[0-2])$/.test(anchor)) return undefined;
    const [y, m] = anchor.split('-').map(Number);
    return new Date(Date.UTC(y, m - 1, 15, 12, 0, 0)).toISOString();
  }

  function PeriodNavigator({ anchor, onChange, sub, right, disabled }) {
    const t = window.I18N ? I18N.useT() : ((k) => k);
    const now = currentAnchor();
    const active = anchor || now;
    const isCurrent = active === now;
    // On ne propose pas d'aller consulter un futur qui n'a rien produit.
    const canGoForward = active < now;

    return (
      <div className="card" style={{ marginBottom: 16 }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '10px 14px', flexWrap: 'wrap' }}>
          <button className="pc-nav" title={t('period.previousMonth')} disabled={disabled}
            onClick={() => onChange(shiftAnchor(active, -1))}>
            <Icon name="chevLeft" size={16} />
          </button>

          <div style={{ minWidth: 150 }}>
            <div style={{ fontSize: 15.5, fontWeight: 800, letterSpacing: '-.2px' }}>
              {anchorLabel(active)}
              {isCurrent && <span className="muted" style={{ fontWeight: 600, fontSize: 12.5 }}> · en cours</span>}
            </div>
            {sub && <div className="muted" style={{ fontSize: 11.5, marginTop: 1 }}>{sub}</div>}
          </div>

          <button className="pc-nav" title={t('period.nextMonth')} disabled={disabled || !canGoForward}
            onClick={() => onChange(shiftAnchor(active, 1))}>
            <Icon name="chevRight" size={16} />
          </button>

          {/* Douze pastilles glissantes, la plus récente à droite. Cliquer vaut mieux que
              douze allers-retours sur la flèche pour remonter à septembre dernier. */}
          <div className="pc-track" style={{ marginLeft: 4 }}>
            {Array.from({ length: 12 }, (_, i) => shiftAnchor(now, i - 11)).map((a) => {
              const [, mm] = a.split('-');
              return (
                <button key={a}
                  className={`pc-chip ${a === active ? 'on' : ''} ${a === now ? 'now' : ''}`}
                  style={{ minWidth: 74 }}
                  title={anchorLabel(a)}
                  disabled={disabled}
                  onClick={() => onChange(a)}>
                  <span className="pc-lbl">{a.slice(0, 4)}</span>
                  <span className="pc-val">{monthShort(Number(mm) - 1)}</span>
                </button>
              );
            })}
          </div>

          {!isCurrent && (
            <Button size="sm" variant="secondary" icon="refresh" onClick={() => onChange(null)}>
              Revenir au mois en cours
            </Button>
          )}
          {right}
        </div>
      </div>
    );
  }


  /* ── Saisie d'un numéro de téléphone ───────────────────────────────────────
     Le préfixe « 🇩🇿 +213 » était une puce DÉCORATIVE, répétée à l'identique dans quatre
     formulaires : un loueur établi en France ne pouvait pas saisir son numéro, et la seule
     validation était `digits.length < 9`, qui accepte 9, 12 ou 40 chiffres ainsi qu'un 0 de
     tête — donnant « +2130661… » côté serveur.

     La normalisation vit dans `api.jsx` (API.toE164), MIROIR du serveur et du mobile : le
     numéro entre dans le sel de dérivation du mot de passe, les trois implémentations doivent
     donc produire exactement la même chaîne.

     Non contrôlé : le composant expose `dial` et `national` séparément plutôt qu'une chaîne
     E.164, parce que c'est ce que le formulaire doit afficher — et que reconstruire l'un à
     partir de l'autre à chaque frappe rendrait la saisie impossible dès le premier chiffre. */
  function PhoneInput({ dial, national, onChange, disabled, error, id }) {
    const country = API.dialCodeFor(dial);
    const setDigits = (raw) => {
      // Le clavier « tel » propose +, *, # et des espaces : sans filtre ils faussent le compte
      // de chiffres. Un chiffre de marge au-delà du maximum national laisse taper le 0
      // interurbain, que `toE164` retirera.
      const digits = String(raw).replace(/\D/g, '').slice(0, API.maxNationalLength(dial) + 1);
      onChange(dial, digits);
    };
    return React.createElement('div', { className: `phone-wrap ${error ? 'err' : ''}` },
      React.createElement('select', {
        className: 'phone-prefix phone-select',
        value: dial,
        disabled,
        'aria-label': window.I18N ? I18N.t('phone.countryCode') : 'Indicatif du pays',
        onChange: (e) => onChange(e.target.value, national),
      }, API.DIAL_CODES.map((c) => React.createElement('option', { key: c.iso, value: c.dial },
        `${c.flag} ${c.dial}`))),
      React.createElement('input', {
        id,
        type: 'tel',
        inputMode: 'numeric',
        placeholder: country.hint,
        value: national,
        disabled,
        onChange: (e) => setDigits(e.target.value),
      }));
  }

  Object.assign(window, {
    PeriodNavigator, currentAnchor, shiftAnchor, anchorLabel, anchorToISO,
    Avatar, Button, IconButton, Badge, StatusBadge, Chip, Field, Input, Textarea, Select, Checkbox,
    PhoneInput,
    Card, CardHead, StatCard, Sparkline, AreaChart, BarChart, PieChart, Modal, ToastProvider, useToast,
    VehiclePhoto, Sk, EmptyState, Dropdown, WilayaMap, MapView,
    LocationBubble, messageLocation, parseBodyLocation, ReactionBar, ChatBubble, quotePreview,
    createTypingSignal, TYPING_EXPIRY_MS, groupAlbums,
  });
})();
