[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:
parent
f81c1ca8e7
commit
e625b0c2f3
11 changed files with 873 additions and 746 deletions
|
|
@ -1,5 +1,36 @@
|
|||
# Changelog - Website Sale Aplicoop
|
||||
|
||||
## [18.0.1.16.0] - 2026-08-17
|
||||
|
||||
### Changed
|
||||
|
||||
- **Payment moved into the checkout**: the separate `/eskaera/<slug>/payment`
|
||||
step is gone. Members review the summary, pick home delivery and choose a
|
||||
payment method on `/eskaera/<slug>/checkout`, in one screen. The old URL
|
||||
redirects there so bookmarks and in-flight sessions keep working.
|
||||
- **The checkout summary is server-side**: it renders the draft `sale.order`
|
||||
instead of the localStorage cart, so what is shown is what gets charged.
|
||||
"Proceed to Checkout" pushes the cart to that draft before navigating,
|
||||
reusing the cycle's draft and rewriting its lines only when they differ.
|
||||
- The home delivery checkbox on the checkout writes to the order through
|
||||
`/eskaera/set-home-delivery`, so the total and the payment amount follow it.
|
||||
|
||||
### Fixed
|
||||
|
||||
- The confirmation notice after saving a draft was destroyed by the immediate
|
||||
redirect to the payment step, so members saw nothing at all. Saving no longer
|
||||
navigates.
|
||||
- Products the member had deleted reappeared between the checkout and the
|
||||
payment page. The checkout auto-loaded the draft into localStorage after the
|
||||
summary had already rendered, resurrecting deleted lines, which were then
|
||||
sent back on confirm. The checkout no longer auto-loads the draft — it
|
||||
renders it.
|
||||
|
||||
### Removed
|
||||
|
||||
- `checkout_labels.js` and the `eskaera_checkout_summary` / `eskaera_payment`
|
||||
templates, superseded by the server-rendered summary and checkout.
|
||||
|
||||
## [18.0.1.13.0] - 2026-08-11
|
||||
|
||||
### Added
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
{ # noqa: B018
|
||||
"name": "Website Sale - Aplicoop",
|
||||
"version": "18.0.1.15.0",
|
||||
"version": "18.0.1.16.0",
|
||||
"category": "Website/Sale",
|
||||
"summary": "Modern replacement of legacy Aplicoop - Collaborative consumption group orders",
|
||||
"author": "Odoo Community Association (OCA), Criptomart",
|
||||
|
|
@ -71,7 +71,6 @@
|
|||
"website_sale_aplicoop/static/src/js/i18n_helpers.js",
|
||||
# Core shop functionality
|
||||
"website_sale_aplicoop/static/src/js/website_sale.js",
|
||||
"website_sale_aplicoop/static/src/js/checkout_labels.js",
|
||||
"website_sale_aplicoop/static/src/js/home_delivery.js",
|
||||
"website_sale_aplicoop/static/src/js/eskaera_payment.js",
|
||||
# Search and pagination
|
||||
|
|
|
|||
|
|
@ -526,6 +526,39 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
product_max_qty[p.id] = net
|
||||
return products_ctx, product_max_qty
|
||||
|
||||
def _draft_matches_lines(self, draft, sale_order_lines, home_delivery):
|
||||
"""Whether `draft` already holds exactly `sale_order_lines`.
|
||||
|
||||
The cart is pushed to the draft on every step of the flow (saving from
|
||||
the shop, entering the checkout), so most of those writes have nothing
|
||||
to change. Rewriting `order_line` anyway would unlink and recreate
|
||||
every line for no reason — it moves their ids, bumps `write_date` and
|
||||
drops anything attached to them.
|
||||
"""
|
||||
if draft.home_delivery != home_delivery:
|
||||
return False
|
||||
if len(draft.order_line) != len(sale_order_lines):
|
||||
return False
|
||||
|
||||
currency = draft.currency_id
|
||||
|
||||
def _key(product_id, qty, price):
|
||||
return (product_id, round(qty, 3), currency.round(price))
|
||||
|
||||
current = sorted(
|
||||
_key(line.product_id.id, line.product_uom_qty, line.price_unit)
|
||||
for line in draft.order_line
|
||||
)
|
||||
incoming = sorted(
|
||||
_key(
|
||||
vals["product_id"],
|
||||
vals["product_uom_qty"],
|
||||
vals["price_unit"],
|
||||
)
|
||||
for _command, _id, vals in sale_order_lines
|
||||
)
|
||||
return current == incoming
|
||||
|
||||
def _merge_or_replace_draft(
|
||||
self,
|
||||
group_order,
|
||||
|
|
@ -555,6 +588,14 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
|
||||
if existing_drafts:
|
||||
draft = existing_drafts[0].sudo()
|
||||
if self._draft_matches_lines(
|
||||
draft, sale_order_lines, effective_home_delivery
|
||||
):
|
||||
_logger.info(
|
||||
"Draft order %s already matches the cart, left untouched",
|
||||
draft.id,
|
||||
)
|
||||
return draft
|
||||
_logger.info(
|
||||
"Replacing existing draft order %s for partner %s",
|
||||
draft.id,
|
||||
|
|
@ -1227,36 +1268,13 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
self._eskaera_payment_confirmation_url(group_order, placed_order)
|
||||
)
|
||||
|
||||
# Los datos del carrito vienen desde localStorage en el frontend
|
||||
# Esta página solo muestra resumen y botón de confirmación
|
||||
|
||||
# DEBUG: Log ALL delivery fields
|
||||
_logger.warning("=== ESKAERA_CHECKOUT DELIVERY DEBUG ===")
|
||||
_logger.warning("group_order.id: %s", group_order.id)
|
||||
_logger.warning("group_order.name: %s", group_order.name)
|
||||
_logger.warning(
|
||||
"group_order.pickup_day: %s (type: %s)",
|
||||
group_order.pickup_day,
|
||||
type(group_order.pickup_day),
|
||||
# The cart itself still lives in localStorage while the member shops,
|
||||
# but from here on everything is read off their draft sale.order: the
|
||||
# checkout shows the very lines and amounts that are about to be paid.
|
||||
# The shop pushes the cart to that draft before sending them here.
|
||||
order_sudo = self._find_recent_draft_order(
|
||||
request.env.user.partner_id.id, group_order
|
||||
)
|
||||
_logger.warning(
|
||||
"group_order.pickup_date: %s (type: %s)",
|
||||
group_order.pickup_date,
|
||||
type(group_order.pickup_date),
|
||||
)
|
||||
_logger.warning(
|
||||
"group_order.delivery_date: %s (type: %s)",
|
||||
group_order.delivery_date,
|
||||
type(group_order.delivery_date),
|
||||
)
|
||||
_logger.warning("group_order.home_delivery: %s", group_order.home_delivery)
|
||||
_logger.warning("group_order.delivery_notice: %s", group_order.delivery_notice)
|
||||
if group_order.pickup_date:
|
||||
_logger.warning(
|
||||
"pickup_date formatted: %s",
|
||||
group_order.pickup_date.strftime("%d/%m/%Y"),
|
||||
)
|
||||
_logger.warning("========================================")
|
||||
|
||||
# Get delivery product from group_order (configured per group order)
|
||||
delivery_product = group_order.delivery_product_id
|
||||
|
|
@ -1278,38 +1296,24 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
labels_json = json.dumps(labels, ensure_ascii=False)
|
||||
|
||||
# Online payment turns the primary button from "save a draft" into
|
||||
# "confirm and pay". Everything the template needs is resolved here so
|
||||
# it only reads attributes.
|
||||
# the payment form itself. Everything the template needs is resolved
|
||||
# here so it only reads attributes.
|
||||
online_payment = bool(group_order.online_payment)
|
||||
payment_available = online_payment and self._has_available_payment_method(
|
||||
group_order
|
||||
)
|
||||
if online_payment:
|
||||
checkout_button = {
|
||||
"label": labels.get("confirm_and_pay", "Confirm and pay"),
|
||||
"hint": labels.get(
|
||||
"confirm_and_pay_hint", "Confirm the order and go to payment"
|
||||
),
|
||||
"done_label": labels.get(
|
||||
"order_ready_for_payment", "Order ready for payment"
|
||||
),
|
||||
"icon": "fa-credit-card",
|
||||
"tooltip_key": "confirm_and_pay",
|
||||
}
|
||||
else:
|
||||
checkout_button = {
|
||||
"label": labels.get("save_draft", "Save Draft"),
|
||||
"hint": labels.get("save_order_as_draft", "Save order as draft"),
|
||||
"done_label": labels.get(
|
||||
"order_saved_as_draft", "Order saved as draft"
|
||||
),
|
||||
"icon": "fa-save",
|
||||
"tooltip_key": "save_draft",
|
||||
}
|
||||
checkout_button = {
|
||||
"label": labels.get("save_draft", "Save Draft"),
|
||||
"hint": labels.get("save_order_as_draft", "Save order as draft"),
|
||||
"done_label": labels.get("order_saved_as_draft", "Order saved as draft"),
|
||||
"icon": "fa-save",
|
||||
"tooltip_key": "save_draft",
|
||||
}
|
||||
|
||||
# Prepare template context with explicit debug info
|
||||
template_context = {
|
||||
"group_order": group_order,
|
||||
"sale_order": order_sudo,
|
||||
"day_names": self._get_day_names(env=request.env),
|
||||
"delivery_product_id": delivery_product_id,
|
||||
"delivery_product_name": delivery_product_name, # Auto-translated to user's language
|
||||
|
|
@ -1328,7 +1332,13 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
),
|
||||
}
|
||||
|
||||
_logger.warning("Template context keys: %s", list(template_context.keys()))
|
||||
if online_payment:
|
||||
redirect_url, payment_values = self._prepare_checkout_payment_context(
|
||||
group_order, order_sudo
|
||||
)
|
||||
if redirect_url:
|
||||
return request.redirect(redirect_url)
|
||||
template_context.update(payment_values)
|
||||
|
||||
return request.render(
|
||||
"website_sale_aplicoop.eskaera_checkout", template_context
|
||||
|
|
@ -1336,9 +1346,13 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
|
||||
# === Online payment ===
|
||||
|
||||
def _eskaera_payment_url(self, group_order):
|
||||
"""Return the payment step URL of `group_order`."""
|
||||
return self._eskaera_url(group_order, suffix="/payment")
|
||||
def _eskaera_checkout_url(self, group_order):
|
||||
"""Return the checkout URL of `group_order`.
|
||||
|
||||
Payment happens on the checkout page: there is no separate step, so
|
||||
the member picks a method next to the very summary they just read.
|
||||
"""
|
||||
return self._eskaera_url(group_order, suffix="/checkout")
|
||||
|
||||
def _eskaera_payment_confirmation_url(self, group_order, sale_order):
|
||||
"""Return the landing URL shown once `sale_order` has been paid."""
|
||||
|
|
@ -1401,6 +1415,44 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
)
|
||||
return values
|
||||
|
||||
def _prepare_checkout_payment_context(self, group_order, order_sudo):
|
||||
"""Resolve the payment half of the checkout page.
|
||||
|
||||
Returns `(redirect_url, values)`: a non-empty `redirect_url` means the
|
||||
member has nothing left to pay here and the caller must send them
|
||||
away instead of rendering. Otherwise `values` completes the checkout
|
||||
context — either the payment form, or the notice of a transaction
|
||||
already under way.
|
||||
"""
|
||||
if not order_sudo:
|
||||
# Nothing saved for this cycle yet: the page renders its empty
|
||||
# state and there is no amount to build a payment form around.
|
||||
return None, {"pending_transaction": False, "payment_ready": False}
|
||||
|
||||
# A transaction already under way must not be duplicated: the first one
|
||||
# to reach `done` confirms the order, so a second would be an overpay.
|
||||
last_tx = order_sudo.get_portal_last_transaction()
|
||||
if last_tx and last_tx.state in ("pending", "authorized", "done"):
|
||||
if last_tx.state == "done":
|
||||
return (
|
||||
self._eskaera_payment_confirmation_url(group_order, order_sudo),
|
||||
{},
|
||||
)
|
||||
return None, {"pending_transaction": last_tx, "payment_ready": False}
|
||||
|
||||
# `_get_payment_values` prices the form as amount_total - amount_paid;
|
||||
# a non-positive amount has nothing left to charge and would silently
|
||||
# render the "no payment method" warning.
|
||||
if order_sudo.currency_id.compare_amounts(order_sudo.amount_total, 0) <= 0:
|
||||
return (
|
||||
self._eskaera_payment_confirmation_url(group_order, order_sudo),
|
||||
{},
|
||||
)
|
||||
|
||||
values = self._get_eskaera_payment_values(group_order, order_sudo)
|
||||
values.update({"pending_transaction": False, "payment_ready": True})
|
||||
return None, values
|
||||
|
||||
@http.route(
|
||||
["/eskaera/<string:group_order_slug>/payment"],
|
||||
type="http",
|
||||
|
|
@ -1409,68 +1461,13 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
)
|
||||
@eskaera_route
|
||||
def eskaera_payment(self, group_order_slug, **post):
|
||||
"""Payment step: pick a method and pay the order placed at checkout."""
|
||||
"""Legacy payment step, folded into the checkout page.
|
||||
|
||||
Kept so bookmarks and sessions in flight when the step disappeared
|
||||
land somewhere sensible rather than on a 404.
|
||||
"""
|
||||
group_order = self._get_group_order_by_slug(group_order_slug)
|
||||
if not group_order or not group_order.online_payment:
|
||||
return request.redirect(self._eskaera_url(group_order, suffix="/checkout"))
|
||||
|
||||
# Entry gate only. Once an order exists the transaction route and the
|
||||
# landing page stay reachable even if the cycle closes meanwhile,
|
||||
# otherwise a member who is already at the provider would come back to
|
||||
# a redirect and their payment would be orphaned.
|
||||
if group_order.state != "open":
|
||||
return request.redirect("/eskaera")
|
||||
|
||||
partner = request.env.user.partner_id
|
||||
try:
|
||||
self._validate_user_group_access(group_order, request.env.user)
|
||||
except ValueError:
|
||||
return request.redirect("/eskaera")
|
||||
|
||||
placed_order = self._find_placed_cycle_order(partner.id, group_order)
|
||||
if placed_order:
|
||||
return request.redirect(
|
||||
self._eskaera_payment_confirmation_url(group_order, placed_order)
|
||||
)
|
||||
|
||||
order_sudo = self._find_recent_draft_order(partner.id, group_order)
|
||||
if not order_sudo:
|
||||
return request.redirect(self._eskaera_url(group_order, suffix="/checkout"))
|
||||
|
||||
# A transaction already under way must not be duplicated: the first one
|
||||
# to reach `done` confirms the order, so a second would be an overpay.
|
||||
last_tx = order_sudo.get_portal_last_transaction()
|
||||
if last_tx and last_tx.state in ("pending", "authorized", "done"):
|
||||
if last_tx.state == "done":
|
||||
return request.redirect(
|
||||
self._eskaera_payment_confirmation_url(group_order, order_sudo)
|
||||
)
|
||||
return request.render(
|
||||
"website_sale_aplicoop.eskaera_payment",
|
||||
{
|
||||
"group_order": group_order,
|
||||
"sale_order": order_sudo,
|
||||
"pending_transaction": last_tx,
|
||||
},
|
||||
)
|
||||
|
||||
# `_get_payment_values` prices the form as amount_total - amount_paid;
|
||||
# a non-positive amount has nothing left to charge and would silently
|
||||
# render the "no payment method" warning.
|
||||
if order_sudo.currency_id.compare_amounts(order_sudo.amount_total, 0) <= 0:
|
||||
return request.redirect(
|
||||
self._eskaera_payment_confirmation_url(group_order, order_sudo)
|
||||
)
|
||||
|
||||
values = self._get_eskaera_payment_values(group_order, order_sudo)
|
||||
values.update(
|
||||
{
|
||||
"group_order": group_order,
|
||||
"sale_order": order_sudo,
|
||||
"pending_transaction": False,
|
||||
}
|
||||
)
|
||||
return request.render("website_sale_aplicoop.eskaera_payment", values)
|
||||
return request.redirect(self._eskaera_checkout_url(group_order))
|
||||
|
||||
@http.route(
|
||||
["/eskaera/<string:group_order_slug>/payment/confirmation/<int:order_id>"],
|
||||
|
|
@ -1969,12 +1966,12 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
"sale_order_id": sale_order.id,
|
||||
"pickup_slot_label": pickup_slot_label,
|
||||
}
|
||||
# With online payment on, saving the cart is only the first half of
|
||||
# placing the order: the frontend follows this URL to the payment
|
||||
# step. The server builds it so the client never assembles routes.
|
||||
# No redirect here. Saving used to bounce the member to a separate
|
||||
# payment step, which also killed the confirmation notice on the
|
||||
# way out; payment now lives on the checkout page, and the caller
|
||||
# decides whether it is staying or moving on.
|
||||
if group_order.online_payment:
|
||||
response_data["message"] = request.env._("Order ready for payment")
|
||||
response_data["redirect_url"] = self._eskaera_payment_url(group_order)
|
||||
|
||||
return request.make_response(
|
||||
json.dumps(response_data),
|
||||
|
|
@ -1992,6 +1989,150 @@ class AplicoopWebsiteSale(WebsiteSale):
|
|||
status=500,
|
||||
)
|
||||
|
||||
@http.route(
|
||||
["/eskaera/set-home-delivery"],
|
||||
type="http",
|
||||
auth="user",
|
||||
website=True,
|
||||
methods=["POST"],
|
||||
csrf=False,
|
||||
)
|
||||
@eskaera_route
|
||||
def set_eskaera_home_delivery(self, **post):
|
||||
"""Move the delivery line on and off the cycle draft.
|
||||
|
||||
On the shop the delivery product is just another cart entry, but the
|
||||
checkout renders the sale.order, and its total is what the payment
|
||||
form charges. So the toggle has to reach the order itself; the client
|
||||
only says yes or no.
|
||||
"""
|
||||
try:
|
||||
data = self._decode_json_body()
|
||||
|
||||
order_id = data.get("order_id")
|
||||
try:
|
||||
order_id = int(order_id)
|
||||
except (TypeError, ValueError):
|
||||
return request.make_response(
|
||||
json.dumps({"error": "order_id is required"}),
|
||||
[("Content-Type", "application/json")],
|
||||
status=400,
|
||||
)
|
||||
|
||||
group_order = request.env["group.order"].sudo().browse(order_id).exists()
|
||||
if not group_order:
|
||||
return request.make_response(
|
||||
json.dumps({"error": f"Order {order_id} not found"}),
|
||||
[("Content-Type", "application/json")],
|
||||
status=400,
|
||||
)
|
||||
if group_order.state != "open":
|
||||
return self._build_group_order_unavailable_response(group_order)
|
||||
|
||||
current_user = request.env.user
|
||||
try:
|
||||
self._validate_user_group_access(group_order, current_user)
|
||||
except ValueError as e:
|
||||
return request.make_response(
|
||||
json.dumps({"error": str(e)}),
|
||||
[("Content-Type", "application/json")],
|
||||
status=403,
|
||||
)
|
||||
|
||||
if placed_response := self._build_already_placed_response(
|
||||
current_user.partner_id.id, group_order
|
||||
):
|
||||
return placed_response
|
||||
|
||||
draft = self._find_recent_draft_order(
|
||||
current_user.partner_id.id, group_order
|
||||
)
|
||||
if not draft:
|
||||
return request.make_response(
|
||||
json.dumps(
|
||||
{
|
||||
"error": request.env._(
|
||||
"No draft orders found for the current order period"
|
||||
)
|
||||
}
|
||||
),
|
||||
[("Content-Type", "application/json")],
|
||||
status=404,
|
||||
)
|
||||
draft = draft[0].sudo()
|
||||
|
||||
is_delivery = self._to_bool(data.get("is_delivery", False))
|
||||
effective_delivery, commitment_date = self._get_effective_delivery_context(
|
||||
group_order, is_delivery
|
||||
)
|
||||
delivery_product = group_order.delivery_product_id
|
||||
|
||||
existing_lines = draft.order_line.filtered(
|
||||
lambda line: line.product_id == delivery_product
|
||||
)
|
||||
if effective_delivery and delivery_product:
|
||||
if not existing_lines:
|
||||
pricing = self._get_pricing_info(
|
||||
delivery_product, self._resolve_pricelist(), quantity=1.0
|
||||
)
|
||||
draft.write(
|
||||
{
|
||||
"order_line": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"product_id": delivery_product.id,
|
||||
"product_uom_qty": 1.0,
|
||||
"price_unit": pricing.get(
|
||||
"price_unit", delivery_product.list_price
|
||||
),
|
||||
"name": delivery_product.with_context(
|
||||
lang=request.env.lang
|
||||
).name,
|
||||
},
|
||||
)
|
||||
]
|
||||
}
|
||||
)
|
||||
elif existing_lines:
|
||||
existing_lines.unlink()
|
||||
|
||||
draft.write(
|
||||
{
|
||||
"home_delivery": effective_delivery,
|
||||
"commitment_date": commitment_date,
|
||||
}
|
||||
)
|
||||
|
||||
_logger.info(
|
||||
"set_eskaera_home_delivery: order %s home_delivery=%s",
|
||||
draft.id,
|
||||
effective_delivery,
|
||||
)
|
||||
|
||||
return request.make_response(
|
||||
json.dumps(
|
||||
{
|
||||
"success": True,
|
||||
"is_delivery": effective_delivery,
|
||||
"sale_order_id": draft.id,
|
||||
"delivery_product_id": (
|
||||
delivery_product.id if delivery_product else None
|
||||
),
|
||||
}
|
||||
),
|
||||
[("Content-Type", "application/json")],
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_logger.exception("set_eskaera_home_delivery: Unexpected error")
|
||||
return request.make_response(
|
||||
json.dumps({"error": str(e)}),
|
||||
[("Content-Type", "application/json")],
|
||||
status=500,
|
||||
)
|
||||
|
||||
@http.route(
|
||||
["/eskaera/confirm"],
|
||||
type="http",
|
||||
|
|
|
|||
|
|
@ -73,9 +73,10 @@ confirms in bulk.
|
|||
offer it everywhere, or set it to restrict the provider to one site.
|
||||
#. Open the group order form → *Online Payment* tab → tick **Online payment**.
|
||||
|
||||
With the flag on, the checkout button becomes "Confirm and pay" and paying is
|
||||
the only way to place an order: the member goes through
|
||||
``/eskaera/<slug>/payment``, picks a method, and their sale order is confirmed
|
||||
as soon as the transaction completes. Members who have not paid by the cutoff
|
||||
With the flag on, paying is the only way to place an order: the payment
|
||||
methods appear on ``/eskaera/<slug>/checkout``, next to the order summary, and
|
||||
the member's sale order is confirmed as soon as the transaction completes.
|
||||
There is no separate payment step — the old ``/eskaera/<slug>/payment`` URL
|
||||
redirects to the checkout. Members who have not paid by the cutoff
|
||||
date still get their draft confirmed by the cron, exactly as they do today —
|
||||
the flag decides how orders are placed, not who gets served.
|
||||
|
|
|
|||
|
|
@ -50,9 +50,11 @@ Paying an order online
|
|||
When the group order has online payment enabled:
|
||||
|
||||
#. Build the cart in ``/eskaera/<slug>`` as usual
|
||||
#. Go to the checkout, review the summary and choose home delivery if offered
|
||||
#. Press **Confirm and pay**: the order is saved and the payment step opens
|
||||
#. Pick a payment method and pay; you are sent to the provider and back
|
||||
#. Press **Proceed to Checkout**: the cart is saved to your order first, so
|
||||
the checkout shows the very lines and amounts that will be charged
|
||||
#. Review the summary, choose home delivery if offered, and pick a payment
|
||||
method — all on the same page
|
||||
#. Press **Confirm and pay**; you are sent to the provider and back
|
||||
#. The confirmation page shows the outcome and empties the local cart
|
||||
|
||||
The order is confirmed the moment the payment goes through, so it can no
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
})();
|
||||
|
|
@ -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 () {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
# Copyright 2026 Criptomart
|
||||
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
|
||||
|
||||
import json
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
|
@ -471,22 +472,25 @@ class TestOnlinePaymentRoutes(HttpCase):
|
|||
def _slug_url(self, suffix=""):
|
||||
return f"/eskaera/{self.group_order.slug}{suffix}"
|
||||
|
||||
def test_payment_page_renders(self):
|
||||
"""The payment step renders for a member with a draft in the cycle."""
|
||||
self._create_draft()
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
response = self.url_open(self._slug_url("/payment"), allow_redirects=True)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(
|
||||
'data-name="Eskaera Payment"',
|
||||
response.text,
|
||||
"The payment step should render its own page, not redirect away",
|
||||
def _publish_a_provider(self):
|
||||
"""Make one provider usable, so the checkout renders the payment form."""
|
||||
provider = self.env.ref("payment.payment_provider_transfer")
|
||||
provider.write(
|
||||
{
|
||||
"state": "test",
|
||||
"is_published": True,
|
||||
"company_id": self.group_order.company_id.id,
|
||||
}
|
||||
)
|
||||
return provider
|
||||
|
||||
def test_payment_page_needs_a_draft(self):
|
||||
"""With nothing in the cart there is nothing to pay for."""
|
||||
def test_legacy_payment_step_redirects_to_checkout(self):
|
||||
"""The separate payment step is gone; its URL lands on the checkout.
|
||||
|
||||
Kept as a redirect rather than dropped so bookmarks, and sessions
|
||||
that were mid-flow when the step disappeared, do not hit a 404.
|
||||
"""
|
||||
self._create_draft()
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
response = self.url_open(self._slug_url("/payment"), allow_redirects=False)
|
||||
|
|
@ -494,8 +498,8 @@ class TestOnlinePaymentRoutes(HttpCase):
|
|||
self.assertEqual(response.status_code, 303)
|
||||
self.assertTrue(response.headers["Location"].endswith("/checkout"))
|
||||
|
||||
def test_payment_page_off_without_online_payment(self):
|
||||
"""The step does not exist for a group order that takes no payments."""
|
||||
def test_legacy_payment_step_redirects_without_online_payment(self):
|
||||
"""Same for a group order that takes no payments at all."""
|
||||
self.group_order.online_payment = False
|
||||
self._create_draft()
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
|
@ -505,22 +509,69 @@ class TestOnlinePaymentRoutes(HttpCase):
|
|||
self.assertEqual(response.status_code, 303)
|
||||
self.assertTrue(response.headers["Location"].endswith("/checkout"))
|
||||
|
||||
def test_checkout_offers_payment(self):
|
||||
"""The checkout button turns into the 'confirm and pay' variant.
|
||||
|
||||
Asserted on `data-tooltip-key` rather than the label: the website runs
|
||||
in whatever language the visitor picked, and the label is translated.
|
||||
"""
|
||||
def test_checkout_renders_the_payment_form(self):
|
||||
"""Picking a payment method happens on the checkout, in one screen."""
|
||||
self._publish_a_provider()
|
||||
self._create_draft()
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
response = self.url_open(self._slug_url("/checkout"), allow_redirects=True)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn('data-tooltip-key="confirm_and_pay"', response.text)
|
||||
self.assertIn(
|
||||
'id="payment_method"',
|
||||
response.text,
|
||||
"The payment form belongs on the checkout page",
|
||||
)
|
||||
self.assertIn(
|
||||
'data-name="Eskaera Checkout"',
|
||||
response.text,
|
||||
"There must be no separate payment step to redirect to",
|
||||
)
|
||||
|
||||
def test_checkout_summary_comes_from_the_order(self):
|
||||
"""The summary is the order, so it cannot drift from what is charged.
|
||||
|
||||
The cart lives in localStorage while the member shops, and the old
|
||||
client-rendered summary was a snapshot of it taken at page load: a
|
||||
draft reloaded afterwards silently added its own lines back, which
|
||||
only became visible one page later, at payment time.
|
||||
"""
|
||||
self._publish_a_provider()
|
||||
self._create_draft()
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
response = self.url_open(self._slug_url("/checkout"), allow_redirects=True)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(self.product.name, response.text)
|
||||
self.assertNotIn(
|
||||
'id="checkout-summary-tbody"',
|
||||
response.text,
|
||||
"The summary must not be the client-rendered table any more",
|
||||
)
|
||||
|
||||
def test_checkout_without_a_draft_shows_the_empty_state(self):
|
||||
"""Nothing saved for the cycle: nothing to summarise and nothing to pay."""
|
||||
self._publish_a_provider()
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
response = self.url_open(self._slug_url("/checkout"), allow_redirects=True)
|
||||
|
||||
# Asserted on markup rather than the empty-state wording: the website
|
||||
# runs in whatever language the visitor picked.
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotIn(
|
||||
"checkout-summary-table",
|
||||
response.text,
|
||||
"With no order there is nothing to summarise",
|
||||
)
|
||||
self.assertNotIn('id="payment_method"', response.text)
|
||||
|
||||
def test_checkout_keeps_save_draft_without_online_payment(self):
|
||||
"""With the flag off the checkout is exactly what it was."""
|
||||
self.group_order.online_payment = False
|
||||
self._create_draft()
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
response = self.url_open(self._slug_url("/checkout"), allow_redirects=True)
|
||||
|
|
@ -566,3 +617,120 @@ class TestOnlinePaymentRoutes(HttpCase):
|
|||
|
||||
self.assertEqual(response.status_code, 303)
|
||||
self.assertIn(f"/payment/confirmation/{order.id}", response.headers["Location"])
|
||||
|
||||
def _post_json(self, route, payload):
|
||||
return self.url_open(
|
||||
route,
|
||||
data=json.dumps(payload),
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
def _save_cart(self, quantity, is_delivery=None):
|
||||
payload = {
|
||||
"order_id": self.group_order.id,
|
||||
"items": [
|
||||
{
|
||||
"product_id": self.product.id,
|
||||
"product_name": self.product.name,
|
||||
"quantity": quantity,
|
||||
"product_price": 10.0,
|
||||
}
|
||||
],
|
||||
}
|
||||
if is_delivery is not None:
|
||||
payload["is_delivery"] = is_delivery
|
||||
return self._post_json("/eskaera/save-order", payload)
|
||||
|
||||
def _cycle_drafts(self):
|
||||
return self.env["sale.order"].search(
|
||||
[
|
||||
("partner_id", "=", self.member_partner.id),
|
||||
("group_order_id", "=", self.group_order.id),
|
||||
("state", "=", "draft"),
|
||||
]
|
||||
)
|
||||
|
||||
def test_saving_the_cart_again_updates_the_same_draft(self):
|
||||
"""Pushing the cart reuses the cycle draft rather than adding another.
|
||||
|
||||
The cart reaches the server from the shop's save button and again on
|
||||
the way into the checkout, so this runs on every ordinary flow.
|
||||
"""
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
first = self._save_cart(1)
|
||||
self.assertEqual(first.status_code, 200)
|
||||
order_id = first.json()["sale_order_id"]
|
||||
|
||||
second = self._save_cart(3)
|
||||
self.assertEqual(second.status_code, 200)
|
||||
self.assertEqual(
|
||||
second.json()["sale_order_id"],
|
||||
order_id,
|
||||
"A second save must update the draft, never create a new one",
|
||||
)
|
||||
|
||||
drafts = self._cycle_drafts()
|
||||
self.assertEqual(len(drafts), 1)
|
||||
self.assertEqual(drafts.order_line.product_uom_qty, 3)
|
||||
|
||||
def test_saving_an_unchanged_cart_leaves_the_lines_alone(self):
|
||||
"""Nothing changed, nothing rewritten.
|
||||
|
||||
Replacing `order_line` unlinks and recreates every line, so an
|
||||
idempotent save would churn their ids for no reason.
|
||||
"""
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
self._save_cart(2)
|
||||
line_ids = self._cycle_drafts().order_line.ids
|
||||
|
||||
self._save_cart(2)
|
||||
|
||||
self.assertEqual(
|
||||
self._cycle_drafts().order_line.ids,
|
||||
line_ids,
|
||||
"An unchanged cart must not rewrite the order lines",
|
||||
)
|
||||
|
||||
def test_home_delivery_toggle_moves_the_line_on_the_order(self):
|
||||
"""The checkout toggle writes to the order, which is what gets charged."""
|
||||
delivery_product = self.env["product.product"].create(
|
||||
{"name": "Home Delivery", "type": "service", "list_price": 5.0}
|
||||
)
|
||||
self.group_order.delivery_product_id = delivery_product
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
self._save_cart(1)
|
||||
|
||||
response = self._post_json(
|
||||
"/eskaera/set-home-delivery",
|
||||
{"order_id": self.group_order.id, "is_delivery": True},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.json()["is_delivery"])
|
||||
draft = self._cycle_drafts()
|
||||
self.assertTrue(draft.home_delivery)
|
||||
self.assertIn(delivery_product, draft.order_line.product_id)
|
||||
|
||||
response = self._post_json(
|
||||
"/eskaera/set-home-delivery",
|
||||
{"order_id": self.group_order.id, "is_delivery": False},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertFalse(response.json()["is_delivery"])
|
||||
draft = self._cycle_drafts()
|
||||
self.assertFalse(draft.home_delivery)
|
||||
self.assertNotIn(delivery_product, draft.order_line.product_id)
|
||||
|
||||
def test_home_delivery_toggle_needs_a_draft(self):
|
||||
"""With nothing saved there is no order to put the delivery line on."""
|
||||
self.authenticate(self.portal_user.login, self.portal_user.login)
|
||||
|
||||
response = self._post_json(
|
||||
"/eskaera/set-home-delivery",
|
||||
{"order_id": self.group_order.id, "is_delivery": True},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 404)
|
||||
|
|
|
|||
|
|
@ -116,9 +116,9 @@ class TestTemplatesRendering(TransactionCase):
|
|||
# The fix ensures no <t t-set="day_names" t-value="[_(...)]"/> exists
|
||||
# which was causing the NoneType error
|
||||
|
||||
def test_eskaera_checkout_summary_template_exists(self):
|
||||
"""Test that eskaera_checkout_summary sub-template exists."""
|
||||
template = self.env.ref("website_sale_aplicoop.eskaera_checkout_summary")
|
||||
def test_order_lines_summary_template_exists(self):
|
||||
"""The checkout summary sub-template exists and reads the sale.order."""
|
||||
template = self.env.ref("website_sale_aplicoop.eskaera_order_lines_summary")
|
||||
self.assertIsNotNone(template)
|
||||
self.assertEqual(template.type, "qweb")
|
||||
# Verify it has the expected structure
|
||||
|
|
@ -127,6 +127,14 @@ class TestTemplatesRendering(TransactionCase):
|
|||
template.arch_db,
|
||||
"Template must have checkout-summary-table id",
|
||||
)
|
||||
# The summary is server-side on purpose: what it shows is what the
|
||||
# payment form charges, so it can never drift from the localStorage
|
||||
# cart the way the old client-rendered table did.
|
||||
self.assertIn(
|
||||
"sale_order.order_line",
|
||||
template.arch_db,
|
||||
"Template must read the order lines, not a client-side cart",
|
||||
)
|
||||
self.assertIn(
|
||||
"Product",
|
||||
template.arch_db,
|
||||
|
|
@ -137,23 +145,36 @@ class TestTemplatesRendering(TransactionCase):
|
|||
template.arch_db,
|
||||
"Template must have Quantity label for translation",
|
||||
)
|
||||
self.assertIn(
|
||||
"Price", template.arch_db, "Template must have Price label for translation"
|
||||
)
|
||||
self.assertIn(
|
||||
"Subtotal",
|
||||
template.arch_db,
|
||||
"Template must have Subtotal label for translation",
|
||||
)
|
||||
|
||||
def test_eskaera_checkout_summary_renders(self):
|
||||
"""Test that eskaera_checkout_summary renders without errors."""
|
||||
template = self.env.ref("website_sale_aplicoop.eskaera_checkout_summary")
|
||||
# Render the template with empty context
|
||||
html = template._render_template(template.xml_id, {})
|
||||
# Should contain the basic table structure
|
||||
def test_order_lines_summary_renders(self):
|
||||
"""The summary renders the lines and total of a real order."""
|
||||
order = self.env["sale.order"].create(
|
||||
{
|
||||
"partner_id": self.supplier.id,
|
||||
"order_line": [
|
||||
(
|
||||
0,
|
||||
0,
|
||||
{
|
||||
"product_id": self.product.id,
|
||||
"product_uom_qty": 2,
|
||||
"price_unit": 5.0,
|
||||
},
|
||||
)
|
||||
],
|
||||
}
|
||||
)
|
||||
template = self.env.ref("website_sale_aplicoop.eskaera_order_lines_summary")
|
||||
|
||||
html = template._render_template(template.xml_id, {"sale_order": order})
|
||||
|
||||
self.assertIn("<table", html)
|
||||
self.assertIn("checkout-summary-table", html)
|
||||
self.assertIn("Product", html)
|
||||
self.assertIn("Quantity", html)
|
||||
self.assertIn("This order's cart is empty", html)
|
||||
self.assertIn("Test Product", html)
|
||||
|
|
|
|||
|
|
@ -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 }}">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue