import React, { useRef, useState, useEffect, useLayoutEffect, useCallback, useMemo } from 'react';
import type { CBNode, Breakpoint, GlobalStyles, DesignSystem, DesignToken, JalpiClass } from '../../types';
import type { EditorStore } from '../../store/editorStore';
import { FloatingToolbar } from './FloatingToolbar';
import { BlockPicker } from './BlockPicker';
import { ICONS, DEFAULT_ICON } from '../../utils/icons';
import { RenderIconValue } from '../panels/IconPickerModal';
import type { IconValue } from '../../types';
import { GenericRenderer } from '../../widgets/GenericRenderer';
import { isSchemaWidget } from '../../widgets/registry';
import { ProductsGridPreview } from '../../widgets/ProductsGridPreview';
import { ProductCategoriesPreview } from '../../widgets/ProductCategoriesPreview';
import { SingleProductPreview } from '../../widgets/SingleProductPreview';

interface Props {
  node: CBNode;
  store: EditorStore;
  isRoot?: boolean;
  wrapperStyle?: React.CSSProperties;
}

// ── Drag-and-Drop ─────────────────────────────────────────────────────────────
// Module-level state: updated imperatively during drag to avoid React re-renders.
// newDef is set by ElementsPanel when dragging a new element from the panel.
export const activeDrag: {
  sourceId:  string | null;
  sourceNode: CBNode | null;
  newDef:    Partial<CBNode> | null;
} = { sourceId: null, sourceNode: null, newDef: null };

const CONTAINER_TYPES = new Set(['section', 'container', 'column']);

function clearDropIndicators(): void {
  document.querySelectorAll('.jalpi-dnd-before, .jalpi-dnd-after, .jalpi-dnd-inside').forEach((el) => {
    (el as HTMLElement).classList.remove('jalpi-dnd-before', 'jalpi-dnd-after', 'jalpi-dnd-inside');
  });
}

function isDescendantOf(node: CBNode, targetId: string): boolean {
  return (node.children ?? []).some(
    (c) => c.id === targetId || isDescendantOf(c, targetId)
  );
}

// ── Resize ────────────────────────────────────────────────────────────────────
// Direct DOM manipulation during drag — no React re-renders until mouse-up.
const rz = {
  type:     null as 'section-h' | 'col-w' | null,
  startPx:  0,
  startV1:  0,
  startV2:  0,
  totalPx:  1,
  zoom:     100,
  el1:      null as HTMLElement | null,
  el2:      null as HTMLElement | null,
  onCommit: null as ((v1: number, v2: number) => void) | null,
};

let rzTip: HTMLDivElement | null = null;
function rzShowTip(text: string, cx: number, cy: number) {
  if (!rzTip) {
    rzTip = document.createElement('div');
    rzTip.className = 'jalpi-rz-tip';
    document.body.appendChild(rzTip);
  }
  rzTip.textContent = text;
  rzTip.style.cssText = `display:block;position:fixed;left:${cx + 12}px;top:${cy - 30}px;`;
}
function rzHideTip() { if (rzTip) rzTip.style.display = 'none'; }

function rzOnMove(e: MouseEvent) {
  if (!rz.type) return;
  const scale = (rz.zoom || 100) / 100;
  if (rz.type === 'section-h') {
    const h = Math.max(0, Math.round(rz.startV1 + (e.clientY - rz.startPx) / scale));
    if (rz.el1) rz.el1.style.minHeight = `${h}px`;
    rzShowTip(`${h}px`, e.clientX, e.clientY);
  } else {
    const dx = (e.clientX - rz.startPx) / scale;
    const pct = (dx / rz.totalPx) * 100;
    const v1 = Math.max(5, Math.min(rz.startV1 + rz.startV2 - 5, rz.startV1 + pct));
    const v2 = (rz.startV1 + rz.startV2) - v1;
    if (rz.el1) { rz.el1.style.flex = `0 1 ${v1.toFixed(1)}%`; rz.el1.style.maxWidth = `${v1.toFixed(1)}%`; }
    if (rz.el2) { rz.el2.style.flex = `0 1 ${v2.toFixed(1)}%`; rz.el2.style.maxWidth = `${v2.toFixed(1)}%`; }
    rzShowTip(`${Math.round(v1)}% / ${Math.round(v2)}%`, e.clientX, e.clientY);
  }
}
function rzOnUp() {
  if (!rz.type) return;
  if (rz.type === 'section-h' && rz.el1) {
    rz.onCommit?.(parseFloat(rz.el1.style.minHeight) || rz.startV1, 0);
  } else if (rz.el1 && rz.el2) {
    rz.onCommit?.(parseFloat(rz.el1.style.maxWidth) || rz.startV1,
                  parseFloat(rz.el2.style.maxWidth) || rz.startV2);
  }
  rz.type = null; rzHideTip();
  document.removeEventListener('mousemove', rzOnMove);
  document.removeEventListener('mouseup',  rzOnUp);
  document.body.style.removeProperty('cursor');
  document.body.style.removeProperty('user-select');
  document.querySelectorAll('.jalpi-col-divider.is-dragging, .jalpi-rz-handle.is-dragging')
    .forEach((el) => el.classList.remove('is-dragging'));
}

// ─────────────────────────────────────────────────────────────────────────────

// Accepts either the legacy per-page GlobalStyles or the new site-wide
// DesignSystem — both carry typography/buttons in the identical composite
// preset shape, which is all this function reads.
type PresetSource = Pick<GlobalStyles, 'typography' | 'buttons'>;

function getPresetStyles(node: CBNode, globalStyles?: PresetSource | null): Record<string, string> {
  if (!node.globalStyleRef || !globalStyles) return {};
  const styles: Record<string, string> = {};
  if (node.globalStyleRef.typographyId) {
    const p = globalStyles.typography?.find((t) => t.id === node.globalStyleRef!.typographyId);
    if (p) {
      if (p.fontFamily)    styles.fontFamily    = p.fontFamily;
      if (p.fontSize)      styles.fontSize      = p.fontSize;
      if (p.fontWeight)    styles.fontWeight    = p.fontWeight;
      if (p.lineHeight)    styles.lineHeight    = p.lineHeight;
      if (p.letterSpacing) styles.letterSpacing = p.letterSpacing;
      if (p.textTransform) styles.textTransform = p.textTransform;
      if (p.color)         styles.color         = p.color;
    }
  }
  if (node.globalStyleRef.buttonId) {
    const p = globalStyles.buttons?.find((b) => b.id === node.globalStyleRef!.buttonId);
    if (p) {
      if (p.backgroundColor) styles.backgroundColor = p.backgroundColor;
      if (p.color)           styles.color           = p.color;
      if (p.borderRadius)    styles.borderRadius    = p.borderRadius;
      if (p.paddingTop)      styles.paddingTop      = p.paddingTop;
      if (p.paddingBottom)   styles.paddingBottom   = p.paddingBottom;
      if (p.paddingLeft)     styles.paddingLeft     = p.paddingLeft;
      if (p.paddingRight)    styles.paddingRight    = p.paddingRight;
      if (p.fontSize)        styles.fontSize        = p.fontSize;
      if (p.fontWeight)      styles.fontWeight      = p.fontWeight;
      if (p.fontFamily)      styles.fontFamily      = p.fontFamily;
      if (p.border)          styles.border          = p.border;
      if (p.boxShadow)       styles.boxShadow       = p.boxShadow;
      if (p.textTransform)   styles.textTransform   = p.textTransform;
    }
  }
  return styles;
}

// Resolves globalStyleRef.tokens (atomic Design System links) for the canvas
// preview, honoring the current breakpoint's tabletValue/mobileValue override
// on each token — mirrors Jalpi_Renderer::resolve_token_refs() on the PHP
// side (minus the var()-wrapping, since this only needs a literal value for
// the live preview, not a CSS custom property reference).
function getTokenStyles(node: CBNode, designSystem: DesignSystem | undefined, breakpoint: Breakpoint): Record<string, string> {
  if (!node.globalStyleRef?.tokens || !designSystem) return {};
  const styles: Record<string, string> = {};
  for (const [prop, ref] of Object.entries(node.globalStyleRef.tokens)) {
    const list = designSystem[ref.category] as DesignToken[] | undefined;
    const token = list?.find((t) => t.id === ref.id);
    if (!token) continue;
    const value =
      breakpoint === 'tablet' ? (token.tabletValue || token.value) :
      breakpoint === 'mobile' ? (token.mobileValue || token.value) :
      token.value;
    if (value) styles[prop] = value;
  }
  return styles;
}

// Class-Based Styling System — merges each attached class's `styles` bucket,
// in class-manager cascade order (classes array is kept sorted by sortOrder —
// see editorStore.ts setClasses/upsertClass), for accurate canvas WYSIWYG.
// Mirrors Jalpi_Renderer::compile_class_css() on the PHP side: classes are
// resolved before the node's own literal styles, which still win below.
function getClassStyles(node: CBNode, classes: JalpiClass[]): Record<string, string> {
  const attachedKeys = (node.settings as { cssClasses?: string[] } | undefined)?.cssClasses;
  if (!attachedKeys?.length || !classes.length) return {};
  const styles: Record<string, string> = {};
  for (const key of attachedKeys) {
    const cls = classes.find((c) => c.key === key);
    if (!cls) continue; // orphaned reference — same silent-skip as the PHP renderer
    for (const [k, v] of Object.entries(cls.styles ?? {})) {
      if (v) styles[k] = v;
    }
  }
  return styles;
}

function buildInlineStyles(
  node: CBNode,
  breakpoint: Breakpoint,
  presetStyles: Record<string, string> = {}
): React.CSSProperties {
  const base = node.styles ?? {};
  const overrides =
    breakpoint === 'tablet' ? (node.tabletStyles ?? {}) :
    breakpoint === 'mobile' ? (node.mobileStyles ?? {}) :
    {};
  const merged: Record<string, string> = {};
  // preset is the base layer — node styles override it, responsive overrides win last
  for (const [k, v] of Object.entries(presetStyles)) { if (v) merged[k] = v; }
  for (const [k, v] of Object.entries(base))         { if (v) merged[k] = v; }
  for (const [k, v] of Object.entries(overrides))    { if (v) merged[k] = v; }

  // Resolve gradient background (normal state)
  if (merged.backgroundType === 'gradient') {
    const c1    = merged.gradientColor1 || '#000000';
    const c2    = merged.gradientColor2 || '#ffffff';
    const angle = merged.gradientAngle  || '90deg';
    merged.backgroundImage = `linear-gradient(${angle}, ${c1}, ${c2})`;
    delete merged.backgroundColor;
  }

  // Compose text-shadow from individual stored props
  const tsH = merged.textShadowH;
  const tsV = merged.textShadowV;
  if (tsH || tsV) {
    const blur  = merged.textShadowBlur  || '0px';
    const color = merged.textShadowColor || 'rgba(0,0,0,0.5)';
    const h = tsH ? (isNaN(Number(tsH)) ? tsH : `${tsH}px`) : '0px';
    const v = tsV ? (isNaN(Number(tsV)) ? tsV : `${tsV}px`) : '0px';
    const b = isNaN(Number(blur))  ? blur  : `${blur}px`;
    merged.textShadow = `${h} ${v} ${b} ${color}`;
  }

  // Compose text-stroke from individual stored props
  const tsw = merged.textStrokeWidth;
  const tsc = merged.textStrokeColor;
  if (tsw) {
    const w = isNaN(Number(tsw)) ? tsw : `${tsw}px`;
    (merged as Record<string, string>).WebkitTextStroke = `${w} ${tsc || '#000000'}`;
  }

  // Strip internal/hover props (not valid CSS) and outerPadding* (applied to wrapper div)
  for (const k of [
    'backgroundType',
    'gradientColor1', 'gradientColor2', 'gradientAngle',
    'gradientColor1Hover', 'gradientColor2Hover',
    'colorHover', 'backgroundColorHover', 'backgroundImageHover',
    'borderColorHover', 'boxShadowHover',
    'outerPaddingTop', 'outerPaddingRight', 'outerPaddingBottom', 'outerPaddingLeft',
    // text-shadow individual parts (already composed above)
    'textShadowH', 'textShadowV', 'textShadowBlur', 'textShadowColor',
    // text-stroke individual parts (already composed above)
    'textStrokeWidth', 'textStrokeColor',
    // non-CSS properties saved for PHP renderer
    'linkColor', 'linkColorHover', 'paragraphSpacing',
  ]) {
    delete merged[k];
  }

  // Normalise bare numbers to px for known dimension properties.
  // BoxSpacingControl stores values as typed (e.g. "20" instead of "20px").
  // A bare number is invalid CSS for any dimension — browsers silently ignore it.
  const DIMENSION_PROPS = new Set([
    'marginTop','marginRight','marginBottom','marginLeft',
    'paddingTop','paddingRight','paddingBottom','paddingLeft',
    'top','right','bottom','left',
    'width','height','maxWidth','minWidth','maxHeight','minHeight',
    'gap','rowGap','columnGap',
    'fontSize','letterSpacing','wordSpacing','textIndent',
    'borderWidth',
    'borderTopWidth','borderRightWidth','borderBottomWidth','borderLeftWidth',
    'borderRadius',
    'borderTopLeftRadius','borderTopRightRadius',
    'borderBottomLeftRadius','borderBottomRightRadius',
    'outlineWidth','outlineOffset',
  ]);
  for (const k of DIMENSION_PROPS) {
    const v = merged[k];
    if (v && /^-?[\d.]+$/.test(v.trim())) merged[k] = `${v.trim()}px`;
  }

  return merged as React.CSSProperties;
}

export const CanvasNode = React.memo(function CanvasNode({ node, store, isRoot, wrapperStyle }: Props) {
  const { state } = store;
  const isSelected = state.selectedId === node.id;
  const isHovered  = state.hoveredId  === node.id;
  const nodeRef    = useRef<HTMLDivElement>(null);
  const [isEditing, setIsEditing] = useState(false);

  const handleClick = useCallback(
    (e: React.MouseEvent) => {
      e.stopPropagation();
      store.selectNode(node.id);
    },
    [node.id, store]
  );

  const handleMouseEnter = useCallback(
    (e: React.MouseEvent) => {
      e.stopPropagation();
      store.hoverNode(node.id);
    },
    [node.id, store]
  );

  const handleMouseLeave = useCallback(() => {
    store.hoverNode(null);
  }, [store]);

  const handleDoubleClick = useCallback(
    (e: React.MouseEvent) => {
      e.stopPropagation();
      if (['heading', 'text', 'button', 'html'].includes(node.type)) {
        setIsEditing(true);
      }
    },
    [node.type]
  );

  // ── Drag handlers ────────────────────────────────────────────────────────────
  const handleDragStart = useCallback((e: React.DragEvent) => {
    if (node.locked || isEditing) { e.preventDefault(); return; }
    e.stopPropagation();
    activeDrag.sourceId   = node.id;
    activeDrag.sourceNode = node;
    e.dataTransfer.effectAllowed = 'move';
    e.dataTransfer.setData('text/plain', node.id);
    // Delay dimming so the drag ghost captures the element at full opacity
    requestAnimationFrame(() => nodeRef.current?.classList.add('jalpi-dnd-dragging'));
  }, [node, isEditing]);

  const handleDragEnd = useCallback(() => {
    activeDrag.sourceId   = null;
    activeDrag.sourceNode = null;
    nodeRef.current?.classList.remove('jalpi-dnd-dragging');
    clearDropIndicators();
  }, []);

  const handleDragOver = useCallback((e: React.DragEvent) => {
    const isNewDrag    = activeDrag.newDef !== null;
    const isReorderDrag = activeDrag.sourceId !== null && activeDrag.sourceId !== node.id;
    if (!isNewDrag && !isReorderDrag) return;
    if (!isNewDrag && activeDrag.sourceNode && isDescendantOf(activeDrag.sourceNode, node.id)) return;

    e.preventDefault();
    e.stopPropagation();
    e.dataTransfer.dropEffect = isNewDrag ? 'copy' : 'move';

    clearDropIndicators();
    const el   = nodeRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const isContainer = CONTAINER_TYPES.has(node.type);
    const isEmpty     = !node.children || node.children.length === 0;

    if (isContainer && isEmpty) {
      el.classList.add('jalpi-dnd-inside');
    } else {
      el.classList.add(e.clientY < rect.top + rect.height / 2 ? 'jalpi-dnd-before' : 'jalpi-dnd-after');
    }
  }, [node]);

  const handleDragLeave = useCallback((e: React.DragEvent) => {
    if (!nodeRef.current?.contains(e.relatedTarget as Node)) {
      nodeRef.current?.classList.remove('jalpi-dnd-before', 'jalpi-dnd-after', 'jalpi-dnd-inside');
    }
  }, []);

  const handleDrop = useCallback((e: React.DragEvent) => {
    e.preventDefault();
    e.stopPropagation();
    clearDropIndicators();

    // ── New element dropped from ElementsPanel ─────────────────────────────
    if (activeDrag.newDef) {
      const def = activeDrag.newDef;
      activeDrag.newDef = null;

      const rect        = nodeRef.current?.getBoundingClientRect();
      const isContainer = CONTAINER_TYPES.has(node.type);

      if (node.type === 'section') {
        // addElementInContainer handles container-type elements correctly too
        store.addElementInContainer(node.id, def);
      } else if (isContainer) {
        store.addElement(node.id, def);
      } else {
        // Drop on an existing element: insert before or after it
        const pos = rect && e.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
        store.addElementBesideNode(def, node.id, pos);
      }
      return;
    }

    // ── Reorder existing node ──────────────────────────────────────────────
    const sourceId = activeDrag.sourceId;
    if (!sourceId || sourceId === node.id) return;
    if (activeDrag.sourceNode && isDescendantOf(activeDrag.sourceNode, node.id)) return;

    const rect        = nodeRef.current?.getBoundingClientRect();
    const isContainer = CONTAINER_TYPES.has(node.type);
    const isEmpty     = !node.children || node.children.length === 0;

    if (isContainer && isEmpty) {
      store.moveNodeInside(sourceId, node.id);
    } else {
      const pos = rect && e.clientY < rect.top + rect.height / 2 ? 'before' : 'after';
      store.moveNodeTo(sourceId, node.id, pos);
    }
  }, [node, store]);
  // ─────────────────────────────────────────────────────────────────────────────

  const isHiddenOnDevice = !!(node.hideOn?.[state.breakpoint]);
  const isLocked         = !!node.locked;

  // Include the user's cssClass so live Custom CSS preview works in the editor
  const nodeSettings    = (node.settings ?? {}) as Record<string, unknown>;
  const userCssClass    = ((nodeSettings.cssClass as string) || '').trim();
  const userCssId       = ((nodeSettings.cssId    as string) || '').trim();

  const wrapperClass = [
    'jalpi-canvas-node',
    `jalpi-canvas-node--${node.type}`,
    isSelected       ? 'is-selected'     : '',
    isHovered && !isSelected ? 'is-hovered' : '',
    isHiddenOnDevice ? 'is-device-hidden' : '',
    isLocked         ? 'is-locked'        : '',
    userCssClass,
  ]
    .filter(Boolean)
    .join(' ');

  const inlineStyles = useMemo(() => {
    // Layering: atomic token links > composite preset links > (node literal
    // styles/tabletStyles/mobileStyles win inside buildInlineStyles itself).
    // Spreading tokenStyles after presetStyles means a property linked both
    // ways resolves to the token — mirrors Jalpi_Renderer::resolve_token_refs()
    // on the PHP side, where token resolution runs before preset resolution
    // and "claims" the style key first.
    // Class-Based Styling System: attached classes' own `styles` sit between
    // the Design System layer above and this node's own literal styles below
    // (applied next, inside buildInlineStyles) — mirrors the PHP cascade in
    // Jalpi_Renderer::compile_class_css (classes emitted before local styles).
    const classStyles = getClassStyles(node, state.classes);
    const presetStyles = {
      ...getPresetStyles(node, state.designSystem),
      ...getTokenStyles(node, state.designSystem, state.breakpoint),
      ...classStyles,
    };
    const styles = buildInlineStyles(node, state.breakpoint, presetStyles);
    // Heading and text: apply Advanced-tab background and border from settings.
    // Sections/columns handle this themselves; these element types do not.
    if (node.type === 'heading' || node.type === 'text') {
      const settings = (node.settings ?? {}) as Record<string, unknown>;
      const bg = settings.bgNormal as Record<string, unknown> | undefined;
      if (bg) applyBgData(styles as React.CSSProperties, bg);
      const bd = settings.borderNormal as Record<string, unknown> | undefined;
      if (bd?.borderType && bd.borderType !== 'none') {
        const s = styles as Record<string, string>;
        const bColor = (bd.borderColor as string) || '#000';
        const hasPerSide = bd.borderTopWidth || bd.borderRightWidth || bd.borderBottomWidth || bd.borderLeftWidth;
        if (hasPerSide) {
          s.borderStyle       = bd.borderType as string;
          s.borderColor       = bColor;
          s.borderTopWidth    = withPx(bd.borderTopWidth    as string) || '0';
          s.borderRightWidth  = withPx(bd.borderRightWidth  as string) || '0';
          s.borderBottomWidth = withPx(bd.borderBottomWidth as string) || '0';
          s.borderLeftWidth   = withPx(bd.borderLeftWidth   as string) || '0';
        } else {
          s.border = `${withPx(bd.borderWidth as string) || '1px'} ${bd.borderType as string} ${bColor}`;
        }
      }
      const s = styles as Record<string, string>;
      const bdAny = bd ?? {};
      const hasPerCorner = bdAny.borderTopLeftRadius || bdAny.borderTopRightRadius || bdAny.borderBottomRightRadius || bdAny.borderBottomLeftRadius;
      if (hasPerCorner) {
        s.borderTopLeftRadius     = withPx(bdAny.borderTopLeftRadius     as string);
        s.borderTopRightRadius    = withPx(bdAny.borderTopRightRadius    as string);
        s.borderBottomRightRadius = withPx(bdAny.borderBottomRightRadius as string);
        s.borderBottomLeftRadius  = withPx(bdAny.borderBottomLeftRadius  as string);
      } else if (bd?.borderRadius) {
        s.borderRadius = withPx(bd.borderRadius as string);
      }
      if (bd?.boxShadow) s.boxShadow = bd.boxShadow as string;

      // Apply Width from Advanced tab
      if (settings.elWidth) {
        (styles as Record<string, string>).width = settings.elWidth as string;
      }
    }
    return retargetViewportUnits(styles as Record<string, unknown>);
  }, [node, state.breakpoint, state.designSystem]);

  // Heading/text overlay from settings.overlayNormal (shown in canvas like section overlays)
  const elementOverlay = useMemo(() => {
    if (node.type !== 'heading' && node.type !== 'text') return null;
    const settings = (node.settings ?? {}) as Record<string, unknown>;
    const ov = settings.overlayNormal as Record<string, unknown> | undefined;
    if (!ov || (!ov.color && !ov.imageUrl && !ov.gradient)) return null;
    return ov;
  }, [node]);

  const renderContent = () => {
    switch (node.type) {
      case 'section':
        return (
          <SectionRenderer node={node} store={store} inlineStyles={inlineStyles} />
        );
      case 'container':
        return (
          <ContainerRenderer node={node} store={store} inlineStyles={inlineStyles} />
        );
      case 'column':
        return (
          <ColumnRenderer node={node} store={store} inlineStyles={inlineStyles} />
        );
      case 'heading':
        return (
          <HeadingRenderer
            key={isEditing ? 'editing' : 'viewing'}
            node={node}
            store={store}
            isEditing={isEditing}
            onEditEnd={() => setIsEditing(false)}
            inlineStyles={inlineStyles}
          />
        );
      case 'text':
        return (
          <TextRenderer
            node={node}
            store={store}
            isEditing={isEditing}
            onEditEnd={() => setIsEditing(false)}
            inlineStyles={inlineStyles}
          />
        );
      case 'image':
        return <ImageRenderer node={node} inlineStyles={inlineStyles} />;
      case 'button':
        return (
          <ButtonRenderer
            node={node}
            store={store}
            isEditing={isEditing}
            onEditEnd={() => setIsEditing(false)}
            inlineStyles={inlineStyles}
          />
        );
      case 'spacer':
        return <SpacerRenderer node={node} />;
      case 'divider':
        return <DividerRenderer node={node} />;
      case 'video':
        return <VideoRenderer node={node} inlineStyles={inlineStyles} />;
      case 'maps':
        return <MapsRenderer node={node} inlineStyles={inlineStyles} />;
      case 'html':
        return (
          <HtmlRenderer
            node={node}
            store={store}
            isEditing={isEditing}
            onEditEnd={() => setIsEditing(false)}
            inlineStyles={inlineStyles}
          />
        );
      case 'icon':
        return <IconRenderer node={node} inlineStyles={inlineStyles} />;
      case 'shortcode':
        return <ShortcodeRenderer node={node} inlineStyles={inlineStyles} />;
      case 'logo':
        return <LogoRenderer node={node} inlineStyles={inlineStyles} />;
      case 'form':
        return <FormRenderer node={node} inlineStyles={inlineStyles} />;
      case 'alert':
        return <AlertRenderer node={node} inlineStyles={inlineStyles} />;
      case 'counter':
        return <CounterRenderer node={node} inlineStyles={inlineStyles} />;
      case 'progress':
        return <ProgressRenderer node={node} inlineStyles={inlineStyles} />;
      case 'testimonial':
        return <TestimonialRenderer node={node} inlineStyles={inlineStyles} />;
      case 'accordion':
        return <AccordionRenderer node={node} inlineStyles={inlineStyles} />;
      case 'badge':
        return <BadgeRenderer node={node} inlineStyles={inlineStyles} />;
      case 'icon-list':
        return <IconListRenderer node={node} inlineStyles={inlineStyles} />;
      case 'pricing-table':
        return <PricingTableRenderer node={node} inlineStyles={inlineStyles} />;
      case 'team-member':
        return <TeamMemberRenderer node={node} inlineStyles={inlineStyles} />;
      case 'faq':
        return <FaqRenderer node={node} inlineStyles={inlineStyles} />;
      case 'table':
        return <TableRenderer node={node} inlineStyles={inlineStyles} />;
      case 'feature-card':
        return <FeatureCardRenderer node={node} inlineStyles={inlineStyles} />;
      case 'cta':
        return <CtaRenderer node={node} inlineStyles={inlineStyles} />;
      case 'stats-card':
        return <StatsCardRenderer node={node} inlineStyles={inlineStyles} />;
      case 'timeline':
        return <TimelineRenderer node={node} inlineStyles={inlineStyles} />;
      case 'testimonials':
        return <TestimonialsCarouselRenderer node={node} inlineStyles={inlineStyles} />;
      case 'tabs':
        return <TabsRenderer node={node} inlineStyles={inlineStyles} />;
      case 'tabs-adv':
        return <TabsAdvRenderer node={node} store={store} inlineStyles={inlineStyles} />;
      case 'menu':
        return <MenuRenderer node={node} inlineStyles={inlineStyles} />;
      case 'adv-menu':
        return <AdvMenuRenderer node={node} store={store} inlineStyles={inlineStyles} />;
      case 'breadcrumbs':
        return <BreadcrumbsRenderer node={node} inlineStyles={inlineStyles} />;
      case 'image-box':
        return <ImageBoxRenderer node={node} inlineStyles={inlineStyles} />;
      case 'social-icons':
        return <SocialIconsRenderer node={node} inlineStyles={inlineStyles} />;
      case 'gallery':
        return <GalleryRenderer node={node} inlineStyles={inlineStyles} />;
      case 'image-carousel':
        return <ImageCarouselRenderer node={node} inlineStyles={inlineStyles} />;
      case 'countdown':
        return <CountdownRenderer node={node} inlineStyles={inlineStyles} />;
      case 'flip-box':
        return <FlipBoxRenderer node={node} inlineStyles={inlineStyles} />;
      case 'star-rating':
        return <StarRatingRenderer node={node} inlineStyles={inlineStyles} />;
      case 'products-grid':
        // Live preview with sampled products — the real WC_Query runs
        // server-side, so the editor renders representative cards instead.
        return <ProductsGridPreview node={node} inlineStyles={inlineStyles} breakpoint={state.breakpoint} />;
      case 'product-categories':
        // Live preview with sampled categories — the real term query runs
        // server-side, so the editor renders representative cards.
        return <ProductCategoriesPreview node={node} inlineStyles={inlineStyles} breakpoint={state.breakpoint} />;
      case 'single-product':
        // Live preview with a sampled product — the real WC_Product and the
        // hook-driven sections resolve server-side.
        return <SingleProductPreview node={node} inlineStyles={inlineStyles} breakpoint={state.breakpoint} />;
      default:
        // Schema-driven registry widgets (no bespoke renderer) preview via the
        // generic renderer. Anything genuinely unknown still renders nothing,
        // exactly as before.
        if (isSchemaWidget(node.type)) {
          return <GenericRenderer node={node} inlineStyles={inlineStyles} />;
        }
        return null;
    }
  };

  const nodeLabelText = node.type === 'section' ? 'Section'
    : node.type === 'container' ? 'Container'
    : node.type === 'column' ? 'Column'
    : node.type.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());

  const nodeLabelEl = (
    <div className="jalpi-node-label" aria-hidden="true">{nodeLabelText}</div>
  );

  const lockBadge = isLocked ? (
    <div className="jalpi-lock-badge" aria-hidden="true">
      <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
        <rect x="3" y="11" width="18" height="11" rx="2"/>
        <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
      </svg>
      Locked
    </div>
  ) : null;

  const hiddenBadge = isHiddenOnDevice ? (
    <div className="jalpi-device-hidden-badge" aria-hidden="true">
      <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
        <line x1="1" y1="1" x2="23" y2="23"/><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/>
        <path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/>
      </svg>
      Hidden on {state.breakpoint}
    </div>
  ) : null;

  // Structural nodes manage their own wrapper.
  // Sections and containers always fill the available width; columns receive
  // explicit flex sizing from the parent ContainerRenderer via wrapperStyle.
  const containerWrapperConstraint: React.CSSProperties = {};
  if (node.type === 'container') {
    const ns = (node.settings ?? {}) as Record<string, unknown>;
    if (ns.contentWidth === 'boxed') {
      // Responsive override wins over desktop default
      const inlineMap = inlineStyles as Record<string, string>;
      const effectiveMax = inlineMap.maxWidth || (ns.boxedWidth as string | undefined);
      if (effectiveMax) {
        containerWrapperConstraint.maxWidth    = effectiveMax;
        containerWrapperConstraint.marginLeft  = 'auto';
        containerWrapperConstraint.marginRight = 'auto';
      }
    }
  }

  const structuralBaseStyle: React.CSSProperties | undefined =
    node.type === 'section'
      ? { width: '100%', boxSizing: 'border-box', ...wrapperStyle }
      : node.type === 'container'
      ? { width: '100%', minWidth: 0, boxSizing: 'border-box', ...wrapperStyle, ...containerWrapperConstraint }
      : wrapperStyle; // column: sized entirely by parent-supplied wrapperStyle

  // outerPadding* (button Advanced tab) → wrapper div only.
  // Not valid CSS properties; must be mapped to padding*.
  const baseS = (node.styles ?? {}) as Record<string, string>;
  const respS = (
    state.breakpoint === 'tablet' ? node.tabletStyles :
    state.breakpoint === 'mobile' ? node.mobileStyles :
    undefined
  ) as Record<string, string> | undefined ?? {};
  const mergedWrap = { ...baseS, ...respS };
  const wrapperSpacingStyle: React.CSSProperties = {};
  if (mergedWrap.outerPaddingTop)    wrapperSpacingStyle.paddingTop    = withPx(mergedWrap.outerPaddingTop);
  if (mergedWrap.outerPaddingRight)  wrapperSpacingStyle.paddingRight  = withPx(mergedWrap.outerPaddingRight);
  if (mergedWrap.outerPaddingBottom) wrapperSpacingStyle.paddingBottom = withPx(mergedWrap.outerPaddingBottom);
  if (mergedWrap.outerPaddingLeft)   wrapperSpacingStyle.paddingLeft   = withPx(mergedWrap.outerPaddingLeft);
  const hasWrapperSpacing = Object.keys(wrapperSpacingStyle).length > 0;

  const dragProps = {
    draggable:     !isLocked,
    onDragStart:   handleDragStart,
    onDragEnd:     handleDragEnd,
    onDragOver:    handleDragOver,
    onDragLeave:   handleDragLeave,
    onDrop:        handleDrop,
  };

  if (node.type === 'section' || node.type === 'container' || node.type === 'column') {
    return (
      <div
        ref={nodeRef}
        className={wrapperClass}
        id={userCssId || undefined}
        style={structuralBaseStyle}
        onClick={handleClick}
        onMouseEnter={handleMouseEnter}
        onMouseLeave={handleMouseLeave}
        onDoubleClick={handleDoubleClick}
        data-node-id={node.id}
        data-node-type={node.type}
        data-content-width={node.type === 'container' ? ((node.settings as Record<string,unknown>)?.contentWidth as string | undefined) || 'full' : undefined}
        {...dragProps}
      >
        {isSelected && (
          <FloatingToolbar
            node={node}
            store={store}
            parentRef={nodeRef}
            onStartEdit={() => setIsEditing(true)}
          />
        )}
        {nodeLabelEl}
        {renderContent()}
        {isSelected && node.type === 'section' && (
          <SectionQuickActions node={node} store={store} />
        )}
        {hiddenBadge}
        {lockBadge}
      </div>
    );
  }

  // Compute overlay style for the element overlay div (plain derivation, not a hook)
  let elementOverlayStyle: React.CSSProperties | null = null;
  if (elementOverlay) {
    // z-index: -1 keeps the overlay BEHIND the content (h2 text).
    // The wrapper uses isolation: isolate so z-index: -1 stays within the wrapper.
    elementOverlayStyle = {
      position: 'absolute', top: 0, right: 0, bottom: 0, left: 0,
      pointerEvents: 'none',
      zIndex: -1,
    };
    applyBgData(elementOverlayStyle, elementOverlay);
    (elementOverlayStyle as Record<string, unknown>).opacity =
      elementOverlay.opacity != null ? Number(elementOverlay.opacity) : 0.5;
  }

  const elementWrapperStyle: React.CSSProperties | undefined =
    (hasWrapperSpacing || elementOverlay)
      ? {
          ...(hasWrapperSpacing ? wrapperSpacingStyle : {}),
          // isolation: isolate creates a stacking context so the overlay's z-index: -1
          // stays behind the content but doesn't leak below the wrapper's background.
          ...(elementOverlay ? { position: 'relative' as const, isolation: 'isolate' } : {}),
        } as React.CSSProperties
      : undefined;

  return (
    <div
      ref={nodeRef}
      className={wrapperClass}
      id={userCssId || undefined}
      style={elementWrapperStyle}
      onClick={handleClick}
      onMouseEnter={handleMouseEnter}
      onMouseLeave={handleMouseLeave}
      onDoubleClick={handleDoubleClick}
      data-node-id={node.id}
      data-node-type={node.type}
      {...dragProps}
    >
      {isSelected && (
        <FloatingToolbar
          node={node}
          store={store}
          parentRef={nodeRef}
          onStartEdit={() => setIsEditing(true)}
        />
      )}
      {nodeLabelEl}
      {elementOverlayStyle && (
        <div className="jalpi-element-overlay" style={elementOverlayStyle} />
      )}
      {renderContent()}
      {hiddenBadge}
      {lockBadge}
    </div>
  );
// Memo comparator: only re-render when this node's data or its selection/hover/breakpoint changes.
// Siblings whose data is unchanged are skipped entirely, eliminating the O(n) re-render storm.
}, (prev, next) => {
  const id = prev.node.id;
  return (
    prev.node           === next.node                                           &&
    prev.isRoot         === next.isRoot                                         &&
    prev.wrapperStyle   === next.wrapperStyle                                   &&
    (prev.store.state.selectedId === id) === (next.store.state.selectedId === id) &&
    (prev.store.state.hoveredId  === id) === (next.store.state.hoveredId  === id) &&
    prev.store.state.breakpoint === next.store.state.breakpoint                &&
    prev.store.state.designSystem === next.store.state.designSystem
  );
});

