/* * SixWings: discount sliders and checkout buttons * Pages: /pitch-pack (reports slider in each card) and /pricing * (billing term slider in each plan card). Every card has its own slider. * * WEBFLOW SETUP * Paste this whole file between into * Page settings → Custom code → "Before tag" of each page * (or once into Site settings → Custom code → Footer code). * On a page without the hooks below the script does nothing. * * HOOKS (Element settings → Custom attributes) * data-sw-slider = ITEM * Empty Div inside the card. The slider is drawn inside it. * Optional: data-sw-label = "How many reports?" to change the label. * data-sw-text = template * Text element. Its text is replaced with the filled-in template: * {price:ITEM} price after discount (packs: total, plans: per month) * {old:ITEM} price before discount * {perReport:ITEM} price per report (Pitch Packs only) * {reports:ITEM} 10, 20 … 100 * {months:ITEM} "1 month", "6 months" * {discount:ITEM} "10%" * An element whose template has {old:…} or {discount:…} hides itself * while that card's discount is 0%. * data-sw-buy = ITEM * A plain link/button, or a wrapper Div around it (needed for * Webflow Button component instances, which take no attributes; * e.g. put it on plan_card-cta). On click it asks the SixWings API * for a checkout link with that card's slider value and sends the * visitor there. Errors show right above the button. Unknown or * empty values are ignored, so other buttons keep working. * ITEM = pitch_pack | branded_pitch_pack | deliver | premium_partner * * Prices below are for display only: the backend decides what is charged, * so keep them in sync with it. */ (function () { 'use strict'; // ------------------------------------------------------------------ // Settings // ------------------------------------------------------------------ // The production site talks to the live API. Every other host // (sixwings-141e1e.webflow.io, local previews) uses the staging API, // which runs Stripe in test mode, so test clicks never charge real cards. var PRODUCTION_HOSTS = ['sixwings.ai', 'www.sixwings.ai']; var IS_PRODUCTION = PRODUCTION_HOSTS.indexOf(location.hostname) !== -1; var CONFIG = { endpoint: IS_PRODUCTION ? 'https://app.sixwings.ai/api/checkout' : 'https://staging.sixwings.ai/api/checkout', timeoutMs: 15000, errorText: 'Unable to start checkout. Please try again.' }; // Slider steps: value sent to the API, discount in whole percent var GROUPS = { packs: { label: 'How many reports?', // 1 pack = 10 reports. Every extra pack adds 5% off, up to 45% steps: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(function (n) { return { value: n, discount: (n - 1) * 5 }; }), amount: function (step) { return step.value * 10 + ' reports'; }, tick: function (step) { return String(step.value * 10); } }, term: { label: 'Billing term', steps: [ { value: 1, discount: 0 }, { value: 3, discount: 5 }, { value: 6, discount: 10 }, { value: 12, discount: 15 } ], amount: function (step) { return months(step.value); }, tick: function (step) { return months(step.value) + (step.discount ? '−' + step.discount + '%' : ''); } } }; // What each card sells: slider type, base price in EUR, API request body var ITEMS = { pitch_pack: { group: 'packs', price: 99, body: function (v) { return { product: 'pitch_pack', quantity: v }; } }, branded_pitch_pack: { // TBC: the API contract has no branded product yet group: 'packs', price: 299, body: function (v) { return { product: 'branded_pitch_pack', quantity: v }; } }, deliver: { group: 'term', price: 249, body: function (v) { return { product: 'plan', plan: 'deliver', termMonths: v }; } }, premium_partner: { group: 'term', price: 399, body: function (v) { return { product: 'plan', plan: 'premium_partner', termMonths: v }; } } }; // ------------------------------------------------------------------ // Styles for the slider and the checkout message (site variables) // ------------------------------------------------------------------ var CSS = [ /* Sits inside a plan card: no frame of its own, full card width */ '.sw-slider{width:100%;text-align:left}', '.sw-slider_head{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.5rem 1rem;margin-bottom:.75rem}', '.sw-slider_label{margin:0;font-size:.875rem;font-weight:500}', '.sw-slider_pill{padding:.25rem .625rem;border-radius:60px;background:#fff1e6;color:#a33100;font-size:.8125rem;font-weight:600;line-height:1.3;white-space:nowrap}', '.sw-slider_pill.is-hint{background:var(--color-neutral--shade-200,#f2f2f2);color:rgba(26,26,26,.6);font-weight:500}', /* Range input: 6px track, filled part in the accent colour, 24px thumb */ '.sw-slider_input{-webkit-appearance:none;appearance:none;display:block;width:100%;height:24px;margin:0;background:transparent;cursor:pointer}', '.sw-slider_input:focus{outline:none}', '.sw-slider_input::-webkit-slider-runnable-track{height:6px;border-radius:6px;background:linear-gradient(to right,var(--main-accent,#ff4800) var(--sw-fill,12px),var(--color-neutral--shade-600,#d9d9d9) var(--sw-fill,12px))}', '.sw-slider_input::-webkit-slider-thumb{-webkit-appearance:none;width:24px;height:24px;margin-top:-9px;border:2px solid var(--main-accent,#ff4800);border-radius:50%;background:#fff;box-shadow:0 2px 8px rgba(26,26,26,.2);transition:box-shadow .15s}', '.sw-slider_input:focus-visible::-webkit-slider-thumb{box-shadow:0 0 0 5px rgba(255,72,0,.25)}', '.sw-slider_input::-moz-range-track{height:6px;border-radius:6px;background:var(--color-neutral--shade-600,#d9d9d9)}', '.sw-slider_input::-moz-range-progress{height:6px;border-radius:6px;background:var(--main-accent,#ff4800)}', '.sw-slider_input::-moz-range-thumb{box-sizing:border-box;width:24px;height:24px;border:2px solid var(--main-accent,#ff4800);border-radius:50%;background:#fff;box-shadow:0 2px 8px rgba(26,26,26,.2)}', '.sw-slider_input:focus-visible::-moz-range-thumb{box-shadow:0 0 0 5px rgba(255,72,0,.25)}', /* Tick labels sit under the thumb positions; the end ones align to the edges */ '.sw-slider_ticks{position:relative;height:1.25rem;margin-top:.5rem;font-size:.75rem;line-height:1.3;color:rgba(26,26,26,.6)}', '.sw-slider_ticks.is-tall{height:2.5rem}', '.sw-slider_tick{position:absolute;top:0;transform:translateX(-50%);text-align:center;white-space:nowrap;cursor:pointer;user-select:none}', '.sw-slider_tick.is-first{transform:none;text-align:left}', '.sw-slider_tick.is-last{transform:none;text-align:right}', '.sw-slider_tick small{display:block;font-size:.6875rem;color:#a33100}', '.sw-slider_tick.is-on{color:var(--color-base--black,#1a1a1a);font-weight:600}', /* Checkout: button while waiting, and the message above it */ '.sw-is-busy{opacity:.6;pointer-events:none}', /* Full row in a flex button group, the pill itself is the inner span */ '.sw-checkout-msg{flex:0 0 100%;margin:0 0 .75rem;text-align:left;white-space:normal}', /* Block wrapper (plan_card-cta) with a message: stack message and button, and keep the button at its own width. Otherwise the wider message stretches .button-box, and its shadow (80% of .button-box) smears */ '.sw-checkout-stack{display:flex;flex-direction:column;align-items:flex-start}', '.sw-checkout-stack>.sw-checkout-msg{flex:none}', '.sw-checkout-stack>a,.sw-checkout-stack>button{width:auto}', '.sw-checkout-msg span{display:inline-block;padding:.5rem .75rem;border-radius:8px;background:#fff1e6;color:#a33100;font-size:.875rem;line-height:1.4}', '.sw-checkout-msg.is-info span{background:#eef6ff;color:#1d4f8f;font-family:monospace;font-size:.75rem;word-break:break-all}' ].join('\n'); // ------------------------------------------------------------------ // State: selected step index per card (ITEM) // ------------------------------------------------------------------ var current = {}; Object.keys(ITEMS).forEach(function (k) { current[k] = 0; }); var uid = 0; function months(n) { return n + (n === 1 ? ' month' : ' months'); } function group(itemKey) { return GROUPS[ITEMS[itemKey].group]; } function step(itemKey) { return group(itemKey).steps[current[itemKey]]; } function maxDiscount(g) { return g.steps.reduce(function (m, s) { return Math.max(m, s.discount); }, 0); } // All money in whole cents to avoid float rounding (matches the price table) function prices(itemKey) { var item = ITEMS[itemKey]; var s = step(itemKey); var packs = item.group === 'packs'; var old = item.price * 100 * (packs ? s.value : 1); var now = Math.round(old * (100 - s.discount) / 100); return { now: now, old: old, perReport: packs ? Math.round(now / (s.value * 10)) : null }; } function money(cents) { var whole = cents % 100 === 0; return '€' + (cents / 100).toLocaleString('en-GB', { minimumFractionDigits: whole ? 0 : 2, maximumFractionDigits: whole ? 0 : 2 }); } // ------------------------------------------------------------------ // Text templates: data-sw-text="for {reports:pitch_pack} reports, one-time" // ------------------------------------------------------------------ var TOKEN = /\{(\w+):(\w+)\}/g; function fill(tpl) { return tpl.replace(TOKEN, function (all, key, itemKey) { if (!ITEMS[itemKey]) return all; var s = step(itemKey); var p = prices(itemKey); switch (key) { case 'price': return money(p.now); case 'old': return money(p.old); case 'perReport': return p.perReport === null ? '' : money(p.perReport); case 'reports': return String(s.value * 10); case 'months': return months(s.value); case 'discount': return s.discount + '%'; default: return all; } }); } // Templates with {old:ITEM} or {discount:ITEM} hide at 0% for that ITEM function hiddenAtZero(tpl) { var m = /\{(?:old|discount):(\w+)\}/.exec(tpl); return m && ITEMS[m[1]] ? step(m[1]).discount === 0 : false; } function renderTexts() { document.querySelectorAll('[data-sw-text]').forEach(function (el) { var tpl = el.getAttribute('data-sw-text'); var hide = hiddenAtZero(tpl); el.style.display = hide ? 'none' : ''; if (!hide) el.textContent = fill(tpl); }); } // ------------------------------------------------------------------ // Sliders // ------------------------------------------------------------------ function buildSlider(host) { var itemKey = host.getAttribute('data-sw-slider'); if (!ITEMS[itemKey] || host.getAttribute('data-sw-ready')) return; host.setAttribute('data-sw-ready', '1'); // Card headings are flex columns aligned to the start: without this the // Div shrinks to its content instead of taking the card width host.style.width = '100%'; host.style.alignSelf = 'stretch'; var g = group(itemKey); var id = 'sw-slider-' + (++uid); var last = g.steps.length - 1; var ticks = g.steps.map(function (s, i) { var cls = 'sw-slider_tick' + (i === 0 ? ' is-first' : '') + (i === last ? ' is-last' : ''); var pos = i === 0 ? 'left:0' : i === last ? 'right:0' : 'left:calc(12px + (100% - 24px) * ' + (i / last) + ')'; return '' + g.tick(s) + ''; }).join(''); host.innerHTML = '
' + '
' + '' + '' + '
' + '' + '' + '
'; host.querySelector('.sw-slider_label').textContent = host.getAttribute('data-sw-label') || g.label; var input = host.querySelector('input'); input.addEventListener('input', function () { select(itemKey, +input.value); }); host.querySelectorAll('.sw-slider_tick').forEach(function (t) { t.addEventListener('click', function () { select(itemKey, +t.getAttribute('data-i')); input.focus(); }); }); } function renderSliders() { document.querySelectorAll('[data-sw-ready]').forEach(function (host) { var itemKey = host.getAttribute('data-sw-slider'); var g = group(itemKey); var s = step(itemKey); var i = current[itemKey]; var last = g.steps.length - 1; var input = host.querySelector('input'); var pill = host.querySelector('.sw-slider_pill'); input.value = i; // Filled part of the track ends under the thumb centre input.style.setProperty('--sw-fill', 'calc(12px + (100% - 24px) * ' + (i / last) + ')'); input.setAttribute('aria-valuetext', g.amount(s) + (s.discount ? ', save ' + s.discount + '%' : '')); pill.textContent = s.discount ? 'Save ' + s.discount + '%' : 'Save up to ' + maxDiscount(g) + '%'; pill.classList.toggle('is-hint', !s.discount); host.querySelectorAll('.sw-slider_tick').forEach(function (t) { t.classList.toggle('is-on', +t.getAttribute('data-i') === i); }); }); } function select(itemKey, index) { current[itemKey] = Math.max(0, Math.min(index, group(itemKey).steps.length - 1)); renderSliders(); renderTexts(); } // ------------------------------------------------------------------ // Checkout // ------------------------------------------------------------------ // Where the message goes, always as the first child, right above the buttons: // - several buy hooks side by side (button-group): one shared message for // the whole group, so two errors never push the buttons apart; // - a hook on a plain link: its parent (card CTA); // - a hook on a wrapper Div around a Button component: the wrapper itself. function messageHost(btn) { var parent = btn.parentElement; var hooksInParent = 0; for (var i = 0; i < parent.children.length; i++) { if (parent.children[i].hasAttribute('data-sw-buy')) hooksInParent++; } if (hooksInParent > 1 || btn.matches('a, button')) return parent; return btn; } function showMessage(btn, text, kind) { var wrap = messageHost(btn); var box = null; for (var i = 0; i < wrap.children.length; i++) { if (wrap.children[i].classList.contains('sw-checkout-msg')) box = wrap.children[i]; } if (!box) { if (!text) return; box = document.createElement('div'); box.setAttribute('role', 'alert'); box.appendChild(document.createElement('span')); fillMessage(box, text, kind); var link = btn.matches('a, button') ? btn : btn.querySelector('a, button'); var before = link ? link.getBoundingClientRect().width : 0; wrap.insertBefore(box, wrap.firstChild); // A wrapper sized by its content (plan_card-cta) grows to the message // width and stretches the button with it. Only then stack the wrapper // and keep the button at its own width; full-width mobile buttons and // flex rows (button-group) stay as they are if (link && getComputedStyle(wrap).display === 'block' && link.getBoundingClientRect().width > before + 1) { wrap.classList.add('sw-checkout-stack'); } return; } fillMessage(box, text, kind); } function fillMessage(box, text, kind) { box.className = 'sw-checkout-msg' + (kind ? ' is-' + kind : ''); box.firstChild.textContent = text || ''; box.style.display = text ? '' : 'none'; } function setBusy(btn, on) { btn.classList.toggle('sw-is-busy', on); if (on) btn.setAttribute('aria-busy', 'true'); else btn.removeAttribute('aria-busy'); } // Resolves with the checkout URL, rejects with a message for the visitor function requestCheckout(body) { // Reference wireframes only: window.SW_CHECKOUT_MOCK = 'success' | 'error' var mock = window.SW_CHECKOUT_MOCK; if (mock) { return new Promise(function (resolve, reject) { setTimeout(function () { if (mock === 'error') reject(CONFIG.errorText); else resolve(null); }, 700); }); } var ctrl = typeof AbortController === 'function' ? new AbortController() : null; var timer = ctrl ? setTimeout(function () { ctrl.abort(); }, CONFIG.timeoutMs) : null; return fetch(CONFIG.endpoint, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), signal: ctrl ? ctrl.signal : undefined }) .then(function (res) { return res.json().catch(function () { return {}; }).then(function (data) { // Only follow https links, never anything else the response might hold if (res.ok && data && typeof data.url === 'string' && /^https:\/\//i.test(data.url)) { return data.url; } var err = new Error((data && typeof data.error === 'string' && data.error) || CONFIG.errorText); err.forVisitor = true; throw err; }); }) .catch(function (err) { // Network errors and timeouts get the generic text throw (err && err.forVisitor) ? err.message : CONFIG.errorText; }) .then(function (url) { if (timer) clearTimeout(timer); return url; }, function (msg) { if (timer) clearTimeout(timer); throw msg; }); } function buy(btn) { var itemKey = btn.getAttribute('data-sw-buy'); if (!ITEMS[itemKey] || btn.getAttribute('aria-busy')) return; var body = ITEMS[itemKey].body(step(itemKey).value); showMessage(btn, ''); setBusy(btn, true); requestCheckout(body).then(function (url) { if (url) { window.location.assign(url); // stays busy until the page changes return; } // Mock success: show what would have been sent setBusy(btn, false); showMessage(btn, 'Mock: POST ' + CONFIG.endpoint + ' ' + JSON.stringify(body) + ' → redirect to the returned url', 'info'); }, function (msg) { setBusy(btn, false); showMessage(btn, msg); }); } // ------------------------------------------------------------------ // Start // ------------------------------------------------------------------ function init() { if (!document.getElementById('sw-checkout-style')) { var style = document.createElement('style'); style.id = 'sw-checkout-style'; style.textContent = CSS; document.head.appendChild(style); } document.querySelectorAll('[data-sw-slider]').forEach(buildSlider); renderSliders(); renderTexts(); document.addEventListener('click', function (e) { var btn = e.target.closest ? e.target.closest('[data-sw-buy]') : null; // Only known items: an empty or mistyped value leaves the link alone if (!btn || !ITEMS[btn.getAttribute('data-sw-buy')]) return; e.preventDefault(); buy(btn); }); // Back button from Stripe restores the page from cache: unlock buttons window.addEventListener('pageshow', function (e) { if (!e.persisted) return; document.querySelectorAll('[data-sw-buy]').forEach(function (b) { setBusy(b, false); }); }); } if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init(); })();