metatroncubeswdev 365e45406a Bundle Offers Shopify app: discount function, admin, storefront, deploy stack
Rust discount function (one automatic discount evaluating all offers in priority
order, parity with the Odoo engine), React Router admin with Polaris, theme app
extension and app proxy pages, webhooks incl. GDPR, managed-pricing limits, and
a Docker Compose stack (Postgres + Caddy HTTPS) for a single VPS.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 21:35:58 -04:00

160 lines
6.9 KiB
JavaScript

/* Bundle Offers storefront script (app embed). Vanilla JS, no dependencies.
* - converts shop-currency deal amounts when the customer shops in another currency
* - bundle pages (/apps/bundles/...): quantity steppers, "N of M chosen", add all items in one request
* - refreshes cart hints after AJAX cart changes */
(function () {
'use strict';
if (window.BundleOffers) return;
var cfgEl = document.getElementById('bundle-offers-data');
var cfg = {};
try { cfg = cfgEl ? JSON.parse(cfgEl.textContent) : {}; } catch (e) { cfg = {}; }
var strings = cfg.strings || {};
var cur = (window.Shopify && Shopify.currency) || {};
var rate = Number(cur.rate) || 1;
var active = cur.active || cfg.currency;
function fmt(amount) {
try {
return new Intl.NumberFormat(document.documentElement.lang || undefined, { style: 'currency', currency: active }).format(amount);
} catch (e) { return amount.toFixed(2) + ' ' + active; }
}
/* ---- currency: $-values are stored in shop currency ---- */
function convertMoney(root) {
if (rate === 1 && active === cfg.currency) return;
root.querySelectorAll('[data-bo-money]').forEach(function (el) {
el.textContent = fmt(Number(el.getAttribute('data-bo-money')) * rate);
});
root.querySelectorAll('[data-bo-fixed]').forEach(function (el) {
var regular = Number(el.getAttribute('data-regular')) / 100; // presentment already
var dt = el.getAttribute('data-dt');
var dv = Number(el.getAttribute('data-dv'));
var saving = dt === 'percent' ? regular * Math.min(dv, 100) / 100
: dt === 'amount' ? Math.min(dv * rate, regular) : Math.max(regular - dv * rate, 0);
var b = el.querySelector('[data-bo-bundle]');
if (b) b.textContent = fmt(regular - saving);
el.querySelectorAll('[data-bo-save]').forEach(function (s) { s.textContent = fmt(saving); });
});
}
/* ---- cart helpers ---- */
var root = (window.Shopify && Shopify.routes && Shopify.routes.root) || '/';
function refreshHints() {
var blocks = document.querySelectorAll('[data-bo-hints]');
if (!blocks.length) return;
fetch(window.location.pathname + window.location.search, { credentials: 'same-origin', headers: { 'X-Requested-With': 'bundle-offers' } })
.then(function (r) { return r.text(); })
.then(function (html) {
var doc = new DOMParser().parseFromString(html, 'text/html');
blocks.forEach(function (el) {
var fresh = doc.querySelector('[data-bo-hints="' + el.getAttribute('data-bo-hints') + '"]');
if (fresh) { el.innerHTML = fresh.innerHTML; convertMoney(el); }
});
})
.catch(function () {});
}
// Themes update the cart with fetch; refresh hints after any cart mutation.
var origFetch = window.fetch;
if (origFetch) {
window.fetch = function (input) {
var url = typeof input === 'string' ? input : (input && input.url) || '';
var p = origFetch.apply(this, arguments);
if (/\/cart\/(add|change|update|clear)/.test(url)) {
p.then(function () { setTimeout(refreshHints, 150); }).catch(function () {});
}
return p;
};
}
document.addEventListener('cart:updated', refreshHints);
document.addEventListener('cart:refresh', refreshHints);
function openDrawerOrCart() {
var drawer = document.querySelector('cart-drawer');
if (cfg.after === 'drawer' && drawer && typeof drawer.renderContents === 'function') {
// Dawn-family themes: re-render the drawer sections, then open it
var ids = typeof drawer.getSectionsToRender === 'function'
? drawer.getSectionsToRender().map(function (s) { return s.id; }) : ['cart-drawer', 'cart-icon-bubble'];
fetch(root + '?sections=' + ids.join(','))
.then(function (r) { return r.json(); })
.then(function (sections) { drawer.renderContents({ sections: sections }); })
.catch(function () { window.location.href = root + 'cart'; });
return;
}
document.dispatchEvent(new CustomEvent('cart:refresh', { bubbles: true }));
window.location.href = root + 'cart';
}
/* ---- bundle page form (rendered by the app proxy) ---- */
function setupBundleForm(form) {
var need = Number(form.getAttribute('data-need')) || 0;
var type = form.getAttribute('data-type');
var counter = form.querySelector('[data-bo-counter]');
var submit = form.querySelector('[type="submit"]');
var error = form.querySelector('[data-bo-error]');
var inputs = form.querySelectorAll('input[data-variant]');
function total() {
var t = 0;
inputs.forEach(function (i) { t += Math.max(0, Math.min(99, parseInt(i.value, 10) || 0)); });
return t;
}
function update() {
var t = total();
if (counter) counter.textContent = counter.getAttribute('data-template').replace('{k}', t).replace('{n}', need);
if (submit && type !== 'fixed') submit.disabled = type === 'mix' ? t < need : t < 1;
}
form.addEventListener('click', function (e) {
var btn = e.target.closest('[data-step]');
if (!btn) return;
var input = btn.parentNode.querySelector('input');
var v = (parseInt(input.value, 10) || 0) + Number(btn.getAttribute('data-step'));
input.value = Math.max(0, Math.min(99, v));
update();
});
form.addEventListener('input', update);
form.addEventListener('submit', function (e) {
e.preventDefault();
var items = [];
inputs.forEach(function (i) {
var q = Math.max(0, Math.min(99, parseInt(i.value, 10) || 0));
if (q > 0) items.push({ id: Number(i.getAttribute('data-variant')), quantity: q });
});
if (type === 'mix' && total() < need) { showError(form.getAttribute('data-need-more')); return; }
if (!items.length) { showError(form.getAttribute('data-nothing')); return; }
var label = submit.textContent;
submit.disabled = true;
submit.textContent = strings.adding || '…';
origFetch(root + 'cart/add.js', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ items: items }),
})
.then(function (r) { if (!r.ok) throw new Error(String(r.status)); return r.json(); })
.then(function () { submit.textContent = strings.added || label; openDrawerOrCart(); })
.catch(function () {
submit.disabled = false;
submit.textContent = label;
showError(strings.error);
});
});
function showError(msg) {
if (!error) return;
error.textContent = msg || '';
error.hidden = !msg;
}
update();
}
function init() {
convertMoney(document);
document.querySelectorAll('form[data-bo-bundle-form]').forEach(setupBundleForm);
}
window.BundleOffers = { refreshHints: refreshHints, convertMoney: convertMoney };
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init);
else init();
})();