/* UI-only In-store screens preview, adapted from the user-supplied Claude export.
* Sample fixtures and edits stay in this module until page reload. No API or device publishing. */
(function(host){
const window = {OFFICE_SCREENS:{}};
const OIcon = props => ;
const KpiCard = props => ;
const PreviewContext = React.createContext(null);
const sampleStores = [{id:'S-001',name:'Camberwell'},{id:'S-002',name:'Glen Iris'},{id:'S-003',name:'Ringwood'},{id:'S-004',name:'Richmond'}];
let previewStore='S-001', previewTab='playlists';
window.findOfficeStore = id => sampleStores.find(s=>s.id===id);
window.officeMoney = n => '$'+Number(n).toFixed(2);
window.useOffice = () => {const office=host.useOffice(); const ctx=React.useContext(PreviewContext);return {...office,scope:ctx.store,
setScreen: key=>office.setScreen(key==='displays'?'inStoreScreens':key),
pushToast: value=>office.pushToast({kind:'hold',text:value.text||[value.title,value.meta].filter(Boolean).join(' · ')})};};
/* =============================================================
CoreIQ Office — Pricing engine
Methods are DATA, not logic: each store runs its own method.
Method 1 is HL Pharmacy Eglinton's HealthyLife-anchored rules.
Exposed on window.OFFICE_PRICING.
============================================================= */
(function () {
// ---- retailers we observe (never transmit to) -----------------
const RETAILERS = {
hl: { id: "hl", name: "HealthyLife Online", short: "HealthyLife", host: "healthylife.com.au" },
cw: { id: "cw", name: "Chemist Warehouse", short: "Chemist W'house", host: "chemistwarehouse.com.au" },
twc: { id: "twc", name: "TerryWhite Chemmart", short: "TerryWhite", host: "terrywhitechemmart.com.au" },
pri: { id: "pri", name: "Priceline", short: "Priceline", host: "priceline.com.au" },
blm: { id: "blm", name: "Blooms The Chemist", short: "Blooms", host: "bloomsthechemist.com.au" },
ww: { id: "ww", name: "Woolworths", short: "Woolworths", host: "woolworths.com.au" },
col: { id: "col", name: "Coles", short: "Coles", host: "coles.com.au" },
};
// ---- HL Pharmacy Eglinton band table (verbatim) ---------------
const HL_BANDS = [
{ max: 4.99, add: 1, normal: 1.50, sale: 1 },
{ max: 9.99, add: 1, normal: 2.50, sale: 2 },
{ max: 19.99, add: 1, normal: 3.50, sale: 2 },
{ max: 24.99, add: 2, normal: 5, sale: 3 },
{ max: 39.99, add: 2, normal: 10, sale: 4 },
{ max: 49.99, add: 2, normal: 10, sale: 5 },
{ max: 99.99, add: 3, normal: 25, sale: 6 },
{ max: 199.99, add: 5, normal: 35, sale: 6 },
{ max: Infinity, add: 6, addPct: 2.5, normal: 35, sale: 6 },
];
// Named lines that are automatically KVI candidates under method 1
const KVI_BRANDS = ["Panadol", "Nurofen", "Claratyne", "Zyrtec", "Nexium", "Voltaren"];
// ---- methods --------------------------------------------------
const METHODS = {
hlprice: {
id: "hlprice", name: "HealthyLife anchor", store: "S-004", storeName: "HL Pharmacy Eglinton",
kind: "reference",
blurb: "Anchors every standard recommendation to HealthyLife Online alone. Chemist Warehouse and TerryWhite are only consulted on the KVI second pass.",
anchor: "hl", bands: HL_BANDS, kviRetailers: ["hl", "cw", "twc"], kviAdd: 1, kviFloorCol: "sale",
rounding: [0.49, 0.99], roundTie: "up",
fallback: { kind: "marketAverage", n: 3 },
escalate: { channel: "Slack · #pricing", who: "management", when: "Any line flagged KVI" },
author: "Justin · HL Pharmacy Eglinton", updated: "14/08/2026",
},
floorgap: {
id: "floorgap", name: "Competitor gap cap", store: "S-001", storeName: "Camberwell",
kind: "competitor",
blurb: "Takes the cheapest live price across the observed set and caps how far above it you sit. Cost-plus floor still wins where the gap cap would go under it.",
anchor: "lowest", observe: ["cw", "twc", "pri", "blm"], gapPct: 8, floorPct: 22,
rounding: [0.49, 0.99], roundTie: "up",
fallback: { kind: "categoryMargin" },
escalate: { channel: "In-app approval queue", who: "owner", when: "Gap over 8% or floor breached" },
author: "Group pricing", updated: "02/08/2026",
},
marginled: {
id: "marginled", name: "Category margin target", store: "S-003", storeName: "Ringwood",
kind: "margin",
blurb: "Prices to a target margin per category. Competitor prices are only a ceiling check — if the target lands you above the cheapest by more than the tolerance, it flags rather than reprices.",
anchor: "margin", observe: ["cw", "twc"], tolerancePct: 15,
targets: { "OTC medicines": 38, "Vitamins": 45, "Skincare": 52, "Baby": 34, "Sun care": 50, "NRT": 40, "First aid": 44 },
rounding: [0.95, 0.99], roundTie: "up",
fallback: { kind: "categoryMargin" },
escalate: { channel: "In-app approval queue", who: "owner", when: "Ceiling tolerance exceeded" },
author: "Group pricing", updated: "02/08/2026",
},
};
// ---- GST ------------------------------------------------------
// Ex-GST cost against inc-GST market prices. GST-free is
// product-specific and always shown as a determination, never silent.
const GST_FREE_REASONS = {
sunscreen: "Single-purpose sunscreen SPF15+ — GST-free. Not applicable to tinted or hybrid products.",
formula: "Infant formula 0–12 months — GST-free. Toddler formula 12m+ is taxable.",
medaid: "Medical aid for illness or disability, not in general use — GST-free.",
};
// ---- products -------------------------------------------------
// cog is ex-GST. gstFree carries its reason so the determination is auditable.
const P = (o) => o;
const PRODUCTS = [
P({ sku: "OTC-PAN-OST-96", name: "Panadol Osteo 665mg 96 tabs", cat: "OTC medicines", cog: 6.85, cur: 12.99, kvi: true,
obs: { hl: { price: 10.99 }, cw: { price: 9.99 }, twc: { price: 12.49 }, pri: { price: 12.99 }, blm: { price: 12.49 }, ww: { price: 11.00 } } }),
P({ sku: "OTC-NUR-ZAV-24", name: "Nurofen Zavance 24 tabs", cat: "OTC medicines", cog: 4.20, cur: 9.49, kvi: true,
obs: { hl: { price: 7.49 }, cw: { price: 6.79 }, twc: { price: 8.99 }, pri: { price: 9.49 }, blm: { price: 8.99 }, col: { price: 7.50 } } }),
P({ sku: "OTC-CLA-30", name: "Claratyne 10mg 30 tabs", cat: "OTC medicines", cog: 11.40, cur: 24.99, kvi: true,
obs: { hl: { price: 19.99, rrp: 27.99, onSale: true }, cw: { price: 17.99 }, twc: { price: 23.49 }, pri: { price: 24.99 }, ww: { price: 21.00 } } }),
P({ sku: "OTC-VOL-EMU-100", name: "Voltaren Emulgel 100g", cat: "OTC medicines", cog: 9.80, cur: 18.99, kvi: true,
obs: { hl: { price: 15.99 }, cw: { price: 14.49 }, twc: { price: 18.49 }, blm: { price: 17.99 } } }),
P({ sku: "OTC-NEX-20-14", name: "Nexium 20mg 14 tabs", cat: "OTC medicines", cog: 8.60, cur: 17.99, kvi: true,
obs: { hl: { price: 14.49 }, cw: { price: 12.99 }, twc: { price: 16.99 } } }),
P({ sku: "VIT-OST-D-250", name: "Ostelin Vitamin D 250 caps", cat: "Vitamins", cog: 12.40, cur: 26.99, kvi: true,
obs: { hl: { price: 23.99, rrp: 32.99, onSale: true }, cw: { price: 22.99 }, twc: { price: 27.99 }, ww: { price: 25.00 } } }),
P({ sku: "VIT-BLK-C-150", name: "Blackmores Bio C 1000mg 150 tabs", cat: "Vitamins", cog: 14.20, cur: 29.99,
obs: { hl: { price: 26.99 }, cw: { price: 24.99 }, twc: { price: 31.99 }, ww: { price: 28.00 } } }),
P({ sku: "VIT-SWI-MAG-200", name: "Swisse Magnesium 200 tabs", cat: "Vitamins", cog: 17.80, cur: 39.99,
obs: { hl: { price: 34.99 }, cw: { price: 32.99 }, twc: { price: 39.99 } } }),
P({ sku: "SUN-CC-50-200", name: "Cancer Council SPF50+ 200ml", cat: "Sun care", cog: 7.60, cur: 16.99, kvi: true,
gstFree: "sunscreen",
obs: { hl: { price: 16.99 }, cw: { price: 14.99 }, twc: { price: 18.49 }, col: { price: 15.50 } } }),
P({ sku: "SUN-LRP-ANT-50", name: "La Roche-Posay Anthelios tinted SPF50+ 50ml", cat: "Sun care", cog: 18.20, cur: 36.99,
obs: { hl: { price: 32.99 }, cw: { price: 29.99 }, pri: { price: 36.99 } } }),
P({ sku: "SKN-BEP-100", name: "Bepanthen Ointment 100g", cat: "Baby", cog: 8.10, cur: 17.49,
obs: { hl: { price: 14.99 }, cw: { price: 13.49 }, twc: { price: 16.99 }, ww: { price: 15.00 } } }),
P({ sku: "BAB-AP-GOLD-900", name: "Aptamil Gold+ 1 infant formula 900g", cat: "Baby", cog: 22.40, cur: 32.99,
gstFree: "formula",
obs: { hl: { price: 29.99 }, cw: { price: 27.99 }, ww: { price: 30.00 }, col: { price: 30.00 } } }),
P({ sku: "SKN-DRM-500", name: "Dermeze Treatment Ointment 500g", cat: "Skincare", cog: 11.90, cur: 22.99,
obs: {}, market: [ { at: "Aspen brand store", price: 24.95 }, { at: "Pharmacy Online", price: 22.99 }, { at: "Superpharmacy", price: 26.50 } ] }),
P({ sku: "SKN-VIC-M89-50", name: "Vichy Minéral 89 50ml", cat: "Skincare", cog: 28.00, cur: 49.99,
obs: { hl: { price: 44.99 }, cw: { price: 41.99 }, pri: { price: 49.99 } } }),
P({ sku: "NRT-NIC-MIN-60", name: "Nicabate Minis 1.5mg 60 loz", cat: "NRT", cog: 11.90, cur: 24.99,
obs: { hl: { price: 21.99 }, cw: { price: 19.99 }, twc: { price: 24.49 } } }),
P({ sku: "MED-ACC-STRIP-100", name: "Accu-Chek Guide test strips 100", cat: "First aid", cog: 29.60, cur: 48.99,
gstFree: "medaid",
obs: { hl: { price: 45.99 }, cw: { price: 42.99 } } }),
P({ sku: "OTC-MOV-30", name: "Movicol sachets 30", cat: "OTC medicines", cog: 9.40, cur: 19.99,
obs: { hl: { price: 17.49 }, cw: { price: 15.99 }, twc: { price: 19.49 } } }),
P({ sku: "SKN-CET-MOI-1L", name: "Cetaphil Moisturising Lotion 1L", cat: "Skincare", cog: 15.60, cur: 32.99,
obs: { hl: { price: 19.50 }, cw: { price: 26.99 }, twc: { price: 32.49 } } }),
];
// ---- observation provenance -----------------------------------
// Every price carries where it came from, when, and how well it matched.
const SEEN = {
hl: { seen: "Today 06:10", conf: 0.97 },
cw: { seen: "Today 06:12", conf: 0.94 },
twc: { seen: "Today 06:14", conf: 0.92 },
pri: { seen: "Yesterday 06:11", conf: 0.90 },
blm: { seen: "22/08 06:09", conf: 0.86 },
ww: { seen: "Today 06:16", conf: 0.88 },
col: { seen: "Today 06:16", conf: 0.87 },
};
const MATCH_NOTES = {
"OTC-PAN-OST-96|cw": { pack: "96 tabs", note: "Same pack" },
"VIT-OST-D-250|cw": { pack: "250 caps", note: "Same pack" },
"SKN-CET-MOI-1L|hl": { pack: "591ml", note: "Different pack — 591ml, not 1L. Unit price used, not shelf price.", unitOnly: true },
"SUN-CC-50-200|col": { pack: "200ml", note: "Same pack" },
"BAB-AP-GOLD-900|cw": { pack: "900g", note: "Same pack" },
"OTC-CLA-30|cw": { pack: "30 tabs", note: "Same pack" },
};
window.OFFICE_PRICING = { RETAILERS, HL_BANDS, KVI_BRANDS, METHODS, GST_FREE_REASONS, PRODUCTS, SEEN, MATCH_NOTES };
})();
/* =============================================================
CoreIQ Office — Promotions
Two kinds: price-level (resolve to a number, POS receives a
price) and basket-level (a rule the register runs against the
whole transaction). Stacking is best-for-customer, never stack.
Exposed on window.OFFICE_PROMOS.
============================================================= */
(function () {
const PR = window.OFFICE_PRICING;
const r2 = v => Math.round(v * 100) / 100;
// ---- mechanics -------------------------------------------------
// where: "price" resolves to a unit price · "basket" needs the till
const MECHANICS = {
pct: { id: "pct", l: "Percentage off", where: "price", icon: "tag",
d: "A straight percentage off the shelf price.", say: p => p.pct + "% off" },
amount: { id: "amount", l: "Dollar amount off", where: "price", icon: "tag",
d: "A fixed amount off the shelf price.", say: p => "$" + p.off.toFixed(2) + " off" },
fixed: { id: "fixed", l: "Fixed promo price", where: "price", icon: "tag",
d: "One price for the duration, whatever the shelf price is.", say: p => "$" + p.price.toFixed(2) },
member: { id: "member", l: "Member price", where: "price", icon: "users",
d: "Only applies once a loyalty card is scanned. Non-members pay the shelf price.", say: p => "$" + p.price.toFixed(2) + " members" },
multibuy: { id: "multibuy", l: "Multibuy", where: "basket", icon: "box",
d: "A quantity of the same line at a set price. The register counts the units.",
say: p => p.pay ? p.qty + " for " + p.pay : p.qty + " for $" + p.price.toFixed(2) },
mixmatch: { id: "mixmatch", l: "Mix & match", where: "basket", icon: "box",
d: "Any combination across a group at a set price. Cheapest units discount first.",
say: p => "any " + p.qty + " for $" + p.price.toFixed(2) },
threshold: { id: "threshold", l: "Spend threshold", where: "basket", icon: "cart",
d: "Applies to the whole transaction once it passes a value.", say: p => "$" + p.off + " off $" + p.spend },
bundle: { id: "bundle", l: "Bundle", where: "basket", icon: "box",
d: "Buy one line, a second line discounts. Both must be in the basket.",
say: p => "buy one, second line " + p.bPct + "% off" },
secondhalf: { id: "secondhalf", l: "Second unit half price", where: "basket", icon: "box",
d: "First unit full price, second at a discount. Register applies per pair.", say: p => "2nd unit " + p.pct + "% off" },
gwp: { id: "gwp", l: "Gift with purchase", where: "basket", icon: "check-circle",
d: "A gift line is added at zero once the condition is met. Stock still moves.", say: p => "free " + p.gift },
};
const LEVELS = {
group: { l: "Everything", rank: 0 },
category: { l: "Category", rank: 1 },
brand: { l: "Brand", rank: 2 },
sku: { l: "Single line", rank: 3 },
list: { l: "Chosen lines", rank: 3 },
};
const CHANNELS = {
pos: { l: "Register & receipt", always: true },
esl: { l: "Electronic shelf labels" },
ticket: { l: "Printed tickets" },
cfd: { l: "Customer display" },
online: { l: "Online & click-collect" },
cat: { l: "Catalogue export" },
prompt: { l: "Staff prompt at the till" },
};
const STORES = ["S-001", "S-002", "S-003", "S-004"];
// ---- the promotions -------------------------------------------
const PROMOS = [
{ id: "PM-1041", name: "Vitamins — 20% off the range", mech: "pct", p: { pct: 20 },
level: "category", key: "Vitamins", from: "31/08/2026", to: "13/09/2026", state: "live",
stores: ["S-001", "S-002", "S-003"], author: "Dana Whitcombe", store: "S-001", origin: "store",
channels: ["pos", "esl", "ticket", "cfd", "online"],
why: "Spring health push. Blackmores and Swisse both running national advertising this fortnight." },
{ id: "PM-1042", name: "Ostelin Vitamin D 250s — $5 off", mech: "amount", p: { off: 5 },
level: "sku", key: "VIT-OST-D-250", from: "31/08/2026", to: "13/09/2026", state: "live",
stores: ["S-001", "S-002", "S-003", "S-004"], author: "Dana Whitcombe", store: "S-001", origin: "store",
channels: ["pos", "esl", "ticket", "online"],
funded: { supplier: "Sanofi CHC", per: 3.00, ref: "CO-OP-2214", claim: "open" },
why: "Sanofi co-op deal. They fund $3 of the $5, we wear $2." },
{ id: "PM-1043", name: "Panadol Osteo 96s — 3 for $30", mech: "multibuy", p: { qty: 3, price: 30 },
level: "sku", key: "OTC-PAN-OST-96", from: "01/09/2026", to: "30/09/2026", state: "live",
stores: ["S-001", "S-002", "S-004"], author: "Justin Marsh", store: "S-004", origin: "store",
channels: ["pos", "ticket", "cfd", "prompt"],
prompt: "Three packs for $30 — worth mentioning to regular buyers.",
why: "Chemist Warehouse has it at $9.99 in the catalogue. A multibuy beats matching on a single unit." },
{ id: "PM-1044", name: "Any 3 cold & flu for $25", mech: "mixmatch", p: { qty: 3, price: 25, group: "Cold & flu" },
level: "list", key: "Cold & flu", keys: ["OTC-SUD-PE-24", "OTC-NUR-ZAV-24", "OTC-MOV-30"],
from: "24/08/2026", to: "21/09/2026", state: "live",
stores: ["S-002"], author: "Suni Kapoor", store: "S-002", origin: "store",
channels: ["pos", "ticket", "prompt"],
prompt: "Any three cold & flu lines for $25 — check the basket before totalling.",
why: "Clearing the winter range before the season turns. Glen Iris only — they carry the most." },
{ id: "PM-1045", name: "$10 off when you spend $50", mech: "threshold", p: { spend: 50, off: 10 },
level: "group", key: "*", from: "05/09/2026", to: "07/09/2026", state: "scheduled", excl: ["consign"],
stores: ["S-001", "S-002", "S-003", "S-004"], author: "Justin Marsh", store: "S-004", origin: "store",
channels: ["pos", "cfd", "cat", "prompt"], excludes: "Excludes prescriptions, S8 and infant formula.",
prompt: "Spend $50 this weekend and take $10 off.",
why: "Father's Day weekend. Drives basket size rather than discounting any one line." },
{ id: "PM-1046", name: "Bepanthen 100g — member price $14.99", mech: "member", p: { price: 14.99 },
level: "sku", key: "SKN-BEP-100", from: "01/09/2026", to: "30/11/2026", state: "live",
stores: ["S-001", "S-002", "S-003", "S-004"], author: "Dana Whitcombe", store: "S-001", origin: "store",
channels: ["pos", "esl", "online"],
why: "Baby category loyalty driver. Members only — the shelf price holds for everyone else." },
{ id: "PM-1047", name: "Blackmores — second unit half price", mech: "secondhalf", p: { pct: 50 },
level: "brand", key: "Blackmores", from: "31/08/2026", to: "13/09/2026", state: "live",
stores: ["S-001", "S-003"], author: "Dana Whitcombe", store: "S-001", origin: "store",
channels: ["pos", "ticket", "prompt"],
prompt: "Second Blackmores item is half price.",
funded: { supplier: "Blackmores", per: 2.50, ref: "CO-OP-2208", claim: "submitted" },
why: "Blackmores co-op. Overlaps the category promo — the register gives whichever is better." },
{ id: "PM-1048", name: "Cancer Council SPF50+ — clearance 60% off", mech: "pct", p: { pct: 60 },
level: "sku", key: "SUN-CC-50-200", from: "28/08/2026", to: "26/09/2026", state: "live",
stores: ["S-001"], author: "Expiry ladder", store: "S-001", origin: "clearance",
channels: ["pos", "esl", "ticket"], batch: "CC5-2280",
why: "Created from the expiry markdown ladder, tier 4. Batch expires 30/09 with 41 units on hand." },
{ id: "PM-1049", name: "Nicabate — buy any, Nicabate gum 25% off", mech: "bundle", p: { bPct: 25, b: "Nicabate gum 2mg 30s" },
level: "brand", key: "Nicabate", from: "14/09/2026", to: "12/10/2026", state: "scheduled",
stores: ["S-001", "S-002", "S-003", "S-004"], author: "Justin Marsh", store: "S-004", origin: "store",
channels: ["pos", "ticket", "prompt"],
prompt: "Add the gum at 25% off — most patients use both.",
why: "Patches and gum are used together. Bundling lifts units without discounting the patches." },
{ id: "PM-1050", name: "Aptamil Gold+ — free bottle brush", mech: "gwp", p: { gift: "Pigeon bottle brush", qty: 2 },
level: "sku", key: "BAB-AP-GOLD-900", from: "17/08/2026", to: "30/08/2026", state: "ended",
stores: ["S-001", "S-002", "S-003", "S-004"], author: "Dana Whitcombe", store: "S-001", origin: "store",
channels: ["pos", "ticket", "prompt"],
why: "Formula is price-checked against the supermarket. A gift competes without moving the price." },
{ id: "PM-1051", name: "Skincare — 15% off dermocosmetics", mech: "pct", p: { pct: 15 },
level: "category", key: "Skincare", from: "14/09/2026", to: "27/09/2026", state: "draft",
stores: ["S-003"], author: "Marco Ellis", store: "S-003", origin: "store",
channels: ["pos", "esl", "ticket"],
why: "Ringwood only. Draft — margin target on Skincare is 52%, this takes it to 41%." },
];
// ---- performance on the ones that have run ---------------------
const PERF = {
"PM-1041": { units: 214, baseUnits: 138, rev: 4820.40, gp: 1682.10, baseGp: 1944.30, days: 2 },
"PM-1042": { units: 96, baseUnits: 42, rev: 2110.00, gp: 812.40, baseGp: 611.80, days: 2 },
"PM-1043": { units: 141, baseUnits: 68, rev: 1410.00, gp: 442.20, baseGp: 417.40, days: 1 },
"PM-1044": { units: 88, baseUnits: 61, rev: 733.00, gp: 241.60, baseGp: 288.90, days: 9 },
"PM-1046": { units: 132, baseUnits: 104, rev: 1978.68, gp: 610.20, baseGp: 617.60, days: 1 },
"PM-1047": { units: 74, baseUnits: 44, rev: 1664.30, gp: 502.80, baseGp: 448.10, days: 2 },
"PM-1048": { units: 31, baseUnits: 4, rev: 210.80, gp: -25.80, baseGp: 33.60, days: 5 },
"PM-1050": { units: 168, baseUnits: 122, rev: 5542.32, gp: 1584.00, baseGp: 1150.20, days: 14 },
};
// ---- stock and velocity, per store ----------------------------
// Promotion duration is meaningless without these two numbers.
const STOCK = {
"OTC-PAN-OST-96": { oh: { "S-001": 88, "S-002": 41, "S-003": 62, "S-004": 34 }, wk: { "S-001": 21, "S-002": 12, "S-003": 16, "S-004": 9 }, lead: 3 },
"OTC-NUR-ZAV-24": { oh: { "S-001": 64, "S-002": 38, "S-003": 51, "S-004": 22 }, wk: { "S-001": 18, "S-002": 11, "S-003": 14, "S-004": 7 }, lead: 3 },
"OTC-CLA-30": { oh: { "S-001": 42, "S-002": 26, "S-003": 33, "S-004": 18 }, wk: { "S-001": 9, "S-002": 6, "S-003": 8, "S-004": 4 }, lead: 4 },
"OTC-VOL-EMU-100": { oh: { "S-001": 55, "S-002": 29, "S-003": 40, "S-004": 21 }, wk: { "S-001": 12, "S-002": 7, "S-003": 10, "S-004": 5 }, lead: 3 },
"OTC-NEX-20-14": { oh: { "S-001": 37, "S-002": 19, "S-003": 28, "S-004": 14 }, wk: { "S-001": 8, "S-002": 5, "S-003": 7, "S-004": 3 }, lead: 5 },
"OTC-MOV-30": { oh: { "S-001": 31, "S-002": 22, "S-003": 26, "S-004": 12 }, wk: { "S-001": 7, "S-002": 5, "S-003": 6, "S-004": 3 }, lead: 4 },
"VIT-OST-D-250": { oh: { "S-001": 46, "S-002": 24, "S-003": 35, "S-004": 16 }, wk: { "S-001": 11, "S-002": 6, "S-003": 9, "S-004": 4 }, lead: 7 },
"VIT-BLK-C-150": { oh: { "S-001": 29, "S-002": 15, "S-003": 21, "S-004": 11 }, wk: { "S-001": 8, "S-002": 5, "S-003": 6, "S-004": 3 }, lead: 7 },
"VIT-SWI-MAG-200": { oh: { "S-001": 18, "S-002": 9, "S-003": 14, "S-004": 6 }, wk: { "S-001": 6, "S-002": 3, "S-003": 5, "S-004": 2 }, lead: 10 },
"SUN-CC-50-200": { oh: { "S-001": 41, "S-002": 12, "S-003": 18, "S-004": 8 }, wk: { "S-001": 4, "S-002": 2, "S-003": 3, "S-004": 1 }, lead: 14 },
"SUN-LRP-ANT-50": { oh: { "S-001": 22, "S-002": 11, "S-003": 16, "S-004": 7 }, wk: { "S-001": 5, "S-002": 3, "S-003": 4, "S-004": 2 }, lead: 10 },
"SKN-BEP-100": { oh: { "S-001": 58, "S-002": 33, "S-003": 44, "S-004": 19 }, wk: { "S-001": 15, "S-002": 9, "S-003": 12, "S-004": 5 }, lead: 4 },
"BAB-AP-GOLD-900": { oh: { "S-001": 72, "S-002": 48, "S-003": 61, "S-004": 26 }, wk: { "S-001": 24, "S-002": 16, "S-003": 20, "S-004": 8 }, lead: 5 },
"SKN-DRM-500": { oh: { "S-001": 26, "S-002": 14, "S-003": 19, "S-004": 9 }, wk: { "S-001": 6, "S-002": 4, "S-003": 5, "S-004": 2 }, lead: 6 },
"SKN-VIC-M89-50": { oh: { "S-001": 15, "S-002": 8, "S-003": 12, "S-004": 5 }, wk: { "S-001": 4, "S-002": 2, "S-003": 3, "S-004": 1 }, lead: 12 },
"SKN-CET-MOI-1L": { oh: { "S-001": 34, "S-002": 18, "S-003": 25, "S-004": 11 }, wk: { "S-001": 9, "S-002": 5, "S-003": 7, "S-004": 3 }, lead: 6 },
"NRT-NIC-MIN-60": { oh: { "S-001": 24, "S-002": 13, "S-003": 18, "S-004": 8 }, wk: { "S-001": 5, "S-002": 3, "S-003": 4, "S-004": 2 }, lead: 8 },
"MED-ACC-STRIP-100":{ oh: { "S-001": 19, "S-002": 10, "S-003": 15, "S-004": 6 }, wk: { "S-001": 4, "S-002": 3, "S-003": 3, "S-004": 2 }, lead: 8 },
};
const stockAt = (sku, store) => {
const s = STOCK[sku];
if (!s) return { oh: 0, wk: 0, lead: 7 };
return { oh: s.oh[store] || 0, wk: s.wk[store] || 0, lead: s.lead };
};
// Expected lift by mechanic and depth. Not a forecast — a planning number,
// stated as such so nobody mistakes it for one.
// A promotion's depth without reference to one product: use the lines it
// actually covers, so a multibuy is not reported as zero discount.
function depthOf(pm) {
const lines = PR.PRODUCTS.filter(p => applies(pm, p));
if (!lines.length) return expectedLift(pm, null);
return lines.reduce((s, p) => s + expectedLift(pm, p), 0) / lines.length;
}
function expectedLift(pm, prod) {
const m = MECHANICS[pm.mech];
let depth = 0;
if (pm.mech === "pct") depth = pm.p.pct / 100;
else if (pm.mech === "amount" && prod) depth = pm.p.off / prod.cur;
else if ((pm.mech === "fixed" || pm.mech === "member") && prod) depth = Math.max(0, 1 - pm.p.price / prod.cur);
else if (pm.mech === "multibuy" && prod) depth = Math.max(0, 1 - pm.p.price / (prod.cur * pm.p.qty));
else if (pm.mech === "mixmatch" && prod) depth = Math.max(0, 1 - pm.p.price / (prod.cur * pm.p.qty));
else if (pm.mech === "secondhalf") depth = pm.p.pct / 200;
else if (pm.mech === "bundle") depth = pm.p.bPct / 200;
else if (pm.mech === "gwp") depth = 0.12;
else if (pm.mech === "threshold") depth = 0.08;
// elasticity is steeper on a KVI, and steeper again with a ticket up
let e = prod && prod.kvi ? 3.4 : 2.2;
if (pm.channels && pm.channels.includes("ticket")) e *= 1.15;
if (m.where === "basket") e *= 0.85;
return Math.max(0, depth * e);
}
// Will the stock last the promotion, or the promotion outlast the stock?
function stockCheck(pm, prod, store) {
const { oh, wk, lead } = stockAt(prod.sku, store);
const span = (parseD(pm.to) - parseD(pm.from)) / 86400000;
const days = Number.isFinite(span) ? Math.max(1, Math.round(span) + 1) : 14;
const lift = expectedLift(pm, prod);
const baseDaily = wk / 7;
const promoDaily = baseDaily * (1 + lift);
const need = Math.round(promoDaily * days);
const runsOut = promoDaily > 0 ? Math.floor(oh / promoDaily) : 999;
const leftOver = Math.max(0, oh - need);
return { oh, wk, lead, days, lift, need, runsOut, leftOver,
short: runsOut < days,
// no point reordering if it lands after the promotion ends
canReorder: runsOut >= lead && lead < days,
heavy: !( runsOut < days ) && leftOver > need * 0.6 && leftOver >= 10 };
}
// ---- exclusions as rules, not prose ---------------------------
const EXCLUSION_SETS = {
rx: { l: "Prescription items", mandatory: true, why: "Dispensary items are priced under the PBS and cannot be discounted by a front-of-shop promotion.", skus: [] },
s8: { l: "Schedule 8 and S4", mandatory: true, why: "Controlled and prescription-only medicines must never carry a retail promotion.", skus: [] },
s3: { l: "Pharmacist-only (S3)", mandatory: false, why: "S3 medicines can be promoted on price but not advertised by name. Tickets are suppressed if this is off.", skus: ["OTC-NEX-20-14"] },
formula: { l: "Infant formula, step 1", mandatory: true, why: "The MAIF Agreement prohibits promotion of infant formula for children under six months. This cannot be turned off.", skus: ["BAB-AP-GOLD-900"] },
nds: { l: "NDSS and government-subsidised", mandatory: true, why: "Subsidised lines are sold at the scheme price. A discount is not ours to give.", skus: ["MED-ACC-STRIP-100"] },
consign: { l: "Consignment stock", mandatory: false, why: "We do not own it, so we cannot discount it without the supplier agreeing.", skus: ["SKN-VIC-M89-50"] },
gift: { l: "Gift cards and postage", mandatory: true, why: "Cash equivalents. Discounting them is a loss with no sale attached.", skus: [] },
kvi: { l: "Known value items", mandatory: false, why: "Optional. Some owners hold KVIs out of blanket promotions because they are already priced to compete.", skus: null },
};
const MANDATORY = Object.keys(EXCLUSION_SETS).filter(k => EXCLUSION_SETS[k].mandatory);
// Which exclusion set, if any, catches this product
function caughtBy(prod, sets) {
for (const k of sets) {
const s = EXCLUSION_SETS[k];
if (!s) continue;
if (k === "kvi") { if (prod.kvi) return k; continue; }
if ((s.skus || []).includes(prod.sku)) return k;
}
return null;
}
// Mandatory sets always apply. A promotion's own list adds to them.
const exclusionsFor = pm => [...new Set([...MANDATORY, ...(pm.excl || [])])];
const excluded = (pm, prod) => caughtBy(prod, exclusionsFor(pm));
// ---- baseline method, stated ----------------------------------
const BASELINE_METHODS = {
trailing8: { l: "Trailing 8 weeks", d: "Average weekly units over the eight weeks before the promotion started, excluding any week that carried a promotion on the same line.",
caveat: "Best default. Understates seasonal lines going into their season and overstates them coming out." },
lastYear: { l: "Same weeks last year", d: "Units in the matching calendar weeks twelve months ago, adjusted for the store's overall growth.",
caveat: "Handles seasonality. Distorted if the line was promoted, out of stock, or newly ranged in that window." },
trailing4: { l: "Trailing 4 weeks", d: "Average weekly units over the four weeks before the promotion started.",
caveat: "Responsive to current demand, but a single competitor catalogue in those four weeks will skew it badly." },
category: { l: "Category-adjusted", d: "Trailing eight weeks, then indexed to how the rest of the category moved during the promotion.",
caveat: "Separates the promotion from a category-wide swing. Needs the category to be big enough to be stable." },
};
const BASELINE_FACTOR = { trailing8: 1, lastYear: 1.12, trailing4: 0.88, category: 1.06 };
// ---- lifecycle ------------------------------------------------
const ACTIONS = {
live: [["pause", "Pause"], ["endEarly", "End early"], ["extend", "Extend"], ["edit", "Edit"]],
paused: [["resume", "Resume"], ["endEarly", "End early"], ["edit", "Edit"]],
scheduled: [["edit", "Edit"], ["cancel", "Cancel"], ["startNow", "Start now"]],
draft: [["edit", "Edit"], ["publish", "Publish"], ["discard", "Discard"]],
ended: [["rerun", "Run it again"], ["edit", "Edit a copy"]],
};
// ---- competitor context ----------------------------------------
// The observations already live in the pricing module. This reads them
// for a promoted price: who we beat, who still beats us, and how old
// the observation is. A stale price is worse than no price, so age is
// always carried alongside the number.
const STALE_AFTER = 3;
function obsAge(k) {
const s = PR.SEEN[k];
if (!s) return { days: 99, label: "not observed", stale: true };
if (/^Today/.test(s.seen)) return { days: 0, label: "today", stale: false };
if (/^Yesterday/.test(s.seen)) return { days: 1, label: "yesterday", stale: false };
const m = s.seen.match(/^(\d{2})\/(\d{2})/);
if (m) {
const dt = new Date(2026, Number(m[2]) - 1, Number(m[1]));
const days = Math.round((TODAY - dt) / 86400000);
return { days, label: days + " days ago", stale: days > STALE_AFTER };
}
return { days: 99, label: s.seen, stale: true };
}
// ourPrice may be null (basket mechanics), in which case we report the
// field without a verdict — a multibuy has no single price to compare.
function competitorView(prod, ourPrice) {
const rows = Object.entries(prod.obs || {}).map(([k, o]) => {
const r = PR.RETAILERS[k];
const age = obsAge(k);
return { k, name: r ? r.short : k, price: o.price, onSale: !!o.onSale, rrp: o.rrp,
conf: (PR.SEEN[k] || {}).conf, age,
// a competitor's catalogue price is not their shelf price
promotional: !!o.onSale,
beat: ourPrice != null ? ourPrice <= o.price : null,
gap: ourPrice != null ? r2(ourPrice - o.price) : null };
}).sort((a, b) => a.price - b.price);
const fresh = rows.filter(r => !r.age.stale);
const usable = fresh.length ? fresh : rows;
const cheapest = usable[0] || null;
// the cheapest non-promotional observation — the price that will still
// be there when their catalogue ends
const cheapestShelf = usable.filter(r => !r.promotional)[0] || null;
const beaten = ourPrice != null ? usable.filter(r => ourPrice > r.price) : [];
const won = ourPrice != null ? usable.filter(r => ourPrice <= r.price) : [];
// how much less discount would still have beaten the cheapest?
const enough = cheapest ? r2(cheapest.price - 0.01) : null;
const overshoot = ourPrice != null && cheapest && ourPrice < enough ? r2(enough - ourPrice) : 0;
return { rows, cheapest, cheapestShelf, beaten, won, enough, overshoot,
anyStale: rows.some(r => r.age.stale), staleCount: rows.filter(r => r.age.stale).length,
position: ourPrice == null ? null : !cheapest ? null : ourPrice <= cheapest.price ? "cheapest"
: beaten.length === 0 ? "cheapest" : beaten.length === usable.length ? "dearest" : "middle" };
}
// Across a whole promotion: how many lines does it actually win?
function competitorRoll(pm, prods) {
let wins = 0, losses = 0, noData = 0, over = 0;
prods.forEach(prod => {
const price = unitPrice(pm, prod);
const cv = competitorView(prod, price);
if (!cv.cheapest) { noData++; return; }
if (price == null) { noData++; return; }
if (price <= cv.cheapest.price) { wins++; if (cv.overshoot > 0.05) over++; }
else losses++;
});
return { wins, losses, noData, over, total: prods.length };
}
// ---- lifecycle overrides ---------------------------------------
// Pause has to change what the register does, not just a badge, so the
// override sits here where resolveFor can see it.
const OVERRIDE = {};
const stateOf = pm => (OVERRIDE[pm.id] && OVERRIDE[pm.id].state) || pm.state;
const toOf = pm => (OVERRIDE[pm.id] && OVERRIDE[pm.id].to) || pm.to;
const setOverride = (id, patch) => { OVERRIDE[id] = Object.assign({}, OVERRIDE[id], patch); };
// ---- does a promotion touch a product? -------------------------
function applies(pm, prod) {
if (excluded(pm, prod)) return false;
if (pm.level === "group") return true;
if (pm.level === "category") return prod.cat === pm.key;
if (pm.level === "brand") return PR.brandOf(prod) === pm.key;
if (pm.level === "sku") return prod.sku === pm.key;
if (pm.level === "list") return (pm.keys || []).includes(prod.sku);
return false;
}
// ---- price-level resolution ------------------------------------
// Returns the unit price a promotion produces, or null for basket rules.
function unitPrice(pm, prod) {
const base = prod.cur;
if (base == null) return null;
const m = MECHANICS[pm.mech];
if (m.where !== "price") return null;
if (pm.mech === "pct") return r2(base * (1 - pm.p.pct / 100));
if (pm.mech === "amount") return r2(base - pm.p.off);
if (pm.mech === "fixed" || pm.mech === "member") return pm.p.price;
return null;
}
// Best for the customer, never stacked. Cost floor still applies.
function resolveFor(prod, store, opts) {
const o = opts || {};
const live = PROMOS.filter(pm =>
(o.states || ["live"]).includes(stateOf(pm)) &&
(!store || store === "all" || pm.stores.includes(store)) &&
applies(pm, prod));
const priced = live.filter(pm => MECHANICS[pm.mech].where === "price")
.map(pm => ({ pm, price: unitPrice(pm, prod) }))
.filter(x => x.price != null && (!x.pm.mech.startsWith("member") || o.member))
.sort((a, b) => a.price - b.price);
const basket = live.filter(pm => MECHANICS[pm.mech].where === "basket");
const winner = priced[0] || null;
const floor = r2(prod.cog * 1.1);
const underFloor = winner ? winner.price < floor : false;
return { all: live, priced, basket, winner, lost: priced.slice(1), floor, underFloor,
saving: winner ? r2(prod.cur - winner.price) : 0 };
}
// ---- the register's view: evaluate a whole basket ---------------
function evaluateBasket(lines, store, member) {
const out = lines.map(l => {
const prod = PR.PRODUCTS.find(p => p.sku === l.sku);
const res = resolveFor(prod, store, { member });
const unit = res.winner ? res.winner.price : prod.cur;
return { ...l, prod, res, unit, gross: r2(prod.cur * l.qty), afterPrice: r2(unit * l.qty) };
});
const events = [];
out.forEach(l => { if (l.res.winner) events.push({ kind: "price", line: l.sku, pm: l.res.winner.pm, saved: r2((l.prod.cur - l.unit) * l.qty) }); });
// basket rules, evaluated in the order the register would
let subtotal = out.reduce((s, l) => s + l.afterPrice, 0);
out.forEach(l => {
const mb = l.res.basket.find(pm => pm.mech === "multibuy");
if (mb && l.qty >= mb.p.qty) {
const sets = Math.floor(l.qty / mb.p.qty);
const was = r2(l.unit * mb.p.qty * sets);
const now = r2(mb.p.price * sets);
if (now < was) { events.push({ kind: "multibuy", line: l.sku, pm: mb, saved: r2(was - now) }); subtotal = r2(subtotal - (was - now)); }
}
const sh = l.res.basket.find(pm => pm.mech === "secondhalf");
if (sh && l.qty >= 2) {
const pairs = Math.floor(l.qty / 2);
const saved = r2(l.unit * (sh.p.pct / 100) * pairs);
events.push({ kind: "secondhalf", line: l.sku, pm: sh, saved });
subtotal = r2(subtotal - saved);
}
});
// mix & match across the basket
const mm = PROMOS.find(pm => pm.mech === "mixmatch" && stateOf(pm) === "live" && (!store || store === "all" || pm.stores.includes(store)));
if (mm) {
const eligible = [];
out.forEach(l => { if ((mm.keys || []).includes(l.sku)) for (let i = 0; i < l.qty; i++) eligible.push(l.unit); });
eligible.sort((a, b) => b - a);
const sets = Math.floor(eligible.length / mm.p.qty);
if (sets > 0) {
const was = r2(eligible.slice(0, sets * mm.p.qty).reduce((s, v) => s + v, 0));
const now = r2(mm.p.price * sets);
if (now < was) { events.push({ kind: "mixmatch", pm: mm, saved: r2(was - now) }); subtotal = r2(subtotal - (was - now)); }
}
}
// threshold last, on what is left
const th = PROMOS.find(pm => pm.mech === "threshold" && stateOf(pm) === "live" && (!store || store === "all" || pm.stores.includes(store)));
if (th && subtotal >= th.p.spend) { events.push({ kind: "threshold", pm: th, saved: th.p.off }); subtotal = r2(subtotal - th.p.off); }
const gross = r2(out.reduce((s, l) => s + l.gross, 0));
return { lines: out, events, gross, total: r2(subtotal), saved: r2(gross - subtotal) };
}
// ---- collisions: two promotions on one product -----------------
function collisions(store) {
const hits = [];
PR.PRODUCTS.forEach(prod => {
const res = resolveFor(prod, store, { member: true, states: ["live", "scheduled"] });
if (res.all.length > 1) hits.push({ prod, res });
});
return hits;
}
const parseD = s => {
if (!s) return new Date(NaN);
if (s.includes("-")) { const [y, m, d] = s.split("-").map(Number); return new Date(y, m - 1, d); }
const [d, m, y] = s.split("/").map(Number);
return new Date(y, m - 1, d);
};
const TODAY = new Date(2026, 8, 2);
const daysLeft = pm => Math.round((parseD(toOf(pm)) - TODAY) / 86400000);
const daysTill = pm => Math.round((parseD(pm.from) - TODAY) / 86400000);
// authored ticket colours, where the store picked one
const TICKET_THEMES = {"PM-1041":"green","PM-1042":"red","PM-1044":"green","PM-1045":"violet","PM-1047":"magenta","PM-1049":"teal","PM-1051":"violet"};
window.OFFICE_PROMOS = { TICKET_THEMES, MECHANICS, STOCK, stockAt, expectedLift, depthOf, stockCheck,
obsAge, competitorView, competitorRoll, STALE_AFTER,
OVERRIDE, stateOf, toOf, setOverride,
EXCLUSION_SETS, MANDATORY, exclusionsFor, excluded, caughtBy, BASELINE_METHODS, BASELINE_FACTOR, ACTIONS, LEVELS, CHANNELS, STORES, PROMOS, PERF,
applies, unitPrice, resolveFor, evaluateBasket, collisions, parseD, TODAY, daysLeft, daysTill, r2 };
})();
/* =============================================================
CoreIQ Office — In-store digital displays
A display is not a bigger shelf ticket. It is read at three to
five metres by someone walking past, so a frame carries one
idea and the playlist is the real object.
Exposed on window.OFFICE_DISPLAYS.
============================================================= */
(function () {
const PM = window.OFFICE_PROMOS;
const PR = window.OFFICE_PRICING;
// Simulated wall clock, so "playing now" and dayparting mean something
const NOW_MIN = 10 * 60 + 40;
const hhmm = m => String(Math.floor(m / 60)).padStart(2, "0") + ":" + String(m % 60).padStart(2, "0");
const toMin = s => { const [h, m] = s.split(":").map(Number); return h * 60 + m; };
// ---- zones -----------------------------------------------------
// Placement decides the content rules, not just the position.
const ZONES = {
window: { l: "Window", full: "Window / street facing", orient: "portrait",
dwell: 2, maxWords: 6,
d: "Two seconds, at five metres, often through glass in daylight. One price, no body copy.",
allow: ["promoPrice", "promoRule", "seasonal"], deny: ["wait", "scriptReady"],
denyWhy: "Queue and script information should not be visible from the street." },
wait: { l: "Dispensary wait", full: "Dispensary waiting area", orient: "landscape",
dwell: 8, maxWords: 40,
d: "The only genuinely captive audience in the shop — several minutes, seated, at two metres. This is where an explanation actually gets read.",
allow: ["wait", "scriptReady", "service", "seasonal", "promoPrice", "promoRule"], deny: [] },
endcap: { l: "End of aisle", full: "End of aisle / category", orient: "portrait",
dwell: 5, maxWords: 12,
d: "Read at two metres by someone already in the category. Can carry the offer mechanic, not a paragraph.",
allow: ["promoPrice", "promoRule", "seasonal"], deny: ["wait", "scriptReady"],
denyWhy: "Nothing dispensary-related belongs on a retail aisle screen." },
till: { l: "Behind the till", full: "Behind the till", orient: "landscape",
dwell: 6, maxWords: 14,
d: "Read over the assistant's shoulder while paying. Best used for the add-on the staff prompt already mentions.",
allow: ["promoRule", "promoPrice", "service"], deny: ["wait", "scriptReady"],
denyWhy: "The customer at the till is already being served." },
counter: { l: "Front counter", full: "Front counter, customer height", orient: "landscape",
dwell: 7, maxWords: 25,
d: "Half a metre away, at eye level, while they wait to be served. Tolerates more detail than any other retail screen.",
allow: ["service", "promoPrice", "promoRule", "seasonal", "scriptReady"], deny: [] },
consult: { l: "Consult room", full: "Consult room door", orient: "portrait",
dwell: 10, maxWords: 30,
d: "Read while waiting for a consult. Services only — a price promotion outside a consult room reads badly.",
allow: ["service", "seasonal"], deny: ["promoPrice", "promoRule", "wait", "scriptReady"],
denyWhy: "A discount on the consult room door undermines the clinical service behind it." },
};
// ---- frame kinds -----------------------------------------------
const KINDS = {
promoPrice: { l: "Promotion · price", icon: "tag", from: "promotion",
d: "One line, one price. Resolved in the office, so the screen, the shelf label and the till agree." },
promoRule: { l: "Promotion · offer", icon: "box", from: "promotion",
d: "A basket mechanic stated as an offer. No unit price, because there isn't one." },
service: { l: "Service available today", icon: "pill", from: "standing",
d: "What the pharmacy can do for someone today, and whether a booking is needed." },
wait: { l: "Now serving", icon: "clock", from: "live",
d: "Live from the dispensary queue. Reduces the number of people asking how long." },
scriptReady:{ l: "Scripts ready", icon: "bell", from: "live",
d: "Collection numbers only, never names. Privacy is the whole design constraint here." },
seasonal: { l: "Seasonal health message", icon: "info", from: "standing",
d: "Health messaging with no product claim. Reviewed before it goes up." },
};
// ---- the physical screens -------------------------------------
const SCREENS = [
{ id: "SCR-01", store: "S-001", zone: "window", orient: "portrait", size: "43\"", state: "online", seen: "40 seconds ago", fw: "2.4.1" },
{ id: "SCR-02", store: "S-001", zone: "wait", orient: "landscape", size: "55\"", state: "online", seen: "1 minute ago", fw: "2.4.1" },
{ id: "SCR-03", store: "S-001", zone: "endcap", orient: "portrait", size: "32\"", state: "online", seen: "2 minutes ago", fw: "2.4.1" },
{ id: "SCR-04", store: "S-001", zone: "till", orient: "landscape", size: "27\"", state: "online", seen: "1 minute ago", fw: "2.3.8" },
{ id: "SCR-05", store: "S-001", zone: "counter", orient: "landscape", size: "24\"", state: "cached", seen: "3 hours ago", fw: "2.3.8",
note: "Running its cached playlist. The Vitamins promotion ended yesterday and this screen has not picked that up." },
{ id: "SCR-06", store: "S-001", zone: "consult", orient: "portrait", size: "24\"", state: "online", seen: "4 minutes ago", fw: "2.4.1" },
{ id: "SCR-07", store: "S-002", zone: "window", orient: "portrait", size: "43\"", state: "online", seen: "50 seconds ago", fw: "2.4.1" },
{ id: "SCR-08", store: "S-002", zone: "wait", orient: "landscape", size: "50\"", state: "online", seen: "1 minute ago", fw: "2.4.1" },
{ id: "SCR-09", store: "S-002", zone: "till", orient: "landscape", size: "27\"", state: "offline", seen: "2 days ago", fw: "2.3.8",
note: "No contact since Tuesday. Likely the power board behind the counter — it went down with the label printer." },
{ id: "SCR-10", store: "S-003", zone: "window", orient: "portrait", size: "40\"", state: "online", seen: "1 minute ago", fw: "2.4.1" },
{ id: "SCR-11", store: "S-003", zone: "endcap", orient: "portrait", size: "32\"", state: "online", seen: "3 minutes ago", fw: "2.4.1" },
{ id: "SCR-12", store: "S-004", zone: "window", orient: "portrait", size: "43\"", state: "online", seen: "2 minutes ago", fw: "2.4.1" },
{ id: "SCR-13", store: "S-004", zone: "counter", orient: "landscape", size: "24\"", state: "online", seen: "1 minute ago", fw: "2.4.1" },
];
// ---- standing content, authored not derived --------------------
const STANDING = [
{ id: "ST-01", kind: "service", head: "Flu vaccination", sub: "Walk in today, no appointment",
body: "From 10 years and over. Under the National Immunisation Program at no charge if you are eligible.",
theme: "teal", review: "Approved 12/08/2026 · Dana Whitcombe" },
{ id: "ST-02", kind: "service", head: "Shingles vaccination", sub: "Book at the counter",
body: "Free under the NIP at 65 and over, and from 50 for First Nations people.",
theme: "teal", review: "Approved 12/08/2026 · Dana Whitcombe" },
{ id: "ST-03", kind: "service", head: "Blood pressure check", sub: "Ask at the counter · 5 minutes",
body: "No booking needed. We will give you the reading to take to your GP.",
theme: "navy", review: "Approved 04/07/2026 · Dana Whitcombe" },
{ id: "ST-04", kind: "service", head: "MedsCheck", sub: "A 20 minute review of everything you take",
body: "Free if you take five or more medicines. Booked in the consult room.",
theme: "navy", review: "Approved 04/07/2026 · Dana Whitcombe" },
{ id: "ST-05", kind: "seasonal", head: "Hay fever season has started", sub: "Ask us which treatment suits you",
body: "Antihistamines, nasal sprays and eye drops all work differently. A pharmacist can tell you which fits your symptoms.",
theme: "green", review: "Approved 25/08/2026 · Dana Whitcombe" },
{ id: "ST-06", kind: "seasonal", head: "Going away?", sub: "Travel health check, two weeks ahead",
body: "Vaccinations, motion sickness, and what to put in a travel kit.",
theme: "violet", review: "Approved 25/08/2026 · Dana Whitcombe" },
{ id: "ST-07", kind: "wait", head: "Now serving", sub: "Live from the dispensary", theme: "navy",
live: { serving: "B 42", waiting: 6, mins: 12 }, review: "System frame · content is live" },
{ id: "ST-08", kind: "scriptReady", head: "Scripts ready for collection", sub: "Bring your collection slip", theme: "navy",
live: { ready: ["A 118", "A 121", "A 122", "B 07", "B 11"] }, review: "System frame · numbers only, no names" },
];
// ---- playlists: manual order, per zone per store ---------------
// Stores drag these into the order they want. Nothing sorts itself.
const PLAYLISTS = {
"S-001:window": [{ ref: "PM-1041", dwell: 6, pin: true }, { ref: "PM-1043", dwell: 6 }, { ref: "PM-1042", dwell: 5 }, { ref: "ST-05", dwell: 5 }],
"S-001:wait": [{ ref: "ST-07", dwell: 12, pin: true }, { ref: "ST-08", dwell: 10 }, { ref: "ST-01", dwell: 9 }, { ref: "ST-04", dwell: 9 }, { ref: "PM-1046", dwell: 7 }],
"S-001:endcap": [{ ref: "PM-1041", dwell: 6 }, { ref: "PM-1047", dwell: 6 }, { ref: "ST-05", dwell: 5 }],
"S-001:till": [{ ref: "PM-1043", dwell: 7, pin: true }, { ref: "PM-1049", dwell: 6 }, { ref: "ST-03", dwell: 6 }],
"S-001:counter": [{ ref: "ST-01", dwell: 8 }, { ref: "ST-02", dwell: 8 }, { ref: "PM-1046", dwell: 6 }, { ref: "ST-06", dwell: 7 }],
"S-001:consult": [{ ref: "ST-04", dwell: 12 }, { ref: "ST-03", dwell: 10 }, { ref: "ST-01", dwell: 10 }],
"S-002:window": [{ ref: "PM-1044", dwell: 6, pin: true }, { ref: "PM-1041", dwell: 6 }, { ref: "ST-05", dwell: 5 }],
"S-002:wait": [{ ref: "ST-07", dwell: 12, pin: true }, { ref: "ST-01", dwell: 9 }, { ref: "ST-08", dwell: 10 }],
"S-002:till": [{ ref: "PM-1044", dwell: 7 }, { ref: "ST-03", dwell: 6 }],
"S-003:window": [{ ref: "PM-1047", dwell: 6 }, { ref: "PM-1041", dwell: 6 }],
"S-003:endcap": [{ ref: "PM-1041", dwell: 6 }, { ref: "PM-1047", dwell: 6 }],
"S-004:window": [{ ref: "PM-1043", dwell: 6, pin: true }, { ref: "PM-1045", dwell: 6 }],
"S-004:counter": [{ ref: "ST-01", dwell: 8 }, { ref: "PM-1045", dwell: 7 }, { ref: "ST-06", dwell: 7 }],
};
// ---- dayparting -------------------------------------------------
// Optional per playlist entry. A screen can change through the day
// where a printed ticket cannot.
const DAYPARTS = {
morning: { l: "Morning", from: "07:00", to: "12:00" },
lunch: { l: "Lunchtime", from: "12:00", to: "14:30" },
afternoon: { l: "Afternoon", from: "14:30", to: "18:00" },
evening: { l: "Evening", from: "18:00", to: "21:00" },
allday: { l: "All day", from: "00:00", to: "23:59" },
};
const PART_OF = { "S-001:window:PM-1042": "morning", "S-001:till:PM-1049": "afternoon", "S-004:window:PM-1045": "afternoon" };
const partFor = (store, zone, ref) => PART_OF[store + ":" + zone + ":" + ref] || "allday";
const inPart = (part, min) => { const p = DAYPARTS[part]; return min >= toMin(p.from) && min <= toMin(p.to); };
// ---- compliance -------------------------------------------------
// A screen is advertising in a way a shelf ticket arguably is not, so
// S3 is excluded here even though it is optional for tickets.
const DISPLAY_EXCLUDES = ["s3"];
function screenBlocked(pm) {
const prods = PR.PRODUCTS.filter(p => PM.applies(pm, p));
if (!prods.length) return { blocked: true, why: "Every line in scope is held out of this promotion." };
const s3 = prods.filter(p => PM.caughtBy(p, ["s3"]));
if (s3.length && s3.length === prods.length) {
return { blocked: true, why: "Every line is a pharmacist-only (S3) medicine. S3 can be promoted on price but not advertised, and a screen is advertising." };
}
if (s3.length) return { blocked: false, warn: s3.length + " S3 line" + (s3.length === 1 ? "" : "s") + " held out of the frame. The rest of the promotion still shows." };
return { blocked: false };
}
// ---- resolve a playlist into frames ----------------------------
function frame(ref) {
if (ref.startsWith("ST-")) {
const st = STANDING.find(s => s.id === ref);
return st ? { id: ref, kind: st.kind, standing: st } : null;
}
const pm = PM.PROMOS.find(p => p.id === ref);
if (!pm) return null;
const kind = PM.MECHANICS[pm.mech].where === "price" ? "promoPrice" : "promoRule";
return { id: ref, kind, pm };
}
// A frame is playing if the promotion is live, the zone allows the
// kind, and the daypart is open. Each failure is named.
function resolvePlaylist(store, zone, min) {
const key = store + ":" + zone;
const z = ZONES[zone];
const entries = PLAYLISTS[key] || [];
return entries.map(e => {
const f = frame(e.ref);
if (!f) return { ...e, missing: true };
const part = partFor(store, zone, e.ref);
const reasons = [];
if (!z.allow.includes(f.kind)) reasons.push({ kind: "zone", say: z.denyWhy || "Not allowed in this zone." });
if (f.pm) {
const st = PM.stateOf(f.pm);
if (st === "paused") reasons.push({ kind: "state", say: "The promotion is paused, so the frame comes down with the price." });
else if (st === "ended") reasons.push({ kind: "state", say: "The promotion ended. A screen still showing it is worse than a screen showing nothing." });
else if (st === "scheduled") reasons.push({ kind: "state", say: "Starts " + f.pm.from + ". Queued, not playing." });
else if (st === "draft") reasons.push({ kind: "state", say: "Still a draft." });
if (!f.pm.stores.includes(store)) reasons.push({ kind: "store", say: "Not running at this store." });
const sb = screenBlocked(f.pm);
if (sb.blocked) reasons.push({ kind: "compliance", say: sb.why });
else if (sb.warn) f.warn = sb.warn;
}
if (!inPart(part, min)) reasons.push({ kind: "daypart", say: "Outside its " + DAYPARTS[part].l.toLowerCase() + " window (" + DAYPARTS[part].from + "–" + DAYPARTS[part].to + ")." });
return { ...e, f, part, reasons, playing: reasons.length === 0 };
});
}
const cycleOf = list => list.filter(e => e.playing).reduce((s, e) => s + e.dwell, 0);
const screensFor = store => SCREENS.filter(s => store === "all" || s.store === store);
const screenIn = (store, zone) => SCREENS.find(s => s.store === store && s.zone === zone);
window.OFFICE_DISPLAYS = { ZONES, KINDS, SCREENS, STANDING, PLAYLISTS, DAYPARTS, DISPLAY_EXCLUDES,
NOW_MIN, hhmm, toMin, partFor, inPart, screenBlocked, frame, resolvePlaylist, cycleOf, screensFor, screenIn };
})();
const TK_THEMES = {
red: { l: "Classic red", flag: "#d81f2a", fg: "#fff", ink: "#b3141d", tint: "#fdecec" },
amber: { l: "Clearance", flag: "#f4a300", fg: "#241a00", ink: "#a06a00", tint: "#fff6e3" },
teal: { l: "Brand teal", flag: "#0d7b6c", fg: "#fff", ink: "#0a6156", tint: "#e6f4f1" },
magenta: { l: "Beauty pink", flag: "#c2185b", fg: "#fff", ink: "#a01449", tint: "#fdeaf1" },
green: { l: "Wellness", flag: "#2e7d32", fg: "#fff", ink: "#256428", tint: "#eaf4ea" },
violet: { l: "Premium", flag: "#5b3fa8", fg: "#fff", ink: "#4a3389", tint: "#efeafa" },
navy: { l: "Navy", flag: "#0f2436", fg: "#fff", ink: "#0f2436", tint: "#e9eef3" },
};
window.TK_THEMES=TK_THEMES;window.themeFor=pm=>window.OFFICE_PROMOS.TICKET_THEMES[pm.id]||(pm.origin==='clearance'?'amber':pm.mech==='member'?'teal':window.OFFICE_PROMOS.MECHANICS[pm.mech].where==='basket'?'green':'red');
/* =============================================================
CoreIQ Office — Displays: frame renderer + playlist editor
============================================================= */
const { useState: dsUseState, useEffect: dsUseEffect, useRef: dsUseRef } = React;
const BRANDS = {
"Panadol": { owner: "Haleon", cat: "OTC medicines", role: "destination" },
"Nurofen": { owner: "Reckitt", cat: "OTC medicines", role: "destination" },
"Claratyne": { owner: "Bayer", cat: "OTC medicines", role: "destination" },
"Voltaren": { owner: "Haleon", cat: "OTC medicines", role: "destination" },
"Nexium": { owner: "Haleon", cat: "OTC medicines", role: "traffic" },
"Movicol": { owner: "Norgine", cat: "OTC medicines", role: "routine" },
"Ostelin": { owner: "Sanofi CHC", cat: "Vitamins", role: "destination" },
"Blackmores": { owner: "Blackmores", cat: "Vitamins", role: "traffic" },
"Swisse": { owner: "H&H Group", cat: "Vitamins", role: "traffic" },
"Cancer Council": { owner: "Cancer Council", cat: "Sun care", role: "traffic" },
"La Roche-Posay": { owner: "L'Oréal", cat: "Sun care", role: "premium" },
"Bepanthen": { owner: "Bayer", cat: "Baby", role: "destination" },
"Aptamil": { owner: "Danone", cat: "Baby", role: "destination" },
"Dermeze": { owner: "Aspen", cat: "Skincare", role: "routine" },
"Vichy": { owner: "L'Oréal", cat: "Skincare", role: "premium" },
"Cetaphil": { owner: "Galderma", cat: "Skincare", role: "traffic" },
"Nicabate": { owner: "Haleon", cat: "NRT", role: "routine" },
"Accu-Chek": { owner: "Roche", cat: "First aid", role: "routine" },
};
const brandOf = (p) => Object.keys(BRANDS).find(b => p.name.startsWith(b)) || "Other";
window.OFFICE_PRICING.brandOf = brandOf;
const DS = window.OFFICE_DISPLAYS;
const DSP = window.OFFICE_PROMOS;
const DSR = window.OFFICE_PRICING;
const dsM = v => window.officeMoney(v);
const dsStore = id => window.findOfficeStore(id) ? window.findOfficeStore(id).name : id;
// -----------------------------------------------------------------
// FRAME RENDERER
// A frame is authored once at full screen size and scaled down, so
// the type sizes here are the real ones. Nothing is styled for the
// preview.
// -----------------------------------------------------------------
function DFrame({ f, zone, width, dim }) {
const z = DS.ZONES[zone];
const portrait = z.orient === "portrait";
const NW = portrait ? 1080 : 1920, NH = portrait ? 1920 : 1080;
const s = width / NW;
const T = window.TK_THEMES;
const dense = z.maxWords >= 20;
let body = null, theme = "navy";
if (!f) {
body =
;
} else if (f.pm) {
const pm = f.pm;
theme = window.themeFor(pm);
const m = DSP.MECHANICS[pm.mech];
const prods = DSR.PRODUCTS.filter(p => DSP.applies(pm, p));
const single = pm.level === "sku" && prods[0];
const price = single ? DSP.unitPrice(pm, prods[0]) : null;
const subject = single ? prods[0].name : pm.level === "group" ? "Storewide" : pm.key;
const offer = m.where === "price" && price != null
? dsM(price)
: m.say(pm.p).replace(/^any /, "ANY ").toUpperCase();
body = (
<>
{pm.origin === "clearance" ? "CLEARANCE" : m.where === "price" ? "SPECIAL" : "OFFER"}
9 ? 150 : 230) : (offer.length > 9 ? 180 : 280)}}>{offer}
{single && price != null && (
was {dsM(prods[0].cur)}
)}
{!single && m.where === "price" && (
{pm.level === "group" ? "every line in the shop" : "off the whole range"}
)}
{subject}
{dense && !single && prods.length > 1 && (
{prods.length} lines across the range
)}
Until {DSP.toOf(pm)}{pm.mech === "member" ? " · members only" : ""}
>
);
} else if (f.standing) {
const st = f.standing;
theme = st.theme;
if (st.kind === "wait") {
body = (
<>
NOW SERVING
{st.live.waiting} waiting
about {st.live.mins} minutes
Take a seat — we will call your number
>
);
} else if (st.kind === "scriptReady") {
body = (
<>
READY FOR COLLECTION
{st.live.ready.map(n => {n} )}
Collection numbers only. Bring your slip to the counter.
>
);
} else {
body = (
<>
{st.kind === "service" ? "AVAILABLE TODAY" : "HEALTH"}
{dense && st.body && {st.body}
}
Ask any of our team
>
);
}
}
const t = T[theme] || T.navy;
return (
);
}
window.DFrame = DFrame;
// -----------------------------------------------------------------
// PLAYLIST EDITOR
// -----------------------------------------------------------------
function DsPlaylists() {
const { scope, pushToast } = window.useOffice();
const store = scope === "all" ? "S-001" : scope;
const zones = Object.keys(DS.ZONES).filter(z => DS.screenIn(store, z));
const [zone, setZone] = dsUseState(zones[0] || "window");
const z = DS.ZONES[zone] || DS.ZONES.window;
const scr = DS.screenIn(store, zone);
// manual order, held per store+zone
const key = store + ":" + zone;
const [orders, setOrders] = dsUseState({});
const list = DS.resolvePlaylist(store, zone, DS.NOW_MIN);
const setList = next => { DS.PLAYLISTS[key] = next.map(({ref,dwell,pin})=>({ref,dwell,pin})); setOrders(o => ({...o,[key]:(o[key]||0)+1})); };
const [drag, setDrag] = dsUseState(null);
const [over, setOver] = dsUseState(null);
const [sel, setSel] = dsUseState(0);
dsUseEffect(() => {setSel(0); if(!zones.includes(zone))setZone(zones[0]||"window");}, [zone,store]);
const move = (from, to) => {
if (!Number.isInteger(from) || from < 0 || from >= list.length || from === to || to == null || to < 0 || to >= list.length) return;
const next = list.slice();
const [it] = next.splice(from, 1);
next.splice(to, 0, it);
setList(next);
};
const playing = list.filter(e => e.playing);
const cycle = playing.reduce((s, e) => s + e.dwell, 0);
const cur = playing[Math.min(sel, Math.max(0, playing.length - 1))];
return (
<>
{zones.map(k => {
const zz = DS.ZONES[k];
const l = DS.resolvePlaylist(store, k, DS.NOW_MIN);
const s2 = DS.screenIn(store, k);
return (
setZone(k)}>
{zz.l}
{l.filter(e => e.playing).length} of {l.length} playing{s2 && s2.state !== "online" ? " · " + s2.state : ""}
);
})}
{/* ---- list ---- */}
{z.orient} {scr ? scr.size : "—"}
cycle {cycle + "s"}
{cycle > 45 && (
A {cycle} second cycle means nobody walking past sees the end of it. Four or five frames is usually the practical limit for a {z.l.toLowerCase()} screen.
)}
{list.map((e, i) => {
const f = e.f;
const kind = f ? DS.KINDS[f.kind] : null;
const label = f ? (f.pm ? f.pm.name : f.standing.head) : e.ref + " (missing)";
const pos = playing.indexOf(e);
return (
setDrag(i)}
onDragOver={ev => { ev.preventDefault(); setOver(i); }}
onDragEnd={() => { setDrag(null); setOver(null); }}
onDrop={ev => { ev.preventDefault(); move(drag, i); setDrag(null); setOver(null); }}
onClick={() => { if (e.playing) setSel(pos); }}>
{e.playing ? pos + 1 : "—"}
{label}
{e.pin && pinned }
{kind ? kind.l : ""}{e.part && e.part !== "allday" ? " · " + DS.DAYPARTS[e.part].l.toLowerCase() + " only" : ""}
{!e.playing && e.reasons && e.reasons.map((r, ri) => (
{r.say}
))}
{e.playing && f && f.warn && (
{f.warn}
)}
{ ev.stopPropagation(); setList(list.map(x => x === e ? { ...x, dwell: Math.max(2, x.dwell - 1) } : x)); }}>
{e.dwell}s
{ ev.stopPropagation(); setList(list.map(x => x === e ? { ...x, dwell: Math.min(20, x.dwell + 1) } : x)); }}>
{ ev.stopPropagation(); move(i, i - 1); }} disabled={i === 0}>
{ ev.stopPropagation(); move(i, i + 1); }} disabled={i === list.length - 1}>
);
})}
Drag to reorder, or use the arrows. Nothing sorts itself — the order on screen is the order here.
What this zone will not carry
Content rules follow the placement, not the promotion.
{Object.entries(DS.KINDS).map(([k, kd]) => {
const ok = z.allow.includes(k);
return (
{kd.l}
{ok ? kd.d : (z.denyWhy || "Not appropriate for this placement.")}
);
})}
Pharmacist-only (S3) medicines are held out of every screen, whatever the zone. S3 can be promoted on price but not advertised, and a screen is advertising in a way a shelf ticket is not.
{/* ---- preview ---- */}
Preview on screen
{scr ? scr.id + " · " + scr.size + " " + z.orient : "No sample screen in this zone"} · {DS.hhmm(DS.NOW_MIN)}
setSel(s => (s - 1 + playing.length) % playing.length)} disabled={playing.length < 2}>
{playing.length ? Math.min(sel, playing.length - 1) + 1 : 0} / {playing.length}
setSel(s => (s + 1) % playing.length)} disabled={playing.length < 2}>
{playing.length === 0
?
Nothing is playing in this zone. A screen showing nothing is better than a screen showing an ended promotion, but it is still a dark screen in a shop window.
:
}
{cur && (
Holds for {cur.dwell} seconds
Frame type {DS.KINDS[cur.f.kind].l}
Words on screen {z.maxWords >= 20 ? "detail allowed" : "under " + z.maxWords}
Read at {zone === "window" ? "5 m, 2 seconds" : zone === "counter" ? "0.5 m, seated" : zone === "wait" ? "2 m, minutes" : "2 m, walking"}
)}
Rendered at the screen's real pixel size and scaled down, so the type sizes are the ones the shop will actually see.
Add a frame
Only content this zone allows is offered.
{[...DSP.PROMOS.filter(pm => DSP.stateOf(pm) === "live" && pm.stores.includes(store)),
...DS.STANDING.filter(st => !st.pending)].filter(x => {
const f = DS.frame(x.id);
return f && z.allow.includes(f.kind) && !list.find(e => e.ref === x.id);
}).map(x => (
{ setList([...list, { ref: x.id, dwell: z.dwell + 2 }]); pushToast({kind:'paid', icon:'check', title:'Added to ' + z.l, meta:'Preview only · kept until page reload; nothing is sent to a screen'}); }}>
+ {x.name || x.head}
))}
>
);
}
window.DsPlaylists = DsPlaylists;
/* =============================================================
CoreIQ Office — Displays: screens, library, shell
============================================================= */
const { useState: d2UseState } = React;
const D2 = window.OFFICE_DISPLAYS;
const D2P = window.OFFICE_PROMOS;
const d2Store = id => window.findOfficeStore(id) ? window.findOfficeStore(id).name : id;
// =================================================================
// THE SCREENS THEMSELVES
// =================================================================
function DsScreens() {
const { scope, pushToast } = window.useOffice();
const rows = D2.screensFor(scope);
const STATE = { online: ["paid", "Online"], cached: ["hold", "Running cached"], offline: ["void", "Offline"] };
const bad = rows.filter(s => s.state !== "online");
return (
<>
s.state === "online").length} delta={{dir: bad.length ? "down" : "up", text: bad.length ? bad.length + " need attention" : "All reporting"}}/>
s.state === "cached").length} delta={{dir:"down", text:"Cached playlist, promotion may have ended"}}/>
s.fw !== "2.4.1").length} delta={{text:"On 2.3.8 · dayparting needs 2.4"}}/>
Each screen pulls its playlist and caches it. A dropped connection shows the last good playlist rather than an error page, which is the right failure — but it also means a screen can keep advertising a promotion that has ended, so a cached screen is a problem to chase rather than a state to ignore.
Sample screen inventory
pushToast({kind:'paid', icon:'refresh', title:'Preview only', meta:'No playlists sent. Screen connection and publishing are not connected.'})}>
Preview push
Screen
Store
Zone
Size
Firmware
State
Last contact
{rows.map(s => {
const [tone, l] = STATE[s.state];
const z = D2.ZONES[s.zone];
const pl = D2.resolvePlaylist(s.store, s.zone, D2.NOW_MIN);
return (
{s.id}
{d2Store(s.store)}
{z.l}
{pl.filter(e => e.playing).length} frames · {D2.cycleOf(pl)}s cycle
{s.size} {z.orient === "portrait" ? "port" : "land"}
{s.fw}
{l}
{s.seen}{s.note &&
{s.note}
}
);
})}
>
);
}
// =================================================================
// FRAME LIBRARY — the standing content, and one promotion three ways
// =================================================================
function DsLibrary() {
const { scope, pushToast } = window.useOffice();
const store = scope === "all" ? "S-001" : scope;
const [pick, setPick] = d2UseState("PM-1041");
const live = D2P.PROMOS.filter(pm => D2P.stateOf(pm) === "live" && pm.stores.includes(store));
const pm = live.find(x => x.id === pick) || live[0];
const f = pm ? D2.frame(pm.id) : null;
const SHOW = ["window", "wait", "counter"];
return (
<>
The same promotion is not the same frame in every zone. A window screen gets the price and nothing else; a waiting-area screen has minutes of attention and can carry the explanation. One authored promotion, three treatments.
One promotion, three zones
{live.map(x => (
setPick(x.id)}>
{x.name}
))}
{pm && (
{SHOW.map(z => {
const zz = D2.ZONES[z];
const allowed = zz.allow.includes(f.kind);
return (
{zz.l}
{zz.orient} · {zz.dwell}s · {zz.maxWords >= 20 ? "detail allowed" : "under " + zz.maxWords + " words"}
{allowed
?
:
{zz.denyWhy}
}
);
})}
)}
Standing content
Authored once and reviewed, not derived from a promotion. Health messaging carries no product claim, and the two queue frames use sample collection numbers in this preview.
{D2.STANDING.map(st => (
{st.head}{st.pending && awaiting review }
{D2.KINDS[st.kind].l}
{st.review}
))}
Free text on a screen is where therapeutic claims creep in. Standing frames are reviewed and dated before they go up.
>
);
}
// =================================================================
// SHELL
// =================================================================
function Displays() {
const { scope, setScreen, pushToast } = window.useOffice();
const [tab, setTab] = d2UseState(previewTab);
const TABS = [
{ k: "playlists", l: "Playlists" },
{ k: "library", l: "Frames" },
{ k: "screens", l: "Screens" },
];
const BODIES = { playlists: window.DsPlaylists, library: DsLibrary, screens: DsScreens };
const Body = BODIES[tab];
return (
Pricing · {scope === "all" ? "all stores" : d2Store(scope)}
In-store screens
Six placements, each with its own content rules. A frame is read in about two seconds at the window and several minutes in the dispensary queue, so the same promotion is not the same frame.
{tab === "library" && (
setScreen("displayFrameNew")}> New frame
)}
pushToast({kind:'paid', icon:'refresh', title:'Preview only', meta:'No playlists sent. Screen connection and publishing are not connected.'})}> Preview push
{TABS.map(t => {previewTab=t.k;setTab(t.k);}}>{t.l} )}
);
}
window.OFFICE_SCREENS = Object.assign(window.OFFICE_SCREENS || {}, { displays: Displays });
/* =============================================================
CoreIQ Office — Displays: standing frame composer
Free text on a screen is where therapeutic claims creep in, so
a new frame is drafted, screened and reviewed before it can be
added to any playlist.
============================================================= */
const { useState: d3UseState } = React;
const D3 = window.OFFICE_DISPLAYS;
// Words a pharmacy screen should not carry without a pharmacist's sign-off.
// Not a compliance engine — a prompt to look again before it goes on a wall.
const D3_CLAIMS = [
{ re: /\b(cure|cures|cured|curing)\b/i, say: "\u201ccure\u201d is a therapeutic claim. Say what the service does instead." },
{ re: /\b(treat|treats|treatment for)\b/i, say: "\u201ctreat\u201d implies a therapeutic claim for a named condition." },
{ re: /\b(prevent|prevents|prevention of)\b/i, say: "\u201cprevent\u201d needs to be supported and is usually better left to the vaccine's own wording." },
{ re: /\b(safe|safest|harmless|no side effects)\b/i, say: "Safety claims cannot be made on a display." },
{ re: /\b(best|number one|no\.? ?1|leading)\b/i, say: "Comparative superiority claims are not permitted." },
{ re: /\b(guaranteed|guarantee)\b/i, say: "A guarantee on a health service is a claim you cannot stand behind." },
{ re: /\b(doctor recommended|clinically proven|proven to)\b/i, say: "Efficacy claims need evidence and a pharmacist's approval." },
{ re: /\b(free)\b/i, say: "\u201cFree\u201d is fine for an NIP service, but say who is eligible or it reads as a retail offer.", soft: true },
];
function DisplayFrameNew() {
const { setScreen, pushToast } = window.useOffice();
const [kind, setKind] = d3UseState("service");
const [head, setHead] = d3UseState("");
const [sub, setSub] = d3UseState("");
const [body, setBody] = d3UseState("");
const [theme, setTheme] = d3UseState("teal");
const [zone, setZone] = d3UseState("counter");
const [ack, setAck] = d3UseState(false);
const zones = Object.keys(D3.ZONES).filter(z => D3.ZONES[z].allow.includes(kind));
const z = D3.ZONES[zones.includes(zone) ? zone : zones[0]];
const zKey = zones.includes(zone) ? zone : zones[0];
const text = [head, sub, body].join(" ").trim();
const words = text ? text.split(/\s+/).length : 0;
const hits = D3_CLAIMS.filter(c => c.re.test(text));
const hard = hits.filter(c => !c.soft);
// the zone's own word budget, not an arbitrary limit
const tight = zones.filter(k => words > D3.ZONES[k].maxWords);
const preview = { id: "ST-DRAFT", kind, standing: { kind, head: head || "Headline", sub, body, theme } };
const ready = head.trim().length > 2 && sub.trim().length > 2 && ack && hard.length === 0;
const save = () => {
const id = "ST-" + String(D3.STANDING.length + 1).padStart(2, "0");
D3.STANDING.push({ id, kind, head: head.trim(), sub: sub.trim(), body: body.trim(), theme,
pending: false, review: "Local preview draft · not submitted or approved" });
pushToast({ kind: "hold", icon: "doc", title: "Preview draft added",
meta: id + " \u00b7 " + head.trim() + " · local preview only; nothing submitted or published" });
previewTab="library";setScreen("displays");
};
const KINDS = [["service", "Service available today"], ["seasonal", "Seasonal health message"]];
return (
What kind of frame
{KINDS.map(([k, l]) => (
{ setKind(k); setTheme(k === "service" ? "teal" : "green"); }}>
{l}
{D3.KINDS[k].d}
))}
Now serving and scripts ready are system frames — they pull live from the dispensary queue and are not authored here.
The words
Read at {zKey === "window" ? "five metres in about two seconds" : zKey === "wait" ? "two metres over several minutes" : "close range while waiting"}. The headline does the work; the rest is only read if they stop.
Headline
setHead(e.target.value)} placeholder="Flu vaccination" maxLength={38}/>
{head.length}/38 · shown at {z.orient === "portrait" ? "120px" : "132px"} on screen
Second line
setSub(e.target.value)} placeholder="Walk in today, no appointment" maxLength={54}/>
{sub.length}/54
Detail
{hits.length > 0 && (
{hits.map((c, i) => (
{c.say}
))}
)}
{tight.length > 0 && (
{words} words is more than {tight.map(k => D3.ZONES[k].l.toLowerCase()).join(", ")} {tight.length === 1 ? "carries" : "carry"}. The detail line is dropped there, so the headline and second line have to stand alone.
)}
Colour
The same seven schemes as the shelf tickets, so the shop reads as one set.
{Object.entries(window.TK_THEMES).map(([k, t]) => (
setTheme(k)}>
{t.l}
))}
Review
setAck(!ack)}>
I have checked this preview wording
Standing frames carry the pharmacy's name with no product packaging around them, so the wording is the whole claim. No review or approval is submitted in this UI preview.
{hard.length > 0 && (
Wording flagged above has to change before this can be submitted. The screening is a prompt, not a legal check — a pharmacist still reads it.
)}
How it will look
Rendered at the screen's real pixel size and scaled down.
{zones.map(k => (
setZone(k)}>
{D3.ZONES[k].l}
{D3.ZONES[k].maxWords >= 20 ? "detail shown" : "headline only"}
))}
Zones it can appear in {zones.map(k => D3.ZONES[k].l).join(", ")}
Words on screen {words}
Detail line {z.maxWords >= 20 ? "shown here" : "hidden in this zone"}
A standing frame is available to every store once approved. Playlists stay per store, so nobody is forced to run it.
);
}
window.OFFICE_SCREENS = Object.assign(window.OFFICE_SCREENS || {}, { displayFrameNew: DisplayFrameNew });
function PreviewShell({composer=false}) {const [store,setStore]=React.useState(previewStore);const Body=composer?window.OFFICE_SCREENS.displayFrameNew:window.OFFICE_SCREENS.displays;
return UI preview · Sample screens, offers and queue numbers. Edits last until page reload; no device connection, publishing or backend saving.Example store {previewStore=e.target.value;setStore(e.target.value);}}>{sampleStores.map(s=>{s.name} )}
;
}
host.OFFICE_SCREENS=Object.assign(host.OFFICE_SCREENS||{},{inStoreScreens:()=> ,displayFrameNew:()=> });
})(window);