diff --git a/assets/views/admin/base.html b/assets/views/admin/base.html index 7db4ee5..a701385 100644 --- a/assets/views/admin/base.html +++ b/assets/views/admin/base.html @@ -78,10 +78,6 @@ class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong"> {{ t(key="admin-products", lang=lang | default(value='sk')) }} - - {{ t(key="admin-discounts", lang=lang | default(value='sk')) }} - {{ t(key="admin-discount-profiles", lang=lang | default(value='sk')) }} diff --git a/assets/views/admin/catalog/discount_form.html b/assets/views/admin/catalog/discount_form.html index 7b3d1f5..d520d27 100644 --- a/assets/views/admin/catalog/discount_form.html +++ b/assets/views/admin/catalog/discount_form.html @@ -12,10 +12,10 @@ {% if audience == "business" %}{{ t(key="audience-business", lang=lang | default(value='sk')) }}{% else %}{{ t(key="audience-personal", lang=lang | default(value='sk')) }}{% endif %}

- {{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/discounts?audience=" ~ audience, size="px-3 py-2 text-sm") }} + {{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products?audience=" ~ audience, size="px-3 py-2 text-sm") }} -
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", attrs=`onclick="return confirm('` ~ t(key="discount-apply-confirm", lang=lang | default(value='sk')) ~ `')"`) }} {% if has_discount %} - {{ ui::button(variant="outline-danger", label=t(key="remove-discount", lang=lang | default(value='sk')), type="submit", attrs=`formaction="/admin/catalog/discounts/` ~ product.id ~ `/remove?audience=` ~ audience ~ `" onclick="return confirm('` ~ t(key="discount-remove-confirm", lang=lang | default(value='sk')) ~ `')"`) }} + {{ ui::button(variant="outline-danger", label=t(key="remove-discount", lang=lang | default(value='sk')), type="submit", attrs=`formaction="/admin/catalog/products/` ~ product.id ~ `/discount/remove?audience=` ~ audience ~ `" onclick="return confirm('` ~ t(key="discount-remove-confirm", lang=lang | default(value='sk')) ~ `')"`) }} {% endif %}
diff --git a/assets/views/admin/catalog/discounts.html b/assets/views/admin/catalog/discounts.html deleted file mode 100644 index 7a8f146..0000000 --- a/assets/views/admin/catalog/discounts.html +++ /dev/null @@ -1,125 +0,0 @@ -{% extends "admin/base.html" %} -{% import "macros/ui.html" as ui %} - -{% block title %}{{ t(key="admin-discounts", lang=lang | default(value='sk')) }}{% endblock title %} -{% block crumb %}{{ t(key="admin-discounts", lang=lang | default(value='sk')) }}{% endblock crumb %} - -{% block content %} -{% set business = audience == "business" %} -
-
-

{{ t(key="admin-discounts", lang=lang | default(value='sk')) }}

-

- {% if business %}{{ t(key="business-discount-desc", lang=lang | default(value='sk')) }}{% else %}{{ t(key="admin-discounts-desc", lang=lang | default(value='sk')) }}{% endif %} -

-
-
- - -
- - {{ t(key="audience-personal", lang=lang | default(value='sk')) }} - - - {{ t(key="audience-business", lang=lang | default(value='sk')) }} - -
- - -
-

{{ t(key="discount-profiles", lang=lang | default(value='sk')) }}

-

- {% if business %}{{ t(key="apply-profiles-business-hint", lang=lang | default(value='sk')) }}{% else %}{{ t(key="apply-profiles-personal-hint", lang=lang | default(value='sk')) }}{% endif %} -

