PageSourceSearch

https://www.tapestry.ai/conversion.js?v=20260804

js tapestry.ai collected 2026-10-05 12:42:49 UTC 39,053 bytes, 847 lines download raw bytes

1/* ============================================================
2   CONVERSION - JS for audience switcher, ROI calc, supplier
3   coverage lookup, interactive Hank, Shoppers waitlist.
4   Loaded after enhancements.js. No external deps.
5   ============================================================ */
6
7(function () {
8  'use strict';
9
10  // ============================================================
11  // AUDIENCE SWITCHER (hero) - single source of truth
12  // Drives the hero copy, CTAs, stat strip, accent color, and the
13  // Hank composer placeholder + suggested prompts for each persona.
14  // Persists choice to localStorage so a return visitor lands in
15  // the right experience.
16  // ============================================================
17  const PERSONAS = {
18    retailer: {
19      label: 'Retailer',
20      accent: 'Real-time retail intelligence, powered by AI',
21      h1Pre: 'Live sales.<br/>Live margin.',
22      h1Accent: 'Live answers',
23      h1Post: '.',
24      sub: 'One live picture of every shelf, shared with your team, suppliers and wholesalers. Hank, your built-in intelligent assistant, surfaces the next action.',
25      ctaPrimary: { label: 'Learn more', href: 'retail-plus.html', dark: true },
26      ctaSecondary: { label: 'Start free for 4 weeks', href: 'SignUp.html?p=retail-plus' },
27      stats: [
28        { num: '+A$27.5k', em: '/yr', lbl: 'Per store · net of subscription' },
29        { num: '4', em: 'hrs/wk', lbl: 'Captured back per store' },
30        { num: '+5', em: 'bps', lbl: 'GM lift · Conservative scenario' }
31      ],
32      hankSub: 'Your retail intelligence assistant',
33      hankScope: 'All stores',
34      hankPeriod: 'This fortnight',
35      hankPlaceholder: 'Ask Hank: where am I losing margin?',
36      hankSuggest: [
37        { title: 'What are my top sellers this week?', cat: 'Analytics' },
38        { title: 'Show me department sales for this month', cat: 'Departments' },
39        { title: 'Which products have the highest margin?', cat: 'Products' },
40        { title: 'Show products running low on stock', cat: 'Inventory' },
41        { title: 'Compare gross margins vs last month', cat: 'Performance' },
42        { title: 'Any unusual sales patterns today?', cat: 'Alerts' }
43      ]
44    },
45    supplier: {
46      label: 'Supplier',
47      accent: 'Real-time retail intelligence, powered by AI',
48      h1Pre: 'Walk into every buying meeting',
49      h1Accent: 'with the data',
50      h1Post: '.',
51      sub: 'Live shelf-level sell-through, direct from your retail partners. Hank, your built-in intelligent assistant, surfaces the next negotiation, the next promo, the next listing argument.',
52      ctaPrimary: { label: 'Explore Supply+', href: 'Product.html?p=supply-plus' },
53      // "Is my retailer on tapestry?" - dev-only until the retailer-lookup
54      // surface (#sized) is ready for public visitors.
55      ctaSecondary: { label: 'Is my retailer on tapestry?', href: '#sized', dev: 'Retailer lookup - WIP' },
56      stats: [],
57      hankSub: 'Your supplier intelligence assistant',
58      hankScope: 'All retailers',
59      hankPeriod: 'This quarter',
60      hankPlaceholder: 'Ask Hank: how is my range performing this week?',
61      hankSuggest: [
62        { title: 'Which of my products are selling best across the network this week?', cat: 'Analytics' },
63        { title: 'Show me sell-through by retailer group', cat: 'Groups' },
64        { title: 'Which products have the highest gross margin across my retailers?', cat: 'Products' },
65        { title: 'Where has my category share shifted this quarter?', cat: 'Category share' },
66        { title: 'Which retail partners are underperforming on my range?', cat: 'Partners' },
67        { title: 'When is the best time to run a promotion on this product?', cat: 'Best times' }
68      ]
69    },
70    wholesaler: {
71      label: 'Wholesaler',
72      accent: 'Real-time retail intelligence, powered by AI',
73      h1Pre: 'Pool your data.',
74      h1Accent: 'Unlock new revenue',
75      h1Post: '.',
76      sub: 'Buying groups, co-ops and networks share live performance - and earn a cut of the value it creates.',
77      ctaPrimary: { label: 'Explore Collectives', href: 'Collectives.html' },
78      ctaSecondary: { label: 'Talk to our group team', href: 'Contact.html' },
79      stats: [],
80      hankSub: 'Your group intelligence assistant',
81      hankScope: 'All members',
82      hankPeriod: 'This quarter',
83      hankPlaceholder: 'Ask Hank: which departments are growing in my stores?',
84      hankSuggest: [
85        { title: 'Show me sell-through across all member stores', cat: 'Analytics' },
86        { title: 'Sell-through by member group', cat: 'Groups' },
87        { title: 'Which products drive the group rebate?', cat: 'Products' },
88        { title: 'Range gaps by member, ranked by lost margin', cat: 'Range' },
89        { title: 'Which members are under-performing this week?', cat: 'Performance' },
90        { title: 'Which suppliers are under-delivering on the rebate?', cat: 'Alerts' }
91      ]
92    },
93    pos: {
94      label: 'POS provider',
95      accent: 'Real-time retail intelligence, powered by AI',
96      h1Pre: 'The AI layer your retailers',
97      h1Accent: 'are asking for',
98      h1Post: '.',
99      sub: 'No rebuild, new revenue, lower churn.',
100      ctaPrimary: { label: 'Explore the partner program', href: 'Partners.html' },
101      ctaSecondary: { label: 'Read API docs', href: 'Developers.html', dev: 'API docs - dev only' },
102      stats: [],
103      hankSub: 'Your integration assistant',
104      hankScope: 'Partner sandbox',
105      hankPeriod: 'Live',
106      hankPlaceholder: 'Ask Hank: what does the API surface look like?',
107      hankSuggest: [
108        { title: 'What does the API surface look like?', cat: 'Integration' },
109        { title: 'Show me the data flow from POS to Hank', cat: 'Architecture' },
110        { title: 'What does certification require technically?', cat: 'Certification' },
111        { title: 'How is revenue share calculated?', cat: 'Commercials' },
112        { title: 'What is the onboarding timeline?', cat: 'Onboarding' },
113        { title: 'Which retailers are asking for this?', cat: 'Demand' }
114      ]
115    },
116  };
117
118  const STORAGE_KEY = 'tap_audience_v1';
119
120  function setText(el, text) {
121    if (el && el.textContent !== text) el.textContent = text;
122  }
123  function setHTML(el, html) {
124    if (el && el.innerHTML !== html) el.innerHTML = html;
125  }
126
127  function applyPersona(key) {
128    const p = PERSONAS[key];
129    if (!p) return;
130
131    // Body-level audience hook so global accent overrides can kick in
132    // (CTAs, Hank bubble in the hero, live pill, etc).
133    document.body.dataset.audience = key;
134
135    const heroCopy = document.querySelector('.hero-copy');
136    if (!heroCopy) return;
137
138    heroCopy.classList.add('is-swapping');
139    setTimeout(() => {
140      // Eyebrow
141      const eyebrow = heroCopy.querySelector('.eyebrow');
142      if (eyebrow) setText(eyebrow, p.accent.toUpperCase());
143
144      // H1 - preserve the static "Retail," / "Sell-through," prefix.
145      // Full stops in the headline are wrapped in a span so CSS can
146      // shrink them - source data keeps clean punctuation. h1Pre /
147      // h1Accent may contain literal "<br/>" tags for explicit line
148      // breaks (e.g. retailer's three-line cadence); the wrapper
149      // skips dots inside HTML tags via a tag-aware regex.
150      const h1 = heroCopy.querySelector('h1');
151      if (h1) {
152        // Match "." but not inside an HTML tag (<br/> etc.)
153        const wrapDots = s => s.replace(/\.(?![^<]*>)/g, '<span class="dot">.</span>');
154        h1.innerHTML =
155          wrapDots(p.h1Pre) + '<br/>' +
156          (p.h1Accent ? '<span class="accent" data-aud="' + key + '">' + wrapDots(p.h1Accent) + '</span>' : '') +
157          wrapDots(p.h1Post);
158      }
159
160      // Sub
161      const sub = heroCopy.querySelector('.hero-sub');
162      if (sub) setText(sub, p.sub);
163
164      // CTAs
165      const ctaWrap = heroCopy.querySelector('.hero-ctas');
166      if (ctaWrap) {
167        // Either CTA can opt into dev-only by setting `dev: true` or
168        // `dev: "label"` on its persona entry. The attribute hides the
169        // CTA in production and surfaces a labelled "DEV" tag when dev
170        // mode is on (see enhancements.js / enhancements.css).
171        const devAttr = (cta) => {
172          if (!cta || !cta.dev) return '';
173          return cta.dev === true
174            ? ' data-dev'
175            : ' data-dev="' + String(cta.dev).replace(/"/g, '&quot;') + '"';
176        };
177        ctaWrap.innerHTML =
178          '<a class="btn ' + (p.ctaPrimary.dark ? 'btn-dark' : 'btn-primary') + ' btn-lg"' + devAttr(p.ctaPrimary) + ' href="' + p.ctaPrimary.href + '">' + p.ctaPrimary.label + '</a>' +
179          '<a class="btn btn-secondary btn-lg"' + devAttr(p.ctaSecondary) + ' href="' + p.ctaSecondary.href + '">' + p.ctaSecondary.label + '</a>';
180      }
181
182      // Stat strip - lives under the hero image (.hero-visual), not in
183      // .hero-copy, so it is looked up from the hero section.
184      const heroSection = heroCopy.closest('.hero') || document;
185      const stats = heroSection.querySelector('.hero-stat-strip');
186      if (stats) {
187        stats.innerHTML = p.stats.map(s =>
188          '<div><span class="num num">' + s.num + (s.em ? '<span class="em" style="margin-left:.14em">' + s.em + '</span>' : '') + '</span>' +
189          '<div class="lbl">' + s.lbl + '</div></div>'
190        ).join('');
191      }
192
193      // Update Hank composer placeholder + suggested-question list
194      updateHankForPersona(p);
195
196      // Sync the sized-to-you tabs so the homepage tool matches
197      syncSizedTabs(key);
198
199      heroCopy.classList.remove('is-swapping');
200    }, 180);
201
202    // Selected state: legacy pill bar (other pages) + the suite cards,
203    // which are the switcher on the homepage.
204    document.querySelectorAll('.audience-pill').forEach(b => {
205      b.setAttribute('aria-selected', b.dataset.aud === key ? 'true' : 'false');
206    });
207    document.querySelectorAll('.suite-card[data-aud]').forEach(c => {
208      c.setAttribute('aria-selected', c.dataset.aud === key ? 'true' : 'false');
209    });
210
211    // Hero visual - one image per persona. Lives here (not in
212    // updateHankForPersona) because it needs the persona `key`.
213    // Persona photo - now lives in the Hank section (the hero holds the
214    // phone frame). Falls back to the hero if the markup moves back.
215    const heroImg = document.querySelector('.hank-demo-img img, .hero-visual img');
216    if (heroImg) {
217      const art = {
218        retailer: ['assets/hero-store-manager.jpg', 'Store manager checking stock on the shelf while talking on a handset'],
219        supplier: ['assets/hero-supplier-warehouse.jpg', 'Warehouse worker on the phone checking a carton in a distribution aisle'],
220        wholesaler: ['assets/hero-wholesaler-office.jpg', 'Wholesale buying manager reviewing figures at her desk'],
221        pos: ['assets/hero-pos-partners.jpg', 'Two colleagues reviewing data together on a monitor in a stockroom office']
222      }[key];
223      if (art) { heroImg.src = art[0]; heroImg.alt = art[1]; }
224    }
225
226    try { localStorage.setItem(STORAGE_KEY, key); } catch (e) {}
227    // Broadcast so other listeners (Hank live demo) can adapt
228    window.dispatchEvent(new CustomEvent('tap:audience', { detail: { audience: key } }));
229  }
230
231  function updateHankForPersona(p) {
232    // Composer text/placeholder
233    const composerInput = document.querySelector('.preview.ask-hank .ask-composer-input');
234    if (composerInput) composerInput.textContent = p.hankPlaceholder;
235    const composerReal = document.querySelector('.preview.ask-hank .ask-composer-real');
236    if (composerReal) composerReal.placeholder = p.hankPlaceholder;
237
238    // Idle-screen subtitle + header scope / period, so the homepage Hank
239    // widget reads like the per-product widget for the chosen persona
240    // (matches the Ask Hank widget on the Supply+ page when supplier).
241    const sub = document.querySelector('.preview.ask-hank .ask-hero-sub');
242    if (sub && p.hankSub) sub.textContent = p.hankSub;
243    const scope = document.querySelector('.preview.ask-hank .ask-scope');
244    if (scope && p.hankScope) scope.textContent = p.hankScope;
245    const period = document.querySelector('.preview.ask-hank .ask-period');
246    if (period && p.hankPeriod) period.textContent = p.hankPeriod;
247
248    // Suggested prompts - rewrite each visible row's title (and category
249    // label when provided). Personas define the full list, so any rows
250    // beyond the list are hidden and restored when a richer persona is
251    // selected.
252    const sugList = document.querySelector('.preview.ask-hank .ask-suggest-list');
253    if (sugList && p.hankSuggest) {
254      const rows = sugList.querySelectorAll('.ask-suggest');
255      rows.forEach((row, i) => {
256        const item = p.hankSuggest[i];
257        if (item == null) { row.style.display = 'none'; return; }
258        row.style.display = '';
259        const title = typeof item === 'string' ? item : item.title;
260        const cat = typeof item === 'object' ? item.cat : null;
261        const titleEl = row.querySelector('.ask-suggest-title');
262        if (titleEl && title) titleEl.textContent = title;
263        const catEl = row.querySelector('.ask-suggest-cat');
264        if (catEl && cat) catEl.textContent = cat;
265      });
266    }
267  }
268
269  function initAudienceSwitcher() {
270    const hero = document.querySelector('.hero-copy');
271    if (!hero) return;
272
273    // The four product cards in .hero-suite ARE the switcher. No pill bar is
274    // injected any more - the suite section and the hero are one block, so
275    // picking a product is the same gesture as picking your role.
276    document.querySelectorAll('.suite-card[data-aud]').forEach(cardEl => {
277      const choose = () => {
278        // Suppress the looping demo once a persona is chosen, to avoid
279        // dueling content swaps. The interactive composer takes over.
280        const preview = document.querySelector('.preview.ask-hank');
281        if (preview) preview.classList.add('persona-locked');
282        applyPersona(cardEl.dataset.aud);
283      };
284      cardEl.addEventListener('click', (e) => {
285        // Let the inner "Learn more" link navigate as a normal link.
286        if (e.target.closest('a')) return;
287        choose();
288      });
289      cardEl.addEventListener('keydown', (e) => {
290        if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); choose(); }
291      });
292    });
293
294    // Restore stored persona, default to retailer
295    let stored = 'retailer';
296    try {
297      const s = localStorage.getItem(STORAGE_KEY);
298      if (s && PERSONAS[s]) stored = s;
299    } catch (e) {}
300    applyPersona(stored);
301  }
302
303  // ============================================================
304  // INTERACTIVE HANK (hero) - replaces the looping demo with a real
305  // window.claude.complete-powered composer. Keeps the loop running
306  // until the user types, at which point we swap modes.
307  // ============================================================
308  function initInteractiveHank() {
309    const preview = document.querySelector('.preview.ask-hank');
310    if (!preview) return;
311    const composer = preview.querySelector('.ask-composer');
312    if (!composer) return;
313
314    const inputSpan = composer.querySelector('.ask-composer-input');
315    const sendBtn = composer.querySelector('.ask-send');
316    if (!inputSpan || !sendBtn) return;
317
318    // Swap the placeholder span for a real input
319    const input = document.createElement('input');
320    input.type = 'text';
321    input.className = 'ask-composer-real';
322    input.placeholder = inputSpan.textContent.trim();
323    input.setAttribute('aria-label', 'Ask Hank');
324    inputSpan.replaceWith(input);
325    composer.classList.add('is-live');
326    sendBtn.classList.add('is-active');
327
328    // Add a "Live" pill in the header so users see it's wired
329    const askName = preview.querySelector('.ask-name');
330    if (askName && !preview.querySelector('.ask-live-pill')) {
331      const pill = document.createElement('span');
332      pill.className = 'ask-live-pill';
333      pill.textContent = 'Live';
334      askName.insertAdjacentElement('afterend', pill);
335    }
336
337    // Persona-aware system prompt
338    function systemPromptFor(audience) {
339      const base = "You are HANK, the Intelligent Assistant inside tapestry - the retail intelligence platform for retailers, suppliers, wholesalers and POS providers. " +
340        "Answer in 2-4 short sentences. Use plain English. Use real-feeling but illustrative retail numbers (e.g. +12% wk/wk, $1,240/wk, 88% confidence). " +
341        "Always include a one-line 'Next step:' recommendation. Always end with 'Confidence NN' (a single integer). " +
342        "Never use emoji. Be specific, direct, senior-analyst in tone. Politely decline anything off-topic from retail / merchandising / category management / supplier performance / POS integration.";
343      const lens = {
344        retailer:  " The user is a RETAILER. Frame answers from the operator's POV: shelf, margin, tasks, shrink, range, store-level decisions.",
345        supplier:  " The user is a SUPPLIER (brand). Frame answers from the brand-side POV: sell-through, distribution, share, gaps, basket-attach.",
346        wholesaler:" The user is a WHOSESALER / BUYING GROUP. Frame answers across member stores: rebate uplift, group sell-through, member performance, supplier compliance, negotiated range.",
347        pos:       " The user is a POS PROVIDER. Frame answers about integration, technical surface, revenue share, certification timeline, partner program.",
348        media:     " The user is a JOURNALIST. Be neutral and quotable. Cite plausible figures but always frame them as illustrative.",
349        investor:  " The user is an INVESTOR. Frame answers around traction, unit economics, moat, market structure. Be specific about mechanism without disclosing private numbers."
350      };
351      return base + (lens[audience] || lens.retailer);
352    }
353
354    function currentAudience() {
355      try {
356        const s = localStorage.getItem(STORAGE_KEY);
357        if (s && PERSONAS[s]) return s;
358      } catch (e) {}
359      return 'retailer';
360    }
361
362    // Build / get a stream area inside the existing body, above the hero/suggest area
363    let stream = preview.querySelector('.ask-live-stream');
364    if (!stream) {
365      stream = document.createElement('div');
366      stream.className = 'ask-live-stream';
367      stream.style.cssText = 'display:none; flex-direction:column; gap:10px; padding: 6px 4px; max-height: 320px; overflow-y: auto;';
368      const body = preview.querySelector('.ask-body');
369      if (body) body.insertBefore(stream, body.firstChild);
370    }
371
372    function bubble(text, who) {
373      const el = document.createElement('div');
374      el.className = 'bubble ' + who;
375      el.textContent = text;
376      stream.appendChild(el);
377      stream.scrollTop = stream.scrollHeight;
378      return el;
379    }
380    function showStream() {
381      // Hide the looping demo + idle UI when user engages
382      const hero = preview.querySelector('.ask-hero');
383      const sugLabel = preview.querySelector('.ask-suggested-label');
384      const sugList = preview.querySelector('.ask-suggest-list');
385      const demoFeed = preview.querySelector('.ask-demo');
386      if (hero) hero.style.display = 'none';
387      if (sugLabel) sugLabel.style.display = 'none';
388      if (sugList) sugList.style.display = 'none';
389      if (demoFeed) demoFeed.style.display = 'none';
390      preview.classList.add('is-live-mode');
391      stream.style.display = 'flex';
392    }
393
394    async function ask(q) {
395      const query = (q || input.value || '').trim();
396      if (!query) return;
397      showStream();
398      bubble(query, 'user');
399      input.value = '';
400      sendBtn.disabled = true;
401      const thinking = bubble('…', 'assistant');
402      thinking.classList.add('is-thinking');
403      try {
404        if (!window.claude || typeof window.claude.complete !== 'function') {
405          throw new Error('Claude unavailable');
406        }
407        const aud = currentAudience();
408        const reply = await window.claude.complete({
409          messages: [{
410            role: 'user',
411            content: 'SYSTEM:\n' + systemPromptFor(aud) + '\n\nUSER (' + aud + '):\n' + query
412          }]
413        });
414        thinking.textContent = reply || '(no response)';
415        thinking.classList.remove('is-thinking');
416      } catch (e) {
417        thinking.textContent =
418          'Hank is offline in this preview. In production he answers against your live store data with sub-second latency. Next step: book a demo to see Hank against real retail data. Confidence -';
419        thinking.classList.remove('is-thinking');
420      } finally {
421        sendBtn.disabled = false;
422      }
423    }
424
425    sendBtn.addEventListener('click', (e) => { e.preventDefault(); ask(); });
426    input.addEventListener('keydown', (e) => {
427      if (e.key === 'Enter') { e.preventDefault(); ask(); }
428    });
429    // Wire the suggested-prompt buttons into the live composer
430    preview.querySelectorAll('.ask-suggest').forEach(btn => {
431      btn.addEventListener('click', () => {
432        const t = btn.querySelector('.ask-suggest-title');
433        if (t) ask(t.textContent.trim());
434      });
435    });
436  }
437
438  // ============================================================
439  // SIZED-TO-YOU - homepage persona-aware tools section
440  // Built dynamically so it stays in sync with audience pills.
441  // ============================================================
442  function syncSizedTabs(key) {
443    document.querySelectorAll('.sized-tab').forEach(t => {
444      t.setAttribute('aria-selected', t.dataset.tool === key ? 'true' : 'false');
445    });
446    document.querySelectorAll('.sized-tool').forEach(t => {
447      t.classList.toggle('is-active', t.dataset.tool === key);
448    });
449  }
450
451  function initSizedTabs() {
452    const section = document.querySelector('.sized');
453    if (!section) return;
454    section.addEventListener('click', (e) => {
455      const tab = e.target.closest('.sized-tab');
456      if (!tab) return;
457      const key = tab.dataset.tool;
458      syncSizedTabs(key);
459      // Also flip the hero persona to match - these stay in lock-step
460      applyPersona(key);
461    });
462  }
463
464  // ============================================================
465  // ROI CALCULATOR (homepage retailer tool)
466  // Mirrors the CONSERVATIVE scenario in EconomicImpactReport.html
467  // exactly. Any change to the report's model must be ported here.
468  //
469  // Conservative scenario parameters (verbatim from the report):
470  //   margin bps gain      = 5 bps
471  //   sales uplift         = 20 bps (of revenue)
472  //   hours saved / week   = 4
473  //   capture factor       = 30%
474  //   risk avoided EV      = A$3,000 / yr
475  //   supplier CM          = A$5,000 / yr  (OPTIONAL upside - NOT in core claim)
476  //   overlap haircut      = 15% (× 0.85)
477  //   labour cost / hr     = A$35 (fixed - not a slider here)
478  //   subscription / store = A$4,200 / yr (subtracted to give "net")
479  //
480  // Formula:
481  //   marginGain   = R · (5/10000) · 0.85
482  //   salesUplift  = R · (5/10000) · GM · 0.85
483  //   hoursValue   = 52 · 4 · 35 · 0.30  = A$2,184
484  //   risk + scm   = A$3,000 + A$5,000   = A$8,000
485  //   perStore     = (marginGain + salesUplift + hoursValue + risk + scm) - 4,200
486  // ============================================================
487  function initROICalculator() {
488    const stores = document.querySelector('#roi-stores');
489    const revenue = document.querySelector('#roi-revenue');
490    const gm = document.querySelector('#roi-gm');
491    if (!stores || !revenue || !gm) return;
492    const storesOut = document.querySelector('#roi-stores-out');
493    const revenueOut = document.querySelector('#roi-revenue-out');
494    const gmOut = document.querySelector('#roi-gm-out');
495
496    const totalOut = document.querySelector('#roi-total');
497    const totalUnit = document.querySelector('#roi-total-unit');
498    const subline = document.querySelector('#roi-subline');
499    const brMargin = document.querySelector('#roi-br-margin');
500    const brSales  = document.querySelector('#roi-br-sales');
501    const brLabour = document.querySelector('#roi-br-labour');
502    const brShrink = document.querySelector('#roi-br-shrink');
503    const brGross  = document.querySelector('#roi-br-gross-val');
504    const brSubLbl = document.querySelector('#roi-br-sub-label');
505    const brSub    = document.querySelector('#roi-br-sub-val');
506    const brNet    = document.querySelector('#roi-br-net-val');
507
508    // Conservative scenario constants (locked to the report).
509    const MB_BPS = 5;            // margin bps gain
510    const SU_BPS = 20;           // sales uplift in bps (report Conservative = 20)
511    const HRS_PER_WEEK = 4;
512    const CAPTURE = 0.30;
513    const RISK_AVOIDED = 3000;
514    // Supplier CM (A$5,000) is OPTIONAL upside and excluded from the core
515    // external-claim figure - shown separately as the "Optional upside" line.
516    const OVERLAP = 0.85;
516        // 15% haircut applied to margin+sales GP
517    const LABOUR_PER_HR = 35;    // AUD/hr fully loaded
518    const WEEKS = 52;
519    const SUB_PER_STORE = 4200;  // Retail+ subscription per store / year
520
521    function fmt(n) {
522      if (n >= 1e6) return (n / 1e6).toFixed(1).replace(/\.0$/, '') + 'm';
523      if (n >= 1e3) return Math.round(n / 1e3) + 'k';
524      return String(Math.round(n));
525    }
526    function fmtFull(n) {
527      return n.toLocaleString('en-AU', { maximumFractionDigits: 0 });
528    }
529    function setSliderFill(el) {
530      if (!el) return;
531      const min = +el.min, max = +el.max, val = +el.value;
532      const pct = ((val - min) / (max - min)) * 100;
533      el.style.setProperty('--p', pct + '%');
534    }
535
536    function recalc() {
537      const S = +stores.value;            // store count
538      const R = +revenue.value * 1e6;     // per-store revenue (input in $M)
539      const GM = +gm.value / 100;         // gross margin %
540
541      // Per-store components (verbatim from report's compute()).
542      // Core figure EXCLUDES supplier CM (optional upside, shown separately).
543      const marginGain  = R * (MB_BPS / 10000) * OVERLAP;
544      const salesUplift = R * (SU_BPS / 10000) * GM * OVERLAP;
545      const hoursValue  = WEEKS * HRS_PER_WEEK * LABOUR_PER_HR * CAPTURE;
546      const riskAvoided = RISK_AVOIDED;
547
548      const perStoreGross = marginGain + salesUplift + hoursValue + riskAvoided;
549      const perStore = Math.max(0, perStoreGross - SUB_PER_STORE);
550      const total = perStore * S;
551
552      // Network-level gross / subscription / net (must tie out top-to-bottom).
553      const grossTotal = perStoreGross * S;
554      const subTotal   = SUB_PER_STORE * S;
555
556      setText(storesOut, fmtFull(S));
557      setText(revenueOut, 'A$' + revenue.value + 'm');
558      setText(gmOut, gm.value + '%');
559
560      setText(totalOut, 'A$' + fmt(total));
561      setText(totalUnit, 'per year · across ' + S + ' store' + (S === 1 ? '' : 's'));
562      setText(subline, '≈ A$' + fmtFull(Math.round(perStore)) + ' per store, per year. Conservative model from the Economic Impact Report, net of Retail+ Pro subscription.');
563
564      // Breakdown - per-store value × store count, matching the
565      // components shown in the report's table. Supplier CM excluded.
566      setText(brMargin, 'A$' + fmt(marginGain * S));
567      setText(brSales,  'A$' + fmt(salesUplift * S));
568      setText(brLabour, 'A$' + fmt(hoursValue * S));
569      setText(brShrink, 'A$' + fmt(riskAvoided * S));
570
571      // Gross subtotal → subscription deduction → net, so the maths reads
572      // top-to-bottom and reconciles with the headline net figure.
573      setText(brGross, 'A$' + fmt(grossTotal));
574      setText(brSubLbl, 'Less: Retail+ Pro subscription (' + fmtFull(S) + ' × A$4,200)');
575      setText(brSub, '−A$' + fmt(subTotal));
576      setText(brNet, 'A$' + fmt(total));
577
578      setSliderFill(stores);
579      setSliderFill(revenue);
580      setSliderFill(gm);
581    }
582
583    [stores, revenue, gm].forEach(el => {
584      if (el) el.addEventListener('input', recalc);
585    });
586    recalc();
587  }
588
589  // ============================================================
590  // SUPPLIER COVERAGE LOOKUP - typeahead search for retailer names.
591  //
592  // Data provenance:
593  //   - LIVE entries reflect confirmed customers (paid + in production).
594  //   - PARTIAL entries reflect retailers actively onboarding (signed
595  //     LOI / pilot agreement, not yet at full coverage).
596  //   - NOTYET entries are major AU retailers we don't yet integrate
597  //     with - included so search returns recognisable results and an
598  //     honest "not yet" / watchlist CTA.
599  //
600  // Confirmed by the team (last reviewed 2026-05):
601  //   - Reddrop Group: live
602  //   - Spudshed: onboarding
603  //
604  // Anything else here should be treated as illustrative until
605  // confirmed by sales. Update this list when a new retailer
606  // moves into production or pilot.
607  // ============================================================
608  const RETAILERS = [
609    // Confirmed - live in production
610    { name: 'Reddrop Group', stores: 47, region: 'AU · VIC', status: 'live', share: 92, since: 'Feb 2024' },
611
612    // Confirmed - onboarding
613    { name: 'Spudshed', stores: 14, region: 'AU · WA', status: 'partial', share: 0, since: 'Onboarding' },
614
615    // Major AU retailers we don't yet integrate with - shown so
616    // suppliers can search for the names they care about and get an
617    // honest "not yet" response + watchlist CTA.
618    { name: 'Woolworths Group', stores: 1100, region: 'AU · National', status: 'notyet', share: 0, since: '' },
619    { name: 'Coles Group', stores: 850, region: 'AU · National', status: 'notyet', share: 0, since: '' },
620    { name: 'ALDI Australia', stores: 590, region: 'AU · National', status: 'notyet', share: 0, since: '' },
621    { name: 'IGA', stores: 1300, region: 'AU · National', status: 'notyet', share: 0, since: '' },
622    { name: 'FoodWorks', stores: 750, region: 'AU · National', status: 'notyet', share: 0, since: '' },
623    { name: 'Drakes Supermarkets', stores: 64, region: 'AU · SA · QLD', status: 'notyet', share: 0, since: '' },
624    { name: 'Ritchies Supa IGA', stores: 145, region: 'AU · National', status: 'notyet', share: 0, since: '' },
625    { name: 'Harris Farm Markets', stores: 26, region: 'AU · NSW', status: 'notyet', share: 0, since: '' },
626    { name: 'Costco Australia', stores: 14, region: 'AU · National', status: 'notyet', share: 0, since: '' },
627    { name: '7-Eleven Australia', stores: 750, region: 'AU · National', status: 'notyet', share: 0, since: '' }
628  ];
629
630  function initCoverageLookup() {
631    const root = document.querySelector('.sized-tool[data-tool="supplier"]');
632    if (!root) return;
633    const input = root.querySelector('.cov-input');
634    const pop = root.querySelector('.cov-pop');
635    const result = root.querySelector('.cov-result');
636
637    function renderEmpty() {
638      result.innerHTML =
639        '<div class="cov-result-empty">' +
640          '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="6"/>
640<path d="M16 16l5 5"/></svg>' +
641          '<div>Search a retailer to see if their shelf data is on tapestry - and what % of their stores are live.</div>' +
642        '</div>';
643    }
644
645    function renderRetailer(r) {
646      let statusLabel, statusClass, statusLine;
647      if (r.status === 'live') {
648        statusLabel = 'On tapestry · Live'; statusClass = 'live';
649        statusLine = '<strong>' + r.share + '%</strong> of stores live since ' + r.since + '. Supply+ access available with retailer consent.';
650      } else if (r.status === 'partial') {
651        statusLabel = 'Onboarding'; statusClass = 'partial';
652        statusLine = (r.share > 0
653          ? '<strong>' + r.share + '%</strong> of stores live since ' + r.since + '. Coverage expanding; supplier access available for live stores.'
654          : 'Currently onboarding to tapestry. Pilot in progress - supplier access opens as coverage goes live.');
655      } else {
656        statusLabel = 'Not yet'; statusClass = 'notyet';
657        statusLine = 'Not yet on tapestry. Add to your watchlist - we will alert you when integration is announced.';
658      }
659      result.innerHTML =
660        '<div class="cov-result-name">' + r.name + '</div>' +
661        '<div class="cov-result-status ' + statusClass + '">' + statusLabel + '</div>' +
662        '<div class="cov-result-stats">' +
663          '<div class="cov-result-stat">' +
664            '<div class="cov-result-stat-num">' + r.stores.toLocaleString('en-AU') + '<span class="unit"> stores</span></div>' +
665            '<div class="cov-result-stat-lbl">' + r.region + '</div>' +
666          '</div>' +
667          '<div class="cov-result-stat">' +
668            '<div class="cov-result-stat-num">' + r.share + '<span class="unit">% live</span></div>' +
669            '<div class="cov-result-stat-lbl">Coverage today</div>' +
670          '</div>' +
671        '</div>' +
672        '<div class="cov-result-line">' + statusLine + '</div>' +
673        '<a class="cov-result-cta" href="Product.html?p=supply-plus">' +
674          (r.status === 'notyet' ? 'Add to watchlist' : 'Request supplier access') +
675          '<svg viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h6M6 3l3 3-3 3"/></svg>' +
676        '</a>';
677    }
678
679    function search(q) {
680      q = q.trim().toLowerCase();
681      if (!q) return [];
682      return RETAILERS.filter(r => r.name.toLowerCase().includes(q)).slice(0, 6);
683    }
684    function renderPop(list) {
685      if (!list.length) {
686        pop.classList.remove('is-open');
687        pop.innerHTML = '';
688        return;
689      }
690      pop.innerHTML = list.map((r, i) =>
691        '<div class="cov-pop-row" data-i="' + i + '"' + (i === 0 ? ' data-focus="1"' : '') + '>' +
692          '<span class="cov-pop-row-name">' + r.name + '</span>' +
693          '<span class="cov-pop-row-meta">' + r.stores.toLocaleString('en-AU') + ' · ' + r.region + '</span>' +
694        '</div>'
695      ).join('');
696      pop.classList.add('is-open');
697      pop.querySelectorAll('.cov-pop-row').forEach((row, i) => {
698        row.addEventListener('click', () => {
699          input.value = list[i].name;
700          renderRetailer(list[i]);
701          pop.classList.remove('is-open');
702        });
703      });
704    }
705
706    input.addEventListener('input', () => {
707      renderPop(search(input.value));
708    });
709    input.addEventListener('focus', () => {
710      if (input.value.trim()) renderPop(search(input.value));
711    });
712    input.addEventListener('keydown', (e) => {
713      if (e.key === 'Enter') {
714        e.preventDefault();
715        const list = search(input.value);
716        if (list.length) {
717          input.value = list[0].name;
718          renderRetailer(list[0]);
719          pop.classList.remove('is-open');
720        }
721      } else if (e.key === 'Escape') {
722        pop.classList.remove('is-open');
723      }
724    });
725    document.addEventListener('click', (e) => {
726      if (!root.contains(e.target)) pop.classList.remove('is-open');
727    });
728
729    // Popular chips
730    root.querySelectorAll('.cov-popular-chip').forEach(chip => {
731      chip.addEventListener('click', () => {
732        const name = chip.textContent.trim();
733        const match = RETAILERS.find(r => r.name === name);
734        if (match) {
735          input.value = match.name;
736          renderRetailer(match);
737        }
738      });
739    });
740
741    renderEmpty();
742  }
743
744  // ============================================================
745  // SHOPPERS WAITLIST - quick email capture + live tally.
746  // ============================================================
747  function initShoppersWaitlist() {
748    const form = document.querySelector('.shop-form');
749    if (!form) return;
750    const tally = form.parentElement && form.parentElement.querySelector('.shop-tally');
751    let count = 12471;
752    form.addEventListener('submit', (e) => {
753      e.preventDefault();
754      form.classList.add('sent');
755      count += 1;
756      const t = document.querySelector('.shop-tally strong');
757      if (t) t.textContent = count.toLocaleString('en-AU');
758    });
759    // Live ticker - gentle increment every 6-12s
760    if (tally) {
761      function tick() {
762        count += Math.floor(Math.random() * 3);
763        const t = tally.querySelector('strong');
764        if (t) t.textContent = count.toLocaleString('en-AU');
765        setTimeout(tick, 6000 + Math.random() * 6000);
766      }
767      tick();
768    }
769  }
770
771  // ============================================================
772  // PRESS KIT - copy boilerplate to clipboard
773  // ============================================================
774  function initPressCopy() {
775    document.querySelectorAll('.pk-boiler-copy').forEach(btn => {
776      btn.addEventListener('click', () => {
777        const target = btn.closest('.pk-boiler');
778        if (!target) return;
779        const text = target.querySelector('.pk-boiler-text');
780        if (!text) return;
781        if (navigator.clipboard) {
782          navigator.clipboard.writeText(text.textContent.trim()).then(() => {
783            const orig = btn.textContent;
784            btn.textContent = 'Copied ✓';
785            setTimeout(() => { btn.textContent = orig; }, 1600);
786          });
787        }
788      });
789    });
790  }
791
792  // ============================================================
793  // ARTICLE SHARE BUTTONS - rewrite share URLs with the live page
794  // URL + title, and wire the copy-link button to clipboard.
795  // No-ops if there's no .article-share on the page.
796  // ============================================================
797  function initArticleShare() {
798    const share = document.querySelector('.article-share');
799    if (!share) return;
800    const url = location.href;
801    const title = document.title || '';
802    const enc = encodeURIComponent;
803
804    const li = share.querySelector('[data-share="linkedin"]');
805    if (li) li.href = 'https://www.linkedin.com/sharing/share-offsite/?url=' + enc(url);
806
807    const x = share.querySelector('[data-share="x"]');
808    if (x) x.href = 'https://twitter.com/intent/tweet?text=' + enc(title.replace(/\s+·.*$/, '')) + '&url=' + enc(url) + '&via=tapestry_ai';
809
810    const copy = share.querySelector('[data-share="copy"]');
811    const toast = share.querySelector('.article-share-toast');
812    if (copy) {
813      copy.addEventListener('click', async () => {
814        try {
815          await navigator.clipboard.writeText(url);
816          if (toast) {
817            toast.textContent = 'Link copied';
818            toast.classList.add('is-visible');
819            setTimeout(() => toast.classList.remove('is-visible'), 1800);
820          }
821        } catch (e) {
822          // fallback: select-and-prompt
823          if (toast) toast.textContent = url;
824        }
825      });
826    }
827  }
828
829  // ============================================================
830  // BOOT
831  // ============================================================
832  document.addEventListener('DOMContentLoaded', () => {
833    initAudienceSwitcher();
834    initInteractiveHank();
835    initSizedTabs();
836    initROICalculator();
837    initCoverageLookup();
838    initShoppersWaitlist();
839    initPressCopy();
840    initArticleShare();
841  });
842
843  // Expose for debug
844  window.__tapestry = window.__tapestry || {};
845  window.__tapestry.applyPersona = applyPersona;
846  window.__tapestry.PERSONAS = PERSONAS;
847})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.