use crate::{controllers::i18n::current_lang, shared::{guard, money::format_price, pricing}, models::{product_variants, products}}; use axum::{ http::{HeaderMap, StatusCode}, response::Redirect, }; use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; use loco_rs::prelude::*; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter}; use serde::Deserialize; use serde_json::json; use time::Duration as TimeDuration; pub(crate) const CART_COOKIE: &str = "cart"; const CART_MAX_AGE_DAYS: i64 = 30; #[derive(Debug, Deserialize)] struct AddForm { variant_id: i32, quantity: Option, } #[derive(Debug, Deserialize)] struct UpdateForm { variant_id: i32, quantity: i32, } #[derive(Debug, Deserialize)] struct RemoveForm { variant_id: i32, } /// Parse the `cart` cookie ("id:qty,id:qty") into `(variant_id, quantity)` /// pairs, silently dropping malformed or non-positive entries. pub(crate) fn parse_cart(jar: &CookieJar) -> Vec<(i32, i32)> { let Some(cookie) = jar.get(CART_COOKIE) else { return Vec::new(); }; cookie .value() .split(',') .filter_map(|entry| { let (id, qty) = entry.split_once(':')?; let id = id.trim().parse::().ok()?; let qty = qty.trim().parse::().ok()?; (qty > 0).then_some((id, qty)) }) .collect() } fn serialize_cart(items: &[(i32, i32)]) -> String { items .iter() .map(|(id, qty)| format!("{id}:{qty}")) .collect::>() .join(",") } fn cart_cookie(value: String) -> Cookie<'static> { Cookie::build((CART_COOKIE, value)) .path("/") .same_site(SameSite::Lax) .max_age(TimeDuration::days(CART_MAX_AGE_DAYS)) .build() } /// Look up a variant whose product is published, returning the variant together /// with its parent product (for name/slug/currency). async fn published_variant( ctx: &AppContext, variant_id: i32, ) -> Result> { let Some(variant) = product_variants::Entity::find_by_id(variant_id) .one(&ctx.db) .await? else { return Ok(None); }; let product = products::Entity::find_by_id(variant.product_id) .filter(products::Column::Published.eq(true)) .one(&ctx.db) .await?; Ok(product.map(|p| (variant, p))) } #[debug_handler] async fn add( jar: CookieJar, State(ctx): State, headers: HeaderMap, Form(form): Form, ) -> Result { let Some((variant, _product)) = published_variant(&ctx, form.variant_id).await? else { return Err(Error::NotFound); }; let mut items = parse_cart(&jar); let add_qty = form.quantity.unwrap_or(1).max(1); if let Some(entry) = items.iter_mut().find(|(id, _)| *id == variant.id) { entry.1 = variant.cap(entry.1 + add_qty); } else { items.push((variant.id, variant.cap(add_qty))); } items.retain(|(_, qty)| *qty > 0); let jar = jar.add(cart_cookie(serialize_cart(&items))); // Adding to the cart should never navigate away: htmx requests get an empty // 204 (the header cart badge updates client-side), and a no-JS submit goes // back to the page the customer was on rather than to the basket. if headers.contains_key("HX-Request") { Ok((jar, StatusCode::NO_CONTENT).into_response()) } else { let back = headers .get("referer") .and_then(|v| v.to_str().ok()) .unwrap_or("/shop") .to_string(); Ok((jar, Redirect::to(&back)).into_response()) } } #[debug_handler] async fn update( jar: CookieJar, State(ctx): State, ViewEngine(v): ViewEngine, headers: HeaderMap, Form(form): Form, ) -> Result { // Clamp the requested quantity to what's available (no cap for untracked // variants); a removed variant clamps to 0 and drops out below. let clamped = match published_variant(&ctx, form.variant_id).await? { Some((variant, _)) => variant.cap(form.quantity), None => 0, }; let mut items = parse_cart(&jar); if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.variant_id) { entry.1 = clamped; } items.retain(|(_, qty)| *qty > 0); let jar = jar.add(cart_cookie(serialize_cart(&items))); cart_response(&ctx, &v, jar, &headers).await } #[debug_handler] async fn remove( jar: CookieJar, State(ctx): State, ViewEngine(v): ViewEngine, headers: HeaderMap, Form(form): Form, ) -> Result { let mut items = parse_cart(&jar); items.retain(|(id, _)| *id != form.variant_id); let jar = jar.add(cart_cookie(serialize_cart(&items))); cart_response(&ctx, &v, jar, &headers).await } /// Response after a cart mutation: for an htmx request, just the `#cart-body` /// fragment (so the page never fully reloads); otherwise a redirect back to /// `/cart` for no-JS fallback. `jar` must already hold the updated cart cookie. async fn cart_response( ctx: &AppContext, v: &TeraView, jar: CookieJar, headers: &HeaderMap, ) -> Result { if !headers.contains_key("HX-Request") { return Ok((jar, Redirect::to("/cart")).into_response()); } let (lines, valid, total) = resolve_cart(ctx, &jar).await?; let currency = lines .first() .and_then(|line| line["currency"].as_str()) .unwrap_or("EUR") .to_string(); // Persist the re-validated cookie (drops now-invalid lines). let jar = jar.add(cart_cookie(serialize_cart(&valid))); let response = format::view( v, "shop/_cart_body.html", json!({ "items": lines, "total": format_price(total), "currency": currency, "lang": current_lang(&jar), }), )?; Ok((jar, response).into_response()) } /// Resolve the cart cookie into priced line items, dropping anything that is no /// longer purchasable and clamping quantities to current stock. Returns the /// (re-validated) lines, the rebuilt cookie value, and the total in cents. pub(crate) async fn resolve_cart( ctx: &AppContext, jar: &CookieJar, ) -> Result<(Vec, Vec<(i32, i32)>, i64)> { // Resolve the cart entries to in-stock products first, 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(); for (id, qty) in parse_cart(jar) { let Some((variant, product)) = published_variant(ctx, id).await? else { continue; }; let qty = variant.cap(qty); if qty == 0 { continue; } items.push((variant, product, qty)); } let variants_only: Vec = items.iter().map(|(v, _, _)| v.clone()).collect(); let priced = pricing::price_variants(ctx, &variants_only, user.as_ref()).await?; let mut lines = Vec::new(); let mut valid = Vec::new(); let mut total: i64 = 0; for ((variant, product, qty), priced) in items.iter().zip(priced.iter()) { let unit_price = priced.price_cents; let line_total = unit_price * i64::from(*qty); total += line_total; valid.push((variant.id, *qty)); lines.push(json!({ "id": variant.id, "name": product.name, "variant_label": variant.label, "slug": product.slug, "price": format_price(unit_price), "regular_price": format_price(priced.regular_cents), "on_sale": priced.is_reduced(), "currency": product.currency, "quantity": qty, "stock": variant.stock, "line_total": format_price(line_total), })); } Ok((lines, valid, total)) } #[debug_handler] async fn show( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { let (lines, valid, total) = resolve_cart(&ctx, &jar).await?; let currency = lines .first() .and_then(|line| line["currency"].as_str()) .unwrap_or("EUR") .to_string(); // Drop any now-invalid lines from the cookie so the badge stays accurate. let rebuilt = serialize_cart(&valid); let c = guard::chrome(&ctx, &jar).await; let response = format::view( &v, "shop/cart.html", json!({ "items": lines, "total": format_price(total), "currency": currency, "logged_in_admin": c.logged_in_admin, "logged_in_customer": c.logged_in_customer, "customer_name": c.customer_name, "customer_account_type": c.customer_account_type, "lang": current_lang(&jar), }), )?; Ok((jar.add(cart_cookie(rebuilt)), response).into_response()) } /// Mini-cart preview for the navbar hover dropdown. Lazy-loaded via htmx from /// the header; returns just the `shop/_cart_preview.html` fragment. #[debug_handler] async fn preview( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { let (lines, valid, total) = resolve_cart(&ctx, &jar).await?; let currency = lines .first() .and_then(|line| line["currency"].as_str()) .unwrap_or("EUR") .to_string(); let rebuilt = serialize_cart(&valid); let response = format::view( &v, "shop/_cart_preview.html", json!({ "items": lines, "total": format_price(total), "currency": currency, "lang": current_lang(&jar), }), )?; Ok((jar.add(cart_cookie(rebuilt)), response).into_response()) } pub fn routes() -> Routes { Routes::new() .add("/cart", get(show)) .add("/cart/add", post(add)) .add("/cart/update", post(update)) .add("/cart/remove", post(remove)) .add("/partials/cart", get(preview)) }