- {% if profiles | length > 0 %} -
- {{ ui::csrf_field() }} -
- {% for profile in profiles %} - - {% endfor %} -
- {{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", size="px-4 py-2 text-sm") }} -
- {% else %} -

- {{ t(key="admin-no-profiles", lang=lang | default(value='sk')) }} - {{ t(key="new-profile", lang=lang | default(value='sk')) }} -

- {% endif %} -
- -
- {% if products | length > 0 %} - - - - {{ ui::th(label=t(key="product", lang=lang | default(value='sk'))) }} - {{ ui::th(label=t(key="price", lang=lang | default(value='sk'))) }} - {{ ui::th(label=t(key="sale-price", lang=lang | default(value='sk'))) }} - {{ ui::th(label=t(key="effective-price", lang=lang | default(value='sk'))) }} - {{ ui::th(label=t(key="status", lang=lang | default(value='sk'))) }} - {{ ui::th(label=t(key="actions", lang=lang | default(value='sk')), align="text-right") }} - - - - {% for product in products %} - {% if business %}{% set on_sale = product.business_on_sale %}{% set sale_price = product.business_sale_price %}{% set pct = product.business_percent_off %} - {% else %}{% set on_sale = product.on_sale %}{% set sale_price = product.sale_price %}{% set pct = product.percent_off %}{% endif %} - - - - - - - - - {% endfor %} - -
-
{{ product.name }}
-
{{ product.regular_price }} {{ product.currency }} - {% if on_sale %} - {{ sale_price }} {{ product.currency }} - (−{{ pct }}%) - {% else %} - - {% endif %} - - {% if product.effective_reduced %} - {{ product.effective_price }} {{ product.currency }} - (−{{ product.effective_percent_off }}%) - {% else %} - {{ product.effective_price }} {{ product.currency }} - {% endif %} - - {% if on_sale %} - {{ ui::badge(label=t(key="on-sale", lang=lang | default(value='sk')), variant="danger") }} - {% else %} - {{ ui::badge(label=t(key="no-discount", lang=lang | default(value='sk')), variant="neutral") }} - {% endif %} - -
- {{ ui::button(variant="outline-secondary", label=t(key="set-discount", lang=lang | default(value='sk')), href="/admin/catalog/discounts/" ~ product.id ~ "/edit?audience=" ~ audience, size="px-3 py-1.5 text-xs") }} - {% if on_sale %} -
- {{ ui::csrf_field() }} - {{ ui::button(variant="outline-danger", label=t(key="remove-discount", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }} -
- {% endif %} -
-
- {% else %} -
-

{{ t(key="admin-no-products", lang=lang | default(value='sk')) }}

-
- {% endif %} -
-{% endblock content %} diff --git a/assets/views/admin/catalog/products.html b/assets/views/admin/catalog/products.html index 94f0827..8951db9 100644 --- a/assets/views/admin/catalog/products.html +++ b/assets/views/admin/catalog/products.html @@ -5,6 +5,7 @@ {% block crumb %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock crumb %} {% block content %} +{% set business = audience == "business" %}

{{ t(key="admin-products", lang=lang | default(value='sk')) }}

@@ -13,13 +14,55 @@ {{ ui::button(label=t(key="new-product", lang=lang | default(value='sk')), href="/admin/catalog/products/new") }}
-
+ + + + +
+

{{ t(key="discount-profiles", lang=lang | default(value='sk')) }}

+

+ {% if business %}{{ t(key="apply-profiles-business-hint", lang=lang | default(value='sk')) }}{% else %}{{ t(key="apply-profiles-personal-hint", lang=lang | default(value='sk')) }}{% endif %} +

+ {% if profiles | length > 0 %} +
+ {{ ui::csrf_field() }} +
+ {% for profile in profiles %} + + {% endfor %} +
+ {{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", size="px-4 py-2 text-sm") }} +
+ {% else %} +

+ {{ t(key="admin-no-profiles", lang=lang | default(value='sk')) }} + {{ t(key="new-profile", lang=lang | default(value='sk')) }} +

+ {% endif %} +
+ +
{% if products | length > 0 %} {{ ui::th(label=t(key="product", lang=lang | default(value='sk'))) }} {{ ui::th(label=t(key="price", lang=lang | default(value='sk'))) }} + {{ ui::th(label=t(key="sale-price", lang=lang | default(value='sk'))) }} + {{ ui::th(label=t(key="effective-price", lang=lang | default(value='sk'))) }} {{ ui::th(label=t(key="stock", lang=lang | default(value='sk'))) }} {{ ui::th(label=t(key="status", lang=lang | default(value='sk'))) }} {{ ui::th(label=t(key="actions", lang=lang | default(value='sk')), align="text-right") }} @@ -41,12 +84,21 @@ + + @@ -60,11 +112,18 @@
{{ product.regular_price }} {{ product.currency }} {% if product.on_sale %} - {{ product.price }} {{ product.currency }} - {{ product.regular_price }} + {{ product.sale_price }} {{ product.currency }} + (−{{ product.percent_off }}%) {% else %} - {{ product.price }} {{ product.currency }} + + {% endif %} + + {% if product.effective_reduced %} + {{ product.effective_price }} {{ product.currency }} + (−{{ product.effective_percent_off }}%) + {% else %} + {{ product.effective_price }} {{ product.currency }} {% endif %} {{ product.stock }}
{{ ui::button(variant="outline-secondary", label=t(key="edit", lang=lang | default(value='sk')), href="/admin/catalog/products/" ~ product.id ~ "/edit", size="px-3 py-1.5 text-xs") }} - {{ ui::button(variant="outline-secondary", label=t(key="discount", lang=lang | default(value='sk')), href="/admin/catalog/discounts/" ~ product.id ~ "/edit", size="px-3 py-1.5 text-xs") }} + {{ ui::button(variant="outline-secondary", label=t(key="set-discount", lang=lang | default(value='sk')), href="/admin/catalog/products/" ~ product.id ~ "/discount/edit?audience=" ~ audience, size="px-3 py-1.5 text-xs") }} + {% if product.on_sale %} +
+ {{ ui::csrf_field() }} + {{ ui::button(variant="outline-danger", label=t(key="remove-discount", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }} +
+ {% endif %} {{ ui::button(variant="outline-secondary", label=t(key="view", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, size="px-3 py-1.5 text-xs") }}
- {{ ui::csrf_field() }} + {{ ui::csrf_field() }} {{ ui::button(variant="outline-danger", label=t(key="delete", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}
diff --git a/src/app.rs b/src/app.rs index ca07554..dfa4791 100644 --- a/src/app.rs +++ b/src/app.rs @@ -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()) diff --git a/src/controllers/admin_discounts.rs b/src/controllers/admin_discounts.rs deleted file mode 100644 index 2f0ffbd..0000000 --- a/src/controllers/admin_discounts.rs +++ /dev/null @@ -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, - sale_price: Option, - percent: Option, -} - -/// Which discount column an audience tab operates on. -fn read_audience(params: &HashMap) -> &'static str { - match params.get("audience").map(String::as_str) { - Some(BUSINESS) => BUSINESS, - _ => "personal", - } -} - -fn current_value(product: &products::Model, audience: &str) -> Option { - if audience == BUSINESS { - product.business_sale_price_cents - } else { - product.sale_price_cents - } -} - -fn set_value(active: &mut products::ActiveModel, audience: &str, value: Option) { - if audience == BUSINESS { - active.business_sale_price_cents = Set(value); - } else { - active.sale_price_cents = Set(value); - } -} - -fn list_redirect(audience: &str) -> Result { - 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::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, - Query(params): Query>, - State(ctx): State, -) -> Result { - 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 = 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 = 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 = 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>, - State(ctx): State, - body: String, -) -> Result { - guard::current_admin(auth, &ctx).await?; - let audience = read_audience(¶ms); - - let profile_ids: Vec = form_urlencoded::parse(body.as_bytes()) - .filter(|(k, _)| k == "profile_ids") - .filter_map(|(_, value)| value.parse::().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 { - 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, - Path(id): Path, - Query(params): Query>, - State(ctx): State, -) -> Result { - 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, - Path(id): Path, - Query(params): Query>, - State(ctx): State, - Form(form): Form, -) -> Result { - 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 { - 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, - Query(params): Query>, - State(ctx): State, -) -> Result { - 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)) -} diff --git a/src/controllers/admin_products.rs b/src/controllers/admin_products.rs index 618001f..46b484e 100644 --- a/src/controllers/admin_products.rs +++ b/src/controllers/admin_products.rs @@ -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::Entity::find_by_id(id) .one(&ctx.db) @@ -113,15 +121,20 @@ async fn index( auth: auth::JWT, jar: CookieJar, ViewEngine(v): ViewEngine, + Query(params): Query>, State(ctx): State, ) -> Result { 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, + category_name: Option, + 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> { + let assigned: HashSet = 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, + sale_price: Option, + percent: Option, +} + +fn read_audience(params: &HashMap) -> &'static str { + match params.get("audience").map(String::as_str) { + Some(BUSINESS) => BUSINESS, + _ => "personal", + } +} + +fn current_value(product: &products::Model, audience: &str) -> Option { + if audience == BUSINESS { + product.business_sale_price_cents + } else { + product.sale_price_cents + } +} + +fn set_value(active: &mut products::ActiveModel, audience: &str, value: Option) { + if audience == BUSINESS { + active.business_sale_price_cents = Set(value); + } else { + active.sale_price_cents = Set(value); + } +} + +fn list_redirect(audience: &str) -> Result { + 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>, + State(ctx): State, + body: String, +) -> Result { + guard::current_admin(auth, &ctx).await?; + let audience = read_audience(¶ms); + + let profile_ids: Vec = form_urlencoded::parse(body.as_bytes()) + .filter(|(k, _)| k == "profile_ids") + .filter_map(|(_, value)| value.parse::().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 { + 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, + Path(id): Path, + Query(params): Query>, + State(ctx): State, +) -> Result { + 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, + Path(id): Path, + Query(params): Query>, + State(ctx): State, + Form(form): Form, +) -> Result { + 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 { + 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, + Query(params): Query>, + State(ctx): State, +) -> Result { + 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), + ) } diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index 7de95e0..327246d 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -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;