discounts page removed, all migrated to the products page in admin
This commit is contained in:
@@ -18,7 +18,7 @@ use std::{path::Path, sync::Arc};
|
||||
use crate::{
|
||||
controllers::{
|
||||
account, admin_categories, admin_customers, admin_dashboard, admin_discount_profiles,
|
||||
admin_discounts, admin_form, admin_orders, admin_products, admin_shipping, auth, auth_pages,
|
||||
admin_form, admin_orders, admin_products, admin_shipping, auth, auth_pages,
|
||||
cart, checkout, home, i18n, media, oauth2,
|
||||
shop,
|
||||
},
|
||||
@@ -105,7 +105,6 @@ impl Hooks for App {
|
||||
// admin
|
||||
.add_route(admin_dashboard::routes())
|
||||
.add_route(admin_products::routes())
|
||||
.add_route(admin_discounts::routes())
|
||||
.add_route(admin_discount_profiles::routes())
|
||||
.add_route(admin_categories::routes())
|
||||
.add_route(admin_orders::routes())
|
||||
|
||||
@@ -1,370 +0,0 @@
|
||||
//! Admin management of per-product discounts, in a place of their own rather
|
||||
//! than on the product editor.
|
||||
//!
|
||||
//! 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, HashSet};
|
||||
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
controllers::i18n::current_lang,
|
||||
models::{audience_discount_profiles, discount_profiles, products},
|
||||
shared::{
|
||||
guard,
|
||||
money::{format_bp, format_price, parse_percent, parse_price_to_cents},
|
||||
pricing,
|
||||
},
|
||||
};
|
||||
|
||||
const BUSINESS: &str = "business";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DiscountForm {
|
||||
/// "fixed" (enter the new price) or "percent" (enter % off). Defaults to
|
||||
/// fixed for older/JSON callers.
|
||||
mode: Option<String>,
|
||||
sale_price: Option<String>,
|
||||
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.
|
||||
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
|
||||
}
|
||||
|
||||
async fn product_by_id(ctx: &AppContext, id: i32) -> Result<products::Model> {
|
||||
products::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
.await?
|
||||
.ok_or_else(|| Error::NotFound)
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
let off = (regular_cents - sale_cents) as f64 / regular_cents as f64 * 100.0;
|
||||
off.round() as i64
|
||||
}
|
||||
|
||||
/// Row shape for the discounts list, carrying both audiences' per-product values
|
||||
/// plus the resolved effective price for the active tab (after profiles).
|
||||
fn list_row(product: &products::Model, effective: &pricing::PricedProduct) -> serde_json::Value {
|
||||
json!({
|
||||
"id": product.id,
|
||||
"name": product.name,
|
||||
"slug": product.slug,
|
||||
"currency": product.currency,
|
||||
"regular_price": format_price(product.price_cents),
|
||||
"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)),
|
||||
// The price this audience actually pays after the per-product discount
|
||||
// and any applied profiles.
|
||||
"effective_price": format_price(effective.price_cents),
|
||||
"effective_reduced": effective.is_reduced(),
|
||||
"effective_percent_off": percent_off(product.price_cents, effective.price_cents),
|
||||
})
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
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)
|
||||
.await?;
|
||||
let effective = pricing::audience_price_many(&ctx, &list, audience).await?;
|
||||
let rows: Vec<serde_json::Value> = list
|
||||
.iter()
|
||||
.zip(effective.iter())
|
||||
.map(|(product, priced)| list_row(product, priced))
|
||||
.collect();
|
||||
|
||||
// Profiles applied globally to this audience, plus all profiles to choose from.
|
||||
let assigned: HashSet<i32> = audience_discount_profiles::Entity::find()
|
||||
.filter(audience_discount_profiles::Column::Audience.eq(audience))
|
||||
.all(&ctx.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|a| a.discount_profile_id)
|
||||
.collect();
|
||||
let all_profiles = discount_profiles::Entity::find()
|
||||
.order_by_asc(discount_profiles::Column::Name)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let profiles: Vec<serde_json::Value> = all_profiles
|
||||
.iter()
|
||||
.map(|p| {
|
||||
json!({
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"percent": format_bp(p.percent_bp),
|
||||
"scope_type": p.scope_type,
|
||||
"assigned": assigned.contains(&p.id),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"admin/catalog/discounts.html",
|
||||
json!({
|
||||
"products": rows,
|
||||
"profiles": profiles,
|
||||
"audience": audience,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Replace the profiles applied to this audience with the submitted checkbox set
|
||||
/// (`profile_ids`, a repeated field parsed directly from the body).
|
||||
#[debug_handler]
|
||||
async fn sync_profiles(
|
||||
auth: auth::JWT,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
State(ctx): State<AppContext>,
|
||||
body: String,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let audience = read_audience(¶ms);
|
||||
|
||||
let profile_ids: Vec<i32> = form_urlencoded::parse(body.as_bytes())
|
||||
.filter(|(k, _)| k == "profile_ids")
|
||||
.filter_map(|(_, value)| value.parse::<i32>().ok())
|
||||
.collect();
|
||||
|
||||
let txn = ctx.db.begin().await?;
|
||||
audience_discount_profiles::Entity::delete_many()
|
||||
.filter(audience_discount_profiles::Column::Audience.eq(audience))
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
for profile_id in profile_ids {
|
||||
audience_discount_profiles::ActiveModel {
|
||||
audience: Set(audience.to_string()),
|
||||
discount_profile_id: Set(profile_id),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&txn)
|
||||
.await?;
|
||||
}
|
||||
txn.commit().await?;
|
||||
list_redirect(audience)
|
||||
}
|
||||
|
||||
/// What to pre-fill the form with: the chosen input mode and the raw values for
|
||||
/// each field, so a rejected submit (or a re-edit) shows what the admin had.
|
||||
#[derive(Default)]
|
||||
struct FormPrefill {
|
||||
mode: String,
|
||||
fixed: String,
|
||||
percent: String,
|
||||
}
|
||||
|
||||
fn render_form(
|
||||
v: &TeraView,
|
||||
jar: &CookieJar,
|
||||
product: &products::Model,
|
||||
audience: &str,
|
||||
prefill: &FormPrefill,
|
||||
error: Option<&str>,
|
||||
) -> Result<Response> {
|
||||
let mode = if prefill.mode == "percent" { "percent" } else { "fixed" };
|
||||
format::view(
|
||||
v,
|
||||
"admin/catalog/discount_form.html",
|
||||
json!({
|
||||
"product": {
|
||||
"id": product.id,
|
||||
"name": product.name,
|
||||
"currency": product.currency,
|
||||
"regular_price": format_price(product.price_cents),
|
||||
"regular_cents": product.price_cents,
|
||||
},
|
||||
"audience": audience,
|
||||
"has_discount": current_value(product, audience).is_some(),
|
||||
"mode": mode,
|
||||
"fixed": prefill.fixed,
|
||||
"percent": prefill.percent,
|
||||
"error": error,
|
||||
"lang": current_lang(jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn edit(
|
||||
auth: auth::JWT,
|
||||
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 price.
|
||||
let prefill = FormPrefill {
|
||||
mode: "fixed".to_string(),
|
||||
fixed: current_value(&product, audience)
|
||||
.map(format_price)
|
||||
.unwrap_or_default(),
|
||||
percent: String::new(),
|
||||
};
|
||||
render_form(&v, &jar, &product, audience, &prefill, None)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn update(
|
||||
auth: auth::JWT,
|
||||
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() {
|
||||
Some("percent") => "percent",
|
||||
_ => "fixed",
|
||||
};
|
||||
let fixed = form.sale_price.unwrap_or_default().trim().to_string();
|
||||
let percent = form.percent.unwrap_or_default().trim().to_string();
|
||||
|
||||
let prefill = FormPrefill {
|
||||
mode: mode.to_string(),
|
||||
fixed: fixed.clone(),
|
||||
percent: percent.clone(),
|
||||
};
|
||||
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, audience).await;
|
||||
}
|
||||
let pct = match parse_percent(&percent) {
|
||||
Some(pct) => pct,
|
||||
None => return render_err("discount-invalid"),
|
||||
};
|
||||
if pct <= 0.0 || pct >= 100.0 {
|
||||
return render_err("discount-percent-range");
|
||||
}
|
||||
percent_to_sale_cents(product.price_cents, pct)
|
||||
} else {
|
||||
if fixed.is_empty() {
|
||||
return clear_discount(&ctx, product, audience).await;
|
||||
}
|
||||
match parse_price_to_cents(&fixed) {
|
||||
Ok(cents) => cents,
|
||||
Err(_) => return render_err("discount-invalid"),
|
||||
}
|
||||
};
|
||||
|
||||
if sale_cents <= 0 {
|
||||
return render_err("discount-must-be-positive");
|
||||
}
|
||||
if sale_cents >= product.price_cents {
|
||||
return render_err("discount-below-regular");
|
||||
}
|
||||
|
||||
let mut active = product.into_active_model();
|
||||
set_value(&mut active, audience, Some(sale_cents));
|
||||
active.update(&ctx.db).await?;
|
||||
list_redirect(audience)
|
||||
}
|
||||
|
||||
async fn clear_discount(
|
||||
ctx: &AppContext,
|
||||
product: products::Model,
|
||||
audience: &str,
|
||||
) -> Result<Response> {
|
||||
let mut active = product.into_active_model();
|
||||
set_value(&mut active, audience, None);
|
||||
active.update(&ctx.db).await?;
|
||||
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, audience).await
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.add("/admin/catalog/discounts", get(index))
|
||||
.add("/admin/catalog/discounts/profiles", post(sync_profiles))
|
||||
.add("/admin/catalog/discounts/{id}/edit", get(edit))
|
||||
.add("/admin/catalog/discounts/{id}", post(update))
|
||||
.add("/admin/catalog/discounts/{id}/remove", post(remove))
|
||||
}
|
||||
@@ -1,12 +1,15 @@
|
||||
//! Admin product CRUD.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use axum::extract::{DefaultBodyLimit, Multipart};
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{
|
||||
ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter,
|
||||
QueryOrder, Set,
|
||||
QueryOrder, Set, TransactionTrait,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
@@ -17,14 +20,19 @@ use crate::{
|
||||
},
|
||||
shared::{
|
||||
guard,
|
||||
money::parse_price_to_cents,
|
||||
money::{format_bp, format_price, parse_percent, parse_price_to_cents},
|
||||
pricing,
|
||||
slug::{slugify, unique_slug},
|
||||
},
|
||||
models::{categories, product_images, products},
|
||||
models::{
|
||||
audience_discount_profiles, categories, discount_profiles, product_images, products,
|
||||
},
|
||||
views::shop as view,
|
||||
};
|
||||
|
||||
/// Which discount column an audience tab operates on.
|
||||
const BUSINESS: &str = "business";
|
||||
|
||||
async fn product_by_id(ctx: &AppContext, id: i32) -> Result<products::Model> {
|
||||
products::Entity::find_by_id(id)
|
||||
.one(&ctx.db)
|
||||
@@ -113,15 +121,20 @@ 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_desc(products::Column::CreatedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
// Effective price each product carries for the active audience, after the
|
||||
// global per-product discount and any profiles assigned to that audience.
|
||||
let effective = pricing::audience_price_many(&ctx, &list, audience).await?;
|
||||
let mut rows = Vec::new();
|
||||
for product in list {
|
||||
for (product, priced) in list.iter().zip(effective.iter()) {
|
||||
let image = product_images::first_for(&ctx, product.id).await?;
|
||||
let category_name = match product.category_id {
|
||||
Some(id) => categories::Entity::find_by_id(id)
|
||||
@@ -130,16 +143,80 @@ async fn index(
|
||||
.map(|c| c.name),
|
||||
None => None,
|
||||
};
|
||||
let priced = pricing::price_for(&ctx, &product, None).await?;
|
||||
rows.push(view::product_card(&product, &priced, image, category_name));
|
||||
rows.push(product_row(product, priced, image, category_name, audience));
|
||||
}
|
||||
|
||||
format::view(
|
||||
&v,
|
||||
"admin/catalog/products.html",
|
||||
json!({ "products": rows, "lang": current_lang(&jar) }),
|
||||
json!({
|
||||
"products": rows,
|
||||
"profiles": load_audience_profiles(&ctx, audience).await?,
|
||||
"audience": audience,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// List-row shape: the product card fields plus the active audience's per-product
|
||||
/// discount and its resolved effective price (after profiles).
|
||||
fn product_row(
|
||||
product: &products::Model,
|
||||
effective: &pricing::PricedProduct,
|
||||
image: Option<String>,
|
||||
category_name: Option<String>,
|
||||
audience: &str,
|
||||
) -> serde_json::Value {
|
||||
let sale = current_value(product, audience);
|
||||
json!({
|
||||
"id": product.id,
|
||||
"name": product.name,
|
||||
"slug": product.slug,
|
||||
"currency": product.currency,
|
||||
"stock": product.stock,
|
||||
"published": product.published,
|
||||
"image": image,
|
||||
"category_name": category_name,
|
||||
"regular_price": format_price(product.price_cents),
|
||||
"on_sale": sale.is_some(),
|
||||
"sale_price": sale.map(format_price),
|
||||
"percent_off": sale.map(|s| percent_off(product.price_cents, s)),
|
||||
"effective_price": format_price(effective.price_cents),
|
||||
"effective_reduced": effective.is_reduced(),
|
||||
"effective_percent_off": percent_off(product.price_cents, effective.price_cents),
|
||||
})
|
||||
}
|
||||
|
||||
/// All discount profiles, flagged with whether they are assigned to `audience`.
|
||||
async fn load_audience_profiles(
|
||||
ctx: &AppContext,
|
||||
audience: &str,
|
||||
) -> Result<Vec<serde_json::Value>> {
|
||||
let assigned: HashSet<i32> = audience_discount_profiles::Entity::find()
|
||||
.filter(audience_discount_profiles::Column::Audience.eq(audience))
|
||||
.all(&ctx.db)
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|a| a.discount_profile_id)
|
||||
.collect();
|
||||
let all_profiles = discount_profiles::Entity::find()
|
||||
.order_by_asc(discount_profiles::Column::Name)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
Ok(all_profiles
|
||||
.iter()
|
||||
.map(|p| {
|
||||
json!({
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"percent": format_bp(p.percent_bp),
|
||||
"scope_type": p.scope_type,
|
||||
"assigned": assigned.contains(&p.id),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn new(
|
||||
auth: auth::JWT,
|
||||
@@ -270,6 +347,255 @@ async fn delete(
|
||||
format::redirect("/admin/catalog/products")
|
||||
}
|
||||
|
||||
// --- Discounts -------------------------------------------------------------
|
||||
//
|
||||
// Two audiences, switched by an `?audience=` tab on the products page:
|
||||
// - **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 off the regular price.
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DiscountForm {
|
||||
/// "fixed" (enter the new price) or "percent" (enter % off). Defaults to
|
||||
/// fixed for older/JSON callers.
|
||||
mode: Option<String>,
|
||||
sale_price: Option<String>,
|
||||
percent: Option<String>,
|
||||
}
|
||||
|
||||
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/products?audience={audience}"))
|
||||
}
|
||||
|
||||
/// Resolve a percentage off the regular price into a fixed sale price in cents.
|
||||
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
|
||||
}
|
||||
|
||||
/// 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;
|
||||
}
|
||||
let off = (regular_cents - sale_cents) as f64 / regular_cents as f64 * 100.0;
|
||||
off.round() as i64
|
||||
}
|
||||
|
||||
/// Replace the profiles applied to this audience with the submitted checkbox set
|
||||
/// (`profile_ids`, a repeated field parsed directly from the body).
|
||||
#[debug_handler]
|
||||
async fn sync_profiles(
|
||||
auth: auth::JWT,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
State(ctx): State<AppContext>,
|
||||
body: String,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let audience = read_audience(¶ms);
|
||||
|
||||
let profile_ids: Vec<i32> = form_urlencoded::parse(body.as_bytes())
|
||||
.filter(|(k, _)| k == "profile_ids")
|
||||
.filter_map(|(_, value)| value.parse::<i32>().ok())
|
||||
.collect();
|
||||
|
||||
let txn = ctx.db.begin().await?;
|
||||
audience_discount_profiles::Entity::delete_many()
|
||||
.filter(audience_discount_profiles::Column::Audience.eq(audience))
|
||||
.exec(&txn)
|
||||
.await?;
|
||||
for profile_id in profile_ids {
|
||||
audience_discount_profiles::ActiveModel {
|
||||
audience: Set(audience.to_string()),
|
||||
discount_profile_id: Set(profile_id),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&txn)
|
||||
.await?;
|
||||
}
|
||||
txn.commit().await?;
|
||||
list_redirect(audience)
|
||||
}
|
||||
|
||||
/// What to pre-fill the discount form with: the chosen input mode and the raw
|
||||
/// values for each field, so a rejected submit (or a re-edit) shows what the
|
||||
/// admin had.
|
||||
#[derive(Default)]
|
||||
struct FormPrefill {
|
||||
mode: String,
|
||||
fixed: String,
|
||||
percent: String,
|
||||
}
|
||||
|
||||
fn render_discount_form(
|
||||
v: &TeraView,
|
||||
jar: &CookieJar,
|
||||
product: &products::Model,
|
||||
audience: &str,
|
||||
prefill: &FormPrefill,
|
||||
error: Option<&str>,
|
||||
) -> Result<Response> {
|
||||
let mode = if prefill.mode == "percent" { "percent" } else { "fixed" };
|
||||
format::view(
|
||||
v,
|
||||
"admin/catalog/discount_form.html",
|
||||
json!({
|
||||
"product": {
|
||||
"id": product.id,
|
||||
"name": product.name,
|
||||
"currency": product.currency,
|
||||
"regular_price": format_price(product.price_cents),
|
||||
"regular_cents": product.price_cents,
|
||||
},
|
||||
"audience": audience,
|
||||
"has_discount": current_value(product, audience).is_some(),
|
||||
"mode": mode,
|
||||
"fixed": prefill.fixed,
|
||||
"percent": prefill.percent,
|
||||
"error": error,
|
||||
"lang": current_lang(jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn discount_edit(
|
||||
auth: auth::JWT,
|
||||
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 price.
|
||||
let prefill = FormPrefill {
|
||||
mode: "fixed".to_string(),
|
||||
fixed: current_value(&product, audience)
|
||||
.map(format_price)
|
||||
.unwrap_or_default(),
|
||||
percent: String::new(),
|
||||
};
|
||||
render_discount_form(&v, &jar, &product, audience, &prefill, None)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn discount_update(
|
||||
auth: auth::JWT,
|
||||
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() {
|
||||
Some("percent") => "percent",
|
||||
_ => "fixed",
|
||||
};
|
||||
let fixed = form.sale_price.unwrap_or_default().trim().to_string();
|
||||
let percent = form.percent.unwrap_or_default().trim().to_string();
|
||||
|
||||
let prefill = FormPrefill {
|
||||
mode: mode.to_string(),
|
||||
fixed: fixed.clone(),
|
||||
percent: percent.clone(),
|
||||
};
|
||||
let render_err =
|
||||
|key: &str| render_discount_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, audience).await;
|
||||
}
|
||||
let pct = match parse_percent(&percent) {
|
||||
Some(pct) => pct,
|
||||
None => return render_err("discount-invalid"),
|
||||
};
|
||||
if pct <= 0.0 || pct >= 100.0 {
|
||||
return render_err("discount-percent-range");
|
||||
}
|
||||
percent_to_sale_cents(product.price_cents, pct)
|
||||
} else {
|
||||
if fixed.is_empty() {
|
||||
return clear_discount(&ctx, product, audience).await;
|
||||
}
|
||||
match parse_price_to_cents(&fixed) {
|
||||
Ok(cents) => cents,
|
||||
Err(_) => return render_err("discount-invalid"),
|
||||
}
|
||||
};
|
||||
|
||||
if sale_cents <= 0 {
|
||||
return render_err("discount-must-be-positive");
|
||||
}
|
||||
if sale_cents >= product.price_cents {
|
||||
return render_err("discount-below-regular");
|
||||
}
|
||||
|
||||
let mut active = product.into_active_model();
|
||||
set_value(&mut active, audience, Some(sale_cents));
|
||||
active.update(&ctx.db).await?;
|
||||
list_redirect(audience)
|
||||
}
|
||||
|
||||
async fn clear_discount(
|
||||
ctx: &AppContext,
|
||||
product: products::Model,
|
||||
audience: &str,
|
||||
) -> Result<Response> {
|
||||
let mut active = product.into_active_model();
|
||||
set_value(&mut active, audience, None);
|
||||
active.update(&ctx.db).await?;
|
||||
list_redirect(audience)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn discount_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, audience).await
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024);
|
||||
Routes::new()
|
||||
@@ -279,10 +605,23 @@ pub fn routes() -> Routes {
|
||||
"/admin/catalog/products",
|
||||
post(create).layer(image_limit.clone()),
|
||||
)
|
||||
.add("/admin/catalog/products/profiles", post(sync_profiles))
|
||||
.add("/admin/catalog/products/{id}/edit", get(edit))
|
||||
.add(
|
||||
"/admin/catalog/products/{id}",
|
||||
post(update).layer(image_limit),
|
||||
)
|
||||
.add("/admin/catalog/products/{id}/delete", post(delete))
|
||||
.add(
|
||||
"/admin/catalog/products/{id}/discount/edit",
|
||||
get(discount_edit),
|
||||
)
|
||||
.add(
|
||||
"/admin/catalog/products/{id}/discount",
|
||||
post(discount_update),
|
||||
)
|
||||
.add(
|
||||
"/admin/catalog/products/{id}/discount/remove",
|
||||
post(discount_remove),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ pub mod admin_categories;
|
||||
pub mod admin_customers;
|
||||
pub mod admin_dashboard;
|
||||
pub mod admin_discount_profiles;
|
||||
pub mod admin_discounts;
|
||||
pub mod admin_form;
|
||||
pub mod admin_orders;
|
||||
pub mod admin_products;
|
||||
|
||||
Reference in New Issue
Block a user