/* 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 =
Frame missing
; } 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.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"}
{st.head}
{st.sub}
{dense && st.body &&
{st.body}
}
Ask any of our team
); } } const t = T[theme] || T.navy; return (
{body}
); } 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 ( ); })}
{/* ---- list ---- */}
{z.full}
{z.d}
{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}
)}
{e.dwell}s
); })}
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)}
{playing.length ? Math.min(sel, playing.length - 1) + 1 : 0} / {playing.length}
{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 => ( ))}
); } 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
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 => ( ))}
{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" && ( )}
{TABS.map(t => )}
); } 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 (

New standing frame

Authored content rather than a promotion. This draft is available in the local preview only; nothing is submitted or published.
What kind of frame
{KINDS.map(([k, l]) => ( ))}
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.