|
| 1 | +/* ============================================================ |
| 2 | + PLOTFLOW · Cart |
| 3 | + A client-side, multi-item cart persisted in localStorage. The |
| 4 | + "Acquire" buttons add editions; the nav "Cart [N]" opens a drawer. |
| 5 | + Checkout posts {items:[{key,qty}]} to the Cloudflare Worker, which |
| 6 | + builds a Stripe Checkout Session and returns its hosted URL. |
| 7 | +
|
| 8 | + Cart items carry their own display fields (name/code/price/edition) |
| 9 | + captured at add-time, so interior pages don't need data/editions.js. |
| 10 | + ============================================================ */ |
| 11 | +(function () { |
| 12 | + var KEY = "plotflow_cart_v1"; |
| 13 | + var CFG = window.PLOTFLOW_CONFIG || {}; |
| 14 | + var CUR = CFG.currency || "$"; |
| 15 | + var DATA = (window.PLOTFLOW && window.PLOTFLOW.suits) || {}; |
| 16 | + |
| 17 | + function read() { |
| 18 | + try { return JSON.parse(localStorage.getItem(KEY)) || []; } catch (e) { return []; } |
| 19 | + } |
| 20 | + function write(items) { |
| 21 | + localStorage.setItem(KEY, JSON.stringify(items)); |
| 22 | + render(); |
| 23 | + } |
| 24 | + function priceNum(s) { return parseFloat(String(s == null ? "" : s).replace(/[^0-9.]/g, "")) || 0; } |
| 25 | + function money(n) { return CUR + (Math.round(n * 100) / 100).toLocaleString(); } |
| 26 | + |
| 27 | + function count() { return read().reduce(function (n, i) { return n + i.qty; }, 0); } |
| 28 | + function subtotal() { return read().reduce(function (s, i) { return s + priceNum(i.price) * i.qty; }, 0); } |
| 29 | + |
| 30 | + function add(key) { |
| 31 | + var items = read(); |
| 32 | + var found = items.filter(function (i) { return i.key === key; })[0]; |
| 33 | + if (found) { found.qty++; } |
| 34 | + else { |
| 35 | + var s = DATA[key] || {}; |
| 36 | + items.push({ key: key, qty: 1, name: s.name || key, code: s.code || "", price: s.price || "", edition: s.edition || "" }); |
| 37 | + } |
| 38 | + write(items); |
| 39 | + openDrawer(); |
| 40 | + } |
| 41 | + function setQty(key, qty) { |
| 42 | + var items = read().map(function (i) { return i.key === key ? Object.assign({}, i, { qty: qty }) : i; }) |
| 43 | + .filter(function (i) { return i.qty > 0; }); |
| 44 | + write(items); |
| 45 | + } |
| 46 | + function remove(key) { write(read().filter(function (i) { return i.key !== key; })); } |
| 47 | + function clear() { write([]); } |
| 48 | + |
| 49 | + /* ---- drawer markup (built once, appended to <body>) ---- */ |
| 50 | + var overlay, drawer, itemsEl, subEl, checkoutBtn; |
| 51 | + function build() { |
| 52 | + overlay = document.createElement("div"); |
| 53 | + overlay.className = "cart-overlay"; |
| 54 | + |
| 55 | + drawer = document.createElement("aside"); |
| 56 | + drawer.className = "cart-drawer"; |
| 57 | + drawer.setAttribute("aria-hidden", "true"); |
| 58 | + drawer.innerHTML = |
| 59 | + '<div class="cart-head"><div><span class="cart-title">Cart</span><span class="star cart-ast">*</span>' + |
| 60 | + '<span class="jp cart-jp">買い物かご</span></div>' + |
| 61 | + '<button class="cart-close" aria-label="Close cart">×</button></div>' + |
| 62 | + '<div class="cart-items"></div>' + |
| 63 | + '<div class="cart-foot">' + |
| 64 | + '<div class="cart-sub"><span class="tiny">Subtotal</span><span class="cart-sub-val">' + money(0) + '</span></div>' + |
| 65 | + '<p class="cart-note tiny">Shipping + any taxes calculated at checkout. Each edition is plotted to order.</p>' + |
| 66 | + '<button class="cart-checkout">Checkout</button>' + |
| 67 | + '</div>'; |
| 68 | + |
| 69 | + document.body.appendChild(overlay); |
| 70 | + document.body.appendChild(drawer); |
| 71 | + |
| 72 | + itemsEl = drawer.querySelector(".cart-items"); |
| 73 | + subEl = drawer.querySelector(".cart-sub-val"); |
| 74 | + checkoutBtn = drawer.querySelector(".cart-checkout"); |
| 75 | + |
| 76 | + overlay.addEventListener("click", closeDrawer); |
| 77 | + drawer.querySelector(".cart-close").addEventListener("click", closeDrawer); |
| 78 | + checkoutBtn.addEventListener("click", checkout); |
| 79 | + |
| 80 | + itemsEl.addEventListener("click", function (e) { |
| 81 | + var row = e.target.closest("[data-key]"); if (!row) return; |
| 82 | + var k = row.getAttribute("data-key"); |
| 83 | + var item = read().filter(function (i) { return i.key === k; })[0]; if (!item) return; |
| 84 | + if (e.target.closest("[data-inc]")) setQty(k, item.qty + 1); |
| 85 | + else if (e.target.closest("[data-dec]")) setQty(k, item.qty - 1); |
| 86 | + else if (e.target.closest("[data-rm]")) remove(k); |
| 87 | + }); |
| 88 | + |
| 89 | + document.addEventListener("keydown", function (e) { if (e.key === "Escape") closeDrawer(); }); |
| 90 | + } |
| 91 | + |
| 92 | + function openDrawer() { if (!drawer) return; drawer.classList.add("open"); overlay.classList.add("show"); drawer.setAttribute("aria-hidden", "false"); } |
| 93 | + function closeDrawer() { if (!drawer) return; drawer.classList.remove("open"); overlay.classList.remove("show"); drawer.setAttribute("aria-hidden", "true"); } |
| 94 | + |
| 95 | + function render() { |
| 96 | + // nav badges |
| 97 | + [].forEach.call(document.querySelectorAll(".cart"), function (b) { b.textContent = "Cart [" + count() + "]"; }); |
| 98 | + if (!itemsEl) return; |
| 99 | + var items = read(); |
| 100 | + if (!items.length) { |
| 101 | + itemsEl.innerHTML = '<div class="cart-empty tiny">Your cart is empty.</div>'; |
| 102 | + } else { |
| 103 | + itemsEl.innerHTML = items.map(function (i) { |
| 104 | + return '<div class="ci" data-key="' + i.key + '">' + |
| 105 | + '<div class="ci-main"><div class="ci-name">' + (i.code ? i.code + " " : "") + i.name + '</div>' + |
| 106 | + '<div class="ci-ed tiny">' + (i.edition || "") + '</div></div>' + |
| 107 | + '<div class="ci-qty"><button data-dec aria-label="Decrease">−</button>' + |
| 108 | + '<span>' + i.qty + '</span>' + |
| 109 | + '<button data-inc aria-label="Increase">+</button></div>' + |
| 110 | + '<div class="ci-price">' + money(priceNum(i.price) * i.qty) + '</div>' + |
| 111 | + '<button class="ci-rm" data-rm aria-label="Remove">×</button>' + |
| 112 | + '</div>'; |
| 113 | + }).join(""); |
| 114 | + } |
| 115 | + if (subEl) subEl.textContent = money(subtotal()); |
| 116 | + if (checkoutBtn) checkoutBtn.disabled = items.length === 0; |
| 117 | + } |
| 118 | + |
| 119 | + function checkout() { |
| 120 | + var items = read(); |
| 121 | + if (!items.length) return; |
| 122 | + if (!CFG.checkoutEndpoint) { |
| 123 | + alert("Checkout isn't connected yet. (Set checkoutEndpoint in scripts/config.js once the Stripe Worker is deployed.)"); |
| 124 | + return; |
| 125 | + } |
| 126 | + var orig = checkoutBtn.textContent; |
| 127 | + checkoutBtn.disabled = true; checkoutBtn.textContent = "Redirecting…"; |
| 128 | + fetch(CFG.checkoutEndpoint, { |
| 129 | + method: "POST", |
| 130 | + headers: { "Content-Type": "application/json" }, |
| 131 | + body: JSON.stringify({ items: items.map(function (i) { return { key: i.key, qty: i.qty }; }) }) |
| 132 | + }) |
| 133 | + .then(function (r) { return r.json().then(function (d) { return { ok: r.ok, d: d }; }); }) |
| 134 | + .then(function (res) { |
| 135 | + if (res.ok && res.d.url) { window.location.href = res.d.url; } |
| 136 | + else { throw new Error((res.d && res.d.error) || "Checkout failed"); } |
| 137 | + }) |
| 138 | + .catch(function (err) { |
| 139 | + alert("Could not start checkout. " + err.message); |
| 140 | + checkoutBtn.disabled = false; checkoutBtn.textContent = orig; |
| 141 | + }); |
| 142 | + } |
| 143 | + |
| 144 | + /* ---- global wiring ---- */ |
| 145 | + document.addEventListener("click", function (e) { |
| 146 | + var acq = e.target.closest("[data-acq]"); |
| 147 | + if (acq) { e.preventDefault(); add(acq.getAttribute("data-acq")); return; } |
| 148 | + var cartBtn = e.target.closest(".cart"); |
| 149 | + if (cartBtn) { e.preventDefault(); openDrawer(); } |
| 150 | + }); |
| 151 | + |
| 152 | + // public API |
| 153 | + window.PlotflowCart = { add: add, remove: remove, clear: clear, open: openDrawer, count: count }; |
| 154 | + |
| 155 | + if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", function () { build(); render(); }); |
| 156 | + else { build(); render(); } |
| 157 | +})(); |
0 commit comments