304 lines
9.4 KiB
Rust
304 lines
9.4 KiB
Rust
use crate::{controllers::i18n::current_lang, shared::{guard, money::format_price, pricing}, models::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 {
|
|
product_id: i32,
|
|
quantity: Option<i32>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct UpdateForm {
|
|
product_id: i32,
|
|
quantity: i32,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RemoveForm {
|
|
product_id: i32,
|
|
}
|
|
|
|
/// Parse the `cart` cookie ("id:qty,id:qty") into `(product_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::<i32>().ok()?;
|
|
let qty = qty.trim().parse::<i32>().ok()?;
|
|
(qty > 0).then_some((id, qty))
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn serialize_cart(items: &[(i32, i32)]) -> String {
|
|
items
|
|
.iter()
|
|
.map(|(id, qty)| format!("{id}:{qty}"))
|
|
.collect::<Vec<_>>()
|
|
.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 published product, returning its current stock cap.
|
|
async fn published_product(ctx: &AppContext, id: i32) -> Result<Option<products::Model>> {
|
|
Ok(products::Entity::find_by_id(id)
|
|
.filter(products::Column::Published.eq(true))
|
|
.one(&ctx.db)
|
|
.await?)
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn add(
|
|
jar: CookieJar,
|
|
State(ctx): State<AppContext>,
|
|
headers: HeaderMap,
|
|
Form(form): Form<AddForm>,
|
|
) -> Result<Response> {
|
|
let Some(product) = published_product(&ctx, form.product_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 == product.id) {
|
|
entry.1 = (entry.1 + add_qty).min(product.stock);
|
|
} else {
|
|
items.push((product.id, add_qty.min(product.stock)));
|
|
}
|
|
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<AppContext>,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
headers: HeaderMap,
|
|
Form(form): Form<UpdateForm>,
|
|
) -> Result<Response> {
|
|
let stock = published_product(&ctx, form.product_id)
|
|
.await?
|
|
.map(|p| p.stock)
|
|
.unwrap_or(0);
|
|
|
|
let mut items = parse_cart(&jar);
|
|
let clamped = form.quantity.clamp(0, stock);
|
|
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.product_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<AppContext>,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
headers: HeaderMap,
|
|
Form(form): Form<RemoveForm>,
|
|
) -> Result<Response> {
|
|
let mut items = parse_cart(&jar);
|
|
items.retain(|(id, _)| *id != form.product_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<Response> {
|
|
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<serde_json::Value>, 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<(products::Model, i32)> = Vec::new();
|
|
for (id, qty) in parse_cart(jar) {
|
|
let Some(product) = published_product(ctx, id).await? else {
|
|
continue;
|
|
};
|
|
let qty = qty.clamp(0, product.stock);
|
|
if qty == 0 {
|
|
continue;
|
|
}
|
|
items.push((product, qty));
|
|
}
|
|
let products_only: Vec<products::Model> = items.iter().map(|(p, _)| p.clone()).collect();
|
|
let priced = pricing::price_many(ctx, &products_only, user.as_ref()).await?;
|
|
|
|
let mut lines = Vec::new();
|
|
let mut valid = Vec::new();
|
|
let mut total: i64 = 0;
|
|
for ((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((product.id, *qty));
|
|
lines.push(json!({
|
|
"id": product.id,
|
|
"name": product.name,
|
|
"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": product.stock,
|
|
"line_total": format_price(line_total),
|
|
}));
|
|
}
|
|
|
|
Ok((lines, valid, total))
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn show(
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<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();
|
|
|
|
// 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<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<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();
|
|
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))
|
|
}
|