[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

@ -338,7 +338,10 @@
<i class="fa fa-truck cart-icon-size" aria-hidden="true" />
</button>
</t>
<a t-attf-href="/eskaera/{{ group_order.slug }}/checkout" class="btn btn-success cart-btn-compact" aria-label="Proceed to Checkout" title="Proceed to Checkout" data-bs-toggle="tooltip">
<!-- js-eskaera-checkout: the cart is pushed to the draft
sale.order before navigating, so the checkout page can
render (and charge) the very lines the member sees. -->
<a t-attf-href="/eskaera/{{ group_order.slug }}/checkout" class="btn btn-success cart-btn-compact js-eskaera-checkout" aria-label="Proceed to Checkout" title="Proceed to Checkout" data-bs-toggle="tooltip">
<i class="fa fa-check cart-icon-size" aria-hidden="true" />
</a>
<button type="button" class="btn btn-outline-danger cart-btn-compact" id="clear-cart-btn" t-attf-data-order-id="{{ group_order.id }}" title="Clear Cart" data-bs-toggle="tooltip" aria-label="Clear Cart">
@ -353,7 +356,7 @@
<button type="button" class="btn btn-outline-danger btn-sm" id="clear-cart-btn-footer" t-attf-data-order-id="{{ group_order.id }}" title="Clear Cart" data-bs-toggle="tooltip" aria-label="Clear Cart">
<i class="fa fa-trash me-1" aria-hidden="true" />Clear Cart
</button>
<a t-attf-href="/eskaera/{{ group_order.slug }}/checkout" class="btn btn-success checkout-btn-lg" title="Proceed to Checkout" data-bs-toggle="tooltip">
<a t-attf-href="/eskaera/{{ group_order.slug }}/checkout" class="btn btn-success checkout-btn-lg js-eskaera-checkout" title="Proceed to Checkout" data-bs-toggle="tooltip">
Proceed to Checkout
</a>
</div>
@ -428,41 +431,9 @@
</script>
</t>
</template>
<template id="eskaera_checkout_summary" name="Checkout Order Summary">
<!-- The table keeps its width and scrolls inside this container; tabindex
makes that scroll reachable from the keyboard (WCAG 2.1.1). -->
<div class="checkout-summary-container" role="region" tabindex="0" aria-label="Order summary">
<table class="table table-hover checkout-summary-table" id="checkout-summary-table">
<caption class="visually-hidden">Products in this order, with quantity, price and subtotal</caption>
<thead class="table-dark">
<tr>
<th scope="col" class="col-name">Product</th>
<th scope="col" class="col-qty text-center">Quantity</th>
<th scope="col" class="col-price text-end">Price</th>
<th scope="col" class="col-subtotal text-end">Subtotal</th>
</tr>
</thead>
<tbody id="checkout-summary-tbody">
<tr id="checkout-empty-row" class="empty-message">
<td colspan="4" class="text-center text-muted py-4">
<i class="fa fa-inbox fa-2x mb-2" aria-hidden="true" />
<p>This order's cart is empty</p>
</td>
</tr>
</tbody>
</table>
<div class="checkout-total-section">
<div class="total-row">
<span class="total-label">Total</span>:
<span class="total-amount" id="checkout-total-amount">0.00</span>
<span class="currency"></span>
</div>
</div>
</div>
</template>
<template id="eskaera_checkout" name="Eskaera Checkout">
<t t-call="website.layout">
<div id="wrap" class="eskaera-checkout-page oe_structure oe_empty" data-name="Eskaera Checkout" t-attf-data-delivery-product-id="{{ delivery_product_id }}" t-attf-data-delivery-product-name="{{ delivery_product_name }}" t-attf-data-delivery-product-price="{{ delivery_product_price }}" t-attf-data-home-delivery-enabled="{{ 'true' if group_order.home_delivery else 'false' }}" t-attf-data-pickup-day="{{ group_order.pickup_day }}" t-attf-data-pickup-date="{{ group_order.pickup_date.strftime('%d/%m/%Y') if group_order.pickup_date else '' }}" t-attf-data-next-pickup-slot-label="{{ group_order.next_pickup_slot_id.label if group_order.next_pickup_slot_id else '' }}" t-attf-data-delivery-notice="{{ (group_order.delivery_notice or '').replace(chr(10), ' ').replace(chr(13), ' ') }}">
<div id="wrap" class="eskaera-checkout-page oe_structure oe_empty" data-name="Eskaera Checkout" t-attf-data-order-id="{{ group_order.id }}" t-attf-data-delivery-product-id="{{ delivery_product_id }}" t-attf-data-delivery-product-name="{{ delivery_product_name }}" t-attf-data-delivery-product-price="{{ delivery_product_price }}" t-attf-data-home-delivery-enabled="{{ 'true' if group_order.home_delivery else 'false' }}" t-attf-data-pickup-day="{{ group_order.pickup_day }}" t-attf-data-pickup-date="{{ group_order.pickup_date.strftime('%d/%m/%Y') if group_order.pickup_date else '' }}" t-attf-data-next-pickup-slot-label="{{ group_order.next_pickup_slot_id.label if group_order.next_pickup_slot_id else '' }}" t-attf-data-delivery-notice="{{ (group_order.delivery_notice or '').replace(chr(10), ' ').replace(chr(13), ' ') }}">
<div class="container mt-5">
<div class="row">
<div class="col-lg-10 offset-lg-1">
@ -536,49 +507,122 @@
</div>
<h4 class="summary-heading mb-3">Order Summary</h4>
<div class="oe_structure oe_empty mb-3" data-name="Before Summary" />
<!-- The summary comes from the saved sale.order, never from
the localStorage cart: these are the very lines and
amounts the payment form is about to charge, so the two
cannot drift apart. The shop pushes the cart here before
sending the member over. -->
<div id="checkout-summary" class="mb-5">
<t t-call="website_sale_aplicoop.eskaera_checkout_summary">
<t t-set="labels" t-value="{}" />
<t t-if="sale_order">
<t t-call="website_sale_aplicoop.eskaera_order_lines_summary" />
</t>
<t t-else="">
<div class="alert alert-info" role="status">
<i class="fa fa-inbox me-2" aria-hidden="true" t-translation="off" />
<span>This order's cart is empty</span>
</div>
</t>
</div>
<div class="oe_structure oe_empty mb-4" data-name="After Summary" />
<t t-if="group_order.home_delivery">
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<div class="form-check eskaera-delivery-check">
<input type="checkbox" class="form-check-input" id="home-delivery-checkbox" name="home_delivery" />
<label class="form-check-label fw-bold" for="home-delivery-checkbox">Home Delivery</label>
<t t-if="sale_order">
<t t-if="group_order.home_delivery">
<div class="card border-0 shadow-sm mb-4">
<div class="card-body">
<div class="form-check eskaera-delivery-check">
<input type="checkbox" class="form-check-input" id="home-delivery-checkbox" name="home_delivery" t-att-checked="sale_order.home_delivery or None" />
<label class="form-check-label fw-bold" for="home-delivery-checkbox">Home Delivery</label>
</div>
<div t-attf-class="alert alert-info mt-3 eskaera-delivery-notice{{ '' if sale_order.home_delivery else ' d-none' }}" id="delivery-info-alert">
<p class="mb-2">
<i class="fa fa-truck" aria-hidden="true" t-translation="off" />
<t t-if="group_order.delivery_date">
<strong>Delivery Information:</strong> Your order will be delivered at
<t t-esc="day_names[(int(group_order.pickup_day) + 1) % 7]" />
<t t-esc="group_order.delivery_date.strftime('%d/%m/%Y')" />
<t t-if="group_order.delivery_notice">
<br />
<t t-esc="group_order.delivery_notice" />
</t>
</t>
</p>
</div>
</div>
</div>
<div id="delivery-info-alert" class="alert alert-info mt-3 d-none eskaera-delivery-notice">
<p class="mb-2">
<i class="fa fa-truck" aria-hidden="true" t-translation="off" />
<t t-if="group_order.delivery_date">
<strong>Delivery Information:</strong> Your order will be delivered at
<t t-esc="day_names[(int(group_order.pickup_day) + 1) % 7]" />
<t t-esc="group_order.delivery_date.strftime('%d/%m/%Y')" />
<t t-if="group_order.delivery_notice">
<br />
<t t-esc="group_order.delivery_notice" />
</t>
</t>
</p>
</t>
<t t-if="online_payment">
<t t-if="pending_transaction">
<div class="alert alert-info" role="alert">
<h5 class="alert-heading">
<i class="fa fa-clock-o me-2" aria-hidden="true" t-translation="off" />
<span>Payment in progress</span>
</h5>
<p class="mb-1">We are still waiting for your payment to be confirmed. Please do not pay again.</p>
<p class="mb-0">
<span>Reference</span>:
<span t-esc="pending_transaction.reference" />
</p>
</div>
<div class="checkout-actions d-grid gap-3">
<a t-att-href="sale_order.get_portal_url()" class="btn btn-outline-secondary btn-lg">
<i class="fa fa-file-text-o" aria-hidden="true" t-translation="off" />
<span>View my order</span>
</a>
</div>
</t>
<t t-elif="payment_ready and payment_available">
<h4 class="summary-heading mb-3">Payment Method</h4>
<!-- The whole provider UI is payment.form; this addon
configures no provider of its own. -->
<div id="payment_method" class="o_not_editable mb-4">
<t t-call="payment.form" />
</div>
<!-- Deliberately outside #o_payment_form: website_sale's
payment_form.js binds every
[name="o_payment_submit_button"] in the document, on
top of payment_form.js's own delegated handler inside
the form. A button inside would get both listeners and
one click would open two transactions. -->
<div class="checkout-actions d-grid gap-3">
<t t-call="payment.submit_button" />
<a t-attf-href="/eskaera/{{ group_order.slug }}" class="btn btn-outline-secondary btn-lg" aria-label="Back to cart page" title="Back to Cart" data-bs-toggle="tooltip">
<i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
<span>Back to Cart</span>
</a>
</div>
</t>
<t t-else="">
<div class="alert alert-warning" role="alert" t-esc="no_payment_method_message" />
<div class="checkout-actions d-grid gap-3">
<a t-attf-href="/eskaera/{{ group_order.slug }}" class="btn btn-outline-secondary btn-lg" aria-label="Back to cart page" title="Back to Cart" data-bs-toggle="tooltip">
<i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
<span>Back to Cart</span>
</a>
</div>
</t>
</t>
<t t-else="">
<!-- No online payment: the draft is confirmed in bulk by
the cutoff cron, so the button only re-saves the cart. -->
<div class="checkout-actions d-grid gap-3" id="checkout-form-labels">
<button class="btn btn-success btn-lg" id="confirm-order-btn" t-attf-data-order-id="{{ group_order.id }}" t-att-data-confirmed-label="checkout_button['done_label']" t-att-data-pickup-label="labels.get('pickup_day_label', 'Pickup Day')" t-att-aria-label="checkout_button['hint']" t-att-data-bs-title="checkout_button['label']" t-att-data-tooltip-key="checkout_button['tooltip_key']" data-bs-toggle="tooltip">
<i t-attf-class="fa {{ checkout_button['icon'] }}" aria-hidden="true" t-translation="off" />
<span t-esc="checkout_button['label']" />
</button>
<a t-attf-href="/eskaera/{{ group_order.slug }}" class="btn btn-outline-secondary btn-lg" aria-label="Back to cart page" title="Back to Cart" data-bs-toggle="tooltip">
<i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
<span>Back to Cart</span>
</a>
</div>
</t>
</t>
<t t-else="">
<div class="checkout-actions d-grid gap-3">
<a t-attf-href="/eskaera/{{ group_order.slug }}" class="btn btn-success btn-lg" aria-label="Back to cart page" title="Back to Cart" data-bs-toggle="tooltip">
<i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
<span>Back to Cart</span>
</a>
</div>
</div>
</t>
<t t-if="online_payment and not payment_available">
<div class="alert alert-warning" role="alert" t-esc="no_payment_method_message" />
</t>
<div class="checkout-actions d-grid gap-3" id="checkout-form-labels">
<button class="btn btn-success btn-lg" id="confirm-order-btn" t-attf-data-order-id="{{ group_order.id }}" t-att-data-confirmed-label="checkout_button['done_label']" t-att-data-pickup-label="labels.get('pickup_day_label', 'Pickup Day')" t-att-aria-label="checkout_button['hint']" t-att-data-bs-title="checkout_button['label']" t-att-data-tooltip-key="checkout_button['tooltip_key']" t-att-disabled="online_payment and not payment_available" data-bs-toggle="tooltip">
<i t-attf-class="fa {{ checkout_button['icon'] }}" aria-hidden="true" t-translation="off" />
<span t-esc="checkout_button['label']" />
</button>
<a t-attf-href="/eskaera/{{ group_order.slug }}" class="btn btn-outline-secondary btn-lg" aria-label="Back to cart page" title="Back to Cart" data-bs-toggle="tooltip">
<i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
<span>Back to Cart</span>
</a>
</div>
</div>
</div>
</div>
@ -593,45 +637,14 @@
console.log('[LABELS] Initialized from server:', window.groupOrderShop.labels);
})();
</script>
<script type="text/javascript">
// Auto-load cart from localStorage when accessing checkout directly
(function() {
'use strict';
// Get order ID from button
var confirmBtn = document.getElementById('confirm-order-btn');
if (!confirmBtn) return;
var orderId = confirmBtn.getAttribute('data-order-id');
var cartKey = 'eskaera_' + orderId + '_cart';
// Check if there's a saved cart and load it
var savedCart = localStorage.getItem(cartKey);
if (savedCart) {
try {
var cart = JSON.parse(savedCart);
console.log('[CHECKOUT AUTO-LOAD] Cart found in localStorage:', cart);
// Simulate cart loading by triggering a custom event
// The checkout_labels.js will listen for cart data
var event = new CustomEvent('cartLoaded', { detail: { cart: cart } });
document.dispatchEvent(event);
} catch (e) {
console.error('[CHECKOUT AUTO-LOAD] Error parsing cart:', e);
}
} else {
console.log('[CHECKOUT AUTO-LOAD] No cart found in localStorage');
}
})();
</script>
</div>
</t>
</template>
<template id="eskaera_order_lines_summary" name="Placed Order Summary">
<!-- Server-side twin of eskaera_checkout_summary: from the payment
step on, the amounts shown must be the ones that will be
charged, so they come from the sale.order and not from the
localStorage cart. -->
<!-- The order summary of every page past the shop. The cart is a
localStorage object while the member shops, but from the
checkout on the amounts shown must be the ones that will be
charged, so they come from the sale.order. -->
<div class="checkout-summary-container" role="region" tabindex="0" aria-label="Order summary">
<table class="table table-hover checkout-summary-table">
<caption class="visually-hidden">Products in this order, with quantity, price and subtotal</caption>
@ -662,69 +675,6 @@
</div>
</div>
</template>
<template id="eskaera_payment" name="Eskaera Payment">
<t t-call="website.layout">
<div id="wrap" class="eskaera-checkout-page oe_structure oe_empty" data-name="Eskaera Payment">
<div class="container mt-5">
<div class="row">
<div class="col-lg-10 offset-lg-1">
<div class="mb-4">
<t t-call="website_sale_aplicoop.order_header">
<t t-set="header_class" t-value="'checkout-header'" />
<t t-set="header_title">Pay Order: <t t-esc="group_order.name" />
</t>
</t>
</div>
<h4 class="summary-heading mb-3">Order Summary</h4>
<div class="mb-5">
<t t-call="website_sale_aplicoop.eskaera_order_lines_summary" />
</div>
<t t-if="pending_transaction">
<div class="alert alert-info" role="alert">
<h5 class="alert-heading">
<i class="fa fa-clock-o me-2" aria-hidden="true" t-translation="off" />
<span>Payment in progress</span>
</h5>
<p class="mb-1">We are still waiting for your payment to be confirmed. Please do not pay again.</p>
<p class="mb-0">
<span>Reference</span>:
<span t-esc="pending_transaction.reference" />
</p>
</div>
<div class="checkout-actions d-grid gap-3">
<a t-att-href="sale_order.get_portal_url()" class="btn btn-outline-secondary btn-lg">
<i class="fa fa-file-text-o" aria-hidden="true" t-translation="off" />
<span>View my order</span>
</a>
</div>
</t>
<t t-else="">
<h4 class="summary-heading mb-3">Payment Method</h4>
<!-- The whole provider UI is payment.form; this addon
configures no provider of its own. -->
<div id="payment_method" class="o_not_editable mb-4">
<t t-call="payment.form" />
</div>
<!-- Deliberately outside #o_payment_form: website_sale's
payment_form.js binds every
[name="o_payment_submit_button"] in the document, on
top of payment_form.js's own delegated handler inside
the form. A button inside would get both listeners and
one click would open two transactions. -->
<div class="checkout-actions d-grid gap-3">
<t t-call="payment.submit_button" />
<a t-attf-href="/eskaera/{{ group_order.slug }}/checkout" class="btn btn-outline-secondary btn-lg">
<i class="fa fa-arrow-left" aria-hidden="true" t-translation="off" />
<span>Back to Checkout</span>
</a>
</div>
</t>
</div>
</div>
</div>
</div>
</t>
</template>
<template id="eskaera_payment_confirmation" name="Eskaera Payment Confirmation">
<t t-call="website.layout">
<div id="wrap" class="eskaera-checkout-page oe_structure oe_empty" data-name="Eskaera Payment Confirmation" t-attf-data-clear-cart-order-id="{{ group_order.id }}">