basket bug fix

This commit is contained in:
Priec
2026-06-29 00:34:31 +02:00
parent 6c341e9f28
commit 72293d8318
7 changed files with 108 additions and 39 deletions

View File

@@ -347,6 +347,8 @@ cart-total = Total
cart-checkout = Proceed to checkout
cart-remove = Remove
cart-remove-confirm = Remove this item from the cart?
cart-out-of-stock = Out of stock
cart-remove-unavailable = Some items in your cart are out of stock. Remove them to continue to checkout.
cart-update = Update
cart-continue = Continue shopping
checkout-title = Checkout

View File

@@ -347,6 +347,8 @@ cart-total = Spolu
cart-checkout = Pokračovať k pokladni
cart-remove = Odstrániť
cart-remove-confirm = Odstrániť túto položku z košíka?
cart-out-of-stock = Vypredané
cart-remove-unavailable = Niektoré položky v košíku sú vypredané. Pred pokračovaním na pokladňu ich odstráňte.
cart-update = Aktualizovať
cart-continue = Pokračovať v nákupe
checkout-title = Pokladňa

File diff suppressed because one or more lines are too long

View File

@@ -20,6 +20,7 @@
<td class="px-4 py-3">
<a href="/shop/{{ item.slug }}" class="font-medium text-on-surface-strong hover:text-primary dark:text-on-surface-dark-strong dark:hover:text-primary-dark">{{ item.name }}</a>
{% if item.variant_label %}<span class="block text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ item.variant_label }}</span>{% endif %}
{% if item.out_of_stock %}<span class="mt-1 inline-flex items-center rounded-radius bg-danger/10 px-2 py-0.5 text-xs font-medium text-danger">{{ t(key="cart-out-of-stock", lang=lang | default(value='sk')) }}</span>{% endif %}
</td>
<td class="px-4 py-3 tabular-nums">
{% if item.on_sale %}
@@ -48,7 +49,7 @@
class="w-20 rounded-radius border border-outline bg-surface-alt px-2 py-1 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark">
</form>
</td>
<td class="px-4 py-3 text-right font-medium tabular-nums">{{ item.line_total }} {{ currency_symbol }}</td>
<td class="px-4 py-3 text-right font-medium tabular-nums">{% if item.out_of_stock %}<span class="text-on-surface/40 dark:text-on-surface-dark/40"></span>{% else %}{{ item.line_total }} {{ currency_symbol }}{% endif %}</td>
<td class="px-4 py-3 text-right">
<form method="post" action="/cart/remove"
hx-post="/cart/remove" hx-target="#cart-body" hx-swap="innerHTML">
@@ -70,9 +71,17 @@
</table>
</div>
{% if has_unavailable %}
<p role="alert" class="mt-4 rounded-radius border border-danger/40 bg-danger/10 p-3 text-sm text-on-surface dark:text-on-surface-dark">{{ t(key="cart-remove-unavailable", lang=lang | default(value='sk')) }}</p>
{% endif %}
<div class="mt-6 flex flex-wrap justify-between gap-3">
{{ ui::button(variant="outline-secondary", label=t(key="cart-continue", lang=lang | default(value='sk')), href="/shop") }}
{% if has_unavailable %}
{{ ui::button(label=t(key="cart-checkout", lang=lang | default(value='sk')), size="px-5 py-2 text-sm", attrs='disabled aria-disabled="true"', extra="opacity-50 cursor-not-allowed pointer-events-none") }}
{% else %}
{{ ui::button(label=t(key="cart-checkout", lang=lang | default(value='sk')), href="/checkout", size="px-5 py-2 text-sm", attrs='hx-boost="false"') }}
{% endif %}
</div>
{% else %}
<div class="rounded-radius border border-outline px-6 py-16 text-center dark:border-outline-dark">

View File

