/* 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 => <host.OIcon {...props}/>;
 const KpiCard = props => <host.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 = <div className="df-mid"><div className="df-eyebrow" style={{fontSize:44}}>Frame missing</div></div>;
  } 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 = (
      <>
        <div className="df-flag" style={{fontSize: portrait ? 46 : 40, padding: (portrait ? "16px 30px" : "14px 26px")}}>
          {pm.origin === "clearance" ? "CLEARANCE" : m.where === "price" ? "SPECIAL" : "OFFER"}
        </div>
        <div className="df-mid">
          <div className="df-offer" style={{fontSize: portrait ? (offer.length > 9 ? 150 : 230) : (offer.length > 9 ? 180 : 280)}}>{offer}</div>
          {single && price != null && (
            <div className="df-was" style={{fontSize: portrait ? 62 : 66}}>was {dsM(prods[0].cur)}</div>
          )}
          {!single && m.where === "price" && (
            <div className="df-was" style={{fontSize: portrait ? 62 : 66}}>
              {pm.level === "group" ? "every line in the shop" : "off the whole range"}
            </div>
          )}
        </div>
        <div className="df-subject" style={{fontSize: portrait ? 84 : 92}}>{subject}</div>
        {dense && !single && prods.length > 1 && (
          <div className="df-body" style={{fontSize: portrait ? 46 : 48}}>{prods.length} lines across the range</div>
        )}
        <div className="df-foot" style={{fontSize: portrait ? 36 : 38}}>Until {DSP.toOf(pm)}{pm.mech === "member" ? " · members only" : ""}</div>
      </>
    );
  } else if (f.standing) {
    const st = f.standing;
    theme = st.theme;
    if (st.kind === "wait") {
      body = (
        <>
          <div className="df-flag" style={{fontSize: 40, padding: "14px 26px"}}>NOW SERVING</div>
          <div className="df-mid">
            <div className="df-offer" style={{fontSize: 300}}>{st.live.serving}</div>
          </div>
          <div className="df-row" style={{fontSize: 54}}>
            <span>{st.live.waiting} waiting</span>
            <span>about {st.live.mins} minutes</span>
          </div>
          <div className="df-foot" style={{fontSize: 38}}>Take a seat — we will call your number</div>
        </>
      );
    } else if (st.kind === "scriptReady") {
      body = (
        <>
          <div className="df-flag" style={{fontSize: 40, padding: "14px 26px"}}>READY FOR COLLECTION</div>
          <div className="df-nums" style={{gap: 20, fontSize: 96}}>
            {st.live.ready.map(n => <span key={n}>{n}</span>)}
          </div>
          <div className="df-foot" style={{fontSize: 38}}>Collection numbers only. Bring your slip to the counter.</div>
        </>
      );
    } else {
      body = (
        <>
          <div className="df-flag" style={{fontSize: portrait ? 44 : 40, padding: (portrait ? "16px 30px" : "14px 26px")}}>
            {st.kind === "service" ? "AVAILABLE TODAY" : "HEALTH"}
          </div>
          <div className="df-mid">
            <div className="df-head" style={{fontSize: portrait ? 120 : 132}}>{st.head}</div>
            <div className="df-sub" style={{fontSize: portrait ? 60 : 64}}>{st.sub}</div>
          </div>
          {dense && st.body && <div className="df-body" style={{fontSize: portrait ? 46 : 48}}>{st.body}</div>}
          <div className="df-foot" style={{fontSize: portrait ? 36 : 38}}>Ask any of our team</div>
        </>
      );
    }
  }

  const t = T[theme] || T.navy;
  return (
    <div className="df-clip" style={{width: NW * s, height: NH * s, opacity: dim ? .38 : 1}}>
      <div className={"df" + (portrait ? " df--p" : " df--l")}
        style={{width: NW, height: NH, transform: "scale(" + s + ")", background: t.flag, color: t.fg,
          "--df-ink": t.ink, "--df-tint": t.tint}}>
        {body}
      </div>
    </div>
  );
}
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 (
    <>
      <div className="ds-zonebar">
        {zones.map(k => {
          const zz = DS.ZONES[k];
          const l = DS.resolvePlaylist(store, k, DS.NOW_MIN);
          const s2 = DS.screenIn(store, k);
          return (
            <button key={k} className={"ds-ztab" + (zone === k ? " is-on" : "")} onClick={() => setZone(k)}>
              <span className={"ds-zorient ds-zorient--" + zz.orient}/>
              <span className="ds-ztab-t">
                <span className="ds-ztab-l">{zz.l}</span>
                <span className="ds-ztab-s">{l.filter(e => e.playing).length} of {l.length} playing{s2 && s2.state !== "online" ? " · " + s2.state : ""}</span>
              </span>
            </button>
          );
        })}
      </div>

      <div className="ds-edit">
        {/* ---- list ---- */}
        <div className="ds-ecol">
          <div className="off-table pg-card">
            <div className="ds-zhead">
              <div>
                <div className="pg-card-h">{z.full}</div>
                <div className="pg-card-s">{z.d}</div>
              </div>
              <div className="ds-zfacts">
                <div><span>{z.orient}</span><strong>{scr ? scr.size : "—"}</strong></div>
                <div><span>cycle</span><strong>{cycle + "s"}</strong></div>
              </div>
            </div>

            {cycle > 45 && (
              <div className="pb-st-warn" style={{marginTop:11}}>
                <OIcon name="info" width={13} height={13}/>
                <span>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.</span>
              </div>
            )}

            <div className="ds-list">
              {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 (
                  <div key={e.ref}
                    className={"ds-item" + (e.playing ? "" : " is-off") + (drag === i ? " is-drag" : "") + (over === i ? " is-over" : "") + (cur === e ? " is-sel" : "")}
                    draggable
                    onDragStart={() => 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); }}>
                    <span className="ds-grip"><OIcon name="list" width={13} height={13}/></span>
                    <span className="ds-num">{e.playing ? pos + 1 : "—"}</span>
                    <div style={{minWidth:0, flex:1}}>
                      <div className="ds-item-n">
                        {label}
                        {e.pin && <span className="ds-pin">pinned</span>}
                      </div>
                      <div className="ds-item-s">
                        {kind ? kind.l : ""}{e.part && e.part !== "allday" ? " · " + DS.DAYPARTS[e.part].l.toLowerCase() + " only" : ""}
                      </div>
                      {!e.playing && e.reasons && e.reasons.map((r, ri) => (
                        <div key={ri} className={"ds-why ds-why--" + r.kind}>
                          <OIcon name={r.kind === "compliance" ? "lock" : r.kind === "daypart" ? "clock" : "info"} width={11} height={11}/>
                          <span>{r.say}</span>
                        </div>
                      ))}
                      {e.playing && f && f.warn && (
                        <div className="ds-why ds-why--compliance"><OIcon name="lock" width={11} height={11}/><span>{f.warn}</span></div>
                      )}
                    </div>
                    <div className="ds-dwell">
                      <button aria-label={"Shorter duration for " + label} onClick={ev => { ev.stopPropagation(); setList(list.map(x => x === e ? { ...x, dwell: Math.max(2, x.dwell - 1) } : x)); }}><OIcon name="minus" width={11} height={11}/></button>
                      <span>{e.dwell}s</span>
                      <button aria-label={"Longer duration for " + label} onClick={ev => { ev.stopPropagation(); setList(list.map(x => x === e ? { ...x, dwell: Math.min(20, x.dwell + 1) } : x)); }}><OIcon name="plus" width={11} height={11}/></button>
                    </div>
                    <div className="ds-moves">
                      <button aria-label={"Move up " + label} onClick={ev => { ev.stopPropagation(); move(i, i - 1); }} disabled={i === 0}><OIcon name="arrow-up" width={12} height={12}/></button>
                      <button aria-label={"Move down " + label} onClick={ev => { ev.stopPropagation(); move(i, i + 1); }} disabled={i === list.length - 1}><OIcon name="arrow-down" width={12} height={12}/></button>
                    </div>
                  </div>
                );
              })}
            </div>
            <div className="pb-st-note">Drag to reorder, or use the arrows. Nothing sorts itself — the order on screen is the order here.</div>
          </div>

          <div className="off-table pg-card">
            <div className="pg-card-h">What this zone will not carry</div>
            <div className="pg-card-s">Content rules follow the placement, not the promotion.</div>
            <div className="ds-rules">
              {Object.entries(DS.KINDS).map(([k, kd]) => {
                const ok = z.allow.includes(k);
                return (
                  <div key={k} className={"ds-rule" + (ok ? " is-on" : "")}>
                    <span className={"xp-yn " + (ok ? "is-y" : "is-n")}><OIcon name={ok ? "check" : "x"} width={10} height={10}/></span>
                    <div>
                      <div className="pb-ex-l">{kd.l}</div>
                      <div className="pb-ex-w">{ok ? kd.d : (z.denyWhy || "Not appropriate for this placement.")}</div>
                    </div>
                  </div>
                );
              })}
            </div>
            <div className="xp-note" style={{marginTop:10}}>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.</div>
          </div>
        </div>

        {/* ---- preview ---- */}
        <div className="ds-ecol">
          <div className="off-table pg-card ds-preview">
            <div className="ds-phead">
              <div>
                <div className="pg-card-h">Preview on screen</div>
                <div className="pg-card-s">{scr ? scr.id + " · " + scr.size + " " + z.orient : "No sample screen in this zone"} · {DS.hhmm(DS.NOW_MIN)}</div>
              </div>
              <div className="ds-pnav">
                <button aria-label="Previous frame" onClick={() => setSel(s => (s - 1 + playing.length) % playing.length)} disabled={playing.length < 2}><OIcon name="chevron-left" width={13} height={13}/></button>
                <span>{playing.length ? Math.min(sel, playing.length - 1) + 1 : 0} / {playing.length}</span>
                <button aria-label="Next frame" onClick={() => setSel(s => (s + 1) % playing.length)} disabled={playing.length < 2}><OIcon name="chevron-right" width={13} height={13}/></button>
              </div>
            </div>

            <div className={"ds-stage ds-stage--" + z.orient}>
              {playing.length === 0
                ? <div className="ds-blank">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.</div>
                : <div className="ds-bezel">
                    <DFrame f={cur.f} zone={zone} width={z.orient === "portrait" ? 216 : 384}/>
                  </div>}
            </div>

            {cur && (
              <div className="ds-pmeta">
                <div className="pg-fact"><span>Holds for</span><strong>{cur.dwell} seconds</strong></div>
                <div className="pg-fact"><span>Frame type</span><strong>{DS.KINDS[cur.f.kind].l}</strong></div>
                <div className="pg-fact"><span>Words on screen</span><strong>{z.maxWords >= 20 ? "detail allowed" : "under " + z.maxWords}</strong></div>
                <div className="pg-fact"><span>Read at</span><strong>{zone === "window" ? "5 m, 2 seconds" : zone === "counter" ? "0.5 m, seated" : zone === "wait" ? "2 m, minutes" : "2 m, walking"}</strong></div>
              </div>
            )}
            <div className="pb-st-note">Rendered at the screen's real pixel size and scaled down, so the type sizes are the ones the shop will actually see.</div>
          </div>

          <div className="off-table pg-card">
            <div className="pg-card-h">Add a frame</div>
            <div className="pg-card-s">Only content this zone allows is offered.</div>
            <div className="ds-add">
              {[...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 => (
                  <button key={x.id} onClick={() => { 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}
                  </button>
                ))}
            </div>
          </div>
        </div>
      </div>
    </>
  );
}
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 (
    <>
      <div className="off-kpi-grid">
        <KpiCard label="Screens" value={rows.length} delta={{text: scope === "all" ? "Across four stores" : "At " + d2Store(scope)}}/>
        <KpiCard label="Online" value={rows.filter(s => s.state === "online").length} delta={{dir: bad.length ? "down" : "up", text: bad.length ? bad.length + " need attention" : "All reporting"}}/>
        <KpiCard label="Showing stale content" value={rows.filter(s => s.state === "cached").length} delta={{dir:"down", text:"Cached playlist, promotion may have ended"}}/>
        <KpiCard label="Firmware behind" value={rows.filter(s => s.fw !== "2.4.1").length} delta={{text:"On 2.3.8 · dayparting needs 2.4"}}/>
      </div>

      <div className="pc-note">
        <OIcon name="info" width={15} height={15} style={{flexShrink:0, marginTop:1}}/>
        <div>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.</div>
      </div>

      <div className="off-table">
        <div className="off-table-toolbar">
          <div style={{fontSize:13, fontWeight:600}}>Sample screen inventory</div>
          <button className="btn btn--secondary btn--sm" style={{marginLeft:'auto'}}
            onClick={() => pushToast({kind:'paid', icon:'refresh', title:'Preview only', meta:'No playlists sent. Screen connection and publishing are not connected.'})}>
            <OIcon name="transfer"/> Preview push
          </button>
        </div>
        <div className="off-grid" style={{gridTemplateColumns:'110px minmax(130px,1fr) minmax(120px,1fr) 100px 90px 130px minmax(200px,1.3fr)'}}>
          <div className="off-grid-headcell">Screen</div>
          <div className="off-grid-headcell">Store</div>
          <div className="off-grid-headcell">Zone</div>
          <div className="off-grid-headcell">Size</div>
          <div className="off-grid-headcell">Firmware</div>
          <div className="off-grid-headcell">State</div>
          <div className="off-grid-headcell">Last contact</div>
          {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 (
              <div key={s.id} className={"off-grid-row" + (s.state === "offline" ? " pc-row--flag" : "")}>
                <div className="off-grid-cell off-cell-mono off-cell-strong">{s.id}</div>
                <div className="off-grid-cell">{d2Store(s.store)}</div>
                <div className="off-grid-cell" style={{whiteSpace:'normal'}}>
                  {z.l}
                  <div className="off-muted" style={{fontSize:11}}>{pl.filter(e => e.playing).length} frames · {D2.cycleOf(pl)}s cycle</div>
                </div>
                <div className="off-grid-cell">{s.size} <span className="off-muted" style={{fontSize:11, marginLeft:3}}>{z.orient === "portrait" ? "port" : "land"}</span></div>
                <div className="off-grid-cell off-cell-mono" style={{fontSize:12}}>{s.fw}</div>
                <div className="off-grid-cell"><span className={"badge badge--" + tone}><span className="badge-dot"/> {l}</span></div>
                <div className="off-grid-cell" style={{whiteSpace:'normal', fontSize:11.5, color:'var(--text-muted)', lineHeight:1.4}}>
                  {s.seen}{s.note && <div style={{marginTop:2}}>{s.note}</div>}
                </div>
              </div>
            );
          })}
        </div>
      </div>
    </>
  );
}

