import { createElement, useEffect, useMemo, useRef, useState } from 'react' import { InsertPicker } from '../editors/builtin' import type { CmsNode, EvaluateContext, ExperimentAnalytics, ExperimentAssignment, ExperimentRule, TypeInfo } from '../api' import { allowedChildren, connectionsOf, labelKey, sortGroups, typeName } from '../schema' import type { DropTarget } from '../page/dnd' import { Icon, TypeIcon } from './Icon' import { Summary } from './Summary' import { useCtrl, useEditorState, usePersistentState } from './hooks' import type { Block, Controller } from '../controller' /** * Left panel: outline + blocks + nodes in one place (no tabs). * * - Outline is a tree with its slots (e.g. a pdp's `info-section` / * `3d-accessories` connections): drag any row by its grip to reorder, drop * palette blocks and searched nodes onto the gaps between rows or into slots. * - Blocks are the new types that fit this page (drag onto the page or the outline). * - Nodes finds *existing* nodes (type filter + text) to link into the page. */ export function Rail() { const ctrl = useCtrl() const open = useEditorState((s) => s.rail) useEffect(() => { if (!open) return // Types carry every schema (titles, groups, slots): one request seeds them all. void ctrl.api .types() .then((ts) => { ctrl.ingestTypes(ts) void ctrl.loadSchemas(ts.map(typeName)) }) .catch(() => {}) void ctrl.fillHandles() }, [open, ctrl]) if (!open) return null return ( ) } // --- outline ----------------------------------------------------------------- type Gap = { parentId: string; handle?: string; index: number } function OutlineTree() { const ctrl = useCtrl() const blocks = useEditorState((s) => s.blocks) const selected = useEditorState((s) => s.selected) const drag = useEditorState((s) => s.drag) useEditorState((s) => s.tick) useEditorState((s) => s.schemasVersion) useEditorState((s) => s.labelsVersion) const [collapsed, setCollapsed] = useState>(new Set()) const [over, setOver] = useState(null) const dragging = !!drag // Our pointerup runs before the page engine's (registered at mount, theirs at // drag start): a drop on a gap commits to the outline, anywhere else on the // panel just cancels the page ghost. useEffect(() => { const onUp = (e: PointerEvent) => { const d = ctrl.store.get().drag if (!d) return const path = e.composedPath() as Element[] const inRail = path.some((el) => el instanceof Element && el.classList?.contains('cms-rail')) if (!inRail) return e.stopImmediatePropagation() e.preventDefault() const gapEl = path.find((el) => el instanceof Element && el.hasAttribute?.('data-ogap')) const target: DropTarget | null = gapEl ? { parentId: gapEl.getAttribute('data-parent') ?? '', handle: gapEl.getAttribute('data-handle') || undefined, index: Number(gapEl.getAttribute('data-index') ?? 0), line: { x: 0, y: 0, w: 0, h: 0 }, } : null setOver(null) ctrl.cancelDrag() if (!target) return if (d.source === 'block' && d.id) void ctrl.dropBlock(d.id, target) else if (d.source === 'existing' && d.nodeId) void ctrl.dropExisting(target, d.nodeId) else if (d.source === 'palette' && d.type && !d.blueprint) void ctrl.dropNew(target, { type: d.type }) else if (d.source === 'palette' && d.blueprint && !target.handle) void ctrl.dropNew(target, { blueprint: d.blueprint }) else if (d.source === 'palette' && d.blueprint && target.handle) ctrl.toast('info', 'Blueprints cannot go straight into a slot — drop it on the page first, then move it') } ctrl.doc.addEventListener('pointerup', onUp, true) return () => ctrl.doc.removeEventListener('pointerup', onUp, true) }, [ctrl]) useEffect(() => { if (!drag) setOver(null) }, [drag]) const roots = useMemo(() => blocks.filter((b) => !b.parentId), [blocks]) const [open, setOpen] = usePersistentState('cms-onpage.sec.outline', true) if (!blocks.length) return null return (

{open && ( <>
    {roots.map((b) => ( ))}
{dragging && } )}
) } function OutlineGap({ parentId, handle, index, over, setOver }: { parentId: string; handle?: string; index: number; over: Gap | null; setOver: (g: Gap | null) => void }) { const ctrl = useCtrl() const drag = useEditorState((s) => s.drag) if (!drag) return null const active = over?.parentId === parentId && (over?.handle ?? '') === (handle ?? '') && over?.index === index // Blueprints cannot land in slots; slot gaps refuse them. const refused = !!drag.blueprint && !!handle // Page drops onto a type the slot does not take are refused too. const typeRefused = !!drag.type && !drag.blueprint && parentId !== '' && !ctrl.accepts(parentId, drag.type) && !(handle ? ctrl.acceptsInSlot(parentId, handle, drag.type) : ctrl.handleForDrop(parentId, drag.type)) return (
(refused ? setOver(null) : setOver({ parentId, handle, index }))} onPointerLeave={() => setOver(null)} /> ) } function OutlineNode({ block: b, level, collapsed, setCollapsed, over, setOver, dragging, selected }: { block: Block level: number collapsed: Set setCollapsed: (f: (s: Set) => Set) => void over: Gap | null setOver: (g: Gap | null) => void dragging: boolean selected: string | null }) { const ctrl = useCtrl() const kids = useEditorState((s) => s.blocks.filter((x) => (x.parentId ?? '') === b.id && !ctrl.handleOf(x.id))) const slots = useMemo(() => ctrl.slotsFor(b.id), [ctrl, b.id, b.schema, kids.length]) useEditorState((s) => s.tick) const isCollapsed = collapsed.has(b.id) const isSel = b.id === selected const toggle = () => setCollapsed((s) => { const n = new Set(s) if (n.has(b.id)) n.delete(b.id) else n.add(b.id) return n }) const title = ctrl.labelFor(b.id) const hasKids = kids.length > 0 || slots.some((sl) => ctrl.slotChildren(b.id, sl.name).length > 0) return (
  • {(b.container || slots.length > 0) && hasKids ? ( ) : ( )} { if (e.button !== 0) return e.stopPropagation() const x0 = e.clientX const y0 = e.clientY const doc = e.currentTarget.ownerDocument const move = (m: PointerEvent) => { if (Math.hypot(m.clientX - x0, m.clientY - y0) < 5) return doc.removeEventListener('pointermove', move, true) doc.removeEventListener('pointerup', stop, true) ctrl.setMode('edit') ctrl.beginDrag(b.id) } const stop = () => { doc.removeEventListener('pointermove', move, true) doc.removeEventListener('pointerup', stop, true) } doc.addEventListener('pointermove', move, true) doc.addEventListener('pointerup', stop, true) }} >
    {!isCollapsed && (
      {dragging && } {kids.map((k, i) => ( ))} {dragging && kids.length > 0 && } {slots.map((sl) => ( ))}
    )}
  • ) } /** A plain (non-slot) child: its own row, followed by the gap after it. */ function OutlineNodeInner(props: { block: Block; index: number; level: number } & Omit[0], 'block' | 'level'>) { const { block, index, level, ...rest } = props return ( <> {rest.dragging && } ) } function SlotGroup({ parentId, level, slot, over, setOver, dragging, collapsed, setCollapsed, selected }: { parentId: string level: number slot: { name: string; def: { to: string[]; kind: string; description?: string }; count: number; accepts: (t: string) => boolean } over: Gap | null setOver: (g: Gap | null) => void dragging: boolean collapsed: Set setCollapsed: (f: (s: Set) => Set) => void selected: string | null }) { const ctrl = useCtrl() const kids = useEditorState((s) => s.blocks.filter((x) => (x.parentId ?? '') === parentId && ctrl.handleOf(x.id) === slot.name)) const add = () => { let key = 0 key = ctrl.openDialog({ id: `insert:${parentId}:${slot.name}`, title: `Add to ${slot.name}`, size: 'sm', content: createElement(InsertPicker, { parentId, handle: slot.name, ctrl, close: () => ctrl.closeDialog(key) }) }) } return (
  • {slot.name} {slot.def.to.length ? slot.def.to.join(', ') : 'any'}
      {dragging && } {kids.map((k, i) => ( ))} {dragging && kids.length > 0 && }
  • ) } // --- palette (new blocks) ------------------------------------------------------- const DRAG_THRESHOLD = 5 type Item = { key: string; label: string; sub?: string; type?: string; blueprint?: string; group: string; description?: string } function Palette() { const ctrl = useCtrl() const [types, setTypes] = useState(null) const [blueprints, setBlueprints] = useState([]) const [q, setQ] = useState('') const { selected, blocks } = useEditorState((s) => ({ selected: s.selected, blocks: s.blocks })) const schemasVersion = useEditorState((s) => s.schemasVersion) const suppressClick = useRef(false) useEffect(() => { void ctrl.api .types() .then((ts) => { ctrl.ingestTypes(ts) // /api/types includes every schema: titles + groups with no further requests setTypes(ts) void ctrl.loadSchemas(ts.map(typeName)) // only for entries that arrived without a schema }) .catch(() => setTypes([])) void ctrl.api.blueprints().then(setBlueprints).catch(() => {}) }, [ctrl]) // Types some container on the page (or the page itself) can hold - the rest would only ever be refused. const items = useMemo(() => { if (!types) return [] const all = types.map(typeName).filter(Boolean) const allowed = new Set() for (const b of blocks) if (b.container) allowedChildren(b.schema, all).forEach((t) => allowed.add(t)) // Slots accept more than plain children (e.g. a pdp's info-section): include slot types too. for (const b of blocks) for (const h of Object.keys(connectionsOf(b.schema))) for (const t of connectionsOf(b.schema)[h]?.to ?? []) allowed.add(t) const rootAllowed = !allowed.size // schemas not loaded yet: show everything rather than nothing return [ ...types .map((t) => ({ t, name: typeName(t) })) .filter(({ name }) => name && !ctrl.isStaticRoot(name) && (rootAllowed || allowed.has(name))) .map(({ name }) => { const title = ctrl.typeTitle(name) return { key: `t:${name}`, label: title, sub: title !== name ? name : undefined, type: name, group: ctrl.groupOfType(name), description: ctrl.schemaOf(name)?.description as string | undefined } }), ...blueprints.map((b) => ({ key: `b:${b.id}`, label: b.name ?? b.id, blueprint: b.id, group: 'Blueprints', description: b.description })), ] }, [types, blueprints, blocks, ctrl, schemasVersion]) const needle = q.toLowerCase() const shown = items.filter((i) => i.label.toLowerCase().includes(needle) || (i.type ?? '').toLowerCase().includes(needle)) const groups = sortGroups(shown.map((i) => i.group).filter((g) => g !== 'Blueprints')) if (shown.some((i) => i.group === 'Blueprints')) groups.push('Blueprints') const addToSelection = async (item: Item) => { const sel = ctrl.block(selected) try { let parent: string let index: number | undefined let handle: string | undefined if (sel && sel.container && (!item.type || ctrl.accepts(sel.id, item.type) || (handle = ctrl.handleForDrop(sel.id, item.type)))) parent = sel.id else if (sel) { parent = await ctrl.parentIdOf(sel.id) handle = ctrl.handleOf(sel.id) index = (await ctrl.position(sel.id)).index + 1 } else parent = await ctrl.pageRoot() if (item.blueprint) await ctrl.insertBlueprint(parent, item.blueprint, index) else if (item.type) await ctrl.insert(parent, item.type, { ...(index !== undefined ? { index } : {}), ...(handle ? { handle } : {}) }) } catch (e) { ctrl.toast('error', (e as Error).message) } } const onPointerDown = (e: React.PointerEvent, item: Item) => { if (e.button !== 0) return const x0 = e.clientX const y0 = e.clientY const doc = e.currentTarget.ownerDocument const move = (m: PointerEvent) => { if (Math.hypot(m.clientX - x0, m.clientY - y0) < DRAG_THRESHOLD) return stop() suppressClick.current = true ctrl.setMode('edit') ctrl.beginPaletteDrag({ type: item.type, blueprint: item.blueprint, label: item.label }) } const stop = () => { doc.removeEventListener('pointermove', move, true) doc.removeEventListener('pointerup', stop, true) } doc.addEventListener('pointermove', move, true) doc.addEventListener('pointerup', stop, true) } const [open, setOpen] = usePersistentState('cms-onpage.sec.blocks', true) return (

    {open && ( <>
    setQ(e.target.value)} />

    Drag onto the page or the outline, or click to add to the selection.

    {!types &&

    Loading…

    } {groups.map((g) => (
    {g} {shown.filter((i) => i.group === g).length}
    {shown .filter((i) => i.group === g) .map((i) => ( ))}
    ))} {types && !shown.length &&

    Nothing matches.

    } )}
    ) } // --- node search (existing nodes) ----------------------------------------------- function nodeTitle(ctrl: Controller, n: CmsNode): string | undefined { const key = labelKey(ctrl.schemaOf(n.dataType)) const v = key ? (n.data as Record)?.[key] : undefined const text = typeof v === 'string' || typeof v === 'number' ? String(v).replace(/\s+/g, ' ').trim() : '' if (text) return text.length > 40 ? `${text.slice(0, 39)}…` : text const first = Object.values(n.data ?? {}).find((x) => typeof x === 'string' && x.trim()) const fb = typeof first === 'string' ? first.trim().slice(0, 40) : '' return fb || undefined } function NodeSearch() { const ctrl = useCtrl() const [typeNames, setTypeNames] = useState([]) const [type, setType] = useState('') const [q, setQ] = useState('') const [results, setResults] = useState(null) const [total, setTotal] = useState(0) const [busy, setBusy] = useState(false) const selected = useEditorState((s) => s.selected) useEditorState((s) => s.schemasVersion) useEffect(() => { void ctrl.api .types() .then((ts) => { ctrl.ingestTypes(ts) setTypeNames(ts.map(typeName).filter(Boolean).sort()) }) .catch(() => {}) }, [ctrl]) useEffect(() => { const t = setTimeout(() => { setBusy(true) void ctrl.api .searchNodes({ type: type || undefined, q: q || undefined, limit: 20 }) .then((r) => { setResults(r.items) setTotal(r.total) void ctrl.loadSchemas([...new Set(r.items.map((n) => n.dataType))]) }) .catch(() => setResults([])) .finally(() => setBusy(false)) }, q ? 250 : 0) return () => clearTimeout(t) }, [q, type, ctrl]) const linkToSelection = async (node: CmsNode) => { const sel = ctrl.block(selected) try { if (sel && sel.container && (ctrl.accepts(sel.id, node.dataType) || ctrl.handleForDrop(sel.id, node.dataType))) { await ctrl.linkExisting(sel.id, node.id, { handle: ctrl.handleForDrop(sel.id, node.dataType) }) } else if (sel) { const parent = await ctrl.parentIdOf(sel.id) const { index } = await ctrl.position(sel.id) await ctrl.linkExisting(parent, node.id, { handle: ctrl.handleOf(sel.id), index: index + 1 }) } else { await ctrl.linkExisting(await ctrl.pageRoot(), node.id, {}) } } catch (e) { ctrl.toast('error', (e as Error).message) } } const onPointerDown = (e: React.PointerEvent, node: CmsNode) => { if (e.button !== 0) return const x0 = e.clientX const y0 = e.clientY const doc = e.currentTarget.ownerDocument const move = (m: PointerEvent) => { if (Math.hypot(m.clientX - x0, m.clientY - y0) < DRAG_THRESHOLD) return stop() ctrl.setMode('edit') ctrl.beginExistingDrag({ id: node.id, type: node.dataType, label: nodeTitle(ctrl, node) ?? node.dataType }) } const stop = () => { doc.removeEventListener('pointermove', move, true) doc.removeEventListener('pointerup', stop, true) } doc.addEventListener('pointermove', move, true) doc.addEventListener('pointerup', stop, true) } const [open, setOpen] = usePersistentState('cms-onpage.sec.nodes', true) return (

    {open && ( <>
    setQ(e.target.value)} />

    Link a shared block into the page — drag it onto the outline, or click + to add to the selection.

    {busy && !results &&

    Searching…

    } {results && !results.length &&

    No nodes match.

    } {!!results?.length && (
      {results.map((n) => (
    • onPointerDown(e, n)} title={`${n.dataType} · ${n.id}`}> {nodeTitle(ctrl, n) ?? n.dataType} {n.dataType} · {n.id.slice(0, 8)}
    • ))}
    )} {!!results?.length && total > results.length &&

    Showing {results.length} of {total} — refine the search.

    } )}
    ) } // --- experiments: QA + eligibility on the page (plan-onpage-experiments-personalisation.md P0) --------- const expCookie = (name: string): string | null => { const m = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)) return m ? decodeURIComponent(m[1]) : null } const setExpCookie = (name: string, value: string | null) => { document.cookie = value === null ? `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/` : `${name}=${encodeURIComponent(value)}; path=/; SameSite=Lax` } const targetingSummary = (rules?: ExperimentRule['targeting']): string => !rules?.length ? 'everyone' : rules.map((r) => `${r.field} ${r.op} ${r.value ?? (r.values ?? []).join('|')}`).join(' · ') const armNames = (rule: ExperimentRule): string[] => Object.keys(rule.variant_weights ?? {}) function ExperimentsPanel() { const ctrl = useCtrl() const [rules, setRules] = useState(null) const [adminDenied, setAdminDenied] = useState(false) const [assign, setAssign] = useState>({}) const [stats, setStats] = useState>({}) const [confirmOff, setConfirmOff] = useState(null) const [tick, setTick] = useState(0) // re-read override cookies after set/clear const [open, setOpen] = usePersistentState('cms-onpage.sec.experiments', false) useEffect(() => { if (!open) return let live = true const ctx: EvaluateContext = { pagePath: location.pathname, referrer: document.referrer || undefined, language: navigator.language || undefined } void ctrl.api .trackingConfig() .then((cfg) => { if (!live) return const rs = cfg.experiment_rules ?? [] setRules(rs) if (!rs.length) return void ctrl.api.evaluateExperiments(rs.map((r) => r.experiment_id), ctx).then( (res) => live && setAssign(Object.fromEntries((res.assignments ?? []).map((a) => [a.experimentId, a]))), () => {}, // evaluate is public; a failure just leaves assignments blank ) void ctrl.api.experimentAnalytics().then( (rows) => live && setStats(Object.fromEntries((rows ?? []).map((s) => [s.experiment_id, s]))), () => live && setAdminDenied(true), // admin-gated: eligibility still works, stats hide ) }) .catch(() => live && (setRules([]), setAdminDenied(true))) return () => void (live = false) }, [open, ctrl]) const toggleDisabled = async (rule: ExperimentRule) => { if (confirmOff !== rule.experiment_id) return void setConfirmOff(rule.experiment_id) setConfirmOff(null) try { const res = await ctrl.api.setExperimentDisabled(rule.experiment_id, !rule.disabled) setRules((rs) => rs?.map((r) => (r.experiment_id === rule.experiment_id ? { ...r, disabled: res.disabled } : r)) ?? rs) ctrl.toast('info', `${rule.experiment_id} ${res.disabled ? 'paused' : 'resumed'}`) } catch (e) { ctrl.toast('error', (e as Error).message) } } return (

    {open && (
    {rules === null &&

    Loading experiments…

    } {rules !== null && !rules.length &&

    No experiments configured in tracking.

    } {adminDenied && !!rules?.length &&

    Tracking admin refused — eligibility still works; stats and pause need an admin credential.

    } {!!rules?.length && (
      {rules.map((rule) => { const a = assign[rule.experiment_id] const s = stats[rule.experiment_id] const arms = armNames(rule) const cookie = expCookie(a?.cookieName ?? `exp_${rule.experiment_id}`) void tick return (
    • {rule.experiment_id} {rule.disabled ? 'paused' : 'live'}
      traffic {Math.round((rule.traffic_fraction ?? 1) * 100)}% · arms {arms.length ? arms.join(' / ') : 'a / b'} · {targetingSummary(rule.targeting)}
      {a ? (a.eligible ? <>you: {a.variant || '—'} : 'you: not eligible') : 'you: …'} {cookie && cookie !== a?.variant && (override: {cookie})}
      {s && (
      {s.total_sessions} sessions · winner {s.winner || '—'} {s.winner ? ` (${Math.round((s.winner_probability ?? 0) * 100)}%)` : ''} {s.significant ? ' · significant' : ''} — {s.conclusion || 'collecting data'}
      )}
    • ) })}
    )}

    Overrides set the storefront variant cookie — reload the page to preview the arm.

    )}
    ) }