[IMP] website_sale_aplicoop: pay on the checkout page, not a step later

The separate /eskaera/<slug>/payment step is gone. Members review the
summary, choose home delivery and pick a payment method on the checkout,
in one screen; the old URL redirects there so bookmarks and sessions that
were mid-flow do not hit a 404.

The checkout now renders the member's draft sale.order instead of the
localStorage cart. That is what fixes the products appearing "out of
nowhere" between the two pages: the summary was a snapshot of localStorage
taken at page load, and `_autoLoadDraftOnInit` then pulled the draft back
into localStorage without re-rendering. Deleting a product in the shop
removed it from the cart but left the line on the draft, so the autoload
resurrected it, the confirm button sent it back, and it only became
visible one page later. The checkout no longer auto-loads the draft — it
renders it, and what it shows is what the payment form charges.

"Proceed to Checkout" pushes the cart to that draft before navigating.
Saving is idempotent: `_merge_or_replace_draft` reuses the cycle's draft
and, through the new `_draft_matches_lines`, rewrites `order_line` only
when the lines actually differ — replacing them unlinks and recreates
every one of them, which is pure churn when nothing changed.

The home delivery checkbox goes through the new /eskaera/set-home-delivery
so the delivery line moves on the order itself. Writing only to
localStorage would have changed the summary and left the amount alone,
which with online payment on is the amount being charged.

Also fixes the confirmation notice nobody ever saw: saving answered with
the payment step URL and the frontend followed it immediately, destroying
the toast in the same tick. Saving no longer navigates; the caller decides
whether it is staying or moving on.

Along the way: checkout_labels.js and the eskaera_checkout_summary /
eskaera_payment templates are removed, superseded by the server-rendered
summary and checkout, and the stale sessionStorage delivery preference no
longer overrides the checkbox the order just rendered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
GitHub Copilot 2026-08-17 19:14:49 +02:00
parent f81c1ca8e7
commit e625b0c2f3
11 changed files with 873 additions and 746 deletions

View file