// =================================================================
// 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 (
    <>
      <div className="pc-note">
        <OIcon name="info" width={15} height={15} style={{flexShrink:0, marginTop:1}}/>
        <div>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.</div>
      </div>

      <div className="off-table pg-card" style={{marginBottom:16}}>
        <div className="pg-card-h">One promotion, three zones</div>
        <div className="ds-picks">
          {live.map(x => (
            <button key={x.id} className={"tk-pick" + (pm && pm.id === x.id ? " is-on" : "")} onClick={() => setPick(x.id)}>
              {x.name}
            </button>
          ))}
        </div>
        {pm && (
          <div className="ds-triple">
            {SHOW.map(z => {
              const zz = D2.ZONES[z];
              const allowed = zz.allow.includes(f.kind);
              return (
                <div key={z} className="ds-tcell">
                  <div className="ds-tcell-h">
                    <strong>{zz.l}</strong>
                    <span>{zz.orient} · {zz.dwell}s · {zz.maxWords >= 20 ? "detail allowed" : "under " + zz.maxWords + " words"}</span>
                  </div>
                  {allowed
                    ? <div className="ds-bezel"><window.DFrame f={f} zone={z} width={zz.orient === "portrait" ? 150 : 268}/></div>
                    : <div className="ds-blank ds-blank--sm">{zz.denyWhy}</div>}
                </div>
              );
            })}
          </div>
        )}
      </div>

      <div className="off-table pg-card">
        <div className="pg-card-h">Standing content</div>
        <div className="pg-card-s">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.</div>
        <div className="ds-stand">
          {D2.STANDING.map(st => (
            <div key={st.id} className="ds-scard">
              <div className="ds-bezel ds-bezel--sm">
                <window.DFrame f={{ id: st.id, kind: st.kind, standing: st }} zone={st.kind === "wait" || st.kind === "scriptReady" ? "wait" : "counter"} width={196}/>
              </div>
              <div className="ds-scard-m">
                <div className="ds-item-n">{st.head}{st.pending && <span className="ds-pending">awaiting review</span>}</div>
                <div className="ds-item-s">{D2.KINDS[st.kind].l}</div>
                <div className="ds-review">{st.review}</div>
              </div>
            </div>
          ))}
        </div>
        <div className="pb-st-note">Free text on a screen is where therapeutic claims creep in. Standing frames are reviewed and dated before they go up.</div>
      </div>
    </>
  );
}

