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, '"') + '"'; 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.