discount for business and personall in discount page
This commit is contained in:
@@ -1,9 +1,15 @@
|
||||
//! Admin management of per-product discounts.
|
||||
//! Admin management of per-product discounts, in a place of their own rather
|
||||
//! than on the product editor.
|
||||
//!
|
||||
//! Discounts live on the product (`sale_price_cents`) but are set here, in a
|
||||
//! place of their own, rather than on the product editor: an admin picks a
|
||||
//! product, enters a discounted price, and the storefront then shows it on sale.
|
||||
//! Editing a product never touches its discount, and vice versa.
|
||||
//! Two audiences, switched by an `?audience=` tab:
|
||||
//! - **personal** (default): the public sale price (`products.sale_price_cents`)
|
||||
//! everyone sees.
|
||||
//! - **business**: a baseline discount for all company accounts
|
||||
//! (`products.business_sale_price_cents`). Per-company profiles/negotiated
|
||||
//! prices still layer on top (lowest price wins). Both are computed off the
|
||||
//! regular price.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
@@ -20,6 +26,8 @@ use crate::{
|
||||
},
|
||||
};
|
||||
|
||||
const BUSINESS: &str = "business";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DiscountForm {
|
||||
/// "fixed" (enter the new price) or "percent" (enter % off). Defaults to
|
||||
@@ -29,8 +37,35 @@ struct DiscountForm {
|
||||
percent: Option<String>,
|
||||
}
|
||||
|
||||
/// Which discount column an audience tab operates on.
|
||||
fn read_audience(params: &HashMap<String, String>) -> &'static str {
|
||||
match params.get("audience").map(String::as_str) {
|
||||
Some(BUSINESS) => BUSINESS,
|
||||
_ => "personal",
|
||||
}
|
||||
}
|
||||
|
||||
fn current_value(product: &products::Model, audience: &str) -> Option<i64> {
|
||||
if audience == BUSINESS {
|
||||
product.business_sale_price_cents
|
||||
} else {
|
||||
product.sale_price_cents
|
||||
}
|
||||
}
|
||||
|
||||
fn set_value(active: &mut products::ActiveModel, audience: &str, value: Option<i64>) {
|
||||
if audience == BUSINESS {
|
||||
active.business_sale_price_cents = Set(value);
|
||||
} else {
|
||||
active.sale_price_cents = Set(value);
|
||||
}
|
||||
}
|
||||
|
||||
fn list_redirect(audience: &str) -> Result<Response> {
|
||||
format::redirect(&format!("/admin/catalog/discounts?audience={audience}"))
|
||||
}
|
||||
|
||||
/// Resolve a percentage off the regular price into a fixed sale price in cents.
|
||||
/// Rounds the discount amount to the nearest cent.
|
||||
fn percent_to_sale_cents(regular_cents: i64, percent: f64) -> i64 {
|
||||
let off = (regular_cents as f64 * percent / 100.0).round() as i64;
|
||||
regular_cents - off
|
||||
@@ -43,8 +78,7 @@ async fn product_by_id(ctx: &AppContext, id: i32) -> Result<products::Model> {
|
||||
.ok_or_else(|| Error::NotFound)
|
||||
}
|
||||
|
||||
/// Percent off the regular price, rounded to a whole number. `0` when there is
|
||||
/// no positive regular price to discount from.
|
||||
/// Percent off the regular price, rounded to a whole number.
|
||||
fn percent_off(regular_cents: i64, sale_cents: i64) -> i64 {
|
||||
if regular_cents <= 0 {
|
||||
return 0;
|
||||
@@ -53,7 +87,8 @@ fn percent_off(regular_cents: i64, sale_cents: i64) -> i64 {
|
||||
off.round() as i64
|
||||
}
|
||||
|
||||
/// Row shape for the discounts list.
|
||||
/// Row shape for the discounts list, carrying both audiences' values so the
|
||||
/// template can show whichever tab is active.
|
||||
fn list_row(product: &products::Model) -> serde_json::Value {
|
||||
json!({
|
||||
"id": product.id,
|
||||
@@ -64,6 +99,11 @@ fn list_row(product: &products::Model) -> serde_json::Value {
|
||||
"on_sale": product.on_sale(),
|
||||
"sale_price": product.sale_price_cents.map(format_price),
|
||||
"percent_off": product.sale_price_cents.map(|sale| percent_off(product.price_cents, sale)),
|
||||
"business_on_sale": product.business_on_sale(),
|
||||
"business_sale_price": product.business_sale_price_cents.map(format_price),
|
||||
"business_percent_off": product
|
||||
.business_sale_price_cents
|
||||
.map(|sale| percent_off(product.price_cents, sale)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -72,9 +112,11 @@ async fn index(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let audience = read_audience(¶ms);
|
||||
let list = products::Entity::find()
|
||||
.order_by_asc(products::Column::Name)
|
||||
.all(&ctx.db)
|
||||
@@ -83,7 +125,7 @@ async fn index(
|
||||
format::view(
|
||||
&v,
|
||||
"admin/catalog/discounts.html",
|
||||
json!({ "products": rows, "lang": current_lang(&jar) }),
|
||||
json!({ "products": rows, "audience": audience, "lang": current_lang(&jar) }),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -96,11 +138,11 @@ struct FormPrefill {
|
||||
percent: String,
|
||||
}
|
||||
|
||||
/// Render the single-product discount form, optionally with a validation error.
|
||||
fn render_form(
|
||||
v: &TeraView,
|
||||
jar: &CookieJar,
|
||||
product: &products::Model,
|
||||
audience: &str,
|
||||
prefill: &FormPrefill,
|
||||
error: Option<&str>,
|
||||
) -> Result<Response> {
|
||||
@@ -115,9 +157,9 @@ fn render_form(
|
||||
"currency": product.currency,
|
||||
"regular_price": format_price(product.price_cents),
|
||||
"regular_cents": product.price_cents,
|
||||
"on_sale": product.on_sale(),
|
||||
"sale_price": product.sale_price_cents.map(format_price),
|
||||
},
|
||||
"audience": audience,
|
||||
"has_discount": current_value(product, audience).is_some(),
|
||||
"mode": mode,
|
||||
"fixed": prefill.fixed,
|
||||
"percent": prefill.percent,
|
||||
@@ -133,17 +175,21 @@ async fn edit(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(id): Path<i32>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let audience = read_audience(¶ms);
|
||||
let product = product_by_id(&ctx, id).await?;
|
||||
// Re-editing always opens in fixed mode showing the current sale price.
|
||||
// Re-editing always opens in fixed mode showing the current price.
|
||||
let prefill = FormPrefill {
|
||||
mode: "fixed".to_string(),
|
||||
fixed: product.sale_price_cents.map(format_price).unwrap_or_default(),
|
||||
fixed: current_value(&product, audience)
|
||||
.map(format_price)
|
||||
.unwrap_or_default(),
|
||||
percent: String::new(),
|
||||
};
|
||||
render_form(&v, &jar, &product, &prefill, None)
|
||||
render_form(&v, &jar, &product, audience, &prefill, None)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
@@ -152,10 +198,12 @@ async fn update(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(id): Path<i32>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
State(ctx): State<AppContext>,
|
||||
Form(form): Form<DiscountForm>,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let audience = read_audience(¶ms);
|
||||
let product = product_by_id(&ctx, id).await?;
|
||||
|
||||
let mode = match form.mode.as_deref() {
|
||||
@@ -165,20 +213,18 @@ async fn update(
|
||||
let fixed = form.sale_price.unwrap_or_default().trim().to_string();
|
||||
let percent = form.percent.unwrap_or_default().trim().to_string();
|
||||
|
||||
// Whatever the mode, both raw inputs are echoed back on error so neither tab
|
||||
// loses what was typed.
|
||||
let prefill = FormPrefill {
|
||||
mode: mode.to_string(),
|
||||
fixed: fixed.clone(),
|
||||
percent: percent.clone(),
|
||||
};
|
||||
let render_err = |key: &str| render_form(&v, &jar, &product, &prefill, Some(key));
|
||||
let render_err = |key: &str| render_form(&v, &jar, &product, audience, &prefill, Some(key));
|
||||
|
||||
// Resolve the entered discount into a fixed sale price in cents. An empty
|
||||
// input in the active mode clears the discount (same as the Remove action).
|
||||
let sale_cents = if mode == "percent" {
|
||||
if percent.is_empty() {
|
||||
return clear_discount(&ctx, product).await;
|
||||
return clear_discount(&ctx, product, audience).await;
|
||||
}
|
||||
let pct = match parse_percent(&percent) {
|
||||
Some(pct) => pct,
|
||||
@@ -190,7 +236,7 @@ async fn update(
|
||||
percent_to_sale_cents(product.price_cents, pct)
|
||||
} else {
|
||||
if fixed.is_empty() {
|
||||
return clear_discount(&ctx, product).await;
|
||||
return clear_discount(&ctx, product, audience).await;
|
||||
}
|
||||
match parse_price_to_cents(&fixed) {
|
||||
Ok(cents) => cents,
|
||||
@@ -198,8 +244,6 @@ async fn update(
|
||||
}
|
||||
};
|
||||
|
||||
// A discount must be a positive price strictly below the regular price —
|
||||
// otherwise it isn't a discount.
|
||||
if sale_cents <= 0 {
|
||||
return render_err("discount-must-be-positive");
|
||||
}
|
||||
@@ -208,27 +252,33 @@ async fn update(
|
||||
}
|
||||
|
||||
let mut active = product.into_active_model();
|
||||
active.sale_price_cents = Set(Some(sale_cents));
|
||||
set_value(&mut active, audience, Some(sale_cents));
|
||||
active.update(&ctx.db).await?;
|
||||
format::redirect("/admin/catalog/discounts")
|
||||
list_redirect(audience)
|
||||
}
|
||||
|
||||
async fn clear_discount(ctx: &AppContext, product: products::Model) -> Result<Response> {
|
||||
async fn clear_discount(
|
||||
ctx: &AppContext,
|
||||
product: products::Model,
|
||||
audience: &str,
|
||||
) -> Result<Response> {
|
||||
let mut active = product.into_active_model();
|
||||
active.sale_price_cents = Set(None);
|
||||
set_value(&mut active, audience, None);
|
||||
active.update(&ctx.db).await?;
|
||||
format::redirect("/admin/catalog/discounts")
|
||||
list_redirect(audience)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn remove(
|
||||
auth: auth::JWT,
|
||||
Path(id): Path<i32>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let audience = read_audience(¶ms);
|
||||
let product = product_by_id(&ctx, id).await?;
|
||||
clear_discount(&ctx, product).await
|
||||
clear_discount(&ctx, product, audience).await
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
|
||||
Reference in New Issue
Block a user