@ -1,340 +0,0 @@
/**
* Checkout Labels Loading
* Fetches translated labels for checkout table summary
* IMPORTANT: This script waits for the cart to be loaded by website_sale.js
* before rendering the checkout summary.
*/
(function () {
"use strict";
console.log("[CHECKOUT] Script loaded");
// Get order ID from button
var confirmBtn = document.getElementById("confirm-order-btn");
if (!confirmBtn) {
console.log("[CHECKOUT] No confirm button found");
return;
}
var orderId = confirmBtn.getAttribute("data-order-id");
if (!orderId) {
console.log("[CHECKOUT] No order ID found");
return;
}
console.log("[CHECKOUT] Order ID:", orderId);
// Get summary div
var summaryDiv = document.getElementById("checkout-summary");
if (!summaryDiv) {
console.log("[CHECKOUT] No summary div found");
return;
}
// Function to fetch labels and render checkout
var fetchLabelsAndRender = function () {
console.log("[CHECKOUT] Fetching labels...");
// Wait for window.groupOrderShop.labels to be initialized (contains hardcoded labels)
var waitForLabels = function (callback, maxWait = 3000, checkInterval = 50) {
var startTime = Date.now();
var checkLabels = function () {
if (
window.groupOrderShop &&
window.groupOrderShop.labels &&
Object.keys(window.groupOrderShop.labels).length > 0
) {
console.log("[CHECKOUT] ✅ Hardcoded labels found, proceeding");
callback();
} else if (Date.now() - startTime < maxWait) {
setTimeout(checkLabels, checkInterval);
} else {
console.log("[CHECKOUT] ⚠️ Timeout waiting for labels, proceeding anyway");
callback();
}
};
checkLabels();
};
waitForLabels(function () {
// Now fetch additional labels from server
// Detect current language from document or navigator
var currentLang =
document.documentElement.lang ||
document.documentElement.getAttribute("lang") ||
navigator.language ||
"es_ES";
console.log("[CHECKOUT] Detected language:", currentLang);
fetch("/eskaera/labels", {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
lang: currentLang,
}),
})
.then(function (response) {
console.log("[CHECKOUT] Response status:", response.status);
return response.json();
})
.then(function (data) {
console.log("[CHECKOUT] Response data:", data);
var serverLabels = data.result || data;
console.log(
"[CHECKOUT] Server labels count:",
Object.keys(serverLabels).length
);
console.log("[CHECKOUT] Sample server labels:", {
draft_merged_success: serverLabels.draft_merged_success,
home_delivery: serverLabels.home_delivery,
});
// CRITICAL: Merge server labels with existing hardcoded labels
// Hardcoded labels MUST take precedence over server labels
if (window.groupOrderShop && window.groupOrderShop.labels) {
var existingLabels = window.groupOrderShop.labels;
console.log(
"[CHECKOUT] Existing hardcoded labels count:",
Object.keys(existingLabels).length
);
console.log("[CHECKOUT] Sample existing labels:", {
draft_merged_success: existingLabels.draft_merged_success,
home_delivery: existingLabels.home_delivery,
});
// Start with server labels, then overwrite with hardcoded ones
var mergedLabels = Object.assign({}, serverLabels);
Object.assign(mergedLabels, existingLabels);
window.groupOrderShop.labels = mergedLabels;
console.log(
"[CHECKOUT] ✅ Merged labels - final count:",
Object.keys(mergedLabels).length
);
console.log("[CHECKOUT] Verification:", {
draft_merged_success: mergedLabels.draft_merged_success,
home_delivery: mergedLabels.home_delivery,
});
} else {
// If no existing labels, use server labels as fallback
if (window.groupOrderShop) {
window.groupOrderShop.labels = serverLabels;
}
console.log("[CHECKOUT] ⚠️ No existing labels, using server labels");
}
window.renderCheckoutSummary(window.groupOrderShop.labels);
})
.catch(function (error) {
console.error("[CHECKOUT] Error:", error);
// Fallback to translated labels
window.renderCheckoutSummary(window.getCheckoutLabels());
});
});
};
// Listen for cart ready event instead of polling
if (window.groupOrderShop && window.groupOrderShop.orderId) {
// Cart already initialized, render immediately
console.log("[CHECKOUT] Cart already ready");
fetchLabelsAndRender();
} else {
// Wait for cart initialization event
console.log("[CHECKOUT] Waiting for cart ready event...");
document.addEventListener(
"groupOrderCartReady",
function () {
console.log("[CHECKOUT] Cart ready event received");
fetchLabelsAndRender();
},
{ once: true }
);
// Fallback timeout in case event never fires
setTimeout(function () {
if (window.groupOrderShop && window.groupOrderShop.orderId) {
console.log("[CHECKOUT] Fallback timeout triggered");
fetchLabelsAndRender();
}
}, 500);
}
/**
* Render order summary table or empty message
* Exposed globally so other scripts can call it
*/
window.renderCheckoutSummary = function (labels) {
labels = labels || window.getCheckoutLabels();
var summaryDiv = document.getElementById("checkout-summary");
if (!summaryDiv) return;
var cartKey =
"eskaera_" +
(document.getElementById("confirm-order-btn")
? document.getElementById("confirm-order-btn").getAttribute("data-order-id")
: "1") +
"_cart";
var cart = JSON.parse(localStorage.getItem(cartKey) || "{}");
var summaryTable = summaryDiv.querySelector(".checkout-summary-table");
var tbody = summaryDiv.querySelector("#checkout-summary-tbody");
var totalSection = summaryDiv.querySelector(".checkout-total-section");
// If no table found, create it with headers (shouldn't happen, but fallback)
if (!summaryTable) {
var html =
'<table class="table table-hover checkout-summary-table" id="checkout-summary-table" role="grid" aria-label="Purchase summary"><thead class="table-dark"><tr>' +
'<th scope="col" class="col-name">' +
escapeHtml(labels.product) +
"</th>" +
'<th scope="col" class="col-qty text-center">' +
escapeHtml(labels.quantity) +
"</th>" +
'<th scope="col" class="col-price text-end">' +
escapeHtml(labels.price) +
"</th>" +
'<th scope="col" class="col-subtotal text-end">' +
escapeHtml(labels.subtotal) +
"</th>" +
'</tr></thead><tbody id="checkout-summary-tbody"></tbody></table>' +
'<div class="checkout-total-section"><div class="total-row">' +
'<span class="total-label">' +
escapeHtml(labels.total) +
"</span>" +
'<span class="total-amount" id="checkout-total-amount">€0.00</span>' +
"</div></div>";
summaryDiv.innerHTML = html;
summaryTable = summaryDiv.querySelector(".checkout-summary-table");
tbody = summaryDiv.querySelector("#checkout-summary-tbody");
totalSection = summaryDiv.querySelector(".checkout-total-section");
}
// Clear only tbody, preserve headers
tbody.innerHTML = "";
if (Object.keys(cart).length === 0) {
// Show empty message if cart is empty
var emptyRow = document.createElement("tr");
emptyRow.id = "checkout-empty-row";
emptyRow.className = "empty-message";
emptyRow.innerHTML =
'<td colspan="4" class="text-center text-muted py-4">' +
'<i class="fa fa-inbox fa-2x mb-2"></i>' +
"<p>" +
escapeHtml(labels.empty) +
"</p>" +
"</td>";
tbody.appendChild(emptyRow);
// Hide total section
totalSection.style.display = "none";
} else {
// Hide empty row if visible
var emptyRow = tbody.querySelector("#checkout-empty-row");
if (emptyRow) emptyRow.remove();
// Get delivery product ID from page data
var checkoutPage = document.querySelector(".eskaera-checkout-page");
var deliveryProductId = checkoutPage
? checkoutPage.getAttribute("data-delivery-product-id")
: null;
// Separate normal products from delivery product
var normalProducts = [];
var deliveryProduct = null;
Object.keys(cart).forEach(function (productId) {
if (productId === deliveryProductId) {
deliveryProduct = { id: productId, item: cart[productId] };
} else {
normalProducts.push({ id: productId, item: cart[productId] });
}
});
// Sort normal products numerically
normalProducts.sort(function (a, b) {
return parseInt(a.id) - parseInt(b.id);
});
var total = 0;
// Render normal products first
normalProducts.forEach(function (product) {
var item = product.item;
var qty = parseFloat(item.quantity || item.qty || 1);
if (isNaN(qty)) qty = 1;
var price = parseFloat(item.price || 0);
if (isNaN(price)) price = 0;
var subtotal = qty * price;
total += subtotal;
var row = document.createElement("tr");
row.innerHTML =
"<td>" +
escapeHtml(item.name) +
"</td>" +
'<td class="text-center">' +
qty.toFixed(2).replace(/\.?0+$/, "") +
"</td>" +
'<td class="text-end">€' +
price.toFixed(2) +
"</td>" +
'<td class="text-end">€' +
subtotal.toFixed(2) +
"</td>";
tbody.appendChild(row);
});
// Render delivery product last if present
if (deliveryProduct) {
var item = deliveryProduct.item;
var qty = parseFloat(item.quantity || item.qty || 1);
if (isNaN(qty)) qty = 1;
var price = parseFloat(item.price || 0);
if (isNaN(price)) price = 0;
var subtotal = qty * price;
total += subtotal;
var row = document.createElement("tr");
row.innerHTML =
"<td>" +
escapeHtml(item.name) +
"</td>" +
'<td class="text-center">' +
qty.toFixed(2).replace(/\.?0+$/, "") +
"</td>" +
'<td class="text-end">€' +
price.toFixed(2) +
"</td>" +
'<td class="text-end">€' +
subtotal.toFixed(2) +
"</td>";
tbody.appendChild(row);
}
// Update total
var totalAmount = summaryDiv.querySelector("#checkout-total-amount");
if (totalAmount) {
totalAmount.textContent = "€" + total.toFixed(2);
}
// Show total section
totalSection.style.display = "block";
}
console.log("[CHECKOUT] Summary rendered");
};
/**
* Escape HTML to prevent XSS
*/
function escapeHtml(text) {
var div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
})();

