(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 && (
<>
{!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 && (
<>
{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.
)}
)
}