// ── Section inline quick-actions bar ────────────────────────────────────────

function SectionQuickActions({ node, store }: { node: CBNode; store: EditorStore }) {
  const [showPicker, setShowPicker] = useState(false);
  const pickerWrapRef = useRef<HTMLDivElement>(null);

  // Close element picker when user clicks outside it
  useEffect(() => {
    if (!showPicker) return;
    const handler = (e: MouseEvent) => {
      if (pickerWrapRef.current && !pickerWrapRef.current.contains(e.target as Node)) {
        setShowPicker(false);
      }
    };
    document.addEventListener('mousedown', handler);
    return () => document.removeEventListener('mousedown', handler);
  }, [showPicker]);

  return (
    <div className="jalpi-section-qactions" onClick={(e) => e.stopPropagation()}>
      {/* Add Container */}
      <button
        className="jalpi-section-qaction"
        title="Add a container to this section"
        onClick={(e) => {
          e.stopPropagation();
          store.addElement(node.id, { type: 'container', settings: { columns: 1 }, children: [] });
        }}
      >
        <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
          <rect x="2" y="4" width="20" height="16" rx="2"/><line x1="12" y1="4" x2="12" y2="20"/>
        </svg>
        Container
      </button>

      {/* Add Element (opens BlockPicker) */}
      <div ref={pickerWrapRef} className="jalpi-sqaction-elem-wrap">
        <button
          className="jalpi-section-qaction"
          title="Add an element to this section"
          onClick={(e) => { e.stopPropagation(); setShowPicker((v) => !v); }}
        >
          <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round">
            <path d="M12 5v14M5 12h14"/>
          </svg>
          Element
        </button>
        {showPicker && (
          <div className="jalpi-sqaction-popup">
            <BlockPicker
              store={store}
              sectionId={node.id}
              onClose={() => setShowPicker(false)}
            />
          </div>
        )}
      </div>

      {/* Duplicate */}
      <button
        className="jalpi-section-qaction"
        title="Duplicate section (⌘D)"
        onClick={(e) => { e.stopPropagation(); store.duplicateNode(node.id); }}
      >
        <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
          <rect x="8" y="8" width="13" height="13" rx="2"/>
          <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
        </svg>
        Duplicate
      </button>

      {/* Delete — always available here so a section can be removed even when the
         top floating toolbar is clipped above the viewport (e.g. the first section). */}
      <button
        className="jalpi-section-qaction jalpi-section-qaction--danger"
        title="Delete section"
        onClick={(e) => { e.stopPropagation(); store.deleteNode(node.id); }}
      >
        <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
          <polyline points="3 6 5 6 21 6"/>
          <path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
          <path d="M10 11v6M14 11v6"/>
        </svg>
        Delete
      </button>
    </div>
  );
}

// --- Sub-renderers ---

function applyBgData(style: React.CSSProperties, bg: Record<string, unknown> | undefined) {
  if (!bg) return;
  const type = (bg.type as string) || 'classic';
  // Legacy model
  if ('value' in bg || 'url' in bg) {
    if (bg.type === 'color')    { style.backgroundColor = (bg.value as string) || '#fff'; return; }
    if (bg.type === 'gradient') { style.background      = (bg.value as string) || 'none'; return; }
    if (bg.type === 'image' && bg.url) {
      style.backgroundImage    = `url(${bg.url as string})`;
      style.backgroundSize     = 'cover';
      style.backgroundPosition = 'center center';
      return;
    }
  }
  // New model
  if (type === 'classic') {
    if (bg.color)    style.backgroundColor = bg.color    as string;
    if (bg.imageUrl) {
      style.backgroundImage    = `url(${bg.imageUrl as string})`;
      style.backgroundSize     = (bg.imageSize     as string) || 'cover';
      style.backgroundPosition = (bg.imagePosition as string) || 'center center';
      style.backgroundRepeat   = 'no-repeat';
    }
  } else if (type === 'gradient' && bg.gradient) {
    style.background = bg.gradient as string;
  }
}

// Append 'px' to a bare number string ("4" → "4px"); leaves "4px"/"0.5em"/"50%" unchanged.
function withPx(v: string | undefined): string {
  if (!v) return '';
  const t = v.trim();
  return /^-?[\d.]+$/.test(t) ? `${t}px` : t;
}

// Convert React inline style object → CSS string (e.g. backgroundColor → background-color)
function reactStyleToCss(s: React.CSSProperties): string {
  return Object.entries(s)
    .map(([k, v]) => `${k.replace(/([A-Z])/g, m => '-' + m.toLowerCase())}:${v as string}`)
    .join(';');
}

// Build border CSS string from border data object (shared by section and column hover IIFEs)
function borderDataToCssStr(bd: Record<string, unknown>): string {
  let css = '';
  if (bd.borderType && bd.borderType !== 'none') {
    const bColor = (bd.borderColor as string) || '#000';
    const hasPerSide = bd.borderTopWidth || bd.borderRightWidth || bd.borderBottomWidth || bd.borderLeftWidth;
    if (hasPerSide) {
      css += `border-style:${bd.borderType as string};border-color:${bColor}`;
      css += `;border-top-width:${withPx(bd.borderTopWidth as string) || '0'}`;
      css += `;border-right-width:${withPx(bd.borderRightWidth as string) || '0'}`;
      css += `;border-bottom-width:${withPx(bd.borderBottomWidth as string) || '0'}`;
      css += `;border-left-width:${withPx(bd.borderLeftWidth as string) || '0'}`;
    } else {
      css += `border:${withPx(bd.borderWidth as string) || '1px'} ${bd.borderType as string} ${bColor}`;
    }
  }
  const hasPerCorner = bd.borderTopLeftRadius || bd.borderTopRightRadius || bd.borderBottomRightRadius || bd.borderBottomLeftRadius;
  if (hasPerCorner) {
    css += `;border-top-left-radius:${withPx(bd.borderTopLeftRadius as string)}`;
    css += `;border-top-right-radius:${withPx(bd.borderTopRightRadius as string)}`;
    css += `;border-bottom-right-radius:${withPx(bd.borderBottomRightRadius as string)}`;
    css += `;border-bottom-left-radius:${withPx(bd.borderBottomLeftRadius as string)}`;
  } else if (bd.borderRadius) {
    css += `;border-radius:${withPx(bd.borderRadius as string)}`;
  }
  if (bd.boxShadow) css += `;box-shadow:${bd.boxShadow as string}`;
  return css;
}

// ── Resize handle: section bottom edge (height) ───────────────────────────────
function SectionResizeHandle({ node, store }: { node: CBNode; store: EditorStore }) {
  const ref = useRef<HTMLDivElement>(null);
  const onMouseDown = (e: React.MouseEvent) => {
    e.stopPropagation(); e.preventDefault();
    const wrapEl = document.querySelector(`[data-node-id="${node.id}"]`) as HTMLElement | null;
    if (!wrapEl) return;
    const s = (node.settings ?? {}) as Record<string, unknown>;
    const startH = parseFloat((s.minHeight as string) || '0') || wrapEl.offsetHeight;
    rz.type    = 'section-h';
    rz.startPx = e.clientY;
    rz.startV1 = startH;
    rz.zoom    = store.state.zoom;
    rz.el1     = wrapEl;
    rz.onCommit = (h) =>
      store.updateNode(node.id, { settings: { minHeight: `${Math.round(h)}px` } });
    ref.current?.classList.add('is-dragging');
    document.body.style.cursor     = 'ns-resize';
    document.body.style.userSelect = 'none';
    document.addEventListener('mousemove', rzOnMove);
    document.addEventListener('mouseup',  rzOnUp);
  };
  return (
    <div ref={ref} className="jalpi-rz-handle jalpi-rz-handle--h" onMouseDown={onMouseDown}>
      <span className="jalpi-rz-handle__grip" />
    </div>
  );
}

// ── Resize divider: between two columns (width) ───────────────────────────────
function ColumnResizeDivider({
  leftId, rightId, leftPct, rightPct, store, containerRef,
}: {
  leftId:       string;
  rightId:      string;
  leftPct:      number;
  rightPct:     number;
  store:        EditorStore;
  containerRef: React.RefObject<HTMLDivElement>;
}) {
  const ref = useRef<HTMLDivElement>(null);
  const onMouseDown = (e: React.MouseEvent) => {
    e.stopPropagation(); e.preventDefault();
    const el1 = document.querySelector(`[data-node-id="${leftId}"]`)  as HTMLElement | null;
    const el2 = document.querySelector(`[data-node-id="${rightId}"]`) as HTMLElement | null;
    if (!containerRef.current || !el1 || !el2) return;
    rz.type    = 'col-w';
    rz.startPx = e.clientX;
    rz.startV1 = leftPct;
    rz.startV2 = rightPct;
    rz.totalPx = containerRef.current.offsetWidth;
    rz.zoom    = store.state.zoom;
    rz.el1     = el1;
    rz.el2     = el2;
    rz.onCommit = (v1, v2) => {
      store.updateNode(leftId,  { settings: { width: `${v1.toFixed(1)}%` } });
      store.updateNode(rightId, { settings: { width: `${v2.toFixed(1)}%` } });
    };
    ref.current?.classList.add('is-dragging');
    document.body.style.cursor     = 'col-resize';
    document.body.style.userSelect = 'none';
    document.addEventListener('mousemove', rzOnMove);
    document.addEventListener('mouseup',  rzOnUp);
  };
  return (
    <div ref={ref} className="jalpi-col-divider" onMouseDown={onMouseDown}>
      <span className="jalpi-col-divider__grip" />
    </div>
  );
}

// ─────────────────────────────────────────────────────────────────────────────

function SectionRenderer({ node, store, inlineStyles }: { node: CBNode; store: EditorStore; inlineStyles: React.CSSProperties }) {
  const settings = (node.settings ?? {}) as Record<string, unknown>;

  // Build CSS string on every render (no useMemo — node is a Valtio proxy whose
  // reference is stable even when settings change, so useMemo([node]) never recomputes).
  // useLayoutEffect compares by string value, so head injection only fires when content changes.
  const sectionCss = (() => {
    const ov          = settings.overlayNormal as Record<string, unknown> | undefined;
    const ovH         = settings.overlayHover  as Record<string, unknown> | undefined;
    const hasOverlayN = !!(ov?.color  || ov?.imageUrl  || ov?.gradient);
    const hasOverlayH = !!(ovH?.color || ovH?.imageUrl || ovH?.gradient);
    const sel       = `[data-node-id="${node.id}"]`;
    const parts: string[] = [];

    if (hasOverlayN) {
      const tmp: React.CSSProperties = {};
      applyBgData(tmp, ov);
      const op = ov!.opacity != null ? Number(ov!.opacity) : 0.5;
      parts.push(`${sel} .jalpi-section-overlay{${reactStyleToCss(tmp)};opacity:${op}}`);
    }

    if (hasOverlayN || hasOverlayH) {
      // Lift section children above overlay so content stays visible over the tint
      parts.push(`${sel} .jalpi-section-inner>:not(.jalpi-section-overlay){position:relative;z-index:1}`);
    }

    const bgH = settings.bgHover as Record<string, unknown> | undefined;
    let innerH = '';
    if (bgH?.color || bgH?.imageUrl || bgH?.gradient) {
      const tmp: React.CSSProperties = {};
      applyBgData(tmp, bgH);
      innerH += reactStyleToCss(tmp);
    }
    const bdH = settings.borderHover as Record<string, unknown> | undefined;
    if (bdH) innerH += (innerH ? ';' : '') + borderDataToCssStr(bdH);
    if (innerH) parts.push(`${sel}:hover .jalpi-section-inner{${innerH}}`);

    if (hasOverlayH) {
      const tmp: React.CSSProperties = {};
      applyBgData(tmp, ovH);
      const op = ovH!.opacity != null ? Number(ovH!.opacity) : 0.5;
      parts.push(`${sel}:hover .jalpi-section-overlay{${reactStyleToCss(tmp)};opacity:${op}}`);
    }

    return parts.join('');
  })();

  useLayoutEffect(() => {
    const el = document.createElement('style');
    el.setAttribute('data-jalpi-section', node.id);
    el.textContent = sectionCss;
    document.head.appendChild(el);
    return () => { el.remove(); };
  }, [sectionCss, node.id]);

  const style: React.CSSProperties = {};

  // Background (new bgNormal model, falls back to legacy background field)
  applyBgData(style, (settings.bgNormal ?? settings.background) as Record<string, unknown> | undefined);

  // Old-model padding (backwards compat)
  const pad = settings.padding as { top?: string; right?: string; bottom?: string; left?: string } | undefined;
  if (pad) {
    style.paddingTop    = pad.top    || '0';
    style.paddingRight  = pad.right  || '0';
    style.paddingBottom = pad.bottom || '0';
    style.paddingLeft   = pad.left   || '0';
  }

  // Content Width: Full Width breaks out of canvas container; Boxed constrains with max-width
  const contentWidth = (settings.contentWidth as string) || 'full';
  if (contentWidth === 'full') {
    style.width       = '100%';  // fills the canvas viewport in the editor (no real breakout needed in preview)
    style.maxWidth    = 'none';
    style.marginLeft  = '0';
    style.marginRight = '0';
  } else if (contentWidth === 'boxed' && settings.boxedWidth) {
    style.maxWidth    = settings.boxedWidth as string;
    style.marginLeft  = 'auto';
    style.marginRight = 'auto';
  }

  // Align Items → flex column layout
  const alignItems = settings.alignItems as string | undefined;
  if (alignItems) {
    style.display       = 'flex';
    style.flexDirection = 'column';
    style.alignItems    = alignItems;
  }

  // Border (borderNormal) — supports per-side widths and per-corner radii
  const bd = settings.borderNormal as Record<string, unknown> | undefined;
  if (bd?.borderType && bd.borderType !== 'none') {
    const bColor = (bd.borderColor as string) || '#000';
    const hasPerSideW = bd.borderTopWidth || bd.borderRightWidth || bd.borderBottomWidth || bd.borderLeftWidth;
    if (hasPerSideW) {
      style.borderStyle       = bd.borderType as string;
      style.borderColor       = bColor;
      style.borderTopWidth    = withPx(bd.borderTopWidth    as string) || '0';
      style.borderRightWidth  = withPx(bd.borderRightWidth  as string) || '0';
      style.borderBottomWidth = withPx(bd.borderBottomWidth as string) || '0';
      style.borderLeftWidth   = withPx(bd.borderLeftWidth   as string) || '0';
    } else {
      style.border = `${withPx(bd.borderWidth as string) || '1px'} ${bd.borderType as string} ${bColor}`;
    }
  }
  const hasPerCorner = bd?.borderTopLeftRadius || bd?.borderTopRightRadius || bd?.borderBottomRightRadius || bd?.borderBottomLeftRadius;
  if (hasPerCorner) {
    style.borderTopLeftRadius     = withPx(bd?.borderTopLeftRadius     as string);
    style.borderTopRightRadius    = withPx(bd?.borderTopRightRadius    as string);
    style.borderBottomRightRadius = withPx(bd?.borderBottomRightRadius as string);
    style.borderBottomLeftRadius  = withPx(bd?.borderBottomLeftRadius  as string);
  } else if (bd?.borderRadius) {
    style.borderRadius = withPx(bd.borderRadius as string);
  }
  if (bd?.boxShadow) style.boxShadow = bd.boxShadow as string;

  // minHeight from settings (set by the resize handle)
  if (settings.minHeight) style.minHeight = settings.minHeight as string;

  // node.styles (Style / Settings tabs) override everything above
  const mergedStyle: React.CSSProperties = { ...style, ...inlineStyles };

  // Always position:relative so the resize handle can be absolute inside
  mergedStyle.position = 'relative';

  // Overlay div needed if either normal or hover overlay is configured
  const ov          = settings.overlayNormal as Record<string, unknown> | undefined;
  const ovH         = settings.overlayHover  as Record<string, unknown> | undefined;
  const needsOverlayDiv = !!(ov?.color || ov?.imageUrl || ov?.gradient ||
                             ovH?.color || ovH?.imageUrl || ovH?.gradient);

  const isEmpty = !node.children?.length;

  return (
    <div className="jalpi-section-inner" style={mergedStyle}>
      {needsOverlayDiv && (
        <div
          className="jalpi-section-overlay"
          style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 0 }}
        />
      )}
      {node.children?.map((child) => (
        <CanvasNode key={child.id} node={child} store={store} />
      ))}

      {isEmpty && (
        <div className="jalpi-dropzone-empty jalpi-section__empty">
          <div className="jalpi-dropzone-empty__btns">
            <button
              type="button"
              className="jalpi-dropzone-btn jalpi-dropzone-btn--add"
              title="Add element"
              onClick={(e) => {
                e.stopPropagation();
                store.selectNode(node.id);
                window.dispatchEvent(new CustomEvent('cb:openElements'));
              }}
            >+</button>
          </div>
          <p className="jalpi-dropzone-empty__hint">Click <em>+</em> to add elements, or click here to configure this section</p>
        </div>
      )}

      <SectionResizeHandle node={node} store={store} />
    </div>
  );
}