View file

@ -68,10 +68,15 @@
}
}
// Get order ID from multiple possible sources
// Get order ID from multiple possible sources. The checkout page
// carries it on its wrapper: with online payment on there is no
// confirm button there, the payment form takes its place.
var confirmBtn = document.getElementById("confirm-order-btn");
var cartContainer = document.getElementById("cart-items-container");
var orderIdElement = confirmBtn || cartContainer;
var orderIdElement =
confirmBtn ||
cartContainer ||
(checkoutPage && checkoutPage.getAttribute("data-order-id") ? checkoutPage : null);
// The URL is not a fallback here: it carries the slug of the order,
// not its id.
@ -81,22 +86,16 @@
console.log("[HomeDelivery] orderId resolved:", this.orderId);
// Handle checkbox (only exists on checkout page)
// Handle checkbox (only exists on checkout page). Its state is
// rendered from `sale_order.home_delivery`, so it is not read back
// from localStorage here: the order is what the payment form
// charges, and the two must not disagree.
var checkbox = document.getElementById("home-delivery-checkbox");
if (checkbox) {
var self = this;
checkbox.addEventListener("change", function () {
if (this.checked) {
self.addDeliveryProduct();
self.showDeliveryInfo();
} else {
self.removeDeliveryProduct();
self.hideDeliveryInfo();
}
self.setDeliveryOnOrder(this.checked, this);
});
// Check if delivery product is already in cart on page load
this.checkDeliveryInCart();
}
// Vincular botón Home Delivery en el shop SOLO si hay un producto de delivery válido
@ -133,11 +132,6 @@
homeDeliveryBtn.classList.remove("btn-outline-warning");
homeDeliveryBtn.classList.add("active", "btn-warning");
}
// Trigger cart reload to update UI
if (typeof window.renderCheckoutSummary === "function") {
window.renderCheckoutSummary();
}
});
// Set initial button state
@ -184,17 +178,92 @@
}
},
checkDeliveryInCart: function () {
if (!this.deliveryProductId) return;
/**
* Move the delivery line on and off the draft order (checkout page).
*
* The checkout renders the sale.order and its total is what gets
* charged, so the toggle has to reach the order writing only to
* localStorage would change the summary and leave the amount alone.
* localStorage is kept in step so the shop cart agrees, and the page
* is reloaded to pick up the new total and payment amount.
*/
setDeliveryOnOrder: function (isDelivery, checkbox) {
var self = this;
var cart = this.getCart();
if (cart[this.deliveryProductId]) {
var checkbox = document.getElementById("home-delivery-checkbox");
if (checkbox) {
checkbox.checked = true;
this.showDeliveryInfo();
}
if (!this.orderId) {
console.warn("[HomeDelivery] No order id, cannot set delivery");
return;
}
if (checkbox) {
checkbox.disabled = true;
}
var xhr = new XMLHttpRequest();
xhr.open("POST", "/eskaera/set-home-delivery", true);
xhr.setRequestHeader("Content-Type", "application/json");
xhr.onload = function () {
if (xhr.status !== 200) {
console.error("[HomeDelivery] set-home-delivery failed:", xhr.status);
self.revertCheckbox(checkbox, !isDelivery);
return;
}
var data = {};
try {
data = JSON.parse(xhr.responseText);
} catch (e) {
data = {};
}
if (!data.success) {
console.error("[HomeDelivery] set-home-delivery rejected:", data.error);
self.revertCheckbox(checkbox, !isDelivery);
return;
}
// Keep the shop cart in step with the order before reloading.
var cart = self.getCart();
if (self.deliveryProductId) {
if (data.is_delivery) {
cart[self.deliveryProductId] = {
id: self.deliveryProductId,
name: self.deliveryProductName,
price: self.deliveryProductPrice,
qty: 1,
};
} else {
delete cart[self.deliveryProductId];
}
try {
localStorage.setItem(
"eskaera_" + self.orderId + "_cart",
JSON.stringify(cart)
);
} catch (e) {
console.warn("[HomeDelivery] Could not update the local cart:", e);
}
}
window.location.reload();
};
xhr.onerror = function () {
console.error("[HomeDelivery] set-home-delivery connection error");
self.revertCheckbox(checkbox, !isDelivery);
};
xhr.send(
JSON.stringify({
order_id: self.orderId,
is_delivery: isDelivery,
})
);
},
revertCheckbox: function (checkbox, previousState) {
if (!checkbox) return;
checkbox.checked = previousState;
checkbox.disabled = false;
},
getCart: function () {
@ -216,14 +285,6 @@
window.groupOrderShop._updateCartDisplay();
}
}
// Re-render checkout summary without reloading
setTimeout(function () {
// Use the global function from checkout_labels.js
if (typeof window.renderCheckoutSummary === "function") {
window.renderCheckoutSummary();
}
}, 50);
},
addDeliveryProduct: function () {

View file

@ -20,8 +20,12 @@
// Get order ID first (needed by i18nManager and other functions)
var confirmBtn = document.getElementById("confirm-order-btn");
var cartContainer = document.getElementById("cart-items-container");
// The checkout page carries the id on its wrapper: with online
// payment on there is no confirm button there, the payment form
// takes its place.
this._checkoutPage = document.querySelector(".eskaera-checkout-page[data-order-id]");
var orderIdElement = confirmBtn || cartContainer;
var orderIdElement = confirmBtn || cartContainer || this._checkoutPage;
if (!orderIdElement) {
console.log("No elements found to get order ID");
return false;
@ -126,6 +130,15 @@
return;
}
// Never on the checkout page. It renders the draft server-side, so
// pulling the same lines back into localStorage would only put the
// two out of step — and it used to resurrect lines the member had
// just deleted in the shop, which then reappeared at payment time.
if (this._checkoutPage) {
console.log("Auto-load draft skipped (checkout renders the order itself)");
return;
}
// Only auto-load if cart is empty
var cartItemsCount = Object.keys(this.cart).length;
if (cartItemsCount > 0) {
@ -442,8 +455,7 @@
// Storage layout:
// eskaera_<id>_cart → items as plain {productId: {...}}
// (keeps the contract used by
// checkout_labels.js, home_delivery.js
// and _saveOrderDraft).
// home_delivery.js and _saveOrderDraft).
// eskaera_<id>_cart_cycle → "YYYY-MM-DD" the cart was saved at.
// Eviction only fires when we KNOW the current cycle and it
// disagrees with the stored one. If either side is missing
@ -472,8 +484,8 @@
return;
}
// Migration: v18.0.1.10.0 wrapped the cart as {cutoff_date, items}
// in the same key, which broke checkout_labels.js / home_delivery.js
// / _saveOrderDraft (they iterated Object.keys treating them as
// in the same key, which broke home_delivery.js and
// _saveOrderDraft (they iterated Object.keys treating them as
// productIds). Unwrap once; subsequent saves use the canonical
// split-key layout below.
var items;
@ -747,7 +759,8 @@
},
_getLabels: function () {
// Get current labels from window.groupOrderShop which is updated by checkout_labels.js
// Get current labels from window.groupOrderShop, seeded by the
// labels the page renders inline.
console.log("[_getLabels] Starting label resolution...");
console.log("[_getLabels] window.groupOrderShop exists:", !!window.groupOrderShop);
@ -880,7 +893,7 @@
_showConfirmation: function (message, onConfirm, onCancel) {
var self = this;
// Get current labels - may be updated by checkout_labels.js endpoint
// Get current labels - seeded by the labels the page renders inline
var labels = this._getLabels();
console.log("[_showConfirmation] Using labels:", labels);
@ -998,29 +1011,15 @@
});
}
// On checkout page: apply sessionStorage delivery preference to checkbox.
// The shop-page toggle may have stored a "false" preference even though
// the checkbox is checked by default in the template.
var checkoutCheckbox = document.getElementById("home-delivery-checkbox");
if (checkoutCheckbox) {
var storedDeliveryPref = sessionStorage.getItem(
"eskaera_is_delivery_" + self.orderId
);
if (storedDeliveryPref !== null) {
checkoutCheckbox.checked = storedDeliveryPref === "true";
console.log(
"[CHECKOUT] Restored delivery checkbox from sessionStorage:",
checkoutCheckbox.checked
);
}
// Sync sessionStorage when user manually changes the checkbox
checkoutCheckbox.addEventListener("change", function () {
sessionStorage.setItem(
"eskaera_is_delivery_" + self.orderId,
this.checked ? "true" : "false"
);
});
}
// The checkout delivery checkbox is no longer restored from
// sessionStorage: it renders from `sale_order.home_delivery`,
// and home_delivery.js writes any change straight back to the
// order. A stale session preference could only contradict it.
// Send the cart to the server before opening the checkout, so
// the page renders the member's current cart rather than
// whatever the draft happened to hold.
this._attachCheckoutLinkListeners();
// Button to reload from draft (in My Cart header - cart pages)
var reloadCartBtn = document.getElementById("reload-cart-btn");
@ -1621,6 +1620,103 @@
self._executeSaveCartAsDraft(items);
},
// Read the cart the way the server wants it. localStorage is the
// source of truth while shopping: home_delivery.js writes to it
// directly, so this.cart can lag behind by a tick.
_collectCartItems: function () {
var cartKey = "eskaera_" + this.orderId + "_cart";
var storedCart = localStorage.getItem(cartKey);
var cart;
try {
cart = storedCart ? JSON.parse(storedCart) : this.cart;
} catch (e) {
cart = this.cart;
}
return Object.keys(cart || {}).map(function (productId) {
var item = cart[productId];
return {
product_id: productId,
product_name: item.name,
quantity: item.qty,
product_price: item.price,
};
});
},
// The checkout renders the draft sale.order, so the cart has to reach
// the server before we navigate. Saving is idempotent: the endpoint
// reuses the cycle's draft and only rewrites its lines when they
// actually differ, so a member who already saved from the shop just
// gets their changes applied.
_attachCheckoutLinkListeners: function () {
var self = this;
var links = document.querySelectorAll(".js-eskaera-checkout");
console.log("[_attachEventListeners] checkout links found:", links.length);
links.forEach(function (link) {
link.addEventListener("click", function (e) {
e.preventDefault();
self._saveCartAndGoToCheckout(link.getAttribute("href"));
});
});
},
_saveCartAndGoToCheckout: function (checkoutUrl) {
var self = this;
var labels = this._getLabels();
var items = this._collectCartItems();
if (items.length === 0) {
this._showNotification(labels.empty_cart || "Your cart is empty", "warning");
return;
}
var orderData = {
order_id: this.orderId,
items: items,
merge_action: "replace",
};
// Delivery preference: the shop toggle owns it on this page.
var deliveryBtn = document.getElementById("home-delivery-btn");
if (deliveryBtn) {
orderData.is_delivery = deliveryBtn.classList.contains("active");
}
var xhr = new XMLHttpRequest();
xhr.open("POST", "/eskaera/save-order", true);
xhr.setRequestHeader("Content-Type", "application/json");
xhr.onload = function () {
if (xhr.status === 200) {
window.location.href = checkoutUrl;
return;
}
if (self._isClosedOrderResponse(xhr)) {
self._clearCurrentOrderCartSilently();
self._updateCartDisplay();
return;
}
if (self._handleAlreadyPlacedResponse(xhr)) {
return;
}
var message = labels.error_saving_draft || "Error saving cart";
try {
var errorData = JSON.parse(xhr.responseText);
message = errorData.error || message;
} catch (e) {
message = message + " (HTTP " + xhr.status + ")";
}
self._showNotification(message, "danger");
};
xhr.onerror = function () {
self._showNotification(labels.connection_error || "Connection error", "danger");
};
xhr.send(JSON.stringify(orderData));
},
_executeSaveCartAsDraft: function (items) {
var self = this;
@ -1934,13 +2030,10 @@
labels.draft_saved_success ||
labels.draft_saved ||
"Order saved as draft successfully";
// No navigation here: the confirmation notice used
// to be wiped out by an immediate redirect to the
// payment step, so the member saw nothing at all.
self._showNotification("\u2713 " + successMsg, "success", 5000);
// With online payment on, the server answers with the
// payment step URL: saving the cart is only half of
// placing the order.
if (data.redirect_url) {
window.location.href = data.redirect_url;
}
} else {
self._showNotification(
"Error: " + (data.error || labels.error_unknown || "Unknown error"),