@@ -9,9 +9,10 @@
<div class="min-w-0 flex-1">
<a href="/shop/{{ item.slug }}" class="block truncate text-sm font-medium text-on-surface-strong hover:text-primary dark:text-on-surface-dark-strong dark:hover:text-primary-dark">{{ item.name }}</a>
{% if item.variant_label %}<span class="block truncate text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ item.variant_label }}</span>{% endif %}
<p class="mt-0.5 text-xs tabular-nums text-on-surface dark:text-on-surface-dark">{{ item.quantity }} × {{ item.price }} {{ currency_symbol }}</p>
{% if item.out_of_stock %}<span class="mt-0.5 inline-flex items-center rounded-radius bg-danger/10 px-1.5 py-0.5 text-xs font-medium text-danger">{{ t(key="cart-out-of-stock", lang=lang | default(value='sk')) }}</span>
{% else %}<p class="mt-0.5 text-xs tabular-nums text-on-surface dark:text-on-surface-dark">{{ item.quantity }} × {{ item.price }} {{ currency_symbol }}</p>{% endif %}
</div>
<span class="shrink-0 text-sm font-semibold tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{{ item.line_total }} {{ currency_symbol }}</span>
<span class="shrink-0 text-sm font-semibold tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{% if item.out_of_stock %}<span class="text-on-surface/40 dark:text-on-surface-dark/40"></span>{% else %}{{ item.line_total }} {{ currency_symbol }}{% endif %}</span>
</div>
{% endfor %}
</div>
@@ -22,7 +23,11 @@
</div>
<div class="flex gap-2">
{{ ui::button(href="/cart", variant="outline-primary", label=t(key="cart-title", lang=lang | default(value='sk')), extra="flex-1", attrs='hx-boost="false"') }}
{% if has_unavailable %}
{{ ui::button(variant="primary", label=t(key="cart-checkout", lang=lang | default(value='sk')), extra="flex-1 opacity-50 cursor-not-allowed pointer-events-none", attrs='disabled aria-disabled="true"') }}
{% else %}
{{ ui::button(href="/checkout", variant="primary", label=t(key="cart-checkout", lang=lang | default(value='sk')), extra="flex-1", attrs='hx-boost="false"') }}
{% endif %}
</div>
</div>
{% else %}

View File

