discounts page removed, all migrated to the products page in admin

This commit is contained in:
Priec
2026-06-22 09:19:38 +02:00
parent 534ba9e8ec
commit bf8f8e54c9
8 changed files with 415 additions and 518 deletions

View File

@@ -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())

View File

@@ -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(&params);
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(&params);
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(&params);
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(&params);
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(&params);
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))
}

View File

@@ -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(&params);
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(&params);
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(&params);
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(&params);
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(&params);
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),
)
}

View File

@@ -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;