function ContainerRenderer({ node, store, inlineStyles }: { node: CBNode; store: EditorStore; inlineStyles: React.CSSProperties }) {
  const containerRef = useRef<HTMLDivElement>(null);
  const settings = (node.settings ?? {}) as Record<string, unknown>;

  // Inject CSS to lift container children above the overlay (same pattern as SectionRenderer)
  const containerOverlayCss = (() => {
    const ov  = settings.overlayNormal as Record<string, unknown> | undefined;
    const ovH = settings.overlayHover  as Record<string, unknown> | undefined;
    const needsRule = !!(ov?.color || ov?.imageUrl || ov?.gradient ||
                         ovH?.color || ovH?.imageUrl || ovH?.gradient);
    if (!needsRule) return '';
    const sel = `[data-node-id="${node.id}"]`;
    return `${sel} .jalpi-container-inner>:not(.jalpi-container-overlay){position:relative;z-index:1}`;
  })();

  useLayoutEffect(() => {
    if (!containerOverlayCss) return;
    const el = document.createElement('style');
    el.setAttribute('data-jalpi-container', node.id);
    el.textContent = containerOverlayCss;
    document.head.appendChild(el);
    return () => { el.remove(); };
  }, [containerOverlayCss, node.id]);

  const mergedStyle: React.CSSProperties = {
    marginLeft:   'auto',
    marginRight:  'auto',
    width:        '100%',
    boxSizing:    'border-box',
    paddingLeft:  '20px',
    paddingRight: '20px',
  };

  // Content width (boxed)
  if (settings.contentWidth === 'boxed' && settings.boxedWidth) {
    mergedStyle.maxWidth = settings.boxedWidth as string;
  }

  // Determine layout mode
  const children   = node.children ?? [];
  const hasColumns = children.length > 0 && children.every((c) => c.type === 'column');
  const layoutMode = (settings.layoutMode as string) || (hasColumns ? '_columns' : 'block');

  if ((hasColumns && layoutMode !== 'grid' && layoutMode !== 'flex') || layoutMode === '_columns') {
    // Column children → flex row (explicit flex/grid mode takes priority)
    mergedStyle.display        = 'flex';
    mergedStyle.flexDirection  = 'row';
    mergedStyle.flexWrap       = 'nowrap';
    mergedStyle.gap            = (settings.columnGap as string) || '20px';
    mergedStyle.paddingLeft    = '0';
    mergedStyle.paddingRight   = '0';
    mergedStyle.alignItems     = 'stretch';
  } else if (layoutMode === 'flex') {
    mergedStyle.display        = 'flex';
    mergedStyle.flexDirection  = ((settings.flexDirection as string) || 'row') as React.CSSProperties['flexDirection'];
    mergedStyle.flexWrap       = ((settings.flexWrapMode  as string) || 'nowrap') as React.CSSProperties['flexWrap'];
    mergedStyle.justifyContent = (settings.flexJustify   as string) || 'flex-start';
    mergedStyle.alignItems     = (settings.flexAlign     as string) || 'stretch';
    mergedStyle.gap            = (settings.flexGap       as string) || '0';
    mergedStyle.paddingLeft    = '0';
    mergedStyle.paddingRight   = '0';
  } else if (layoutMode === 'grid') {
    const gridAutoMode   = (settings.gridAutoMode   as string) || 'none';
    const gridColumns    = (settings.gridColumns    as string) || '3';
    const gridMinColSize = (settings.gridMinColSize as string) || '200px';
    const gridGap        = (settings.gridGap        as string) || '20px';
    const gridRows       = (settings.gridRows       as string) || '';
    let templateCols: string;
    if (gridAutoMode === 'fill')     templateCols = `repeat(auto-fill, minmax(${gridMinColSize}, 1fr))`;
    else if (gridAutoMode === 'fit') templateCols = `repeat(auto-fit, minmax(${gridMinColSize}, 1fr))`;
    else                             templateCols = `repeat(${parseInt(gridColumns) || 3}, 1fr)`;
    mergedStyle.display             = 'grid';
    mergedStyle.gridTemplateColumns = templateCols;
    mergedStyle.gap                 = gridGap;
    mergedStyle.paddingLeft         = '0';
    mergedStyle.paddingRight        = '0';
    if (gridRows && gridRows !== 'auto') {
      const rc = parseInt(gridRows);
      if (rc > 0) mergedStyle.gridTemplateRows = `repeat(${rc}, 1fr)`;
    }
  } else {
    // Block mode — backwards-compat legacy grid/flex
    const cols = (settings.columns as number) || 1;
    if (cols > 1) {
      mergedStyle.display             = 'grid';
      mergedStyle.gridTemplateColumns = `repeat(${cols}, 1fr)`;
      mergedStyle.gap                 = (settings.gap as string) || '30px';
    } else if (settings.direction && !settings.alignItems) {
      mergedStyle.display        = 'flex';
      mergedStyle.flexDirection  = settings.direction as React.CSSProperties['flexDirection'];
      mergedStyle.justifyContent = (settings.justifyContent as string) || 'flex-start';
      mergedStyle.alignItems     = (settings.alignItems     as string) || 'stretch';
      mergedStyle.flexWrap       = ((settings.flexWrap as string) || 'nowrap') as React.CSSProperties['flexWrap'];
      if (settings.gap) mergedStyle.gap = settings.gap as string;
    } else if (settings.alignItems) {
      mergedStyle.display       = 'flex';
      mergedStyle.flexDirection = 'column';
      mergedStyle.alignItems    = settings.alignItems as string;
    }
  }

  // Background (new model)
  const bg = settings.bgNormal as Record<string, unknown> | undefined;
  if (bg) applyBgData(mergedStyle, bg);

  // Border — per-side widths + per-corner radii
  const bd = settings.borderNormal as Record<string, unknown> | undefined;
  if (bd) {
    if (bd.borderType && bd.borderType !== 'none') {
      const bColor = (bd.borderColor as string) || '#000000';
      const hasPerSide = bd.borderTopWidth || bd.borderRightWidth || bd.borderBottomWidth || bd.borderLeftWidth;
      if (hasPerSide) {
        mergedStyle.borderStyle       = bd.borderType as string;
        mergedStyle.borderColor       = bColor;
        mergedStyle.borderTopWidth    = withPx(bd.borderTopWidth    as string) || '0';
        mergedStyle.borderRightWidth  = withPx(bd.borderRightWidth  as string) || '0';
        mergedStyle.borderBottomWidth = withPx(bd.borderBottomWidth as string) || '0';
        mergedStyle.borderLeftWidth   = withPx(bd.borderLeftWidth   as string) || '0';
      } else {
        mergedStyle.border = `${withPx(bd.borderWidth as string) || '1px'} ${bd.borderType} ${bColor}`;
      }
    }
    const hasPerCorner = bd.borderTopLeftRadius || bd.borderTopRightRadius || bd.borderBottomRightRadius || bd.borderBottomLeftRadius;
    if (hasPerCorner) {
      mergedStyle.borderTopLeftRadius     = withPx(bd.borderTopLeftRadius     as string);
      mergedStyle.borderTopRightRadius    = withPx(bd.borderTopRightRadius    as string);
      mergedStyle.borderBottomRightRadius = withPx(bd.borderBottomRightRadius as string);
      mergedStyle.borderBottomLeftRadius  = withPx(bd.borderBottomLeftRadius  as string);
    } else if (bd.borderRadius) {
      mergedStyle.borderRadius = withPx(bd.borderRadius as string);
    }
    if (bd.boxShadow) mergedStyle.boxShadow = bd.boxShadow as string;
  }

  // Overlay
  const ov        = settings.overlayNormal as Record<string, unknown> | undefined;
  const hasOverlay = !!(ov?.color || ov?.imageUrl || ov?.gradient);
  if (hasOverlay) mergedStyle.position = 'relative';

  // Strip legacy '100%' maxWidth that old containers stored in node.styles,
  // but preserve real responsive overrides (e.g. tabletStyles.maxWidth).
  const inlineMap = inlineStyles as Record<string, string>;
  const { maxWidth: _drop, ...safeInline } = inlineMap;
  if (_drop && _drop !== '100%') safeInline.maxWidth = _drop;
  Object.assign(mergedStyle, safeInline);

  const overlayStyle: React.CSSProperties = {};
  if (hasOverlay) {
    applyBgData(overlayStyle, ov);
    overlayStyle.opacity = ov?.opacity != null ? Number(ov.opacity) : 0.5;
  }

  const isEmpty = children.length === 0;

  const isBoxed   = settings.contentWidth === 'boxed';
  const boxedW    = (settings.boxedWidth as string) || '';
  const boxedLabel = isBoxed && boxedW ? `max-w: ${boxedW}` : undefined;

  return (
    <div ref={containerRef} className="jalpi-container-inner" style={{ ...mergedStyle, position: 'relative' }} data-boxed-label={boxedLabel || undefined}>
      {hasOverlay && (
        <div
          className="jalpi-container-overlay"
          style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 0, ...overlayStyle }}
        />
      )}

      {node.children?.map((child, idx) => {
        if (hasColumns && layoutMode !== 'grid' && child.type === 'column') {
          const cs = (child.settings ?? {}) as Record<string, unknown>;
          const cw = (cs.width as string) || `${Math.round(100 / children.length)}%`;
          // inlineStyles carries the active-breakpoint override (tabletStyles/mobileStyles merged).
          // For flex mode, settings.flexDirection is the desktop setting; inlineStyles wins when
          // a responsive override (Stack Vertically toggle) is in effect.
          const flexDirOverride =
            ((inlineStyles as Record<string, string>).flexDirection) ||
            (layoutMode === 'flex' ? ((settings.flexDirection as string) || 'row') : 'row');
          const isVerticalStack = flexDirOverride === 'column' || flexDirOverride === 'column-reverse';
          const colWrapperStyle: React.CSSProperties = isVerticalStack
            ? { width: '100%', boxSizing: 'border-box', minWidth: 0 }
            : { flex: `0 1 ${cw}`, maxWidth: cw, boxSizing: 'border-box', minWidth: 0 };
          const isLast  = idx === children.length - 1;
          const nextCol = !isLast ? children[idx + 1] : null;
          const nw = nextCol
            ? ((nextCol.settings ?? {}) as Record<string, unknown>).width as string | undefined
            : undefined;
          return (
            <React.Fragment key={child.id}>
              <CanvasNode node={child} store={store} wrapperStyle={colWrapperStyle} />
              {!isLast && !isVerticalStack && nextCol && (
                <ColumnResizeDivider
                  leftId={child.id}
                  rightId={nextCol.id}
                  leftPct={parseFloat(cw) || (100 / children.length)}
                  rightPct={parseFloat(nw || '') || (100 / children.length)}
                  store={store}
                  containerRef={containerRef}
                />
              )}
            </React.Fragment>
          );
        }
        return <CanvasNode key={child.id} node={child} store={store} />;
      })}

      {/* Block mode: empty state */}
      {!hasColumns && isEmpty && (
        <div className="jalpi-dropzone-empty">
          <div className="jalpi-dropzone-empty__btns">
            <button
              type="button"
              className="jalpi-dropzone-btn jalpi-dropzone-btn--add"
              title="Add element"
              onClick={(e) => {
                e.stopPropagation();
                store.selectNode(node.id);
                window.dispatchEvent(new CustomEvent('cb:openElements'));
              }}
            >+</button>
          </div>
        </div>
      )}

    </div>
  );
}

function ColumnRenderer({ node, store, inlineStyles }: { node: CBNode; store: EditorStore; inlineStyles: React.CSSProperties }) {
  const settings = (node.settings ?? {}) as Record<string, unknown>;

  // Build CSS string on every render (no useMemo — same Valtio proxy reference issue as SectionRenderer)
  const columnCss = (() => {
    const ov          = settings.overlayNormal as Record<string, unknown> | undefined;
    const ovH         = settings.overlayHover  as Record<string, unknown> | undefined;
    const hasOverlayN = !!(ov?.color  || ov?.imageUrl  || ov?.gradient);
    const hasOverlayH = !!(ovH?.color || ovH?.imageUrl || ovH?.gradient);
    const sel   = `[data-node-id="${node.id}"]`;
    const parts: string[] = [];

    if (hasOverlayN) {
      const tmp: React.CSSProperties = {};
      applyBgData(tmp, ov);
      const op = ov!.opacity != null ? Number(ov!.opacity) : 0.5;
      parts.push(`${sel} .jalpi-column-overlay{${reactStyleToCss(tmp)};opacity:${op}}`);
    }

    if (hasOverlayN || hasOverlayH) {
      // Lift column children above overlay so content stays visible over the tint
      parts.push(`${sel} .jalpi-column-inner>:not(.jalpi-column-overlay){position:relative;z-index:1}`);
    }

    const bgH = settings.bgHover as Record<string, unknown> | undefined;
    let innerH = '';
    if (bgH?.color || bgH?.imageUrl || bgH?.gradient) {
      const tmp: React.CSSProperties = {};
      applyBgData(tmp, bgH);
      innerH += reactStyleToCss(tmp);
    }
    const bdH = settings.borderHover as Record<string, unknown> | undefined;
    if (bdH) innerH += (innerH ? ';' : '') + borderDataToCssStr(bdH);
    if (innerH) parts.push(`${sel}:hover .jalpi-column-inner{${innerH}}`);

    if (hasOverlayH) {
      const tmp: React.CSSProperties = {};
      applyBgData(tmp, ovH);
      const op = ovH!.opacity != null ? Number(ovH!.opacity) : 0.5;
      parts.push(`${sel}:hover .jalpi-column-overlay{${reactStyleToCss(tmp)};opacity:${op}}`);
    }

    return parts.join('');
  })();

  useLayoutEffect(() => {
    const el = document.createElement('style');
    el.setAttribute('data-jalpi-column', node.id);
    el.textContent = columnCss;
    document.head.appendChild(el);
    return () => { el.remove(); };
  }, [columnCss, node.id]);

  const style: React.CSSProperties = {
    width:     '100%',
    boxSizing: 'border-box',
    minHeight: '100px',
    padding:   '8px',
    position:  'relative',
  };

  if (settings.alignSelf) style.alignSelf = settings.alignSelf as string;

  // Background
  const bg = settings.bgNormal as Record<string, unknown> | undefined;
  if (bg) applyBgData(style, bg);

  // Border — per-side widths + per-corner radii
  const bd = settings.borderNormal as Record<string, unknown> | undefined;
  if (bd) {
    if (bd.borderType && bd.borderType !== 'none') {
      const bColor = (bd.borderColor as string) || '#000';
      const hasPerSide = bd.borderTopWidth || bd.borderRightWidth || bd.borderBottomWidth || bd.borderLeftWidth;
      if (hasPerSide) {
        style.borderStyle       = bd.borderType as string;
        style.borderColor       = bColor;
        style.borderTopWidth    = withPx(bd.borderTopWidth    as string) || '0';
        style.borderRightWidth  = withPx(bd.borderRightWidth  as string) || '0';
        style.borderBottomWidth = withPx(bd.borderBottomWidth as string) || '0';
        style.borderLeftWidth   = withPx(bd.borderLeftWidth   as string) || '0';
      } else {
        style.border = `${withPx(bd.borderWidth as string) || '1px'} ${bd.borderType} ${bColor}`;
      }
    }
    const hasPerCorner = bd.borderTopLeftRadius || bd.borderTopRightRadius || bd.borderBottomRightRadius || bd.borderBottomLeftRadius;
    if (hasPerCorner) {
      style.borderTopLeftRadius     = withPx(bd.borderTopLeftRadius     as string);
      style.borderTopRightRadius    = withPx(bd.borderTopRightRadius    as string);
      style.borderBottomRightRadius = withPx(bd.borderBottomRightRadius as string);
      style.borderBottomLeftRadius  = withPx(bd.borderBottomLeftRadius  as string);
    } else if (bd.borderRadius) {
      style.borderRadius = withPx(bd.borderRadius as string);
    }
    if (bd.boxShadow) style.boxShadow = bd.boxShadow as string;
  }

  // Overlay div needed if either normal or hover overlay is set
  const ov          = settings.overlayNormal as Record<string, unknown> | undefined;
  const ovH         = settings.overlayHover  as Record<string, unknown> | undefined;
  const needsOverlayDiv = !!(ov?.color || ov?.imageUrl || ov?.gradient ||
                             ovH?.color || ovH?.imageUrl || ovH?.gradient);
  if (needsOverlayDiv) style.position = 'relative';

  Object.assign(style, inlineStyles);

  const isEmpty = !node.children || node.children.length === 0;

  return (
    <div className="jalpi-column-inner" style={style}>
      {needsOverlayDiv && (
        <div
          className="jalpi-column-overlay"
          style={{ position: 'absolute', inset: 0, pointerEvents: 'none', zIndex: 0 }}
        />
      )}
      {node.children?.map((child) => (
        <CanvasNode key={child.id} node={child} store={store} />
      ))}

      {isEmpty && (
        <div className="jalpi-dropzone-empty">
          <div className="jalpi-dropzone-empty__btns">
            <button
              type="button"
              className="jalpi-dropzone-btn jalpi-dropzone-btn--add"
              title="Add element"
              onClick={(e) => {
                e.stopPropagation();
                store.selectNode(node.id);
                window.dispatchEvent(new CustomEvent('cb:openElements'));
              }}
            >+</button>
          </div>
        </div>
      )}

    </div>
  );
}

function HeadingRenderer({
  node,
  store,
  isEditing,
  onEditEnd,
  inlineStyles,
}: {
  node: CBNode;
  store: EditorStore;
  isEditing: boolean;
  onEditEnd: () => void;
  inlineStyles: React.CSSProperties;
}) {
  const content = node.content as { text?: string; tag?: string };
  const Tag     = (content?.tag || 'h2') as keyof JSX.IntrinsicElements;

  if (isEditing) {
    // Use a native <input> instead of contentEditable — React manages inputs
    // correctly with defaultValue so there is zero risk of text duplication.
    return (
      <input
        type="text"
        className="jalpi-heading-input"
        defaultValue={content?.text || ''}
        placeholder="Add Your Heading Text Here"
        // eslint-disable-next-line jsx-a11y/no-autofocus
        autoFocus
        style={inlineStyles as React.CSSProperties}
        onBlur={(e) => {
          store.updateNodeContent(node.id, { text: e.target.value.trim() });
          onEditEnd();
        }}
        onKeyDown={(e) => {
          if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); }
          if (e.key === 'Escape')  { e.currentTarget.blur(); }
        }}
        onClick={(e) => e.stopPropagation()}
      />
    );
  }

  if (!content?.text) {
    return (
      <Tag style={{ ...inlineStyles, margin: 0 }}>
        <span className="jalpi-heading-placeholder">Add Your Heading Text Here</span>
      </Tag>
    );
  }
  // Heading text may contain inline markup — <br/> for a deliberate line break,
  // <strong>/<em>/<span> for emphasis. The frontend renders it through
  // wp_kses_post(), so rendering it as a plain React text node here would show
  // the raw tags ("ELEVEN KIDS AND<BR />ONE HIRED COURT.") and make the editor
  // disagree with the published page. Mirror the frontend, but allow only the
  // small set of inline tags a heading can legitimately contain.
  return (
    <Tag
      style={{ ...inlineStyles, margin: 0 }}
      dangerouslySetInnerHTML={{ __html: sanitizeHeadingHtml(content.text) }}
    />
  );
}

/**
 * Retarget viewport-relative units to the canvas.
 *
 * `vw`/`vh` resolve against the BROWSER window, but the editor canvas is a
 * narrower panel (and shrinks further in tablet/mobile preview). Fluid type
 * such as `clamp(3.2rem, 10.5vw, 7.4rem)` therefore rendered at full desktop
 * size inside a ~1000px canvas and wrapped mid-word.
 *
 * `.jalpi-canvas` declares `container-type: inline-size`, so swapping `vw` for
 * `cqw` (and `vh` for `cqh`) makes those values resolve against the canvas —
 * the editor then matches the published page at every breakpoint. Falls back to
 * leaving values untouched where container queries are unsupported.
 */
const SUPPORTS_CQ =
  typeof CSS !== 'undefined' &&
  typeof CSS.supports === 'function' &&
  CSS.supports('container-type: inline-size');

function retargetViewportUnits<T extends Record<string, unknown>>(styles: T): T {
  if (!SUPPORTS_CQ) return styles;
  let changed = false;
  const out: Record<string, unknown> = {};
  for (const key in styles) {
    const val = styles[key];
    if (typeof val === 'string' && /\d(?:\.\d+)?v[wh]\b/.test(val)) {
      out[key] = val.replace(/(\d(?:\.\d+)?)vw\b/g, '$1cqw')
                    .replace(/(\d(?:\.\d+)?)vh\b/g, '$1cqh');
      changed = true;
    } else {
      out[key] = val;
    }
  }
  return (changed ? out : styles) as T;
}

/**
 * Strip everything except the inline tags a heading may contain.
 * Deliberately narrow: no attributes survive, so no href/src/style/on* can be
 * smuggled in, and anything unrecognised is escaped rather than dropped.
 */
function sanitizeHeadingHtml(raw: string): string {
  const ALLOWED = /^(br|strong|b|em|i|u|s|sub|sup|span|small|mark)$/i;
  return raw.replace(/<\/?([a-zA-Z0-9-]+)[^>]*>/g, (match, tag) => {
    if (!ALLOWED.test(tag)) {
      // Escape the whole thing so it is visible but inert.
      return match.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
    }
    // Re-emit the tag with no attributes at all.
    return match.startsWith('</') ? `</${tag.toLowerCase()}>` : `<${tag.toLowerCase()}>`;
  });
}

function TextRenderer({
  node,
  store,
  isEditing,
  onEditEnd,
  inlineStyles,
}: {
  node: CBNode;
  store: EditorStore;
  isEditing: boolean;
  onEditEnd: () => void;
  inlineStyles: React.CSSProperties;
}) {
  const content    = node.content as { html?: string; text?: string };
  const storedHtml = content?.html || content?.text || '';

  if (isEditing) {
    return (
      <div
        contentEditable
        suppressContentEditableWarning
        style={inlineStyles}
        className="jalpi-inline-edit"
        dangerouslySetInnerHTML={{ __html: storedHtml }}
        onBlur={(e) => {
          const raw     = (e.target as HTMLElement).innerHTML;
          const cleaned = raw === '<br>' ? '' : raw;
          store.updateNodeContent(node.id, { html: cleaned });
          onEditEnd();
        }}
        onKeyDown={(e) => { if (e.key === 'Escape') (e.target as HTMLElement).blur(); }}
      />
    );
  }

  if (!storedHtml) {
    return (
      <div style={inlineStyles} className="jalpi-text-placeholder">
        <span className="jalpi-text-placeholder__inner">Double-click to add text…</span>
      </div>
    );
  }

  return (
    <div style={inlineStyles} dangerouslySetInnerHTML={{ __html: storedHtml }} />
  );
}