// =================================================================
// 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 (
    <div className="off-screen">
      <header className="off-screen-header">
        <div className="off-screen-header-meta">
          <span className="off-screen-eyebrow">Pricing · {scope === "all" ? "all stores" : d2Store(scope)}</span>
          <h2 className="off-screen-title">In-store screens</h2>
          <div className="off-muted" style={{fontSize:13, marginTop:4}}>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.</div>
        </div>
        <div className="off-screen-actions">
          {tab === "library" && (
            <button className="btn btn--ghost btn--sm" onClick={() => setScreen("displayFrameNew")}><OIcon name="plus"/> New frame</button>
          )}
          <button className="btn btn--secondary btn--sm" onClick={() => pushToast({kind:'paid', icon:'refresh', title:'Preview only', meta:'No playlists sent. Screen connection and publishing are not connected.'})}><OIcon name="transfer"/> Preview push</button>
        </div>
      </header>
      <div className="off-subtabs">
        {TABS.map(t => <button key={t.k} className={"off-subtab" + (tab === t.k ? " off-subtab--active" : "")} onClick={() => {previewTab=t.k;setTab(t.k);}}>{t.l}</button>)}
      </div>
      <div className="off-screen-body off-promotions-body"><Body/></div>
    </div>
  );
}

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 (
    <div className="off-screen">
      <header className="off-screen-header">
        <div className="off-screen-header-meta">
          <button className="sup-back" onClick={() => setScreen("displays")}><OIcon name="chevron-right" style={{transform:'rotate(180deg)'}}/> In-store screens</button>
          <h2 className="off-screen-title">New standing frame</h2>
          <div className="off-muted" style={{fontSize:13, marginTop:4}}>Authored content rather than a promotion. This draft is available in the local preview only; nothing is submitted or published.</div>
        </div>
        <div className="off-screen-actions">
          <button className="btn btn--ghost btn--sm" onClick={() => setScreen("displays")}>Cancel</button>
          <button className="btn btn--primary btn--sm" disabled={!ready} onClick={save}><OIcon name="check"/> Add preview draft</button>
        </div>
      </header>

      <div className="off-screen-body off-promotions-body">
        <div className="ds-edit">
          <div className="ds-ecol">
            <div className="off-table pg-card">
              <div className="pg-card-h">What kind of frame</div>
              <div className="pb-kinds" style={{marginTop:11}}>
                {KINDS.map(([k, l]) => (
                  <button key={k} className={"pb-kind" + (kind === k ? " is-on" : "")} onClick={() => { setKind(k); setTheme(k === "service" ? "teal" : "green"); }}>
                    <OIcon name={D3.KINDS[k].icon} width={15} height={15}/>
                    <span>
                      <span className="pb-kind-l">{l}</span>
                      <span className="pb-kind-d">{D3.KINDS[k].d}</span>
                    </span>
                  </button>
                ))}
              </div>
              <div className="xp-note" style={{marginTop:10}}>Now serving and scripts ready are system frames — they pull live from the dispensary queue and are not authored here.</div>
            </div>

            <div className="off-table pg-card">
              <div className="pg-card-h">The words</div>
              <div className="pg-card-s">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.</div>
              <div className="pb-fields">
                <label className="pb-f">
                  <span>Headline</span>
                  <input value={head} onChange={e => setHead(e.target.value)} placeholder="Flu vaccination" maxLength={38}/>
                  <em>{head.length}/38 · shown at {z.orient === "portrait" ? "120px" : "132px"} on screen</em>
                </label>
                <label className="pb-f">
                  <span>Second line</span>
                  <input value={sub} onChange={e => setSub(e.target.value)} placeholder="Walk in today, no appointment" maxLength={54}/>
                  <em>{sub.length}/54</em>
                </label>
                <label className="pb-f">
                  <span>Detail</span>
                  <textarea value={body} onChange={e => setBody(e.target.value)} rows={3}
                    placeholder="Who is eligible, what it costs, whether a booking is needed."/>
                  <em>Only shown in zones with time to read it. Hidden on window and aisle screens.</em>
                </label>
              </div>

              {hits.length > 0 && (
                <div className="ds-claims">
                  {hits.map((c, i) => (
                    <div key={i} className={"pc-flagbox" + (c.soft ? " is-soft" : "")}>
                      <OIcon name={c.soft ? "info" : "alert"} width={13} height={13}/>
                      <span>{c.say}</span>
                    </div>
                  ))}
                </div>
              )}

              {tight.length > 0 && (
                <div className="pb-st-warn" style={{marginTop:9}}>
                  <OIcon name="info" width={13} height={13}/>
                  <span>{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.</span>
                </div>
              )}
            </div>

            <div className="off-table pg-card">
              <div className="pg-card-h">Colour</div>
              <div className="pg-card-s">The same seven schemes as the shelf tickets, so the shop reads as one set.</div>
              <div className="tk-swatches">
                {Object.entries(window.TK_THEMES).map(([k, t]) => (
                  <button key={k} className={"tk-sw" + (theme === k ? " is-on" : "")} onClick={() => setTheme(k)}>
                    <span style={{background: t.flag}}/>
                    <em>{t.l}</em>
                  </button>
                ))}
              </div>
            </div>

            <div className="off-table pg-card">
              <div className="pg-card-h">Review</div>
              <button className={"pg-member" + (ack ? " is-on" : "")} onClick={() => setAck(!ack)}>
                <span className="iaq-toggle-sw"><span/></span>
                <span>
                  <span className="iaq-toggle-l">I have checked this preview wording</span>
                  <span className="iaq-toggle-h">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.</span>
                </span>
              </button>
              {hard.length > 0 && (
                <div className="pc-warn" style={{marginTop:11}}>
                  <OIcon name="alert" width={15} height={15} style={{flexShrink:0, marginTop:1}}/>
                  <div>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.</div>
                </div>
              )}
            </div>
          </div>

          <div className="ds-ecol">
            <div className="off-table pg-card ds-preview">
              <div className="ds-phead">
                <div>
                  <div className="pg-card-h">How it will look</div>
                  <div className="pg-card-s">Rendered at the screen's real pixel size and scaled down.</div>
                </div>
              </div>
              <div className="ds-zonebar" style={{marginTop:11, marginBottom:0}}>
                {zones.map(k => (
                  <button key={k} className={"ds-ztab" + (zKey === k ? " is-on" : "")} onClick={() => setZone(k)}>
                    <span className={"ds-zorient ds-zorient--" + D3.ZONES[k].orient}/>
                    <span className="ds-ztab-t">
                      <span className="ds-ztab-l">{D3.ZONES[k].l}</span>
                      <span className="ds-ztab-s">{D3.ZONES[k].maxWords >= 20 ? "detail shown" : "headline only"}</span>
                    </span>
                  </button>
                ))}
              </div>
              <div className={"ds-stage ds-stage--" + z.orient}>
                <div className="ds-bezel">
                  <window.DFrame f={preview} zone={zKey} width={z.orient === "portrait" ? 216 : 384}/>
                </div>
              </div>
              <div className="ds-pmeta">
                <div className="pg-fact"><span>Zones it can appear in</span><strong>{zones.map(k => D3.ZONES[k].l).join(", ")}</strong></div>
                <div className="pg-fact"><span>Words on screen</span><strong>{words}</strong></div>
                <div className="pg-fact"><span>Detail line</span><strong>{z.maxWords >= 20 ? "shown here" : "hidden in this zone"}</strong></div>
              </div>
              <div className="pb-st-note">A standing frame is available to every store once approved. Playlists stay per store, so nobody is forced to run it.</div>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}

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 <PreviewContext.Provider value={{store}}><div className="office-displays-preview"><div className="ds-preview-banner"><span><b>UI preview</b> · Sample screens, offers and queue numbers. Edits last until page reload; no device connection, publishing or backend saving.</span><label>Example store <select aria-label="Example store" value={store} onChange={e=>{previewStore=e.target.value;setStore(e.target.value);}}>{sampleStores.map(s=><option key={s.id} value={s.id}>{s.name}</option>)}</select></label></div><Body/></div></PreviewContext.Provider>;
 }
 host.OFFICE_SCREENS=Object.assign(host.OFFICE_SCREENS||{},{inStoreScreens:()=> <PreviewShell/>,displayFrameNew:()=> <PreviewShell composer/>});
})(window);