@@ -173,12 +173,19 @@ async fn add(
let user = guard::current_user(&ctx, &jar).await;
let mut items = stored_cart(&ctx, user.as_ref(), &jar).await?;
let add_qty = form.quantity.unwrap_or(1).max(1);
// Clamp to on-hand stock when tracked and in stock; an out-of-stock (stock 0)
// item is still added at the requested quantity so it isn't silently dropped —
// it shows in the cart as unavailable until restocked or removed.
let new_qty = |desired: i32| match variant.stock {
Some(0) => desired.max(1),
Some(s) => desired.clamp(1, s),
None => desired.max(1),
};
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == variant.id) {
entry.1 = variant.cap(entry.1 + add_qty);
entry.1 = new_qty(entry.1 + add_qty);
} else {
items.push((variant.id, variant.cap(add_qty)));
items.push((variant.id, new_qty(add_qty)));
}
items.retain(|(_, qty)| *qty > 0);
let jar = persist_cart(&ctx, jar, user.as_ref(), &items).await?;
@@ -253,17 +260,18 @@ async fn cart_response(
}
let cur = currency::resolve(ctx, &jar).await;
let (lines, valid, total) = resolve_cart(ctx, &jar, &cur).await?;
// Persist the re-validated cookie (drops now-invalid lines).
let cart = resolve_cart(ctx, &jar, &cur).await?;
// Persist the re-validated cookie (keeps out-of-stock lines, drops gone ones).
let user = guard::current_user(ctx, &jar).await;
let jar = persist_cart(ctx, jar, user.as_ref(), &valid).await?;
let jar = persist_cart(ctx, jar, user.as_ref(), &cart.stored).await?;
let response = format::view(
v,
"shop/_cart_body.html",
json!({
"items": lines,
"total": cur.format(total),
"items": cart.lines,
"total": cur.format(cart.total),
"currency_symbol": cur.symbol,
"has_unavailable": cart.has_unavailable,
"lang": current_lang(&jar),
}),
)?;
@@ -273,12 +281,25 @@ async fn cart_response(
/// Resolve the active cart into priced line items, dropping anything that is no
/// longer purchasable and clamping quantities to current stock. Guests resolve
/// from the cookie; authenticated users resolve from their account cart.
/// The outcome of resolving the active cart. `lines` is for display and includes
/// out-of-stock items (flagged `available: false`); `purchasable` is the in-stock
/// subset used for the `total` and for checkout; `stored` is the full set written
/// back to the cart (so out-of-stock items are *kept*, not silently dropped);
/// `has_unavailable` is set when any line is currently out of stock.
pub(crate) struct ResolvedCart {
pub lines: Vec<serde_json::Value>,
pub purchasable: Vec<(i32, i32)>,
pub stored: Vec<(i32, i32)>,
pub total: i64,
pub has_unavailable: bool,
}
pub(crate) async fn resolve_cart(
ctx: &AppContext,
jar: &CookieJar,
cur: &Currency,
) -> Result<(Vec<serde_json::Value>, Vec<(i32, i32)>, i64)> {
// Resolve the cart entries to in-stock products first, then price them all
) -> Result<ResolvedCart> {
// Resolve the cart entries to their (published) products, then price them all
// for the current viewer in one batch (the price depends on who's logged in).
let user = guard::current_user(ctx, jar).await;
let mut items: Vec<(product_variants::Model, products::Model, i32)> = Vec::new();
@@ -286,8 +307,7 @@ pub(crate) async fn resolve_cart(
let Some((variant, product)) = published_variant(ctx, id).await? else {
continue;
};
let qty = variant.cap(qty);
if qty == 0 {
if qty <= 0 {
continue;
}
items.push((variant, product, qty));
@@ -297,13 +317,29 @@ pub(crate) async fn resolve_cart(
let priced = pricing::price_variants(ctx, &variants_only, user.as_ref()).await?;
let mut lines = Vec::new();
let mut valid = Vec::new();
let mut purchasable = Vec::new();
let mut stored = Vec::new();
let mut total: i64 = 0;
let mut has_unavailable = false;
for ((variant, product, qty), priced) in items.iter().zip(priced.iter()) {
// Out-of-stock (tracked stock of 0) items are kept in the cart, shown as
// unavailable and excluded from the total/checkout, instead of being
// silently removed. In-stock quantities are clamped to what's on hand.
let out_of_stock = matches!(variant.stock, Some(0));
let store_qty = if out_of_stock { *qty } else { variant.cap(*qty) };
if store_qty <= 0 {
continue;
}
stored.push((variant.id, store_qty));
let unit_price = priced.price_cents;
let line_total = unit_price * i64::from(*qty);
total += line_total;
valid.push((variant.id, *qty));
let line_total = unit_price * i64::from(store_qty);
if out_of_stock {
has_unavailable = true;
} else {
total += line_total;
purchasable.push((variant.id, store_qty));
}
lines.push(json!({
"id": variant.id,
"name": product.name,
@@ -312,17 +348,25 @@ pub(crate) async fn resolve_cart(
"price": cur.format(unit_price),
"regular_price": cur.format(priced.regular_cents),
"on_sale": priced.is_reduced(),
"quantity": qty,
"quantity": store_qty,
"stock": variant.stock,
"available": !out_of_stock,
"out_of_stock": out_of_stock,
"line_total": cur.format(line_total),
}));
}
if let Some(user) = user.as_ref() {
account_cart_items::Model::replace_for_user(&ctx.db, user.id, &valid).await?;
account_cart_items::Model::replace_for_user(&ctx.db, user.id, &stored).await?;
}
Ok((lines, valid, total))
Ok(ResolvedCart {
lines,
purchasable,
stored,
total,
has_unavailable,
})
}
#[debug_handler]
@@ -332,16 +376,17 @@ async fn show(
State(ctx): State<AppContext>,
) -> Result<Response> {
let cur = currency::resolve(&ctx, &jar).await;
let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?;
let cart = resolve_cart(&ctx, &jar, &cur).await?;
let c = guard::chrome(&ctx, &jar).await;
let response = format::view(
&v,
"shop/cart.html",
json!({
"items": lines,
"total": cur.format(total),
"items": cart.lines,
"total": cur.format(cart.total),
"currency_symbol": cur.symbol,
"has_unavailable": cart.has_unavailable,
"logged_in_admin": c.logged_in_admin,
"logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
@@ -352,7 +397,7 @@ async fn show(
)?;
let user = guard::current_user(&ctx, &jar).await;
let jar = persist_cart(&ctx, jar, user.as_ref(), &valid).await?;
let jar = persist_cart(&ctx, jar, user.as_ref(), &cart.stored).await?;
Ok((jar, response).into_response())
}
@@ -365,19 +410,20 @@ async fn preview(
State(ctx): State<AppContext>,
) -> Result<Response> {
let cur = currency::resolve(&ctx, &jar).await;
let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?;
let cart = resolve_cart(&ctx, &jar, &cur).await?;
let response = format::view(
&v,
"shop/_cart_preview.html",
json!({
"items": lines,
"total": cur.format(total),
"items": cart.lines,
"total": cur.format(cart.total),
"currency_symbol": cur.symbol,
"has_unavailable": cart.has_unavailable,
"lang": current_lang(&jar),
}),
)?;
let user = guard::current_user(&ctx, &jar).await;
let jar = persist_cart(&ctx, jar, user.as_ref(), &valid).await?;
let jar = persist_cart(&ctx, jar, user.as_ref(), &cart.stored).await?;
Ok((jar, response).into_response())
}

View File

@@ -171,10 +171,13 @@ async fn info_page(
) -> Result<Response> {
// Checkout and everything past it (orders, confirmation) stay in the EUR
// base — the settlement currency — even when the buyer browsed in another.
let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
if lines.is_empty() {
let cart = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
// No purchasable lines, or some line is out of stock: back to the basket to
// resolve it before checking out.
if cart.purchasable.is_empty() || cart.has_unavailable {
return format::redirect("/cart");
}
let (lines, subtotal) = (cart.lines, cart.total);
// Prefill the form for a logged-in customer: contact name/email come from
// the user account, the address/phone from their saved profile (if any).
@@ -248,8 +251,8 @@ async fn submit_info(
State(ctx): State<AppContext>,
Form(form): Form<InfoForm>,
) -> Result<Response> {
let (_lines, valid, _total) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
if valid.is_empty() {
let cart = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
if cart.purchasable.is_empty() || cart.has_unavailable {
return format::redirect("/cart");
}
@@ -343,10 +346,11 @@ async fn payment_page(
Query(params): Query<std::collections::HashMap<String, String>>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
if lines.is_empty() {
let cart = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
if cart.purchasable.is_empty() || cart.has_unavailable {
return format::redirect("/cart");
}
let (lines, subtotal) = (cart.lines, cart.total);
if decode_state::<CheckoutInfo>(&jar, INFO_COOKIE).is_none() {
return format::redirect("/checkout/info");
}
@@ -446,10 +450,11 @@ async fn place_order(
State(ctx): State<AppContext>,
Form(form): Form<PaymentForm>,
) -> Result<Response> {
let (_lines, valid, subtotal) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
if valid.is_empty() {
let cart = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
if cart.purchasable.is_empty() || cart.has_unavailable {
return format::redirect("/cart");
}
let (valid, subtotal) = (cart.purchasable, cart.total);
let Some(info) = decode_state::<CheckoutInfo>(&jar, INFO_COOKIE) else {
return format::redirect("/checkout/info");
};