function ImageRenderer({
  node,
  inlineStyles,
}: {
  node: CBNode;
  inlineStyles: React.CSSProperties;
}) {
  const content = node.content as { url?: string; alt?: string; caption?: string; linkUrl?: string };
  const s = (node.settings ?? {}) as Record<string, string>;
  const str = (k: string) => s[k] || '';
  const [hovered, setHovered] = React.useState(false);

  // textAlign from styles → wrapper; everything else → <img>
  const { textAlign, ...imgBaseStyles } = inlineStyles as Record<string, unknown>;

  const buildFilter = (suffix: string) => {
    const parts: string[] = [];
    const blur = str(`img_blur${suffix}`);
    const bri  = str(`img_brightness${suffix}`);
    const con  = str(`img_contrast${suffix}`);
    const sat  = str(`img_saturate${suffix}`);
    const hue  = str(`img_hue${suffix}`);
    if (blur && blur !== '0' && blur !== '0px') parts.push(`blur(${blur})`);
    if (bri  && bri  !== '100' && bri  !== '100%') parts.push(`brightness(${parseFloat(bri.replace('%',''))/100})`);
    if (con  && con  !== '100' && con  !== '100%') parts.push(`contrast(${parseFloat(con.replace('%',''))/100})`);
    if (sat  && sat  !== '100' && sat  !== '100%') parts.push(`saturate(${parseFloat(sat.replace('%',''))/100})`);
    if (hue  && hue  !== '0'   && hue  !== '0deg') parts.push(`hue-rotate(${hue})`);
    return parts.length ? parts.join(' ') : undefined;
  };

  const suffix      = hovered ? 'Hover' : '';
  const opacityRaw  = str(`img_opacity${suffix}`) || '100';
  const opacity     = parseFloat(opacityRaw) / 100;

  const brTop    = str('img_br_top');
  const brRight  = str('img_br_right');
  const brBottom = str('img_br_bottom');
  const brLeft   = str('img_br_left');
  const bRadius  = (brTop || brRight || brBottom || brLeft)
    ? `${brTop||'0'} ${brRight||'0'} ${brBottom||'0'} ${brLeft||'0'}` : undefined;

  const bdType   = str('img_bdType');
  const hasBd    = bdType && bdType !== 'none' && bdType !== 'default';
  const bdColor  = str('img_bdColor') || '#000';
  const bdwT     = str('img_bdw_top')    || '1px';
  const bdwR     = str('img_bdw_right')  || '1px';
  const bdwB     = str('img_bdw_bottom') || '1px';
  const bdwL     = str('img_bdw_left')   || '1px';

  const hasHover = !!(str('img_opacityHover') || str('img_blurHover') || str('img_brightnessHover'));

  // Alignment: margin auto on the block-level img (preserves figcaption as full-width block)
  const alignMargin =
    textAlign === 'center' ? { marginLeft: 'auto' as const, marginRight: 'auto' as const } :
    textAlign === 'right'  ? { marginLeft: 'auto' as const, marginRight: '0' as const } :
    {};

  const imgStyle: React.CSSProperties = {
    maxWidth: '100%',                           // default — user's maxWidth (spread below) overrides this
    ...(imgBaseStyles as React.CSSProperties),  // user's width / maxWidth / height
    display: 'block',
    ...alignMargin,
    objectFit:    str('img_objectFit') as React.CSSProperties['objectFit'] || undefined,
    opacity:      opacity !== 1 ? opacity : undefined,
    filter:       buildFilter(suffix) || undefined,
    borderRadius: bRadius || undefined,
    borderTop:    hasBd ? `${bdwT} ${bdType} ${bdColor}` : undefined,
    borderRight:  hasBd ? `${bdwR} ${bdType} ${bdColor}` : undefined,
    borderBottom: hasBd ? `${bdwB} ${bdType} ${bdColor}` : undefined,
    borderLeft:   hasBd ? `${bdwL} ${bdType} ${bdColor}` : undefined,
    boxShadow:    str('img_boxShadow') || undefined,
    transition:   hasHover ? 'opacity 0.3s ease, filter 0.3s ease' : undefined,
  };

  const wrapStyle: React.CSSProperties = {};

  if (!content?.url) {
    return (
      <div className="jalpi-image-placeholder">
        <span>🖼 Click to set image</span>
      </div>
    );
  }

  const caption = content.caption || '';

  const capShadowH = str('img_cap_textShadowH');
  const capShadowV = str('img_cap_textShadowV');
  const capShadowB = str('img_cap_textShadowBlur');
  const captionStyle: React.CSSProperties = {
    textAlign:       (str('img_cap_textAlign') || undefined) as React.CSSProperties['textAlign'],
    color:           str('img_cap_color')   || undefined,
    backgroundColor: str('img_cap_bgColor') || undefined,
    padding:         str('img_cap_spacing') || '8px',
    fontFamily:      str('img_cap_fontFamily')    || undefined,
    fontSize:        str('img_cap_fontSize')      || undefined,
    fontWeight:      (str('img_cap_fontWeight') || undefined) as React.CSSProperties['fontWeight'],
    lineHeight:      str('img_cap_lineHeight')    || undefined,
    letterSpacing:   str('img_cap_letterSpacing') || undefined,
    textTransform:   (str('img_cap_textTransform') || undefined) as React.CSSProperties['textTransform'],
    textShadow:      (capShadowH || capShadowV || capShadowB)
      ? `${capShadowH||'0px'} ${capShadowV||'0px'} ${capShadowB||'0px'} ${str('img_cap_textShadowColor')||'rgba(0,0,0,0.5)'}`
      : undefined,
  };

  const img = (
    <img
      src={content.url}
      alt={content.alt || ''}
      style={imgStyle}
      onMouseEnter={() => setHovered(true)}
      onMouseLeave={() => setHovered(false)}
    />
  );

  return (
    <figure style={{ margin: 0, ...wrapStyle }}>
      {content.linkUrl ? <a href={content.linkUrl}>{img}</a> : img}
      {caption && <figcaption style={captionStyle}>{caption}</figcaption>}
    </figure>
  );
}

function ButtonRenderer({
  node,
  store,
  isEditing,
  onEditEnd,
  inlineStyles,
}: {
  node: CBNode;
  store: EditorStore;
  isEditing: boolean;
  onEditEnd: () => void;
  inlineStyles: React.CSSProperties;
}) {
  const content = node.content as {
    text?:         string;
    url?:          string;
    newTab?:       boolean;
    buttonType?:   string;
    icon?:         string;
    iconPosition?: string;
    iconSpacing?:  number;
    buttonId?:     string;
  };

  const btnClass = [
    'jalpi-btn-preview',
    content?.buttonType && content.buttonType !== 'default'
      ? `jalpi-btn-preview--${content.buttonType}`
      : '',
  ].filter(Boolean).join(' ');

  // Support new IconValue object and legacy string key
  const rawIcon = content?.icon;
  const iconSpacing = content?.iconSpacing ?? 6;
  const iconEl = rawIcon
    ? <span className="jalpi-btn-icon" style={{ display:'inline-flex', alignItems:'center' }}>
        {typeof rawIcon === 'string'
          ? <span dangerouslySetInnerHTML={{ __html: ICONS[rawIcon]?.svg || '' }} />
          : <RenderIconValue value={rawIcon as IconValue} />}
      </span>
    : null;
  const label = content?.text || 'Click Here';
  const pos   = content?.iconPosition || 'before';
  const gapStyle: React.CSSProperties = iconEl ? { gap: `${iconSpacing}px` } : {};

  // textAlign/width/height control layout at the wrapper level — strip them from the <a>
  const { textAlign, width: _w, height: _h, ...btnStyles } = inlineStyles as React.CSSProperties & { textAlign?: string; width?: string; height?: string };
  const justifyMap: Record<string, string> = { left: 'flex-start', center: 'center', right: 'flex-end', justify: 'stretch' };
  const wrapStyle: React.CSSProperties = {
    display:        'flex',
    justifyContent: justifyMap[textAlign || 'center'] || 'center',
    width:          '100%',
  };

  // Hover state — apply hover styles when mouse is over the button
  const [btnHovered, setBtnHovered] = useState(false);
  const ns = (node.styles ?? {}) as Record<string, string>;
  const hoverStyles: React.CSSProperties = {};
  if (btnHovered) {
    if (ns.colorHover) hoverStyles.color = ns.colorHover;
    if (ns.backgroundType === 'gradient') {
      const c1    = ns.gradientColor1Hover || ns.gradientColor1 || '#000000';
      const c2    = ns.gradientColor2Hover || ns.gradientColor2 || '#ffffff';
      const angle = ns.gradientAngle       || '90deg';
      hoverStyles.backgroundImage = `linear-gradient(${angle}, ${c1}, ${c2})`;
    } else if (ns.backgroundColorHover) {
      hoverStyles.backgroundColor = ns.backgroundColorHover;
    }
    if (ns.borderColorHover) hoverStyles.borderColor = ns.borderColorHover;
    if (ns.boxShadowHover)   hoverStyles.boxShadow   = ns.boxShadowHover;
  }

  if (isEditing) {
    return (
      <div style={wrapStyle}>
        <input
          type="text"
          className={`jalpi-heading-input ${btnClass}`}
          defaultValue={content?.text || ''}
          placeholder="Button text"
          autoFocus
          style={{ ...btnStyles, display: 'inline-block' } as React.CSSProperties}
          onBlur={(e) => {
            store.updateNodeContent(node.id, { text: e.target.value });
            onEditEnd();
          }}
          onKeyDown={(e) => {
            if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); }
            if (e.key === 'Escape') e.currentTarget.blur();
          }}
          onClick={(e) => e.stopPropagation()}
        />
      </div>
    );
  }

  return (
    <div style={wrapStyle}>
      <span
        role="link"
        data-href={content?.url || '#'}
        style={{ ...btnStyles, display: 'inline-flex', alignItems: 'center', cursor: 'default', ...gapStyle, ...hoverStyles }}
        className={btnClass}
        onMouseEnter={() => setBtnHovered(true)}
        onMouseLeave={() => setBtnHovered(false)}
      >
        {pos === 'before' && iconEl}
        {label}
        {pos === 'after' && iconEl}
      </span>
    </div>
  );
}

function DividerRenderer({ node }: { node: CBNode }) {
  const s  = (node.styles   ?? {}) as Record<string, string>;
  const st = (node.settings ?? {}) as { borderStyle?: string; width?: string; align?: string };

  // Parse color and thickness from explicit longhands first, then fall back
  // to parsing the borderTop shorthand written by DividerContent's update().
  const parseShorthand = (v: string) => ({
    color: (v.match(/#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)/) || [])[0] || '#e5e7eb',
    width: (v.match(/(\d+(?:\.\d+)?)px/) || ['', '1'])[1] + 'px',
  });
  const sh        = parseShorthand(s.borderTop || '');
  const color     = s.borderTopColor  || sh.color;
  const thickness = s.borderTopWidth  || sh.width;
  const lineStyle = st.borderStyle    || s.borderTopStyle || 'solid';
  const width     = st.width          || s.width          || '100%';
  const align     = st.align          || 'center';
  const ml        = align === 'left'  ? '0' : 'auto';
  const mr        = align === 'right' ? '0' : 'auto';

  return (
    <div
      style={{
        display:       'block',
        width:         width,
        height:        0,
        borderTop:     `${thickness} ${lineStyle} ${color}`,
        marginTop:     s.marginTop    || '20px',
        marginBottom:  s.marginBottom || '20px',
        marginLeft:    ml,
        marginRight:   mr,
        boxSizing:     'border-box',
      }}
    />
  );
}

function SpacerRenderer({ node }: { node: CBNode }) {
  const s = node.settings as { height?: string };
  return (
    <div
      className="jalpi-spacer-preview"
      style={{ height: s?.height || '40px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
    >
      <span className="jalpi-spacer-label">{s?.height || '40px'}</span>
    </div>
  );
}

const ASPECT_RATIOS: Record<string, number> = {
  '16:9': 56.25, '4:3': 75, '1:1': 100, '9:16': 177.78, '21:9': 42.86, '3:4': 133.33,
};

function VideoRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  const c = (node.content ?? {}) as {
    source?: string; url?: string; startTime?: string; endTime?: string;
    autoplay?: boolean; muted?: boolean; loop?: boolean; controls?: boolean;
    captions?: boolean; privacyMode?: boolean; lazyLoad?: boolean; suggestedVideos?: string;
  };
  const s   = (node.settings ?? {}) as Record<string, string>;
  const str = (k: string) => s[k] || '';
  const url = c.url || '';

  const aspect = str('vid_aspect') || '16:9';
  const padPct = ASPECT_RATIOS[aspect] ?? 56.25;

  // Border / radius from settings; width comes through inlineStyles (node.styles.width)
  const wrapStyle: React.CSSProperties = { maxWidth: '100%' };
  const bdType = str('vid_bdType');
  if (bdType && bdType !== 'default' && bdType !== 'none') {
    wrapStyle.border = `${str('vid_bdw_top') || '1px'} ${bdType} ${str('vid_bdColor') || '#e5e7eb'}`;
  }
  if (str('vid_br_top')) {
    wrapStyle.borderRadius = `${str('vid_br_top')} ${str('vid_br_right') || str('vid_br_top')} ${str('vid_br_bottom') || str('vid_br_top')} ${str('vid_br_left') || str('vid_br_top')}`;
    wrapStyle.overflow = 'hidden';
  }

  if (!url) {
    return (
      <div className="jalpi-video-placeholder" style={{ ...inlineStyles, ...wrapStyle }}>
        <span className="jalpi-video-placeholder__icon">▶</span>
        <span>Set a video URL in Properties</span>
      </div>
    );
  }

  const source = c.source || (
    /youtube\.com|youtu\.be/.test(url) ? 'youtube' :
    /vimeo\.com/.test(url)             ? 'vimeo'   : 'self'
  );

  const innerStyle: React.CSSProperties = { position: 'relative', paddingBottom: `${padPct}%`, height: 0, overflow: 'hidden' };
  const iframeStyle: React.CSSProperties = { position: 'absolute', top: 0, left: 0, width: '100%', height: '100%', border: 'none' };

  if (source === 'youtube') {
    const m = url.match(/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([A-Za-z0-9_-]{11})/);
    if (m) {
      const params: string[] = ['enablejsapi=0'];
      if (c.autoplay)                          params.push('autoplay=1');
      if (c.muted)                             params.push('mute=1');
      if (c.loop)                              params.push(`loop=1&playlist=${m[1]}`);
      if (c.controls === false)                params.push('controls=0');
      if (c.captions)                          params.push('cc_load_policy=1');
      if (c.suggestedVideos !== 'any')         params.push('rel=0');
      if (c.startTime)                         params.push(`start=${parseInt(c.startTime)}`);
      if (c.endTime)                           params.push(`end=${parseInt(c.endTime)}`);
      const domain = c.privacyMode ? 'www.youtube-nocookie.com' : 'www.youtube.com';
      const src = `https://${domain}/embed/${m[1]}?${params.join('&')}`;
      return (
        <div style={{ ...inlineStyles, ...wrapStyle }}>
          <div style={innerStyle}>
            <iframe src={src} style={iframeStyle} allowFullScreen title="Video" />
          </div>
        </div>
      );
    }
  }

  if (source === 'vimeo') {
    const m = url.match(/vimeo\.com\/(\d+)/);
    if (m) {
      const params: string[] = [];
      if (c.autoplay)     params.push('autoplay=1');
      if (c.muted)        params.push('muted=1');
      if (c.loop)         params.push('loop=1');
      if (c.privacyMode)  params.push('dnt=1');
      if (c.startTime)    params.push(`#t=${c.startTime}s`);
      const src = `https://player.vimeo.com/video/${m[1]}${params.length ? '?' + params.join('&') : ''}`;
      return (
        <div style={{ ...inlineStyles, ...wrapStyle }}>
          <div style={innerStyle}>
            <iframe src={src} style={iframeStyle} allowFullScreen title="Video" />
          </div>
        </div>
      );
    }
  }

  // Self-hosted
  return (
    <div style={{ ...inlineStyles, ...wrapStyle }}>
      <video
        src={url}
        controls={c.controls !== false}
        autoPlay={!!c.autoplay}
        muted={!!c.muted}
        loop={!!c.loop}
        style={{ width: '100%', display: 'block' }}
      />
    </div>
  );
}

function MapsRenderer({
  node,
  inlineStyles,
}: {
  node: CBNode;
  inlineStyles: React.CSSProperties;
}) {
  const content = node.content as { address?: string; height?: string };
  const address = content?.address || '';
  const height  = content?.height  || '400px';

  if (!address) {
    return (
      <div className="jalpi-maps-placeholder" style={{ height, ...inlineStyles }}>
        <span className="jalpi-maps-placeholder__icon">⊕</span>
        <span>Set an address in Properties</span>
      </div>
    );
  }

  const src = `https://maps.google.com/maps?q=${encodeURIComponent(address)}&output=embed`;
  return (
    <iframe
      src={src}
      width="100%"
      height={height}
      style={{ border: 0, display: 'block', ...inlineStyles }}
      frameBorder={0}
      allowFullScreen
      title="Google Maps"
    />
  );
}

function HtmlRenderer({
  node,
  store,
  isEditing,
  onEditEnd,
  inlineStyles,
}: {
  node: CBNode;
  store: EditorStore;
  isEditing: boolean;
  onEditEnd: () => void;
  inlineStyles: React.CSSProperties;
}) {
  const content = node.content as { html?: string };

  if (isEditing) {
    return (
      <textarea
        className="jalpi-html-editor"
        defaultValue={content?.html || ''}
        autoFocus
        onClick={(e) => e.stopPropagation()}
        onBlur={(e) => {
          store.updateNodeContent(node.id, { html: (e.target as HTMLTextAreaElement).value });
          onEditEnd();
        }}
        onKeyDown={(e) => {
          if (e.key === 'Escape') (e.target as HTMLTextAreaElement).blur();
        }}
      />
    );
  }

  if (!content?.html) {
    return (
      <div className="jalpi-html-placeholder" style={inlineStyles}>
        <span className="jalpi-html-placeholder__icon">{'</>'}</span>
        <span>Double-click to edit HTML</span>
      </div>
    );
  }

  return (
    <div
      className="jalpi-html-preview"
      style={inlineStyles}
      dangerouslySetInnerHTML={{ __html: content.html }}
    />
  );
}

function IconRenderer({
  node,
  inlineStyles,
}: {
  node: CBNode;
  inlineStyles: React.CSSProperties;
}) {
  const content = node.content as {
    icon?: string | IconValue; size?: string; color?: string; align?: string;
    view?: string; viewColor?: string; viewSize?: string; borderRadius?: string;
    link?: string; newTab?: boolean;
  };
  const st = node.styles ?? {};

  // Style tab values are authoritative; content tab values are legacy fallbacks
  const size         = st.iconSize    || (content as any)?.size  || '48px';
  const color        = st.color       || 'currentColor';
  const align        = (st.textAlign  || 'center') as React.CSSProperties['textAlign'];
  const view         = content?.view  || 'default';
  // Secondary Color (style tab) = box bg/border; fall back to legacy content.viewColor
  const boxColor     = st.iconBoxColor || (content as any)?.viewColor || '#e8e8e8';
  const borderRadius = content?.borderRadius ?? '50%';
  // Padding is ALWAYS inner padding of the box (Elementor behaviour)
  const iconPadding  = st.iconPadding || '';
  const rotateDeg    = st.iconRotate  ? `rotate(${st.iconRotate})` : '';

  const iconVal   = content?.icon;
  const isLegacy  = typeof iconVal === 'string';
  const legacyDef = isLegacy ? (ICONS[iconVal as string] || ICONS[DEFAULT_ICON]) : null;

  // Hover colors (can only be applied via a <style> block — inline styles can't do :hover)
  const colorHover   = (st as Record<string,string>).iconColorHover    || '';
  const boxColHover  = (st as Record<string,string>).iconBoxColorHover || '';
  const scopeCls     = `jalpi-icon-n-${node.id}`;

  const hoverCssLines: string[] = [];
  // !important is required to override the inline `color` style on .jalpi-icon-inner
  if (colorHover)  hoverCssLines.push(`.${scopeCls}:hover .jalpi-icon-inner{color:${colorHover} !important;}`);
  if (boxColHover && view === 'stacked') hoverCssLines.push(`.${scopeCls}:hover .jalpi-icon-box{background:${boxColHover} !important;}`);
  if (boxColHover && view === 'framed')  hoverCssLines.push(`.${scopeCls}:hover .jalpi-icon-box{border-color:${boxColHover} !important;}`);

  const iconNode = (
    <span className="jalpi-icon-inner" style={{ fontSize: size, color, display:'inline-flex', alignItems:'center', lineHeight:1,
      ...(rotateDeg ? { transform: rotateDeg } : {}) }}>
      {!isLegacy && iconVal ? (
        <RenderIconValue value={iconVal as IconValue} />
      ) : (
        <span dangerouslySetInnerHTML={{ __html: legacyDef?.svg || ICONS[DEFAULT_ICON].svg }} />
      )}
    </span>
  );

  const hasView = view === 'stacked' || view === 'framed';

  // Box auto-sizes from icon + padding (no explicit width/height — matches Elementor)
  const viewWrapStyle: React.CSSProperties =
    view === 'stacked' ? { display:'inline-flex', alignItems:'center', justifyContent:'center', lineHeight:1,
      background: boxColor, borderRadius, ...(iconPadding ? { padding: iconPadding } : {}) } :
    view === 'framed'  ? { display:'inline-flex', alignItems:'center', justifyContent:'center', lineHeight:1,
      border: `2px solid ${boxColor}`, borderRadius, ...(iconPadding ? { padding: iconPadding } : {}) } :
    {};

  const iconInner = hasView
    ? <span className="jalpi-icon-box" style={viewWrapStyle}>{iconNode}</span>
    : iconNode;

  const wrapped = content?.link
    ? <a href={content.link} target={content.newTab ? '_blank' : undefined} rel={content.newTab ? 'noopener noreferrer' : undefined} style={{ display:'inline-block', lineHeight:0 }}>{iconInner}</a>
    : iconInner;

  const containerStyle: React.CSSProperties = {
    textAlign: align,
    ...inlineStyles,
  };

  return (
    <>
      {hoverCssLines.length > 0 && <style>{hoverCssLines.join('\n')}</style>}
      <div className={`jalpi-icon-preview ${scopeCls}`} style={containerStyle}>
        {wrapped}
      </div>
    </>
  );
}

function ShortcodeRenderer({
  node,
  inlineStyles,
}: {
  node: CBNode;
  inlineStyles: React.CSSProperties;
}) {
  const content = node.content as { shortcode?: string };
  return (
    <div className="jalpi-shortcode-preview" style={inlineStyles}>
      <code>{content?.shortcode || '[shortcode]'}</code>
    </div>
  );
}

const FORM_PLUGIN_LABELS: Record<string, string> = {
  cf7:          'Contact Form 7',
  forminator:   'Forminator',
  wpforms:      'WPForms',
  gravityforms: 'Gravity Forms',
};

function FormRenderer({
  node,
  inlineStyles,
}: {
  node: CBNode;
  inlineStyles: React.CSSProperties;
}) {
  const content = node.content as { plugin?: string; form_id?: string };
  const plugin  = content?.plugin  || '';
  const formId  = content?.form_id || '';
  const label   = plugin ? (FORM_PLUGIN_LABELS[plugin] ?? plugin) : '';

  return (
    <div className="jalpi-form-canvas-preview" style={inlineStyles}>
      <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" style={{ flexShrink: 0 }}>
        <rect x="3" y="3" width="18" height="18" rx="2"/>
        <line x1="7" y1="8"  x2="17" y2="8"/>
        <line x1="7" y1="12" x2="17" y2="12"/>
        <line x1="7" y1="16" x2="13" y2="16"/>
      </svg>
      {plugin && formId ? (
        <span>
          <strong>{label}</strong> · Form #{formId}
        </span>
      ) : (
        <span>Select a form in Properties</span>
      )}
    </div>
  );
}

function LogoRenderer({
  node,
  inlineStyles,
}: {
  node: CBNode;
  inlineStyles: React.CSSProperties;
}) {
  const content = node.content as { url?: string; alt?: string; width?: string; linkUrl?: string; newTab?: boolean };

  if (!content?.url) {
    return (
      <div className="jalpi-logo-placeholder" style={inlineStyles}>
        <span>◈ Set a logo URL in Properties</span>
      </div>
    );
  }

  const img = (
    <img
      src={content.url}
      alt={content.alt || ''}
      style={{ width: content.width || 'auto', height: 'auto', display: 'block' }}
    />
  );

  return (
    <div style={inlineStyles}>
      {content.linkUrl ? <a href={content.linkUrl}>{img}</a> : img}
    </div>
  );
}

/* ── Alert ─────────────────────────────────────────────── */
const ALERT_COLORS: Record<string, { bg: string; border: string; icon: string }> = {
  info:    { bg: '#eff6ff', border: '#3b82f6', icon: 'ℹ' },
  success: { bg: '#f0fdf4', border: '#22c55e', icon: '✓' },
  warning: { bg: '#fffbeb', border: '#f59e0b', icon: '⚠' },
  error:   { bg: '#fef2f2', border: '#ef4444', icon: '✕' },
};

function AlertRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  const c = (node.content ?? {}) as { variant?: string; title?: string; text?: string };
  const v = ALERT_COLORS[c.variant || 'info'] ?? ALERT_COLORS.info;
  return (
    <div style={{ display: 'flex', gap: 10, padding: '12px 14px', borderRadius: 8, background: v.bg, borderLeft: `4px solid ${v.border}`, ...inlineStyles }}>
      <span style={{ fontSize: 16, fontWeight: 700, color: v.border, lineHeight: '1.4' }}>{v.icon}</span>
      <div style={{ flex: 1 }}>
        {c.title && <div style={{ fontWeight: 700, fontSize: 13, color: '#1a1a1a', marginBottom: 2 }}>{c.title}</div>}
        <div style={{ fontSize: 12, color: '#374151' }}>{c.text || 'Alert message.'}</div>
      </div>
    </div>
  );
}

/* ── Counter ────────────────────────────────────────────── */
function CounterRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  type CItem = { startNumber?: string; endNumber?: string; number?: string; prefix?: string; suffix?: string; title?: string; label?: string };
  const raw = (node.content ?? {}) as any;
  const s   = (node.settings ?? {}) as Record<string, string>;
  const str = (k: string) => s[k] || '';

  // Normalise to items array (backward compat with legacy single-counter)
  const items: CItem[] = raw.items?.length
    ? raw.items
    : [{ startNumber: raw.startNumber, endNumber: raw.endNumber ?? raw.number, prefix: raw.prefix, suffix: raw.suffix, title: raw.title ?? raw.label }];

  // Outer wrapper — settings-driven background/border/padding/gap
  const gapVal = str('cnt_gap') || '24px';
  const wrapStyle: React.CSSProperties = {
    display: 'flex', flexDirection: 'row', flexWrap: 'wrap',
    justifyContent: 'center', alignItems: 'flex-start', gap: gapVal, padding: '16px',
    ...inlineStyles,
  };
  if (str('cnt_bg_normal')) wrapStyle.background = str('cnt_bg_normal');
  if (str('cnt_bdType') && str('cnt_bdType') !== 'default' && str('cnt_bdType') !== 'none') {
    wrapStyle.border = `${str('cnt_bdw_top') || '1px'} ${str('cnt_bdType')} ${str('cnt_bdColor') || '#e5e7eb'}`;
  }
  if (str('cnt_br_top')) wrapStyle.borderRadius = `${str('cnt_br_top')} ${str('cnt_br_right') || str('cnt_br_top')} ${str('cnt_br_bottom') || str('cnt_br_top')} ${str('cnt_br_left') || str('cnt_br_top')}`;
  if (str('cnt_pad_top') || str('cnt_pad_right')) {
    wrapStyle.padding = `${str('cnt_pad_top') || '16px'} ${str('cnt_pad_right') || '16px'} ${str('cnt_pad_bottom') || '16px'} ${str('cnt_pad_left') || '16px'}`;
  }

  // Item layout settings
  const titlePos   = str('cnt_titlePos')   || 'bottom';
  const titleAlign = str('cnt_titleAlign') || 'center';
  const titleGapV  = str('cnt_titleGap')   || '6px';
  const numPos     = str('cnt_numPos')     || 'center';

  const itemFlexDir = (titlePos === 'top' ? 'column-reverse' : titlePos === 'left' ? 'row-reverse' : titlePos === 'right' ? 'row' : 'column') as React.CSSProperties['flexDirection'];
  const itemAlign   = (titleAlign === 'left' ? 'flex-start' : titleAlign === 'right' ? 'flex-end' : 'center') as React.CSSProperties['alignItems'];

  const numStyle: React.CSSProperties = {
    fontSize: str('cnt_num_fontSize') || '3rem',
    fontWeight: (str('cnt_num_fontWeight') || '800') as React.CSSProperties['fontWeight'],
    color: str('cnt_num_color') || '#6366f1',
    lineHeight: 1, letterSpacing: '-0.02em',
    textAlign: numPos as React.CSSProperties['textAlign'],
  };
  const titleStyle: React.CSSProperties = {
    fontSize: str('cnt_title_fontSize') || '1rem',
    fontWeight: (str('cnt_title_fontWeight') || '500') as React.CSSProperties['fontWeight'],
    color: str('cnt_title_color') || '#6b7280',
  };

  return (
    <div style={wrapStyle}>
      {items.map((item, i) => {
        const val   = item.endNumber ?? item.number ?? '100';
        const title = item.title ?? item.label ?? '';
        return (
          <div key={i} style={{ display: 'flex', flexDirection: itemFlexDir, alignItems: itemAlign, gap: titleGapV, flex: '1 1 120px', minWidth: 80 }}>
            <div style={numStyle}>
              {item.prefix && <span style={{ fontSize: '1.5rem', fontWeight: 700, verticalAlign: 'super' }}>{item.prefix}</span>}
              {val}
              {item.suffix && <span style={{ fontSize: '1.5rem', fontWeight: 700, verticalAlign: 'super' }}>{item.suffix}</span>}
            </div>
            {title && <div style={titleStyle}>{title}</div>}
          </div>
        );
      })}
    </div>
  );
}

/* ── Progress ───────────────────────────────────────────── */
function ProgressRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  type PItem = { title?: string; label?: string; titleTag?: string; showTitle?: boolean; percentage?: number; showPercentage?: boolean; innerText?: string; color?: string };
  const c     = (node.content ?? {}) as { items?: PItem[] };
  const items = c.items ?? [];
  const s     = (node.settings ?? {}) as Record<string, string>;
  const str   = (k: string, fb = '') => s[k] || fb;

  const trackBg     = str('pb_track_bg',     '#e5e7eb');
  const trackH      = str('pb_track_height', '8px');
  const trackR      = str('pb_track_radius', '99px');
  const titleColor  = str('pb_title_color',  '#374151');
  const titleFs     = str('pb_title_fontSize', '13px');
  const titleFw     = str('pb_title_fontWeight', '600') as React.CSSProperties['fontWeight'];
  const pctColor    = str('pb_pct_color',    '#6b7280');
  const pctFs       = str('pb_pct_fontSize', '12px');
  const innerColor  = str('pb_inner_color',  '#9ca3af');
  const innerFs     = str('pb_inner_fontSize','11px');

  const normDim = (v: string, fb: string) => v && /[\d]/.test(v) ? (/\D/.test(v) ? v : v + 'px') : fb;

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: 14, padding: '4px 0', ...inlineStyles }}>
      {items.length === 0 ? (
        <div style={{ fontSize: 12, color: '#9ca3af', textAlign: 'center' }}>Add progress bars in Properties</div>
      ) : items.map((item, i) => {
        const title     = item.title || item.label || '';
        const Tag       = (item.titleTag || 'h3') as keyof JSX.IntrinsicElements;
        const showTitle = item.showTitle !== false;
        const pct       = Math.min(100, Math.max(0, item.percentage ?? 0));
        const showPct   = item.showPercentage !== false;
        const color     = item.color || '#6366f1';
        return (
          <div key={i}>
            {(showTitle && title) && (
              <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', marginBottom: 4 }}>
                <Tag style={{ margin: 0, fontSize: normDim(titleFs,'13px'), fontWeight: titleFw, color: titleColor }}>{title}</Tag>
                {showPct && <span style={{ fontSize: normDim(pctFs,'12px'), fontWeight: 600, color: pctColor }}>{pct}%</span>}
              </div>
            )}
            <div style={{ height: normDim(trackH,'8px'), borderRadius: normDim(trackR,'99px'), background: trackBg, overflow: 'hidden' }}>
              <div style={{ height: '100%', width: `${pct}%`, borderRadius: normDim(trackR,'99px'), background: color, transition: 'width 0.6s cubic-bezier(.4,0,.2,1)' }} />
            </div>
            {item.innerText && (
              <div style={{ fontSize: normDim(innerFs,'11px'), color: innerColor, marginTop: 3 }}>{item.innerText}</div>
            )}
          </div>
        );
      })}
    </div>
  );
}

