diff --git a/client/src/lib/engagementRules.js b/client/src/lib/engagementRules.js index 5611e80..7e1eb59 100644 --- a/client/src/lib/engagementRules.js +++ b/client/src/lib/engagementRules.js @@ -101,15 +101,56 @@ export function segmentChoicesFor(trigger, ceilings, segments) { */ export function describeReach(preview) { if (!preview) return '' - if (preview.dormant) return `Resolves to nobody right now — ${preview.reason || 'dormant'}.` + const why = operatorWords(preview.reason) + if (preview.dormant) return `Resolves to nobody right now — ${why || 'dormant'}.` if (preview.permitted === false) { return `Reaches ${preview.count}, but this trigger does not permit that audience — saving will be refused.` } - if (preview.reason) return `${preview.count} right now — ${preview.reason}.` + if (why) return `${preview.count} right now — ${why}.` if (preview.capped) return `At least ${preview.count} people (the preview stops counting there).` return preview.count === 1 ? '1 person right now.' : `${preview.count} people right now.` } +/** + * The server says "segment"; these screens say "saved audience". + * + * The API, the schema and the docs all call it a segment and should keep doing + * so - it is one word for one table. But an operator meets the concept here, + * under a heading that says "Audiences", and a sentence that switches vocabulary + * mid-screen reads as a sentence about something else. + */ +export function operatorWords(text) { + if (!text) return text + // Word-wise rather than a regex, so "segmented" and the like are left alone. + const swap = { segment: 'saved audience', segments: 'saved audiences' } + return String(text) + .split(' ') + .map((word) => swap[word] || word) + .join(' ') +} + +/** + * The one audience choice that silently reaches nobody, said out loud. + * + * `members` is the ceiling for "a module-declared list". Without a saved + * audience naming WHICH list there is no list, and core knows no game vocabulary + * with which to guess - so the rule resolves to the empty set every time it + * fires. It is also the DEFAULT the moment an operator picks a `members`-ceiling + * trigger, which is what makes it a trap rather than a curiosity: the rule saves, + * switches on, and mails nobody, with nothing on the screen saying so unless the + * operator happens to press Preview. + * + * Returns a sentence, or null when there is nothing to warn about. + */ +export function audienceWarning(form) { + if (!form) return null + if (form.audienceSegmentId) return null + if (form.audience === 'members') { + return 'This reaches nobody as it stands. “Members of a module-declared list” needs a saved audience naming which list.' + } + return null +} + // ── Segment expressions ──────────────────────────────────────────────────── /** @@ -127,7 +168,16 @@ export function notPlacementError(expression) { if (!node || typeof node !== 'object') return null if (!node.op) return null if (node.op === 'not' && !underAnd) { - return 'A "not" can only be used inside an "all of" group — on its own it would mean "everyone except…".' + return 'An excluded audience can only be used alongside an included one — on its own it would mean “everyone except…”.' + } + // The same rule from the other side: a group of nothing but exclusions has + // no set to take them from. The composer offers "exclude" on every row, so + // this is one checkbox away at all times and is worth saying before the + // round trip - the server refuses it, correctly, but only after a save. + if ((node.op === 'and' || node.op === 'or') && (node.nodes || []).length) { + if ((node.nodes || []).every((c) => c && c.op === 'not')) { + return 'At least one audience has to be included — a list made only of exclusions has nothing to exclude from.' + } } for (const child of node.nodes || []) { const err = walk(child, node.op === 'and') diff --git a/client/src/routes/admin/views/EngagementAudiences.jsx b/client/src/routes/admin/views/EngagementAudiences.jsx index 0bd892b..de09168 100644 --- a/client/src/routes/admin/views/EngagementAudiences.jsx +++ b/client/src/routes/admin/views/EngagementAudiences.jsx @@ -50,12 +50,13 @@ const toGroup = (expression) => // ── One leaf: an audience and its declared parameters ────────────────────── -function LeafRow({ audiences, node, onChange, onRemove, negated, onToggleNegate, canNegate }) { +function LeafRow({ audiences, node, onChange, onRemove, negated, onToggleNegate, canNegate, first }) { const declared = audiences.find((a) => a.id === node.audienceId) return (