eshop
This commit is contained in:
203
src/controllers/cart.rs
Normal file
203
src/controllers/cart.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
use crate::{
|
||||
controllers::{catalog::format_price, i18n::current_lang},
|
||||
models::_entities::products,
|
||||
};
|
||||
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>,
|
||||
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);
|
||||
|
||||
format::render()
|
||||
.cookies(&[cart_cookie(serialize_cart(&items))])?
|
||||
.redirect("/cart")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn update(
|
||||
jar: CookieJar,
|
||||
State(ctx): State<AppContext>,
|
||||
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);
|
||||
|
||||
format::render()
|
||||
.cookies(&[cart_cookie(serialize_cart(&items))])?
|
||||
.redirect("/cart")
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn remove(jar: CookieJar, Form(form): Form<RemoveForm>) -> Result<Response> {
|
||||
let mut items = parse_cart(&jar);
|
||||
items.retain(|(id, _)| *id != form.product_id);
|
||||
|
||||
format::render()
|
||||
.cookies(&[cart_cookie(serialize_cart(&items))])?
|
||||
.redirect("/cart")
|
||||
}
|
||||
|
||||
/// 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)> {
|
||||
let mut lines = Vec::new();
|
||||
let mut valid = Vec::new();
|
||||
let mut total: i64 = 0;
|
||||
|
||||
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;
|
||||
}
|
||||
let line_total = product.price_cents * 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(product.price_cents),
|
||||
"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 response = format::view(
|
||||
&v,
|
||||
"shop/cart.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))
|
||||
}
|
||||
Reference in New Issue
Block a user