/* ── Testimonial ────────────────────────────────────────── */
function TestimonialRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  const c = (node.content ?? {}) as { text?: string; name?: string; role?: string; avatarUrl?: string; rating?: number };
  const stars = Math.max(1, Math.min(5, c.rating ?? 5));
  return (
    <div style={{ padding: '16px', background: '#fff', borderRadius: 10, boxShadow: '0 1px 6px rgba(0,0,0,.08)', border: '1px solid #f3f4f6', ...inlineStyles }}>
      <div style={{ display: 'flex', gap: 2, marginBottom: 10 }}>
        {Array.from({ length: 5 }, (_, i) => (
          <span key={i} style={{ fontSize: 14, color: i < stars ? '#f59e0b' : '#d1d5db' }}>★</span>
        ))}
      </div>
      <p style={{ fontSize: 13, color: '#374151', lineHeight: 1.6, margin: '0 0 12px', fontStyle: 'italic' }}>
        "{c.text || 'Testimonial quote.'}"
      </p>
      <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
        {c.avatarUrl
          ? <img src={c.avatarUrl} alt="" style={{ width: 32, height: 32, borderRadius: '50%', objectFit: 'cover' }} />
          : <div style={{ width: 32, height: 32, borderRadius: '50%', background: '#e5e7eb', flexShrink: 0, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 14, color: '#9ca3af' }}>👤</div>
        }
        <div>
          <div style={{ fontSize: 12, fontWeight: 700, color: '#111827' }}>{c.name || 'Author Name'}</div>
          {c.role && <div style={{ fontSize: 11, color: '#6b7280' }}>{c.role}</div>}
        </div>
      </div>
    </div>
  );
}

/* ── Accordion ──────────────────────────────────────────── */
function AccordionRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  const c = (node.content ?? {}) as {
    items?: Array<{ title: string; body: string }>;
    itemPosition?: string;
    iconPosition?: string;
    expandIcon?: string;
    collapseIcon?: string;
  };
  const s = (node.settings ?? {}) as Record<string, string>;
  const items = c.items ?? [];

  const str = (k: string, fallback = '') => s[k] || fallback;

  // Wrapper gap between items
  const itemGap    = str('acc_item_gap', '4px');
  const contentGap = str('acc_content_gap', '');

  // Global border (same across all states)
  const bdType  = str('acc_bdType');
  const bdColor = str('acc_bdColor') || '#e5e7eb';

  const globalBorderStyle = (): React.CSSProperties => {
    if (!bdType || bdType === 'default') return { border: `1px solid ${bdColor}` };
    if (bdType === 'none') return { border: 'none' };
    const wt = str('acc_bdw_top')    || '1px';
    const wr = str('acc_bdw_right')  || '1px';
    const wb = str('acc_bdw_bottom') || '1px';
    const wl = str('acc_bdw_left')   || '1px';
    if (wt === wr && wr === wb && wb === wl) return { border: `${wt} ${bdType} ${bdColor}` };
    return { borderTopWidth: wt, borderRightWidth: wr, borderBottomWidth: wb, borderLeftWidth: wl, borderStyle: bdType, borderColor: bdColor };
  };

  // Global border radius
  const globalBorderRadius = (): React.CSSProperties => {
    const tl = str('acc_br_top',    '7px');
    const tr = str('acc_br_right',  tl);
    const br = str('acc_br_bottom', tl);
    const bl = str('acc_br_left',   tl);
    if (tl === tr && tr === br && br === bl) return { borderRadius: tl || '7px' };
    return { borderTopLeftRadius: tl || '7px', borderTopRightRadius: tr || '7px', borderBottomRightRadius: br || '7px', borderBottomLeftRadius: bl || '7px' };
  };

  // Global padding
  const globalPadding = (): React.CSSProperties => {
    const pt = str('acc_pad_top');
    const pr = str('acc_pad_right');
    const pb = str('acc_pad_bottom');
    const pl = str('acc_pad_left');
    if (!pt && !pr && !pb && !pl) return {};
    return { paddingTop: pt || '0', paddingRight: pr || '0', paddingBottom: pb || '0', paddingLeft: pl || '0' };
  };

  // Per-state background only
  const itemBg = (state: 'normal' | 'active') => str(`acc_bg_${state}`, state === 'active' ? '#f9fafb' : '#ffffff');


  // Header styles
  const hdrBg      = str('acc_hdr_bg', '#f9fafb');
  const hdrColor   = str('acc_hdr_color', '#111827');
  const hdrFontSz  = str('acc_hdr_fontSize', '13px');
  const hdrPad     = {
    paddingTop:    str('acc_hdr_pad_top',    '10px'),
    paddingRight:  str('acc_hdr_pad_right',  '14px'),
    paddingBottom: str('acc_hdr_pad_bottom', '10px'),
    paddingLeft:   str('acc_hdr_pad_left',   '14px'),
  };

  // Content body styles
  const bodyBg     = str('acc_body_bg', '#ffffff');
  const bodyColor  = str('acc_body_color', '#374151');
  const bodyFontSz = str('acc_body_fontSize', '13px');
  const bodyPad    = {
    paddingTop:    str('acc_body_pad_top',    '10px'),
    paddingRight:  str('acc_body_pad_right',  '14px'),
    paddingBottom: str('acc_body_pad_bottom', '10px'),
    paddingLeft:   str('acc_body_pad_left',   '14px'),
  };

  // Icon rendering
  const iconPos      = c.iconPosition ?? 'right';
  const expandIcon   = c.expandIcon   ?? 'default';
  const collapseIcon = c.collapseIcon ?? 'default';
  const itemPosition = c.itemPosition ?? 'left';

  const renderIcon = (isOpen: boolean) => {
    const which = isOpen ? collapseIcon : expandIcon;
    if (which === 'none') return null;
    if (which === 'plus' || which === 'minus') {
      return <span style={{ fontSize: 16, lineHeight: 1, color: hdrColor, opacity: .7 }}>{isOpen ? '−' : '+'}</span>;
    }
    return (
      <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke={hdrColor} strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"
        style={{ opacity: .6, transform: isOpen ? 'rotate(180deg)' : 'rotate(0deg)', transition: 'transform .2s' }}>
        <polyline points="6 9 12 15 18 9"/>
      </svg>
    );
  };

  return (
    <div style={{ display: 'flex', flexDirection: 'column', gap: itemGap || '4px', ...inlineStyles }}>
      {items.length === 0 ? (
        <div style={{ fontSize: 12, color: '#9ca3af', textAlign: 'center', padding: '8px 0' }}>Add accordion panels in Properties</div>
      ) : items.map((item, i) => {
        const isOpen  = i === 0;
        const state   = isOpen ? 'active' : 'normal';
        const icon    = renderIcon(isOpen);
        const showIconLeft  = icon && iconPos === 'left';
        const showIconRight = icon && iconPos !== 'left';

        return (
          <div key={i} style={{ overflow: 'hidden', background: itemBg(state), ...globalBorderStyle(), ...globalBorderRadius(), ...globalPadding() }}>
            {/* Header row */}
            <div style={{
              display: 'flex',
              alignItems: 'center',
              justifyContent: itemPosition === 'center' ? 'center' : itemPosition === 'right' ? 'flex-end' : 'space-between',
              gap: 8,
              background: hdrBg,
              fontWeight: 600,
              fontSize: hdrFontSz,
              color: hdrColor,
              marginTop:    contentGap && i > 0 ? contentGap : undefined,
              ...hdrPad,
            }}>
              {showIconLeft && icon}
              <span style={{ flex: itemPosition === 'justify' ? 1 : undefined }}>{item.title}</span>
              {showIconRight && icon}
            </div>

            {/* Body */}
            {isOpen && (
              <div style={{ borderTop: '1px solid #f3f4f6', background: bodyBg, fontSize: bodyFontSz, color: bodyColor, ...bodyPad }}>
                {item.body || <span style={{ opacity: .4, fontStyle: 'italic' }}>Panel content…</span>}
              </div>
            )}
          </div>
        );
      })}
    </div>
  );
}

/* ── Badge ──────────────────────────────────────────────── */
const BADGE_COLORS: Record<string, { bg: string; color: string }> = {
  primary:   { bg: '#f0f0f0', color: '#1a1a1a' },
  secondary: { bg: '#f3f4f6', color: '#374151' },
  success:   { bg: '#dcfce7', color: '#15803d' },
  warning:   { bg: '#fef3c7', color: '#92400e' },
  error:     { bg: '#fee2e2', color: '#b91c1c' },
  info:      { bg: '#dbeafe', color: '#1d4ed8' },
};

function BadgeRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  const c = (node.content ?? {}) as { text?: string; variant?: string };
  const v = BADGE_COLORS[c.variant || 'primary'] ?? BADGE_COLORS.primary;
  return (
    <div style={inlineStyles}>
      <span style={{ display: 'inline-block', padding: '3px 10px', borderRadius: 999, fontSize: 11, fontWeight: 700, background: v.bg, color: v.color, letterSpacing: '.02em' }}>
        {c.text || 'Badge'}
      </span>
    </div>
  );
}

/* ── Icon List ──────────────────────────────────────────── */
function IconListRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  const c = (node.content ?? {}) as Record<string, unknown>;
  const s = (node.settings ?? {}) as Record<string, string>;
  const str = (k: string) => s[k] || '';

  const layout      = (c.layout ?? 'list') as string;
  const defaultIcon = c.defaultIcon as IconValue | string | undefined;
  const showDivider = !!s['il_divider'];

  const rawItems = (c.items ?? []) as unknown[];
  const items = rawItems.map((it: unknown) =>
    typeof it === 'string'
      ? { text: it, icon: '', link: '', newTab: false }
      : { text: (it as any).text ?? '', icon: (it as any).icon ?? '', link: (it as any).link ?? '', newTab: !!(it as any).newTab }
  );

  const gap          = str('il_gap')          || '8px';
  const iconColor    = str('il_icon_color')   || '#6366f1';
  const iconBg       = str('il_icon_bg')      || 'transparent';
  const iconSize     = str('il_icon_size')    || '14px';
  const iconBr       = str('il_icon_br')      || '0px';
  const iconSpacing  = str('il_icon_spacing') || '8px';
  const textColor      = str('il_text_color')          || '#374151';
  const textFamily     = str('il_text_fontFamily')     || '';
  const textSize       = str('il_text_fontSize')       || '14px';
  const textWeight     = str('il_text_fontWeight')     || '400';
  const textStyle2     = str('il_text_fontStyle')      || 'normal';
  const textTransform  = str('il_text_textTransform')  || 'none';
  const textDecor      = str('il_text_textDecoration') || 'none';
  const textLH         = str('il_text_lineHeight')     || '';
  const textLS         = str('il_text_letterSpacing')  || '';
  const divColor     = str('il_div_color')    || '#e5e7eb';
  const divWeight    = str('il_div_weight')   || '1px';
  const divStyle     = str('il_div_style')    || 'solid';

  const wrapStyle: React.CSSProperties = {
    listStyle: 'none', margin: 0, padding: 0,
    display: 'flex',
    flexDirection: layout === 'inline' ? 'row' : 'column',
    flexWrap: layout === 'inline' ? 'wrap' : 'nowrap',
    gap,
    ...inlineStyles,
  };
  const iconStyle: React.CSSProperties = {
    display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
    flexShrink: 0, width: iconSize, height: iconSize, fontSize: iconSize,
    color: iconColor, background: iconBg, borderRadius: iconBr,
  };
  const textStyle: React.CSSProperties = {
    color: textColor,
    ...(textFamily     && { fontFamily:      textFamily }),
    fontSize:             textSize,
    fontWeight:           textWeight as React.CSSProperties['fontWeight'],
    fontStyle:            textStyle2 as React.CSSProperties['fontStyle'],
    textTransform:        textTransform as React.CSSProperties['textTransform'],
    textDecoration:       textDecor,
    ...(textLH         && { lineHeight:      textLH }),
    ...(textLS         && { letterSpacing:   textLS }),
  };

  return (
    <ul style={wrapStyle}>
      {items.length === 0
        ? <li style={{ fontSize: 12, color: '#9ca3af' }}>Add items in Properties</li>
        : items.map((item, i) => (
          <li key={i} style={{
            display: 'flex', alignItems: 'center', gap: iconSpacing,
            borderBottom: showDivider && i < items.length - 1 ? `${divWeight} ${divStyle} ${divColor}` : undefined,
            paddingBottom: showDivider && i < items.length - 1 ? gap : undefined,
          }}>
            <span style={iconStyle}><RenderIconValue value={(item.icon || defaultIcon || 'check') as IconValue} /></span>
            <span style={textStyle}>{item.text || 'List item'}</span>
          </li>
        ))}
    </ul>
  );
}

/* ── Pricing Table ──────────────────────────────────────── */
function PricingTableRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  const c = (node.content ?? {}) as { name?: string; currency?: string; amount?: string; period?: string; features?: string[]; btnText?: string; featured?: boolean; tagline?: string };
  const features = c.features ?? [];
  return (
    <div style={{ border: `2px solid ${c.featured ? '#1a1a1a' : '#e5e7eb'}`, borderRadius: 12, overflow: 'hidden', background: '#fff', ...inlineStyles }}>
      {c.featured && (
        <div style={{ background: '#1a1a1a', color: '#fff', textAlign: 'center', fontSize: 11, fontWeight: 700, padding: '3px 0', letterSpacing: '.05em' }}>POPULAR</div>
      )}
      <div style={{ padding: '16px 20px' }}>
        <div style={{ fontWeight: 800, fontSize: 16, color: '#111827', marginBottom: 2 }}>{c.name || 'Plan'}</div>
        {c.tagline && <div style={{ fontSize: 11, color: '#6b7280', marginBottom: 10 }}>{c.tagline}</div>}
        <div style={{ display: 'flex', alignItems: 'baseline', gap: 2, margin: '8px 0' }}>
          <span style={{ fontSize: 14, fontWeight: 700, color: '#1a1a1a' }}>{c.currency || '$'}</span>
          <span style={{ fontSize: 32, fontWeight: 800, color: '#111827', lineHeight: 1 }}>{c.amount || '0'}</span>
          <span style={{ fontSize: 12, color: '#9ca3af' }}>/{c.period || 'mo'}</span>
        </div>
        {features.slice(0, 4).map((f, i) => (
          <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, color: '#374151', marginBottom: 4 }}>
            <span style={{ color: '#22c55e', fontWeight: 700, fontSize: 11 }}>✓</span>{f}
          </div>
        ))}
        {features.length > 4 && <div style={{ fontSize: 11, color: '#9ca3af', marginTop: 2 }}>+{features.length - 4} more…</div>}
        <div style={{ marginTop: 12, background: c.featured ? '#1a1a1a' : '#f3f4f6', color: c.featured ? '#fff' : '#374151', borderRadius: 7, padding: '7px 0', textAlign: 'center', fontSize: 13, fontWeight: 700 }}>
          {c.btnText || 'Get Started'}
        </div>
      </div>
    </div>
  );
}

/* ── Team Member ────────────────────────────────────────── */
function TeamMemberRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  const c = (node.content ?? {}) as { photoUrl?: string; name?: string; role?: string; bio?: string };
  return (
    <div style={{ textAlign: 'center', padding: '16px', background: '#fff', borderRadius: 12, border: '1px solid #f3f4f6', ...inlineStyles }}>
      {c.photoUrl
        ? <img src={c.photoUrl} alt="" style={{ width: 72, height: 72, borderRadius: '50%', objectFit: 'cover', margin: '0 auto 10px', display: 'block' }} />
        : <div style={{ width: 72, height: 72, borderRadius: '50%', background: '#e5e7eb', margin: '0 auto 10px', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 26, color: '#9ca3af' }}>👤</div>
      }
      <div style={{ fontWeight: 800, fontSize: 14, color: '#111827' }}>{c.name || 'Team Member'}</div>
      {c.role && <div style={{ fontSize: 11, color: '#1a1a1a', fontWeight: 600, marginTop: 2 }}>{c.role}</div>}
      {c.bio  && <div style={{ fontSize: 11, color: '#6b7280', marginTop: 6, lineHeight: 1.5 }}>{c.bio.slice(0, 80)}{c.bio.length > 80 ? '…' : ''}</div>}
    </div>
  );
}

/* ── FAQ ────────────────────────────────────────────────── */
type FaqItem = { question: string; answer: string; defaultOpen?: boolean };

function FaqIconSvg({ type, isOpen, dur }: { type: string; isOpen: boolean; dur: string }) {
  if (type === 'chevron') return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" strokeLinejoin="round" style={{ width: '100%', height: '100%', display: 'block' }}>
      <polyline points="6 9 12 15 18 9" />
    </svg>
  );
  if (type === 'arrow') return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" style={{ width: '100%', height: '100%', display: 'block' }}>
      <line x1="12" y1="5" x2="12" y2="19" />
      <polyline points="19 12 12 19 5 12" />
    </svg>
  );
  if (type === 'plus') return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.5} strokeLinecap="round" style={{ width: '100%', height: '100%', display: 'block' }}>
      <line x1="5" y1="12" x2="19" y2="12" />
      <line x1="12" y1="5" x2="12" y2="19" style={{ opacity: isOpen ? 0 : 1, transition: `opacity ${dur}` }} />
    </svg>
  );
  if (type === 'circle') return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" style={{ width: '100%', height: '100%', display: 'block' }}>
      <circle cx="12" cy="12" r="9" />
      <line x1="8" y1="12" x2="16" y2="12" />
      <line x1="12" y1="8" x2="12" y2="16" style={{ opacity: isOpen ? 0 : 1, transition: `opacity ${dur}` }} />
    </svg>
  );
  return null;
}

function FaqRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  const c = (node.content ?? {}) as { items?: FaqItem[] };
  const s = (node.settings ?? {}) as Record<string, string>;
  const str = (k: string) => (s[k] as string) || '';
  const items = (c.items ?? []) as FaqItem[];

  const [openSet, setOpenSet] = useState<Set<number>>(() => {
    const init = new Set<number>();
    items.forEach((item, i) => { if (item.defaultOpen) init.add(i); });
    if (init.size === 0 && items.length > 0) init.add(0);
    return init;
  });

  const layout     = str('faq_layout')    || 'boxed';
  const iconType   = str('faq_icon')      || 'chevron';
  const iconPos    = str('faq_icon_pos')  || 'right';
  const iconSize   = str('faq_icon_size') || '20px';
  const iconColor  = str('faq_icon_color')        || '#9ca3af';
  const iconActCol = str('faq_icon_active_color') || '#6366f1';
  const itemGap    = str('faq_item_gap')   || '';
  const animDur    = str('faq_anim_dur')   || '0.3s';
  const animEase   = str('faq_anim_ease')  || 'ease';
  const multiOpen  = str('faq_multiple_open') === '1';

  const qColor   = str('faq_q_color')        || '#111827';
  const qActCol  = str('faq_q_active_color') || '#6366f1';
  const qSize    = str('faq_q_fontSize')     || '15px';
  const qWeight  = (str('faq_q_fontWeight')  || '600') as React.CSSProperties['fontWeight'];
  const qPadV    = str('faq_q_pad_v') || '';
  const qPadH    = str('faq_q_pad_h') || '';

  const aColor   = str('faq_a_color')     || '#4b5563';
  const aSize    = str('faq_a_fontSize')  || '14px';
  const aLH      = str('faq_a_lineHeight') || '1.75';
  const aPadV    = str('faq_a_pad_v') || '';
  const aPadH    = str('faq_a_pad_h') || '';

  const itemBg      = str('faq_item_bg')              || '';
  const itemActBg   = str('faq_item_active_bg')       || '';
  const itemBdType  = str('faq_item_bd_type')         || '';
  const itemBdColor = str('faq_item_bd_color')        || '';
  const itemActBd   = str('faq_item_active_bd_color') || '';
  const itemBdW     = str('faq_item_bd_width')        || '1px';
  const itemBr      = str('faq_item_br')              || '';
  const itemShadow  = str('faq_item_shadow')          || '';
  const itemActShd  = str('faq_item_active_shadow')   || '';

  const toggle = (i: number) => {
    setOpenSet(prev => {
      const next = new Set(prev);
      if (next.has(i)) { next.delete(i); }
      else { if (!multiOpen) next.clear(); next.add(i); }
      return next;
    });
  };

  // Container
  const isClassicLike = layout === 'classic' || layout === 'minimal';
  const hasGap = layout === 'boxed' || layout === 'separated';
  const containerStyle: React.CSSProperties = {
    display: 'flex', flexDirection: 'column',
    ...(hasGap ? { gap: itemGap || '10px' } : {}),
    ...(layout === 'framed' ? {
      border: `1px solid ${itemBdColor || '#e5e7eb'}`,
      borderRadius: itemBr || '12px',
      overflow: 'hidden',
    } : {}),
    ...inlineStyles,
  };

  if (items.length === 0) {
    return (
      <div style={{ padding: '24px', textAlign: 'center', color: '#9ca3af', fontSize: 12, border: '2px dashed #e5e7eb', borderRadius: 8, ...inlineStyles }}>
        Add FAQ items in Properties
      </div>
    );
  }

  return (
    <div style={containerStyle}>
      {items.map((item, i) => {
        const isOpen = openSet.has(i);
        const isClassLike2 = isClassicLike;

        // Item style
        const itemStyle: React.CSSProperties = { overflow: 'hidden' };
        if (layout === 'framed' && i > 0) itemStyle.borderTop = `1px solid ${itemBdColor || '#e5e7eb'}`;
        if (itemBdType && itemBdType !== 'none') {
          itemStyle.border = `${itemBdW} ${itemBdType} ${isOpen && itemActBd ? itemActBd : (itemBdColor || '#e5e7eb')}`;
        } else if (layout === 'boxed') {
          itemStyle.border = `1px solid ${isOpen ? (itemActBd || '#6366f1') : (itemBdColor || '#e5e7eb')}`;
        } else if (layout === 'classic') {
          itemStyle.borderBottom = `1px solid ${itemBdColor || '#e5e7eb'}`;
          if (i === 0) itemStyle.borderTop = `1px solid ${itemBdColor || '#e5e7eb'}`;
        } else if (layout === 'minimal') {
          itemStyle.borderBottom = `1px solid ${itemBdColor || 'rgba(0,0,0,.07)'}`;
        }
        if (layout === 'boxed' || layout === 'separated') itemStyle.borderRadius = itemBr || '10px';
        else if (itemBr && layout !== 'framed' && layout !== 'classic' && layout !== 'minimal') itemStyle.borderRadius = itemBr;

        // Background
        if (isOpen && itemActBg) itemStyle.background = itemActBg;
        else if (itemBg) itemStyle.background = itemBg;
        else if (layout === 'boxed') itemStyle.background = '#fff';
        else if (layout === 'separated') itemStyle.background = isOpen ? '#fff' : '#f9fafb';
        else if (layout === 'framed' && isOpen) itemStyle.background = '#f9fafb';

        // Shadow
        if (isOpen && itemActShd) itemStyle.boxShadow = itemActShd;
        else if (isOpen && layout === 'boxed') itemStyle.boxShadow = '0 4px 16px rgba(99,102,241,.12)';
        else if (itemShadow) itemStyle.boxShadow = itemShadow;

        // Question padding
        const qPv = qPadV || (isClassLike2 ? '16px' : '18px');
        const qPh = qPadH || (isClassLike2 ? '0' : layout === 'framed' ? '24px' : '20px');

        // Answer padding
        const aPv = aPadV || (isClassLike2 ? '16px' : '20px');
        const aPh = aPadH || (isClassLike2 ? '0' : layout === 'framed' ? '24px' : '20px');

        // Icon
        const iconStyle: React.CSSProperties = {
          flexShrink: 0, width: iconSize, height: iconSize,
          display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
          color: isOpen ? iconActCol : iconColor,
          transform: (iconType === 'chevron' || iconType === 'arrow') && isOpen ? 'rotate(180deg)' : 'none',
          transition: `transform ${animDur} ${animEase}, color ${animDur}`,
          position: 'relative',
        };

        return (
          <div key={i} style={itemStyle}>
            <button
              type="button"
              onClick={() => toggle(i)}
              style={{
                display: 'flex',
                alignItems: 'center',
                flexDirection: iconPos === 'left' ? 'row-reverse' : 'row',
                justifyContent: iconPos === 'left' ? 'flex-end' : 'space-between',
                gap: 12,
                width: '100%',
                padding: `${qPv} ${qPh}`,
                background: 'none',
                border: 'none',
                textAlign: 'left',
                cursor: 'pointer',
                fontSize: qSize,
                fontWeight: qWeight,
                color: isOpen ? qActCol : qColor,
                lineHeight: 1.4,
                transition: `color ${animDur}`,
              }}
            >
              <span style={{ flex: 1 }}>{item.question || 'Question'}</span>
              {iconType !== 'none' && (
                <span style={iconStyle}>
                  <FaqIconSvg type={iconType} isOpen={isOpen} dur={animDur} />
                </span>
              )}
            </button>
            <div style={{
              display: 'grid',
              gridTemplateRows: isOpen ? '1fr' : '0fr',
              transition: `grid-template-rows ${animDur} ${animEase}`,
            }}>
              <div style={{ overflow: 'hidden' }}>
                <div style={{ padding: `0 ${aPh} ${aPv}`, fontSize: aSize, lineHeight: aLH || 1.75, color: aColor }}>
                  {item.answer || 'Answer text goes here.'}
                </div>
              </div>
            </div>
          </div>
        );
      })}
    </div>
  );
}

/* ── Table ──────────────────────────────────────────────── */
function TableRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  const c = (node.content ?? {}) as { headers?: string[]; rows?: string[][]; caption?: string };
  const headers = c.headers ?? [];
  const rows    = c.rows    ?? [];
  return (
    <div style={{ overflowX: 'auto', ...inlineStyles }}>
      {c.caption && <div style={{ fontSize: 11, color: '#6b7280', marginBottom: 4, fontStyle: 'italic' }}>{c.caption}</div>}
      <table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
        {headers.length > 0 && (
          <thead>
            <tr>
              {headers.map((h, i) => (
                <th key={i} style={{ padding: '7px 10px', background: '#f9fafb', borderBottom: '2px solid #e5e7eb', textAlign: 'left', fontWeight: 700, color: '#374151', whiteSpace: 'nowrap' }}>{h}</th>
              ))}
            </tr>
          </thead>
        )}
        <tbody>
          {rows.map((row, ri) => (
            <tr key={ri} style={{ background: ri % 2 ? '#f9fafb' : '#fff' }}>
              {row.map((cell, ci) => (
                <td key={ci} style={{ padding: '6px 10px', borderBottom: '1px solid #f3f4f6', color: '#374151' }}>{cell}</td>
              ))}
            </tr>
          ))}
          {rows.length === 0 && (
            <tr><td colSpan={Math.max(1, headers.length)} style={{ padding: '10px', textAlign: 'center', color: '#9ca3af', fontSize: 11 }}>Add rows in Properties</td></tr>
          )}
        </tbody>
      </table>
    </div>
  );
}

/* ── Feature Card ───────────────────────────────────────── */
// Legacy emoji fallbacks (used only when icon is a plain legacy string)
const FEATURE_CARD_EMOJI: Record<string, string> = {
  star: '★', lightning: '⚡', shield: '🛡', heart: '♥', rocket: '🚀', check: '✓',
};

function FeatureCardRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  const c = (node.content ?? {}) as { icon?: string | IconValue; title?: string; text?: string; view?: string; linkUrl?: string };
  const s = (node.settings ?? {}) as Record<string, string>;
  const str = (k: string) => s[k] || '';
  // Normalize unitless numbers (e.g. "1") to CSS-valid px values
  const toPx = (v: string) => v && /^-?[\d.]+$/.test(v) ? `${v}px` : v;

  const iconPos      = str('fc_iconPosition') || 'top';
  const align        = str('fc_align')        || 'center';
  const iconSpacing  = str('fc_iconSpacing')  || '15px';
  const contentSpacing = str('fc_contentSpacing') || '0px';
  const iconColor    = str('fc_icon_color')   || '#6366f1';
  const iconBg       = str('fc_icon_bg')      || '#f0f4ff';
  const iconView     = c.view || 'default';
  const iconSize     = toPx(str('fc_icon_size'))    || '48px';
  const iconPad      = toPx(str('fc_icon_padding')) || '12px';
  const iconBr       = toPx(str('fc_icon_br'))      || '12px';
  const bg           = str('fc_bg')           || '#ffffff';
  const bdType       = str('fc_bdType')       || 'none';
  const hasBorder    = bdType !== 'none';
  const bdColor      = str('fc_bdColor')      || '#f3f4f6';
  const bdwTop    = toPx(str('fc_bdw_top'))    || '1px';
  const bdwRight  = toPx(str('fc_bdw_right'))  || '1px';
  const bdwBottom = toPx(str('fc_bdw_bottom')) || '1px';
  const bdwLeft   = toPx(str('fc_bdw_left'))   || '1px';
  const brTop    = toPx(str('fc_br_top'))    || '12px';
  const brRight  = toPx(str('fc_br_right'))  || '12px';
  const brBottom = toPx(str('fc_br_bottom')) || '12px';
  const brLeft   = toPx(str('fc_br_left'))   || '12px';
  const padTop    = toPx(str('fc_pad_top'))    || '20px';
  const padRight  = toPx(str('fc_pad_right'))  || '16px';
  const padBottom = toPx(str('fc_pad_bottom')) || '20px';
  const padLeft   = toPx(str('fc_pad_left'))   || '16px';

  const titleTypo: React.CSSProperties = {
    fontFamily:     str('fc_title_fontFamily')     || undefined,
    fontSize:       str('fc_title_fontSize')       || '14px',
    fontWeight:     (str('fc_title_fontWeight')    || '700') as React.CSSProperties['fontWeight'],
    fontStyle:      (str('fc_title_fontStyle')     || 'normal') as React.CSSProperties['fontStyle'],
    textTransform:  (str('fc_title_textTransform') || 'none') as React.CSSProperties['textTransform'],
    textDecoration: str('fc_title_textDecoration') || 'none',
    lineHeight:     str('fc_title_lineHeight')     || undefined,
    letterSpacing:  str('fc_title_letterSpacing')  || undefined,
  };
  const descTypo: React.CSSProperties = {
    fontFamily:     str('fc_desc_fontFamily')     || undefined,
    fontSize:       str('fc_desc_fontSize')       || '12px',
    fontWeight:     (str('fc_desc_fontWeight')    || '400') as React.CSSProperties['fontWeight'],
    fontStyle:      (str('fc_desc_fontStyle')     || 'normal') as React.CSSProperties['fontStyle'],
    textTransform:  (str('fc_desc_textTransform') || 'none') as React.CSSProperties['textTransform'],
    textDecoration: str('fc_desc_textDecoration') || 'none',
    lineHeight:     str('fc_desc_lineHeight')     || '1.5',
    letterSpacing:  str('fc_desc_letterSpacing')  || undefined,
  };

  const isHoriz = iconPos === 'left' || iconPos === 'right';
  const rawIcon = c.icon;
  const iconEl = rawIcon
    ? (typeof rawIcon === 'string'
        ? <span style={{ fontSize: iconSize, color: iconColor }}>{FEATURE_CARD_EMOJI[rawIcon] ?? '★'}</span>
        : <RenderIconValue value={rawIcon} />)
    : null;

  // In column mode, icon aligns itself horizontally via alignSelf
  const iconAlignSelf = !isHoriz
    ? (align === 'center' ? 'center' : align === 'right' ? 'flex-end' : 'flex-start') as React.CSSProperties['alignSelf']
    : undefined;

  const iconBoxStyle: React.CSSProperties =
    iconView === 'framed'
      ? { background: 'transparent', border: `2px solid ${iconBg}` }
      : iconView === 'default'
        ? { background: 'transparent' }
        : { background: iconBg }; // stacked

  const iconWrapper = (
    <div style={{
      display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
      flexShrink: 0, lineHeight: 1,
      alignSelf: iconAlignSelf,
      fontSize: iconSize,
      color: iconColor,
      borderRadius: iconBr,
      padding: iconView === 'default' ? '0' : iconPad,
      marginBottom: isHoriz ? 0 : iconSpacing,
      marginRight:  iconPos === 'left'  ? iconSpacing : 0,
      marginLeft:   iconPos === 'right' ? iconSpacing : 0,
      ...iconBoxStyle,
    }}>
      {iconEl}
    </div>
  );

  // Auto-contrast: if no explicit title/desc color set and bg is dark, default to white text
  const bgIsDark = (() => {
    const m = bg.match(/^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})/i);
    if (!m) return false;
    const lum = 0.299 * parseInt(m[1], 16) + 0.587 * parseInt(m[2], 16) + 0.114 * parseInt(m[3], 16);
    return lum < 128;
  })();
  const resolvedTitleColor = str('fc_title_color') || (bgIsDark ? '#ffffff' : '#111827');
  const resolvedDescColor  = str('fc_desc_color')  || (bgIsDark ? '#cccccc' : '#6b7280');

  const textBlock = (
    <div style={{ flex: 1, minWidth: 0 }}>
      <div style={{ color: resolvedTitleColor, marginBottom: contentSpacing || '6px', ...titleTypo }}>{c.title || 'Feature Title'}</div>
      <div style={{ color: resolvedDescColor, ...descTypo }}>{c.text || 'Feature description.'}</div>
    </div>
  );

  const wrapStyle: React.CSSProperties = {
    ...inlineStyles,  // base (width/margin from Advanced tab)
    display: 'flex',
    flexDirection: iconPos === 'left' ? 'row' : iconPos === 'right' ? 'row-reverse' : 'column',
    alignItems: isHoriz ? 'flex-start' : 'stretch',  // stretch = full-width children in column mode
    textAlign: align as React.CSSProperties['textAlign'],
    background: bg,   // always wins over inlineStyles
    borderRadius: `${brTop} ${brRight} ${brBottom} ${brLeft}`,
    padding: `${padTop} ${padRight} ${padBottom} ${padLeft}`,
    // Per-side border widths (matches PHP renderer behavior)
    ...(hasBorder ? {
      borderTopWidth: bdwTop, borderRightWidth: bdwRight,
      borderBottomWidth: bdwBottom, borderLeftWidth: bdwLeft,
      borderStyle: bdType as React.CSSProperties['borderStyle'],
      borderColor: bdColor,
    } : { border: 'none' }),
  };

  const inner = (
    <div style={wrapStyle}>
      {iconEl && iconWrapper}
      {textBlock}
    </div>
  );

  return c.linkUrl
    ? <a href={c.linkUrl} style={{ display: 'block', textDecoration: 'none', color: 'inherit' }}>{inner}</a>
    : inner;
}

/* ── CTA ────────────────────────────────────────────────── */
function CtaRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  const c = (node.content ?? {}) as { title?: string; subtitle?: string; primaryText?: string; secondaryText?: string };
  const s = (node.settings ?? {}) as Record<string, string>;
  const str = (k: string) => s[k] || '';

  const bg        = str('cta_bg')          || 'linear-gradient(135deg,#1a1a1a 0%,#333333 100%)';
  const padTop    = str('cta_pad_top')     || '40px';
  const padRight  = str('cta_pad_right')   || '32px';
  const padBottom = str('cta_pad_bottom')  || '40px';
  const padLeft   = str('cta_pad_left')    || '32px';
  const brTL      = str('cta_br_top')      || '12px';
  const brTR      = str('cta_br_right')    || '12px';
  const brBR      = str('cta_br_bottom')   || '12px';
  const brBL      = str('cta_br_left')     || '12px';
  const bdType    = str('cta_bd_type')     || 'none';
  const bdwTop    = str('cta_bdw_top')     || '1px';
  const bdwRight  = str('cta_bdw_right')   || '1px';
  const bdwBottom = str('cta_bdw_bottom')  || '1px';
  const bdwLeft   = str('cta_bdw_left')    || '1px';
  const bdColor   = str('cta_bd_color')    || 'rgba(255,255,255,0.2)';
  const shadow    = str('cta_shadow');
  const align     = (str('cta_align') || 'center') as React.CSSProperties['textAlign'];
  const btnAlign  = str('cta_btn_align') || 'center';
  const btnJust: React.CSSProperties['justifyContent'] =
    btnAlign === 'right' ? 'flex-end' : btnAlign === 'left' ? 'flex-start' : 'center';

  const titleColor  = str('cta_title_color')       || '#ffffff';
  const titleSize   = str('cta_title_fontSize')    || '26px';
  const titleWeight = (str('cta_title_fontWeight') || '700') as React.CSSProperties['fontWeight'];
  const titleFam    = str('cta_title_fontFamily')  || undefined;
  const titleLH     = str('cta_title_lineHeight')  || '1.3';

  const subColor  = str('cta_sub_color')       || 'rgba(255,255,255,0.8)';
  const subSize   = str('cta_sub_fontSize')    || '16px';
  const subWeight = (str('cta_sub_fontWeight') || '400') as React.CSSProperties['fontWeight'];
  const subFam    = str('cta_sub_fontFamily')  || undefined;
  const subLH     = str('cta_sub_lineHeight')  || '1.6';

  const pbBg         = str('cta_pbtn_bg')          || '#ffffff';
  const pbColor      = str('cta_pbtn_color')        || '#1a1a1a';
  const pbPadTop     = str('cta_pbtn_pad_top')      || '12px';
  const pbPadRight   = str('cta_pbtn_pad_right')    || '28px';
  const pbPadBottom  = str('cta_pbtn_pad_bottom')   || '12px';
  const pbPadLeft    = str('cta_pbtn_pad_left')     || '28px';
  const pbBrTL       = str('cta_pbtn_br_top')       || '7px';
  const pbBrTR       = str('cta_pbtn_br_right')     || '7px';
  const pbBrBR       = str('cta_pbtn_br_bottom')    || '7px';
  const pbBrBL       = str('cta_pbtn_br_left')      || '7px';
  const pbBdType     = str('cta_pbtn_bd_type')      || 'none';
  const pbBdwTop     = str('cta_pbtn_bdw_top')      || '1px';
  const pbBdwRight   = str('cta_pbtn_bdw_right')    || '1px';
  const pbBdwBottom  = str('cta_pbtn_bdw_bottom')   || '1px';
  const pbBdwLeft    = str('cta_pbtn_bdw_left')     || '1px';
  const pbBdColor    = str('cta_pbtn_bd_color')     || 'transparent';
  const pbShadow     = str('cta_pbtn_shadow');
  const pbSize       = str('cta_pbtn_fontSize')     || '15px';
  const pbWeight     = (str('cta_pbtn_fontWeight')  || '700') as React.CSSProperties['fontWeight'];
  const pbFam        = str('cta_pbtn_fontFamily')   || undefined;

  const sbBg         = str('cta_sbtn_bg')          || 'rgba(255,255,255,0.12)';
  const sbColor      = str('cta_sbtn_color')        || '#ffffff';
  const sbPadTop     = str('cta_sbtn_pad_top')      || '12px';
  const sbPadRight   = str('cta_sbtn_pad_right')    || '28px';
  const sbPadBottom  = str('cta_sbtn_pad_bottom')   || '12px';
  const sbPadLeft    = str('cta_sbtn_pad_left')     || '28px';
  const sbBrTL       = str('cta_sbtn_br_top')       || '7px';
  const sbBrTR       = str('cta_sbtn_br_right')     || '7px';
  const sbBrBR       = str('cta_sbtn_br_bottom')    || '7px';
  const sbBrBL       = str('cta_sbtn_br_left')      || '7px';
  const sbBdType     = str('cta_sbtn_bd_type')      || 'solid';
  const sbBdwTop     = str('cta_sbtn_bdw_top')      || '1px';
  const sbBdwRight   = str('cta_sbtn_bdw_right')    || '1px';
  const sbBdwBottom  = str('cta_sbtn_bdw_bottom')   || '1px';
  const sbBdwLeft    = str('cta_sbtn_bdw_left')     || '1px';
  const sbBdColor    = str('cta_sbtn_bd_color')     || 'rgba(255,255,255,0.3)';
  const sbShadow     = str('cta_sbtn_shadow');
  const sbSize       = str('cta_sbtn_fontSize')     || '15px';
  const sbWeight     = (str('cta_sbtn_fontWeight')  || '600') as React.CSSProperties['fontWeight'];
  const sbFam        = str('cta_sbtn_fontFamily')   || undefined;

  const wrapStyle: React.CSSProperties = {
    textAlign:    align,
    padding:      `${padTop} ${padRight} ${padBottom} ${padLeft}`,
    background:   bg,
    borderRadius: `${brTL} ${brTR} ${brBR} ${brBL}`,
    boxShadow:    shadow || undefined,
    ...inlineStyles,
  };
  if (bdType !== 'none') {
    wrapStyle.border      = `${bdwTop} ${bdType} ${bdColor}`;
    wrapStyle.borderWidth = `${bdwTop} ${bdwRight} ${bdwBottom} ${bdwLeft}`;
  }

  const pbStyle: React.CSSProperties = {
    display: 'inline-block', cursor: 'pointer', textDecoration: 'none', lineHeight: 1.4,
    padding:      `${pbPadTop} ${pbPadRight} ${pbPadBottom} ${pbPadLeft}`,
    background:   pbBg, color: pbColor,
    borderRadius: `${pbBrTL} ${pbBrTR} ${pbBrBR} ${pbBrBL}`,
    boxShadow:    pbShadow || undefined,
    fontSize: pbSize, fontWeight: pbWeight, fontFamily: pbFam,
  };
  if (pbBdType !== 'none') {
    pbStyle.border      = `${pbBdwTop} ${pbBdType} ${pbBdColor}`;
    pbStyle.borderWidth = `${pbBdwTop} ${pbBdwRight} ${pbBdwBottom} ${pbBdwLeft}`;
  } else {
    pbStyle.border = 'none';
  }

  const sbStyle: React.CSSProperties = {
    display: 'inline-block', cursor: 'pointer', textDecoration: 'none', lineHeight: 1.4,
    padding:      `${sbPadTop} ${sbPadRight} ${sbPadBottom} ${sbPadLeft}`,
    background:   sbBg, color: sbColor,
    borderRadius: `${sbBrTL} ${sbBrTR} ${sbBrBR} ${sbBrBL}`,
    boxShadow:    sbShadow || undefined,
    fontSize: sbSize, fontWeight: sbWeight, fontFamily: sbFam,
  };
  if (sbBdType !== 'none') {
    sbStyle.border      = `${sbBdwTop} ${sbBdType} ${sbBdColor}`;
    sbStyle.borderWidth = `${sbBdwTop} ${sbBdwRight} ${sbBdwBottom} ${sbBdwLeft}`;
  } else {
    sbStyle.border = 'none';
  }

  return (
    <div style={wrapStyle}>
      <div style={{ fontWeight: titleWeight, fontSize: titleSize, color: titleColor, marginBottom: 10, lineHeight: titleLH, fontFamily: titleFam }}>
        {c.title || 'Ready to get started?'}
      </div>
      {c.subtitle && (
        <div style={{ fontSize: subSize, fontWeight: subWeight, color: subColor, marginBottom: 28, fontFamily: subFam, lineHeight: subLH }}>
          {c.subtitle}
        </div>
      )}
      <div style={{ display: 'flex', gap: 12, justifyContent: btnJust, flexWrap: 'wrap' }}>
        <span style={pbStyle}>{c.primaryText || 'Get Started'}</span>
        {c.secondaryText && <span style={sbStyle}>{c.secondaryText}</span>}
      </div>
    </div>
  );
}

/* ── Stats Card ─────────────────────────────────────────── */
function StatsCardRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  const c = (node.content ?? {}) as { value?: string; label?: string; sublabel?: string };
  return (
    <div style={{ textAlign: 'center', padding: '20px 16px', background: '#fff', borderRadius: 12, border: '1px solid #f3f4f6', ...inlineStyles }}>
      <div style={{ fontSize: 40, fontWeight: 900, color: '#1a1a1a', lineHeight: 1 }}>{c.value || '0'}</div>
      <div style={{ fontSize: 13, fontWeight: 600, color: '#111827', marginTop: 6 }}>{c.label || 'Label'}</div>
      {c.sublabel && <div style={{ fontSize: 11, color: '#9ca3af', marginTop: 3 }}>{c.sublabel}</div>}
    </div>
  );
}

/* ── Timeline ───────────────────────────────────────────── */
function TimelineRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  const c = (node.content ?? {}) as { items?: Array<{ date: string; title: string; text: string }> };
  const items = c.items ?? [];
  return (
    <div style={{ paddingLeft: 20, borderLeft: '3px solid #e5e7eb', display: 'flex', flexDirection: 'column', gap: 16, ...inlineStyles }}>
      {items.length === 0 ? (
        <div style={{ fontSize: 12, color: '#9ca3af' }}>Add timeline events in Properties</div>
      ) : items.map((item, i) => (
        <div key={i} style={{ position: 'relative' }}>
          <div style={{ position: 'absolute', left: -26, top: 3, width: 10, height: 10, borderRadius: '50%', background: i === 0 ? '#1a1a1a' : '#d1d5db', border: '2px solid #fff', boxShadow: '0 0 0 2px ' + (i === 0 ? '#1a1a1a' : '#d1d5db') }} />
          {item.date && <div style={{ fontSize: 10, fontWeight: 700, color: '#1a1a1a', textTransform: 'uppercase', letterSpacing: '.05em', marginBottom: 2 }}>{item.date}</div>}
          <div style={{ fontWeight: 700, fontSize: 13, color: '#111827', marginBottom: 2 }}>{item.title}</div>
          {item.text && <div style={{ fontSize: 12, color: '#6b7280', lineHeight: 1.4 }}>{item.text}</div>}
        </div>
      ))}
    </div>
  );
}

/* ── Tabs ───────────────────────────────────────────────── */
function TabsRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  const c = (node.content ?? {}) as { items?: Array<{ label: string; content: string }>; tabStyle?: string };
  const items = c.items ?? [];
  const [activeIdx, setActiveIdx] = React.useState(0);
  const tabStyle = c.tabStyle || 'underline';
  const isPill   = tabStyle === 'pill';
  const isBoxed  = tabStyle === 'boxed';

  return (
    <div style={{ fontFamily: 'inherit', ...inlineStyles }}>
      <div style={{
        display: 'flex', gap: isPill ? 4 : 0, flexWrap: 'wrap',
        borderBottom: !isPill ? '2px solid #e5e7eb' : 'none',
        background: isBoxed ? '#f9fafb' : 'transparent',
        borderRadius: isBoxed ? '8px 8px 0 0' : 0,
        padding: isBoxed ? '4px 4px 0' : 0,
      }}>
        {items.map((item, i) => (
          <button key={i} onClick={(e) => { e.stopPropagation(); setActiveIdx(i); }} style={{
            padding: isPill ? '5px 14px' : '8px 16px',
            fontSize: 13, fontWeight: i === activeIdx ? 700 : 400, border: 'none', cursor: 'pointer',
            color: i === activeIdx ? '#1a1a1a' : '#6b7280',
            background: isPill ? (i === activeIdx ? '#f0f0f0' : 'transparent')
                      : isBoxed && i === activeIdx ? '#fff' : 'transparent',
            borderRadius: isPill ? 999 : isBoxed ? '6px 6px 0 0' : 0,
            borderBottom: !isPill && !isBoxed && i === activeIdx ? '2px solid #1a1a1a' : '2px solid transparent',
            marginBottom: !isPill && !isBoxed ? -2 : 0,
          }}>
            {item.label || `Tab ${i + 1}`}
          </button>
        ))}
      </div>
      <div style={{ padding: '14px 4px 4px', fontSize: 13, color: '#374151', lineHeight: 1.6 }}>
        {items[activeIdx]?.content || (items.length === 0 ? 'Add tabs in Properties' : '')}
      </div>
    </div>
  );
}

/* ── Tabs (Advanced) — each tab is a real droppable container of widgets ── */
/* Breadcrumbs — canvas preview.
 *
 * The real trail depends on the queried page, which does not exist in the
 * editor, so a representative Home / Parent / Current trail is shown. That
 * matches what the PHP element renders in an admin context. */
function BreadcrumbsRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  const c = (node.content ?? {}) as Record<string, unknown>;
  const sepMap: Record<string, string> = {
    chevron: '›', slash: '/', arrow: '→', dot: '·', pipe: '|',
  };
  const sep = sepMap[(c.separator as string) || 'chevron'] ?? '›';
  const showHome = c.showHome !== false;
  const showCurrent = c.showCurrent !== false;

  const crumbs: string[] = [];
  if (showHome) crumbs.push((c.homeLabel as string) || 'Home');
  crumbs.push('Parent Page');
  if (showCurrent) crumbs.push('Current Page');

  return (
    <nav className="jalpi-el jalpi-breadcrumbs" style={inlineStyles}>
      <ol className="jalpi-breadcrumbs__list">
        {crumbs.map((label, i) => (
          <li key={i} className={'jalpi-breadcrumbs__item' + (i === crumbs.length - 1 ? ' is-current' : '')}>
            {i === crumbs.length - 1
              ? <span className="jalpi-breadcrumbs__current">{label}</span>
              : <span className="jalpi-breadcrumbs__link">{label}</span>}
            {i < crumbs.length - 1 && <span className="jalpi-breadcrumbs__sep">{sep}</span>}
          </li>
        ))}
      </ol>
    </nav>
  );
}

/* Menu — canvas preview.
 *
 * frontend.css is NOT loaded in the editor, so this preview carries its own
 * layout inline; with class names alone the list would render as a plain
 * stacked <ul> that looks nothing like a menu.
 *
 * The real items live in a WordPress menu resolved server-side, so the labels
 * are fetched from the REST endpoint. Submenus are shown expanded inline —
 * the point of the canvas is to see the structure being edited, and a
 * hover-only flyout would be invisible while configuring it.
 */
type MenuPreviewItem = { id?: number; label: string; hasChildren: boolean };

type AdvMegaCfg = { enabled?: boolean; cols?: number; panelIndex?: number };

/**
 * Advanced Menu canvas preview.
 *
 * The menu bar is a static preview (the real markup is built server-side from
 * wp_nav_menu), but a mega panel is a REAL container child rendered through
 * CanvasNode — so widgets are dropped straight into it, exactly as they are
 * anywhere else on the page. Clicking an item with a panel opens it for
 * editing; the open item is tracked locally rather than in content, since it
 * is an editor concern with no meaning on the frontend.
 */
function AdvMenuRenderer({ node, store, inlineStyles }: { node: CBNode; store: EditorStore; inlineStyles: React.CSSProperties }) {
  const c = (node.content ?? {}) as Record<string, unknown>;
  const layout = (c.layout as string) === 'vertical' ? 'vertical' : 'horizontal';
  const align = (c.align as string) || 'left';
  const indicator = (c.submenuIndicator as string) || 'arrow';
  const menuId = Number(c.menuId ?? 0);
  const mega = (c.megaItems ?? {}) as Record<string, AdvMegaCfg>;

  const [items, setItems] = React.useState<MenuPreviewItem[]>([]);
  const [menuName, setMenuName] = React.useState('');
  const [openItem, setOpenItem] = React.useState<number | null>(null);

  React.useEffect(() => {
    if (!menuId) { setItems([]); setMenuName(''); return; }

    // The editor's config global is `jalpiEditorConfig` (see
    // Jalpi_Editor::enqueue_editor). Using the wrong name silently yields
    // undefined and the request never fires.
    const w = window as unknown as { jalpiEditorConfig?: { apiUrl?: string; nonce?: string } };
    const base = w.jalpiEditorConfig?.apiUrl;
    if (!base) return;

    let cancelled = false;
    fetch(`${base}/nav-menus`, {
      credentials: 'same-origin',
      headers: w.jalpiEditorConfig?.nonce ? { 'X-WP-Nonce': w.jalpiEditorConfig.nonce } : {},
    })
      .then((r) => r.json())
      .then((d) => {
        if (cancelled) return;
        const found = (d?.menus ?? []).find((m: { id: number }) => m.id === menuId);
        setItems(found?.items ?? []);
        setMenuName(found?.name ?? '');
      })
      .catch(() => { if (!cancelled) { setItems([]); setMenuName(''); } });

    // The node can be deselected or the id changed while the request is in
    // flight; without this a stale response would overwrite newer state.
    return () => { cancelled = true; };
  }, [menuId]);

  const justify =
    align === 'center' ? 'center'
    : align === 'right' ? 'flex-end'
    : align === 'justify' ? 'space-between'
    : 'flex-start';

  // Style-tab values live in node.settings, which buildInlineStyles() only
  // consumes for heading/text — so without reading them here nothing on the
  // Style tab shows up in the canvas.
  const s = (node.settings ?? {}) as Record<string, unknown>;
  const sv = (key: string): string => {
    const v = s[key];
    return typeof v === 'string' ? v.trim() : typeof v === 'number' ? String(v) : '';
  };
  const withUnit = (v: string): string => (v !== '' && /^-?\d+(\.\d+)?$/.test(v) ? `${v}px` : v);
  const set = (target: Record<string, string>, prop: string, v: string) => {
    if (v !== '') target[prop] = v;
  };

  const gap = withUnit(sv('am_gap')) || withUnit((c.itemSpacing as string) ?? '');

  const linkStyle: Record<string, string> = {};
  set(linkStyle, 'color',         sv('am_color'));
  set(linkStyle, 'background',    sv('am_bg'));
  set(linkStyle, 'fontFamily',    sv('am_font_family'));
  set(linkStyle, 'fontSize',      withUnit(sv('am_font_size')));
  set(linkStyle, 'fontWeight',    sv('am_font_weight'));
  set(linkStyle, 'letterSpacing', withUnit(sv('am_letter_spacing')));
  set(linkStyle, 'textTransform', sv('am_text_transform'));
  set(linkStyle, 'padding',       sv('am_padding'));
  set(linkStyle, 'margin',        sv('am_margin'));
  set(linkStyle, 'borderRadius',  sv('am_radius'));

  const navStyle: Record<string, string> = {};
  const menuWidth = withUnit((c.menuWidth as string) ?? '');
  if (menuWidth) {
    navStyle.width = menuWidth;
    navStyle.maxWidth = '100%';
    navStyle.margin = align === 'center' ? '0 auto' : align === 'right' ? '0 0 0 auto' : '0 auto 0 0';
  }

  const panelStyle: Record<string, string> = {};
  set(panelStyle, 'background',   sv('am_panel_bg'));
  set(panelStyle, 'padding',      sv('am_panel_padding'));
  set(panelStyle, 'borderRadius', sv('am_panel_radius'));

  if (!menuId) {
    return (
      <nav className="jalpi-el jalpi-advmenu" style={inlineStyles}>
        <div
          style={{
            padding: '10px 14px',
            border: '1px dashed #cbd5e1',
            borderRadius: 6,
            color: '#64748b',
            fontSize: 13,
            background: '#f8fafc',
          }}
        >
          Choose a menu under <strong>Content → Menu Source</strong>.
        </div>
      </nav>
    );
  }

  const panels = node.children ?? [];
  const openCfg = openItem != null ? mega[String(openItem)] : undefined;
  const openPanel =
    openCfg?.enabled && typeof openCfg.panelIndex === 'number' && openCfg.panelIndex >= 0
      ? panels[openCfg.panelIndex]
      : undefined;

  return (
    // display:block + width:100% so justify-content has room to push against.
    // Without it the <nav> shrink-wraps its items and every alignment other
    // than left looks identical. The frontend gets this from frontend.css,
    // which the editor never loads.
    <nav
      className="jalpi-el jalpi-advmenu"
      style={{ display: 'block', width: '100%', position: 'relative', ...navStyle, ...inlineStyles }}
    >
      {items.length === 0 ? (
        <div style={{ padding: '10px 14px', color: '#64748b', fontSize: 13 }}>
          {menuName ? `“${menuName}” has no items yet.` : 'Loading menu…'}
        </div>
      ) : (
        <ul
          style={{
            display: 'flex',
            flexDirection: layout === 'vertical' ? 'column' : 'row',
            flexWrap: 'wrap',
            alignItems: layout === 'vertical' ? 'stretch' : 'center',
            justifyContent: justify,
            gap: gap || 4,
            listStyle: 'none',
            margin: 0,
            padding: 0,
          }}
        >
          {items.map((item, i) => {
            const itemId = Number(item.id ?? 0);
            const cfg = mega[String(itemId)];
            const isMega = !!cfg?.enabled;
            const isOpen = openItem === itemId;
            return (
              <li key={itemId || i} style={{ position: 'relative' }}>
                <span
                  onClick={(e) => {
                    if (!isMega) return;
                    // Opening a panel is an editing action, not a selection
                    // change — let the click through and the node would be
                    // reselected on every toggle.
                    e.stopPropagation();
                    setOpenItem(isOpen ? null : itemId);
                  }}
                  style={{
                    display: 'inline-flex',
                    alignItems: 'center',
                    gap: 6,
                    padding: '8px 12px',
                    lineHeight: 1.4,
                    whiteSpace: 'nowrap',
                    cursor: isMega ? 'pointer' : 'default',
                    outline: isOpen ? '2px solid #2271b1' : undefined,
                    outlineOffset: -2,
                    borderRadius: 4,
                    ...linkStyle,
                  }}
                >
                  <span>{item.label}</span>
                  {isMega ? (
                    <span
                      title="Has a mega panel"
                      style={{ fontSize: 9, opacity: 0.75, color: '#2271b1' }}
                    >
                      ▦
                    </span>
                  ) : item.hasChildren && indicator !== 'none' ? (
                    // A small caret is enough to signal "has a submenu"; the
                    // frontend's CSS-drawn indicators need styles we lack here.
                    <span style={{ fontSize: 9, opacity: 0.5, marginLeft: 2 }}>▾</span>
                  ) : null}
                </span>
              </li>
            );
          })}
        </ul>
      )}

      {/* The open mega panel, rendered as a real editable subtree. Only one is
          mounted at a time — a menu can have many panels, and mounting them
          all would put every widget in every panel on the canvas at once. */}
      {openPanel && (
        <div
          style={{
            position: 'relative',
            marginTop: 6,
            padding: 12,
            border: '2px solid #2271b1',
            borderRadius: 8,
            background: '#fff',
            ...panelStyle,
          }}
        >
          <div
            style={{
              display: 'flex',
              justifyContent: 'space-between',
              alignItems: 'center',
              marginBottom: 8,
              fontSize: 11,
              color: '#2271b1',
              fontWeight: 600,
            }}
          >
            <span>Mega panel — drop elements below</span>
            <button
              type="button"
              onClick={(e) => { e.stopPropagation(); setOpenItem(null); }}
              style={{ border: 0, background: 'none', cursor: 'pointer', color: '#64748b', fontSize: 14 }}
            >
              ×
            </button>
          </div>
          <div
            style={{
              display: 'grid',
              gridTemplateColumns: `repeat(${Math.max(1, Math.min(12, openCfg?.cols ?? 3))}, minmax(0, 1fr))`,
              gap: 12,
            }}
          >
            <CanvasNode node={openPanel} store={store} />
          </div>
        </div>
      )}
    </nav>
  );
}

function MenuRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  const c = (node.content ?? {}) as Record<string, unknown>;
  const layout = (c.layout as string) === 'vertical' ? 'vertical' : 'horizontal';
  const align = (c.align as string) || 'left';
  const submenuIcon = (c.submenuIcon as string) || 'chevron';
  const showSubmenus = c.showSubmenus !== false;
  const menuId = Number(c.menuId ?? 0);

  const [items, setItems] = React.useState<MenuPreviewItem[]>([]);
  const [menuName, setMenuName] = React.useState('');

  React.useEffect(() => {
    if (!menuId) { setItems([]); setMenuName(''); return; }

    // The editor's config global is `jalpiEditorConfig` (see
    // Jalpi_Editor::enqueue_editor). Using the wrong name silently yields
    // undefined and the request never fires.
    const w = window as unknown as { jalpiEditorConfig?: { apiUrl?: string; nonce?: string } };
    const base = w.jalpiEditorConfig?.apiUrl;
    if (!base) return;

    let cancelled = false;
    fetch(`${base}/nav-menus`, {
      credentials: 'same-origin',
      headers: w.jalpiEditorConfig?.nonce ? { 'X-WP-Nonce': w.jalpiEditorConfig.nonce } : {},
    })
      .then((r) => r.json())
      .then((d) => {
        if (cancelled) return;
        const found = (d?.menus ?? []).find((m: { id: number }) => m.id === menuId);
        setItems(found?.items ?? []);
        setMenuName(found?.name ?? '');
      })
      .catch(() => { if (!cancelled) { setItems([]); setMenuName(''); } });

    // The node can be deselected or the id changed while the request is in
    // flight; without this a stale response would overwrite newer state.
    return () => { cancelled = true; };
  }, [menuId]);

  const justify =
    align === 'center' ? 'center'
    : align === 'right' ? 'flex-end'
    : align === 'space-between' ? 'space-between'
    : 'flex-start';

  // Style-tab values live in node.settings, which buildInlineStyles() only
  // consumes for heading/text — so without reading them here nothing on the
  // Style tab shows up in the canvas.
  const s = (node.settings ?? {}) as Record<string, unknown>;
  const sv = (key: string): string => {
    const v = s[key];
    return typeof v === 'string' ? v.trim() : typeof v === 'number' ? String(v) : '';
  };
  const withUnit = (v: string): string => (v !== '' && /^-?\d+(\.\d+)?$/.test(v) ? `${v}px` : v);
  const set = (target: Record<string, string>, prop: string, v: string) => {
    if (v !== '') target[prop] = v;
  };

  // Item Gap (Style) overrides Item Spacing (Content) — same precedence as
  // Jalpi_Element_Menu::emit_css().
  const gap = withUnit(sv('menu_gap')) || withUnit((c.itemSpacing as string) ?? '');

  const linkStyle: Record<string, string> = {};
  set(linkStyle, 'color',         sv('menu_color'));
  set(linkStyle, 'fontSize',      withUnit(sv('menu_font_size')));
  set(linkStyle, 'fontWeight',    sv('menu_font_weight'));
  set(linkStyle, 'letterSpacing', withUnit(sv('menu_letter_spacing')));
  set(linkStyle, 'textTransform', sv('menu_text_transform'));
  set(linkStyle, 'padding',       sv('menu_padding'));
  set(linkStyle, 'margin',        sv('menu_margin'));

  const navStyle: Record<string, string> = {};
  set(navStyle, 'background',   sv('menu_bg'));
  set(navStyle, 'borderRadius', sv('menu_radius'));
  const borderStyle = sv('menu_border_style');
  if (borderStyle && borderStyle !== 'none') {
    const bw = withUnit(sv('menu_border_width')) || '1px';
    const bc = sv('menu_border_color') || 'currentColor';
    navStyle.border = `${bw} ${borderStyle} ${bc}`;
  }

  // Menu Width mirrors the frontend: the nav narrows and its margin honours
  // the chosen alignment.
  const menuWidth = withUnit((c.menuWidth as string) ?? '');
  if (menuWidth) {
    navStyle.width = menuWidth;
    navStyle.maxWidth = '100%';
    navStyle.margin = align === 'center' ? '0 auto' : align === 'right' ? '0 0 0 auto' : '0 auto 0 0';
  }

  if (!menuId) {
    return (
      <nav className={`jalpi-el jalpi-menu jalpi-menu--${layout}`} style={inlineStyles}>
        <div
          style={{
            padding: '10px 14px',
            border: '1px dashed #cbd5e1',
            borderRadius: 6,
            color: '#64748b',
            fontSize: 13,
            background: '#f8fafc',
          }}
        >
          Choose a menu under <strong>Content → Menu</strong>.
        </div>
      </nav>
    );
  }

  return (
    // display:block + width:100% so justify-content has room to push against.
    // Without it the <nav> shrink-wraps its items and every alignment other
    // than left looks identical. The frontend gets this from frontend.css,
    // which the editor never loads.
    <nav
      className={`jalpi-el jalpi-menu jalpi-menu--${layout}`}
      style={{ display: 'block', width: '100%', ...navStyle, ...inlineStyles }}
    >
      {items.length === 0 ? (
        <div style={{ padding: '10px 14px', color: '#64748b', fontSize: 13 }}>
          {menuName ? `“${menuName}” has no items yet.` : 'Loading menu…'}
        </div>
      ) : (
        <ul
          style={{
            display: 'flex',
            flexDirection: layout === 'vertical' ? 'column' : 'row',
            flexWrap: 'wrap',
            alignItems: layout === 'vertical' ? 'stretch' : 'center',
            justifyContent: justify,
            gap: gap || 4,
            listStyle: 'none',
            margin: 0,
            padding: 0,
          }}
        >
          {items.map((item, i) => (
            <li key={i} className="jalpi-menu__item">
              <span
                className="jalpi-menu__link"
                style={{
                  display: 'inline-flex',
                  alignItems: 'center',
                  gap: 6,
                  padding: '8px 12px',
                  lineHeight: 1.4,
                  whiteSpace: 'nowrap',
                  ...linkStyle,
                }}
              >
                <span className="jalpi-menu__label">{item.label}</span>
                {item.hasChildren && showSubmenus && submenuIcon !== 'none' ? (
                  // A small caret is enough to signal "has a submenu"; the
                  // frontend's CSS-drawn indicators need styles we lack here.
                  <span style={{ fontSize: 9, opacity: 0.5, marginLeft: 2 }}>▾</span>
                ) : null}
              </span>
            </li>
          ))}
        </ul>
      )}
    </nav>
  );
}

function TabsAdvRenderer({ node, store, inlineStyles }: { node: CBNode; store: EditorStore; inlineStyles: React.CSSProperties }) {
  const c = (node.content ?? {}) as {
    items?: Array<{ id?: string; title?: string; icon?: string | IconValue; cssId?: string }>;
    tabStyle?: string;
    activeTab?: number;
  };
  const items    = c.items ?? [];
  const panels   = node.children ?? [];
  const tabStyle = c.tabStyle || 'underline';
  const isPill   = tabStyle === 'pill';
  const isBoxed  = tabStyle === 'boxed';
  // Active tab persists in content so drops/switches survive re-renders.
  const activeIdx = Math.min(Math.max(0, c.activeTab ?? 0), Math.max(0, items.length - 1));

  const setActive = (i: number) => {
    if (i !== activeIdx) store.updateNodeContent(node.id, { activeTab: i });
  };

  return (
    <div style={{ fontFamily: 'inherit', ...inlineStyles }}>
      {/* Nav */}
      <div style={{
        display: 'flex', gap: isPill ? 4 : 0, flexWrap: 'wrap',
        borderBottom: !isPill ? '2px solid #e5e7eb' : 'none',
        background: isBoxed ? '#f9fafb' : 'transparent',
        borderRadius: isBoxed ? '8px 8px 0 0' : 0,
        padding: isBoxed ? '4px 4px 0' : 0,
      }}>
        {items.map((item, i) => {
          const active = i === activeIdx;
          const hasIcon = !!item.icon && (typeof item.icon === 'object' || (typeof item.icon === 'string' && item.icon !== ''));
          return (
            <button key={item.id ?? i} onClick={(e) => { e.stopPropagation(); setActive(i); }} style={{
              display: 'inline-flex', alignItems: 'center', gap: 6,
              padding: isPill ? '5px 14px' : '8px 16px',
              fontSize: 13, fontWeight: active ? 700 : 400, border: 'none', cursor: 'pointer',
              color: active ? '#1a1a1a' : '#6b7280',
              background: isPill ? (active ? '#f0f0f0' : 'transparent')
                        : isBoxed && active ? '#fff' : 'transparent',
              borderRadius: isPill ? 999 : isBoxed ? '6px 6px 0 0' : 0,
              borderBottom: !isPill && !isBoxed && active ? '2px solid #1a1a1a' : '2px solid transparent',
              marginBottom: !isPill && !isBoxed ? -2 : 0,
            }}>
              {hasIcon && <span style={{ display: 'inline-flex', width: 14, height: 14 }}><RenderIconValue value={item.icon as IconValue} /></span>}
              {item.title || `Tab ${i + 1}`}
            </button>
          );
        })}
      </div>

      {/* Panels — render ALL container children but show only the active one, so
          every tab's container stays mounted (drops + refs stay stable). */}
      <div style={{ paddingTop: 8 }}>
        {panels.map((panel, i) => (
          <div key={panel.id} style={{ display: i === activeIdx ? 'block' : 'none' }}>
            <CanvasNode node={panel} store={store} />
          </div>
        ))}
        {panels.length === 0 && (
          <div style={{ padding: 16, color: '#94a3b8', fontSize: 13 }}>Add tabs in the Content panel.</div>
        )}
      </div>
    </div>
  );
}

/* ── Image Box ──────────────────────────────────────────── */
function ImageBoxRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  const c = (node.content ?? {}) as { url?: string; alt?: string; title?: string; description?: string; titleTag?: string; imagePosition?: string };
  const pos  = c.imagePosition || 'top';
  const isH  = pos === 'left' || pos === 'right';
  const Tag  = (c.titleTag || 'h3') as keyof JSX.IntrinsicElements;

  const img = c.url
    ? <img src={c.url} alt={c.alt || ''} style={{ width: isH ? 80 : '100%', height: isH ? 80 : 140, objectFit: 'cover', borderRadius: 8, flexShrink: 0, display: 'block' }} />
    : <div style={{ width: isH ? 80 : '100%', height: isH ? 80 : 120, background: '#e5e7eb', borderRadius: 8, display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 24, color: '#9ca3af', flexShrink: 0 }}>🖼</div>;

  return (
    <div style={{ display: 'flex', flexDirection: pos === 'right' ? 'row-reverse' : isH ? 'row' : 'column', gap: 16, alignItems: isH ? 'center' : 'stretch', ...inlineStyles }}>
      {img}
      <div style={{ flex: 1 }}>
        {c.title && <Tag style={{ margin: '0 0 6px', fontSize: 16, fontWeight: 700, color: '#111827' } as React.CSSProperties}>{c.title}</Tag>}
        {c.description && <p style={{ margin: 0, fontSize: 13, color: '#6b7280', lineHeight: 1.6 }}>{c.description}</p>}
        {!c.title && !c.description && <span style={{ fontSize: 12, color: '#9ca3af' }}>Add text in Properties</span>}
      </div>
    </div>
  );
}

/* ── Social Icons ───────────────────────────────────────── */
const SOCIAL_META: Record<string, { label: string; color: string; letter: string }> = {
  facebook:  { label: 'Facebook',  color: '#1877f2', letter: 'f'  },
  instagram: { label: 'Instagram', color: '#e4405f', letter: '◎'  },
  twitter:   { label: 'Twitter/X', color: '#000000', letter: 'X'  },
  linkedin:  { label: 'LinkedIn',  color: '#0a66c2', letter: 'in' },
  youtube:   { label: 'YouTube',   color: '#ff0000', letter: '▶'  },
  tiktok:    { label: 'TikTok',   color: '#010101', letter: '♪'  },
  pinterest: { label: 'Pinterest', color: '#e60023', letter: 'P'  },
  github:    { label: 'GitHub',    color: '#181717', letter: '◉'  },
  email:     { label: 'Email',     color: '#ea4335', letter: '@'  },
  whatsapp:  { label: 'WhatsApp', color: '#25d366', letter: 'W'  },
};

function SocialIconsRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  const c = (node.content ?? {}) as { items?: Array<{ platform: string; url: string }>; iconStyle?: string; iconSize?: string; align?: string; color?: string };
  const items      = c.items ?? [];
  const iconStyle  = c.iconStyle || 'circle';
  const size       = parseInt(c.iconSize || '40') || 40;
  const align      = c.align || 'center';
  const customColor = c.color || '';

  return (
    <div style={{ display: 'flex', gap: 8, justifyContent: align === 'left' ? 'flex-start' : align === 'right' ? 'flex-end' : 'center', flexWrap: 'wrap', ...inlineStyles }}>
      {items.length === 0
        ? <div style={{ fontSize: 12, color: '#9ca3af' }}>Add social icons in Properties</div>
        : items.map((item, i) => {
          const meta = SOCIAL_META[item.platform] ?? { label: item.platform, color: '#6b7280', letter: (item.platform[0] ?? '?').toUpperCase() };
          const bg   = customColor || meta.color;
          return (
            <div key={i} style={{
              width: size, height: size,
              borderRadius: iconStyle === 'circle' ? '50%' : iconStyle === 'square' ? Math.round(size * 0.2) : 0,
              background: iconStyle === 'minimal' ? 'transparent' : bg,
              border: iconStyle === 'minimal' ? `2px solid ${bg}` : 'none',
              display: 'flex', alignItems: 'center', justifyContent: 'center',
              fontSize: Math.round(size * 0.38), color: iconStyle === 'minimal' ? bg : '#fff', fontWeight: 700,
            }}>{meta.letter}</div>
          );
        })
      }
    </div>
  );
}

/* ── Gallery ────────────────────────────────────────────── */
function GalleryRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  const c = (node.content ?? {}) as {
    items?: Array<{ id?: string; url: string; alt: string; caption: string; linkUrl?: string; category?: string }>;
    layout?: string;
    enableLightbox?: boolean;
    enableFilter?: boolean;
  };
  const s    = (node.settings ?? {}) as Record<string, string>;
  const str  = (k: string) => s[k] || '';
  const toPx = (v: string, d = '8px') => v ? (/^\d+$/.test(v) ? `${v}px` : v) : d;

  const items       = c.items ?? [];
  const layout      = c.layout || 'grid';
  const cols        = parseInt(str('gal_cols_desktop') || '3');
  const gap         = toPx(str('gal_gap'), '12px');
  const ratio       = str('gal_img_ratio') || '1/1';
  const captionShow = str('gal_caption_show') || 'hover';
  const capColor    = str('gal_caption_color') || '#ffffff';
  const capBg       = str('gal_caption_bg')    || 'rgba(0,0,0,0.6)';
  const br          = `${toPx(str('gal_img_br_top'),'8px')} ${toPx(str('gal_img_br_right'),'8px')} ${toPx(str('gal_img_br_bottom'),'8px')} ${toPx(str('gal_img_br_left'),'8px')}`;
  const shadow      = str('gal_img_shadow') || undefined;
  const bdType      = str('gal_img_bd_type') || 'none';
  const bdColor     = str('gal_img_bd_color') || '#e5e7eb';
  const hover       = str('gal_hover') || 'zoom';

  const itemBase: React.CSSProperties = {
    position: 'relative', overflow: 'hidden', borderRadius: br,
    boxShadow: shadow || undefined,
    border: bdType !== 'none' ? `1px ${bdType} ${bdColor}` : undefined,
    cursor: c.enableLightbox ? 'zoom-in' : undefined,
  };
  const gridWrap: React.CSSProperties = layout === 'masonry'
    ? { columnCount: cols, columnGap: gap }
    : { display: 'grid', gridTemplateColumns: `repeat(${cols}, 1fr)`, gap };

  const displayItems = items.length > 0 ? items : Array.from({ length: cols * 2 }, (_, i) => ({ id: String(i), url: '', alt: '', caption: '' }));

  return (
    <div style={{ ...inlineStyles }}>
      <div style={gridWrap}>
        {displayItems.map((item, i) => {
          const masonry: React.CSSProperties = layout === 'masonry' ? { breakInside: 'avoid' as const, marginBottom: gap } : {};
          const sizeStyle: React.CSSProperties = layout !== 'masonry' ? { aspectRatio: ratio } : {};
          return (
            <div key={item.id || i} style={{ ...itemBase, ...masonry, ...sizeStyle }}>
              {item.url
                ? <img src={item.url} alt={item.alt || ''} style={{ width: '100%', height: layout === 'masonry' ? 'auto' : '100%', objectFit: 'cover', display: 'block',
                    transition: hover === 'zoom' ? 'transform .4s ease' : hover === 'fade' ? 'opacity .3s ease' : undefined }} />
                : <div style={{ width: '100%', aspectRatio: ratio, background: '#e5e7eb', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 22, color: '#9ca3af' }}>🖼</div>
              }
              {hover === 'overlay' && <div style={{ position: 'absolute', inset: 0, background: str('gal_overlay_color') || 'rgba(0,0,0,0.4)', opacity: 0, transition: 'opacity .3s' }} />}
              {item.caption && captionShow !== 'never' && (
                <div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: capBg, color: capColor, fontSize: 12, padding: '6px 10px', textAlign: 'center',
                  opacity: captionShow === 'hover' ? 0 : 1, transition: 'opacity .3s' }}>
                  {item.caption}
                </div>
              )}
            </div>
          );
        })}
      </div>
    </div>
  );
}

/* ── Image Carousel ─────────────────────────────────────── */
type ICItem = { url: string; alt: string; caption: string; description: string; linkUrl: string; linkNewTab: boolean };

function ImageCarouselRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  const c       = (node.content  ?? {}) as { items?: ICItem[] };
  const s       = (node.settings ?? {}) as Record<string, unknown>;
  const str     = (k: string, d = '') => { const v = s[k]; return (v != null && v !== '') ? String(v) : d; };
  const items   = (c.items ?? []) as ICItem[];

  const layout  = str('ic_layout', 'classic');
  const defCols = layout === 'card' ? 3 : 1;
  const cols    = parseInt(str('ic_cols', String(defCols)), 10) || defCols;
  const gap     = parseInt(str('ic_gap', '16'), 10) || 16;
  const imgH    = str('ic_img_height', '260px');
  const imgFit  = (str('ic_img_fit', 'cover') || 'cover') as React.CSSProperties['objectFit'];
  const imgBr   = str('ic_img_br', '');
  const hover   = str('ic_hover', 'none');
  const showArrows  = str('ic_arrows', '1') !== '0';
  const showDots    = str('ic_dots',   '1') !== '0';
  const capPos      = str('ic_caption_pos', 'below');
  const capColor    = str('ic_caption_color', '');
  const capSize     = str('ic_caption_size',  '');
  const descColor   = str('ic_desc_color',    '');
  const descSize    = str('ic_desc_size',     '');
  const capBg       = str('ic_caption_bg',    '');
  const cardBg      = str('ic_card_bg',     '#fff');
  const cardBr      = str('ic_card_br',     '12px');
  const cardShadow  = str('ic_card_shadow', '0 2px 14px rgba(0,0,0,.09)');
  const cardPad     = str('ic_card_pad',    '');
  const arrBg       = str('ic_arrow_bg',    'rgba(0,0,0,.45)');
  const arrColor    = str('ic_arrow_color', '#fff');
  const arrSize     = parseInt(str('ic_arrow_size', '20px'), 10) || 20;
  const arrBr       = str('ic_arrow_br',    '50%');
  const ovColor     = str('ic_hover_overlay_color', 'rgba(0,0,0,.4)');
  const dotColor    = str('ic_dot_color',   'rgba(0,0,0,.22)');
  const dotActive   = str('ic_dot_active_color', '#6366f1');
  const dotSize     = parseInt(str('ic_dot_size', '8px'), 10) || 8;

  const [idx, setIdx] = useState(0);

  if (items.length === 0) {
    return (
      <div style={{ padding: 24, textAlign: 'center', color: '#9ca3af', fontSize: 12, border: '2px dashed #e5e7eb', borderRadius: 10, ...inlineStyles }}>
        Add images in the Content tab
      </div>
    );
  }

  const n = items.length;
  const maxIdx = Math.max(0, n - (layout === 'card' ? cols : 1));

  // ── Masonry layout ───────────────────────────────────────
  if (layout === 'masonry') {
    return (
      <div style={{ columns: cols, columnGap: gap, ...inlineStyles }}>
        {items.map((item, i) => (
          <div key={i} style={{ breakInside: 'avoid', marginBottom: gap }}>
            {item.url
              ? <img src={item.url} alt={item.alt || ''} style={{ width: '100%', display: 'block', borderRadius: imgBr || undefined }} />
              : <div style={{ width: '100%', height: 180, background: '#e5e7eb', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 32, color: '#9ca3af', borderRadius: imgBr || undefined }}>🖼</div>
            }
            {capPos === 'below' && item.caption && (
              <div style={{ fontSize: 12, color: '#6b7280', marginTop: 4 }}>{item.caption}</div>
            )}
          </div>
        ))}
      </div>
    );
  }

  // ── Slide width per col ──────────────────────────────────
  const visibleCols = layout === 'card' ? cols : 1;
  const slideWidthPct = 100 / visibleCols;

  const renderSlide = (item: ICItem, i: number) => {
    const isCard   = layout === 'card';
    const isModern = layout === 'modern';

    const imgEl = item.url
      ? <img src={item.url} alt={item.alt || ''} style={{ width: '100%', height: imgH, objectFit: imgFit, display: 'block', ...(imgBr && !isCard ? { borderRadius: imgBr } : {}) }} />
      : <div style={{ width: '100%', height: imgH, background: '#e5e7eb', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 36, color: '#9ca3af', ...(imgBr && !isCard ? { borderRadius: imgBr } : {}) }}>🖼</div>;

    const captionEl = capPos === 'below' && !isModern && (item.caption || item.description) ? (
      <div style={{ padding: '8px 4px 4px', ...(capBg ? { background: capBg } : {}) }}>
        {item.caption && <div style={{ fontSize: capSize || 13, fontWeight: 500, color: capColor || '#374151' }}>{item.caption}</div>}
        {item.description && <div style={{ fontSize: descSize || 12, color: descColor || '#6b7280', marginTop: 2 }}>{item.description}</div>}
      </div>
    ) : null;

    const overlayEl = (isModern || capPos === 'overlay') && (item.caption || item.description) ? (
      <div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, background: capBg || 'linear-gradient(to top, rgba(0,0,0,.65), transparent)', padding: '24px 14px 12px', pointerEvents: 'none' }}>
        {item.caption && <div style={{ color: capColor || '#fff', fontWeight: 600, fontSize: capSize || 15 }}>{item.caption}</div>}
        {item.description && <div style={{ color: descColor || 'rgba(255,255,255,.82)', fontSize: descSize || 13, marginTop: 2 }}>{item.description}</div>}
      </div>
    ) : null;

    const hoverOverlayEl = hover === 'overlay'
      ? <div style={{ position: 'absolute', inset: 0, background: ovColor, opacity: 0, transition: 'opacity 0.3s', pointerEvents: 'none' }} className="jalpi-ic__overlay" />
      : null;

    const innerStyle: React.CSSProperties = isCard
      ? { overflow: 'hidden', background: cardBg, borderRadius: cardBr, boxShadow: cardShadow, ...(cardPad ? { padding: cardPad } : {}) }
      : { overflow: 'hidden' };

    return (
      <div key={i} style={{ flex: `0 0 ${slideWidthPct}%`, padding: isCard ? 4 : 0 }}>
        <div style={innerStyle}>
          <div style={{ position: 'relative', overflow: 'hidden' }}>
            {imgEl}
            {hoverOverlayEl}
            {overlayEl}
          </div>
          {captionEl}
        </div>
      </div>
    );
  };

  const offset = `${-idx * (slideWidthPct + gap / 10)}%`;

  return (
    <div style={{ position: 'relative', ...inlineStyles }}>
      {/* Viewport */}
      <div style={{ overflow: 'hidden' }}>
        <div style={{ display: 'flex', gap: gap, transform: `translateX(${offset})`, transition: 'transform 0.4s ease' }}>
          {items.map((item, i) => renderSlide(item, i))}
        </div>
      </div>

      {/* Arrows */}
      {showArrows && (
        <>
          <button onClick={() => setIdx(Math.max(0, idx - 1))} style={{ position: 'absolute', left: 10, top: '40%', transform: 'translateY(-50%)', width: arrSize + 16, height: arrSize + 16, borderRadius: arrBr, background: arrBg, color: arrColor, border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 2, opacity: idx === 0 ? 0.3 : 1 }}>
            <svg width={arrSize} height={arrSize} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
          </button>
          <button onClick={() => setIdx(Math.min(maxIdx, idx + 1))} style={{ position: 'absolute', right: 10, top: '40%', transform: 'translateY(-50%)', width: arrSize + 16, height: arrSize + 16, borderRadius: arrBr, background: arrBg, color: arrColor, border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 2, opacity: idx >= maxIdx ? 0.3 : 1 }}>
            <svg width={arrSize} height={arrSize} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
          </button>
        </>
      )}

      {/* Dots */}
      {showDots && n > 0 && (
        <div style={{ display: 'flex', justifyContent: 'center', gap: 8, marginTop: 12, flexWrap: 'wrap' }}>
          {items.map((_, i) => (
            <button key={i} onClick={() => setIdx(i)} style={{ width: i === idx ? 20 : dotSize, height: dotSize, borderRadius: dotSize / 2, background: i === idx ? dotActive : dotColor, border: 'none', cursor: 'pointer', padding: 0, transition: 'all 0.2s' }} />
          ))}
        </div>
      )}
    </div>
  );
}

/* ── Countdown ──────────────────────────────────────────── */
function CountdownRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  const c = (node.content ?? {}) as { targetDate?: string; label?: string };

  const calcTime = React.useCallback(() => {
    if (!c.targetDate) return { days: 0, hours: 0, minutes: 0, seconds: 0 };
    const diff = new Date(c.targetDate).getTime() - Date.now();
    if (diff <= 0) return { days: 0, hours: 0, minutes: 0, seconds: 0 };
    return {
      days:    Math.floor(diff / 86400000),
      hours:   Math.floor((diff / 3600000) % 24),
      minutes: Math.floor((diff / 60000) % 60),
      seconds: Math.floor((diff / 1000) % 60),
    };
  }, [c.targetDate]);

  const [time, setTime] = React.useState(calcTime);
  React.useEffect(() => {
    setTime(calcTime());
    const t = setInterval(() => setTime(calcTime()), 1000);
    return () => clearInterval(t);
  }, [calcTime]);

  const units = [
    { label: 'Days',    value: time.days },
    { label: 'Hours',   value: time.hours },
    { label: 'Minutes', value: time.minutes },
    { label: 'Seconds', value: time.seconds },
  ];
  return (
    <div style={{ textAlign: 'center', ...inlineStyles }}>
      {c.label && <div style={{ fontSize: 14, color: '#6b7280', marginBottom: 12 }}>{c.label}</div>}
      {!c.targetDate
        ? <div style={{ fontSize: 12, color: '#9ca3af' }}>Set a target date in Properties</div>
        : <div style={{ display: 'flex', gap: 12, justifyContent: 'center', flexWrap: 'wrap' }}>
          {units.map((u) => (
            <div key={u.label} style={{ minWidth: 64, padding: '12px 8px', background: '#f9fafb', borderRadius: 8, border: '1px solid #e5e7eb' }}>
              <div style={{ fontSize: 28, fontWeight: 800, color: '#1a1a1a', lineHeight: 1 }}>{String(u.value).padStart(2, '0')}</div>
              <div style={{ fontSize: 10, color: '#9ca3af', marginTop: 4, textTransform: 'uppercase', letterSpacing: '.05em' }}>{u.label}</div>
            </div>
          ))}
        </div>
      }
    </div>
  );
}

/* ── Flip Box ───────────────────────────────────────────── */
function FlipBoxRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  const c = (node.content ?? {}) as {
    frontTitle?: string; frontDescription?: string; frontBg?: string; frontColor?: string;
    backTitle?: string; backDescription?: string; backBg?: string; backColor?: string;
    btnText?: string; btnUrl?: string; height?: string;
  };
  const [flipped, setFlipped] = React.useState(false);
  const height = c.height || '220px';
  return (
    <div
      style={{ perspective: '1000px', height, cursor: 'pointer', ...inlineStyles }}
      onClick={(e) => { e.stopPropagation(); setFlipped((f) => !f); }}
      title="Click to preview flip"
    >
      <div style={{
        position: 'relative', width: '100%', height: '100%',
        transformStyle: 'preserve-3d',
        transition: 'transform 0.6s ease',
        transform: flipped ? 'rotateY(180deg)' : 'rotateY(0deg)',
      }}>
        <div style={{
          position: 'absolute', inset: 0, backfaceVisibility: 'hidden',
          background: c.frontBg || '#1a1a1a', borderRadius: 12, color: c.frontColor || '#fff',
          display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
          padding: '20px', textAlign: 'center',
        }}>
          <div style={{ fontSize: 20, fontWeight: 800, marginBottom: 8 }}>{c.frontTitle || 'Front Side'}</div>
          {c.frontDescription && <div style={{ fontSize: 13, opacity: 0.85, lineHeight: 1.5 }}>{c.frontDescription}</div>}
          <div style={{ marginTop: 12, fontSize: 10, opacity: 0.5 }}>Click to flip ↻</div>
        </div>
        <div style={{
          position: 'absolute', inset: 0, backfaceVisibility: 'hidden',
          transform: 'rotateY(180deg)',
          background: c.backBg || '#111827', borderRadius: 12, color: c.backColor || '#fff',
          display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
          padding: '20px', textAlign: 'center',
        }}>
          <div style={{ fontSize: 20, fontWeight: 800, marginBottom: 8 }}>{c.backTitle || 'Back Side'}</div>
          {c.backDescription && <div style={{ fontSize: 13, opacity: 0.85, lineHeight: 1.5, marginBottom: 12 }}>{c.backDescription}</div>}
          {c.btnText && (
            <span style={{ padding: '7px 18px', background: '#fff', color: c.backBg || '#111827', borderRadius: 999, fontSize: 12, fontWeight: 700, display: 'inline-block' }}>
              {c.btnText}
            </span>
          )}
        </div>
      </div>
    </div>
  );
}

/* ── Star Rating ────────────────────────────────────────── */
function StarRatingRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  const c      = (node.content ?? {}) as { rating?: number; maxRating?: number; label?: string; starColor?: string; size?: string; align?: string };
  const rating = Math.max(0, Math.min(c.maxRating ?? 5, c.rating ?? 4));
  const max    = c.maxRating ?? 5;
  const color  = c.starColor || '#f59e0b';
  const size   = parseInt(c.size || '24') || 24;
  const align  = (c.align || 'left') as React.CSSProperties['textAlign'];
  return (
    <div style={{ textAlign: align, ...inlineStyles }}>
      <div style={{ display: 'inline-flex', gap: 2, verticalAlign: 'middle' }}>
        {Array.from({ length: max }, (_, i) => (
          <span key={i} style={{ fontSize: size, color: i < rating ? color : '#d1d5db', lineHeight: 1 }}>★</span>
        ))}
      </div>
      {c.label && <div style={{ fontSize: 12, color: '#6b7280', marginTop: 4 }}>{c.label}</div>}
    </div>
  );
}

/* ── Testimonials Carousel ──────────────────────────────── */
const TCARD_SHADOW_MAP: Record<string, string> = {
  none: 'none',
  sm:   '0 1px 8px rgba(0,0,0,.06)',
  md:   '0 4px 20px rgba(0,0,0,.10)',
  lg:   '0 8px 32px rgba(0,0,0,.16)',
  xl:   '0 16px 48px rgba(0,0,0,.20)',
};

function TestimonialsCarouselRenderer({ node, inlineStyles }: { node: CBNode; inlineStyles: React.CSSProperties }) {
  type TItem = { text: string; name: string; role: string; avatarUrl: string; rating: number };
  const c     = (node.content ?? {}) as { layout?: string; cols?: number; items?: TItem[] };
  const s     = (node.settings ?? {}) as Record<string, string>;
  const str   = (k: string) => s[k] || '';
  const items = c.items ?? [];
  const isCarousel = c.layout === 'carousel';
  const cols  = c.cols ?? 3;

  const cardBg      = str('ts_card_bg')      || '#ffffff';
  const cardShadow  = TCARD_SHADOW_MAP[str('ts_card_shadow') || 'sm'] ?? TCARD_SHADOW_MAP.sm;
  const cardRadius  = str('ts_card_radius')  || '16px';
  const cardPad     = str('ts_card_pad')     || '28px';
  const cardGap     = str('ts_gap')          || '24px';
  const bdType      = str('ts_bd_type')      || 'none';
  const cardBorder  = bdType !== 'none' ? `${str('ts_bd_width') || '1px'} ${bdType} ${str('ts_bd_color') || '#e5e7eb'}` : undefined;
  const cardAlign   = (str('ts_align') || 'left') as React.CSSProperties['textAlign'];

  const quoteColor  = str('ts_quote_color')    || '#374151';
  const quoteSize   = str('ts_q_fontSize')     || '15px';
  const quoteFStyle = (str('ts_quote_fstyle')  || 'italic') as React.CSSProperties['fontStyle'];
  const quoteWeight = str('ts_q_fontWeight')   || '400';
  const quoteLH     = str('ts_q_lineHeight')   || '1.75';
  const quoteFam    = str('ts_q_fontFamily')   || undefined;
  const qmarkColor  = str('ts_qmark_color')    || 'rgba(99,102,241,0.10)';
  const qmarkSize   = str('ts_qmark_size')     || '72px';

  const avSize     = str('ts_avatar_size')     || '44px';
  const avRadius   = str('ts_avatar_radius')   || '50%';
  const nameColor  = str('ts_name_color')      || '#111827';
  const nameSize   = str('ts_name_fontSize')   || '14px';
  const nameFam    = str('ts_name_fontFamily') || undefined;
  const nameWeight = str('ts_name_fontWeight') || '600';
  const roleColor  = str('ts_role_color')      || '#6b7280';
  const roleSize   = str('ts_role_fontSize')   || '12px';
  const roleFam    = str('ts_role_fontFamily') || undefined;
  const starFilled = str('ts_star_color')    || '#f59e0b';
  const starEmpty  = str('ts_star_empty')    || '#d1d5db';
  const starSize   = str('ts_star_size')     || '16px';

  const avNum = parseInt(avSize) || 44;
  const starJustify: React.CSSProperties['justifyContent'] = cardAlign === 'center' ? 'center' : cardAlign === 'right' ? 'flex-end' : 'flex-start';
  const authorJustify = starJustify;

  const cardBase: React.CSSProperties = {
    background: cardBg, borderRadius: cardRadius,
    boxShadow: cardShadow, padding: cardPad,
    border: cardBorder, textAlign: cardAlign,
    display: 'flex', flexDirection: 'column',
    position: 'relative', overflow: 'hidden',
  };

  if (items.length === 0) {
    return (
      <div style={{ padding: '32px', textAlign: 'center', color: '#9ca3af', fontSize: 13, border: '2px dashed #e5e7eb', borderRadius: 16, ...inlineStyles }}>
        <div style={{ fontSize: 36, marginBottom: 8, opacity: 0.3, fontFamily: 'Georgia, serif' }}>❝</div>
        <div>Add testimonials in Properties</div>
      </div>
    );
  }

  return (
    <div style={{
      display: isCarousel ? 'flex' : 'grid',
      gridTemplateColumns: isCarousel ? undefined : `repeat(${cols}, 1fr)`,
      flexDirection: isCarousel ? 'row' : undefined,
      gap: cardGap,
      overflowX: isCarousel ? 'auto' : undefined,
      ...inlineStyles,
    }}>
      {items.map((item, i) => {
        const filled = Math.max(1, Math.min(5, item.rating ?? 5));
        const initial = (item.name || '?')[0]?.toUpperCase();
        return (
          <div key={i} style={{ flex: isCarousel ? '0 0 280px' : undefined, ...cardBase }}>
            {/* Decorative quote mark */}
            <div style={{
              position: 'absolute', top: 10,
              left: cardAlign === 'center' ? '50%' : 14,
              transform: cardAlign === 'center' ? 'translateX(-50%)' : undefined,
              fontSize: qmarkSize, lineHeight: 1, color: qmarkColor,
              fontFamily: 'Georgia, serif', pointerEvents: 'none', userSelect: 'none', zIndex: 0,
            }}>❝</div>
            {/* Stars */}
            <div style={{ display: 'flex', gap: 3, marginBottom: 12, justifyContent: starJustify, position: 'relative', zIndex: 1 }}>
              {Array.from({ length: 5 }, (_, si) => (
                <span key={si} style={{ fontSize: starSize, lineHeight: 1, color: si < filled ? starFilled : starEmpty }}>★</span>
              ))}
            </div>
            {/* Quote */}
            <p style={{ fontSize: quoteSize, color: quoteColor, lineHeight: quoteLH, margin: '0 0 20px', fontStyle: quoteFStyle, fontWeight: parseInt(quoteWeight) as React.CSSProperties['fontWeight'], fontFamily: quoteFam, flexGrow: 1, position: 'relative', zIndex: 1 }}>
              {item.text || 'Testimonial quote goes here.'}
            </p>
            {/* Author */}
            <div style={{ display: 'flex', alignItems: 'center', gap: 12, paddingTop: 16, borderTop: '1px solid rgba(0,0,0,.06)', marginTop: 'auto', justifyContent: authorJustify, position: 'relative', zIndex: 1 }}>
              {item.avatarUrl
                ? <img src={item.avatarUrl} alt={item.name} style={{ width: avNum, height: avNum, borderRadius: avRadius, objectFit: 'cover', flexShrink: 0 }} />
                : <div style={{ width: avNum, height: avNum, borderRadius: avRadius, background: 'linear-gradient(135deg,#667eea,#764ba2)', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontSize: avNum * 0.42, fontWeight: 600, flexShrink: 0 }}>
                    {initial}
                  </div>
              }
              <div>
                <div style={{ fontSize: nameSize, fontWeight: parseInt(nameWeight) as React.CSSProperties['fontWeight'], color: nameColor, fontFamily: nameFam, lineHeight: 1.3 }}>{item.name || 'Author Name'}</div>
                {item.role && <div style={{ fontSize: roleSize, color: roleColor, fontFamily: roleFam, lineHeight: 1.3, marginTop: 2 }}>{item.role}</div>}
              </div>
            </div>
          </div>
        );
      })}
    </div>
  );
}
