discount for business and personall in discount page
This commit is contained in:
@@ -211,6 +211,9 @@ price = Price
|
|||||||
sale-price = Sale price
|
sale-price = Sale price
|
||||||
admin-discounts = Discounts
|
admin-discounts = Discounts
|
||||||
admin-discounts-desc = Set discounted product prices. A discount shows up as a sale in the shop.
|
admin-discounts-desc = Set discounted product prices. A discount shows up as a sale in the shop.
|
||||||
|
business-discount-desc = A baseline discount for all business accounts (off the regular price). Profiles and negotiated prices apply on top (lowest price wins).
|
||||||
|
audience-personal = Personal
|
||||||
|
audience-business = Business
|
||||||
on-sale = On sale
|
on-sale = On sale
|
||||||
no-discount = No discount
|
no-discount = No discount
|
||||||
discount = Discount
|
discount = Discount
|
||||||
|
|||||||
@@ -211,6 +211,9 @@ price = Cena
|
|||||||
sale-price = Zľavnená cena
|
sale-price = Zľavnená cena
|
||||||
admin-discounts = Zľavy
|
admin-discounts = Zľavy
|
||||||
admin-discounts-desc = Nastavte zľavnené ceny produktov. Zľava sa v obchode zobrazí ako akcia.
|
admin-discounts-desc = Nastavte zľavnené ceny produktov. Zľava sa v obchode zobrazí ako akcia.
|
||||||
|
business-discount-desc = Základná zľava pre všetky firemné účty (z bežnej ceny). Profily a dohodnuté ceny sa uplatnia navyše (platí najnižšia cena).
|
||||||
|
audience-personal = Osobné
|
||||||
|
audience-business = Firemné
|
||||||
on-sale = V akcii
|
on-sale = V akcii
|
||||||
no-discount = Bez zľavy
|
no-discount = Bez zľavy
|
||||||
discount = Zľava
|
discount = Zľava
|
||||||
|
|||||||
@@ -6,11 +6,16 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h1>
|
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h1>
|
||||||
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/discounts", size="px-3 py-2 text-sm") }}
|
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">
|
||||||
|
{% if audience == "business" %}{{ t(key="audience-business", lang=lang | default(value='sk')) }}{% else %}{{ t(key="audience-personal", lang=lang | default(value='sk')) }}{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{ 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") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="/admin/catalog/discounts/{{ product.id }}"
|
<form method="post" action="/admin/catalog/discounts/{{ product.id }}?audience={{ audience }}"
|
||||||
x-data="{
|
x-data="{
|
||||||
mode: '{{ mode }}',
|
mode: '{{ mode }}',
|
||||||
fixed: '{{ fixed }}',
|
fixed: '{{ fixed }}',
|
||||||
@@ -89,8 +94,8 @@
|
|||||||
|
|
||||||
<div class="flex flex-wrap gap-3 pt-2">
|
<div class="flex flex-wrap gap-3 pt-2">
|
||||||
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit") }}
|
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit") }}
|
||||||
{% if product.on_sale %}
|
{% 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"') }}
|
{{ 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 ~ '"') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -5,14 +5,29 @@
|
|||||||
{% block crumb %}{{ t(key="admin-discounts", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
{% block crumb %}{{ t(key="admin-discounts", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% set business = audience == "business" %}
|
||||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-discounts", lang=lang | default(value='sk')) }}</h1>
|
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-discounts", lang=lang | default(value='sk')) }}</h1>
|
||||||
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-discounts-desc", lang=lang | default(value='sk')) }}</p>
|
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">
|
||||||
|
{% if business %}{{ t(key="business-discount-desc", lang=lang | default(value='sk')) }}{% else %}{{ t(key="admin-discounts-desc", lang=lang | default(value='sk')) }}{% endif %}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 {{ ui::table_wrap_cls() }}">
|
<!-- audience tabs -->
|
||||||
|
<div class="mt-4 inline-flex rounded-radius border border-outline p-1 dark:border-outline-dark">
|
||||||
|
<a href="/admin/catalog/discounts?audience=personal"
|
||||||
|
class="rounded-radius px-4 py-1.5 text-sm font-medium {% if not business %}bg-primary/10 text-on-surface-strong dark:bg-primary-dark/10 dark:text-on-surface-dark-strong{% else %}text-on-surface/70 dark:text-on-surface-dark/70{% endif %}">
|
||||||
|
{{ t(key="audience-personal", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
<a href="/admin/catalog/discounts?audience=business"
|
||||||
|
class="rounded-radius px-4 py-1.5 text-sm font-medium {% if business %}bg-primary/10 text-on-surface-strong dark:bg-primary-dark/10 dark:text-on-surface-dark-strong{% else %}text-on-surface/70 dark:text-on-surface-dark/70{% endif %}">
|
||||||
|
{{ t(key="audience-business", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 {{ ui::table_wrap_cls() }}">
|
||||||
{% if products | length > 0 %}
|
{% if products | length > 0 %}
|
||||||
<table class="{{ ui::table_cls() }}">
|
<table class="{{ ui::table_cls() }}">
|
||||||
<thead class="{{ ui::thead_cls() }}">
|
<thead class="{{ ui::thead_cls() }}">
|
||||||
@@ -26,21 +41,23 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody class="{{ ui::tbody_cls() }}">
|
<tbody class="{{ ui::tbody_cls() }}">
|
||||||
{% for product in products %}
|
{% 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 %}
|
||||||
<tr class="{{ ui::row_cls() }}">
|
<tr class="{{ ui::row_cls() }}">
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</div>
|
<div class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 tabular-nums">{{ product.regular_price }} {{ product.currency }}</td>
|
<td class="px-4 py-3 tabular-nums">{{ product.regular_price }} {{ product.currency }}</td>
|
||||||
<td class="px-4 py-3 tabular-nums">
|
<td class="px-4 py-3 tabular-nums">
|
||||||
{% if product.on_sale %}
|
{% if on_sale %}
|
||||||
<span class="font-medium text-danger">{{ product.sale_price }} {{ product.currency }}</span>
|
<span class="font-medium text-danger">{{ sale_price }} {{ product.currency }}</span>
|
||||||
<span class="ml-1 text-xs text-on-surface/60 dark:text-on-surface-dark/60">(−{{ product.percent_off }}%)</span>
|
<span class="ml-1 text-xs text-on-surface/60 dark:text-on-surface-dark/60">(−{{ pct }}%)</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="text-on-surface/40 dark:text-on-surface-dark/40">—</span>
|
<span class="text-on-surface/40 dark:text-on-surface-dark/40">—</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
{% if product.on_sale %}
|
{% if on_sale %}
|
||||||
{{ ui::badge(label=t(key="on-sale", lang=lang | default(value='sk')), variant="danger") }}
|
{{ ui::badge(label=t(key="on-sale", lang=lang | default(value='sk')), variant="danger") }}
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ ui::badge(label=t(key="no-discount", lang=lang | default(value='sk')), variant="neutral") }}
|
{{ ui::badge(label=t(key="no-discount", lang=lang | default(value='sk')), variant="neutral") }}
|
||||||
@@ -48,9 +65,9 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex flex-wrap justify-end gap-2">
|
<div class="flex flex-wrap justify-end gap-2">
|
||||||
{{ ui::button(variant="outline-secondary", label=t(key="set-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/discounts/" ~ product.id ~ "/edit?audience=" ~ audience, size="px-3 py-1.5 text-xs") }}
|
||||||
{% if product.on_sale %}
|
{% if on_sale %}
|
||||||
<form method="post" action="/admin/catalog/discounts/{{ product.id }}/remove">
|
<form method="post" action="/admin/catalog/discounts/{{ product.id }}/remove?audience={{ audience }}">
|
||||||
{{ ui::csrf_field() }}
|
{{ 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") }}
|
{{ 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") }}
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ mod m20260620_000001_add_totp_to_users;
|
|||||||
mod m20260621_000001_add_sale_price_to_products;
|
mod m20260621_000001_add_sale_price_to_products;
|
||||||
mod m20260621_000002_account_product_prices;
|
mod m20260621_000002_account_product_prices;
|
||||||
mod m20260621_000003_discount_profiles;
|
mod m20260621_000003_discount_profiles;
|
||||||
|
mod m20260621_000004_add_business_sale_price_to_products;
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -80,6 +81,7 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260621_000001_add_sale_price_to_products::Migration),
|
Box::new(m20260621_000001_add_sale_price_to_products::Migration),
|
||||||
Box::new(m20260621_000002_account_product_prices::Migration),
|
Box::new(m20260621_000002_account_product_prices::Migration),
|
||||||
Box::new(m20260621_000003_discount_profiles::Migration),
|
Box::new(m20260621_000003_discount_profiles::Migration),
|
||||||
|
Box::new(m20260621_000004_add_business_sale_price_to_products::Migration),
|
||||||
// inject-above (do not remove this comment)
|
// inject-above (do not remove this comment)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// Optional per-product discounted price (minor units) shown to ALL
|
||||||
|
// business (company) accounts as a baseline, computed off the regular
|
||||||
|
// price like the personal sale. Per-company profiles/negotiated prices
|
||||||
|
// still layer on top (lowest price wins).
|
||||||
|
add_column(m, "products", "business_sale_price_cents", ColType::BigIntegerNull).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
remove_column(m, "products", "business_sale_price_cents").await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
//! Two audiences, switched by an `?audience=` tab:
|
||||||
//! place of their own, rather than on the product editor: an admin picks a
|
//! - **personal** (default): the public sale price (`products.sale_price_cents`)
|
||||||
//! product, enters a discounted price, and the storefront then shows it on sale.
|
//! everyone sees.
|
||||||
//! Editing a product never touches its discount, and vice versa.
|
//! - **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 axum_extra::extract::cookie::CookieJar;
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
@@ -20,6 +26,8 @@ use crate::{
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const BUSINESS: &str = "business";
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct DiscountForm {
|
struct DiscountForm {
|
||||||
/// "fixed" (enter the new price) or "percent" (enter % off). Defaults to
|
/// "fixed" (enter the new price) or "percent" (enter % off). Defaults to
|
||||||
@@ -29,8 +37,35 @@ struct DiscountForm {
|
|||||||
percent: 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.
|
/// 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 {
|
fn percent_to_sale_cents(regular_cents: i64, percent: f64) -> i64 {
|
||||||
let off = (regular_cents as f64 * percent / 100.0).round() as i64;
|
let off = (regular_cents as f64 * percent / 100.0).round() as i64;
|
||||||
regular_cents - off
|
regular_cents - off
|
||||||
@@ -43,8 +78,7 @@ async fn product_by_id(ctx: &AppContext, id: i32) -> Result<products::Model> {
|
|||||||
.ok_or_else(|| Error::NotFound)
|
.ok_or_else(|| Error::NotFound)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Percent off the regular price, rounded to a whole number. `0` when there is
|
/// Percent off the regular price, rounded to a whole number.
|
||||||
/// no positive regular price to discount from.
|
|
||||||
fn percent_off(regular_cents: i64, sale_cents: i64) -> i64 {
|
fn percent_off(regular_cents: i64, sale_cents: i64) -> i64 {
|
||||||
if regular_cents <= 0 {
|
if regular_cents <= 0 {
|
||||||
return 0;
|
return 0;
|
||||||
@@ -53,7 +87,8 @@ fn percent_off(regular_cents: i64, sale_cents: i64) -> i64 {
|
|||||||
off.round() as 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 {
|
fn list_row(product: &products::Model) -> serde_json::Value {
|
||||||
json!({
|
json!({
|
||||||
"id": product.id,
|
"id": product.id,
|
||||||
@@ -64,6 +99,11 @@ fn list_row(product: &products::Model) -> serde_json::Value {
|
|||||||
"on_sale": product.on_sale(),
|
"on_sale": product.on_sale(),
|
||||||
"sale_price": product.sale_price_cents.map(format_price),
|
"sale_price": product.sale_price_cents.map(format_price),
|
||||||
"percent_off": product.sale_price_cents.map(|sale| percent_off(product.price_cents, sale)),
|
"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,
|
auth: auth::JWT,
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
ViewEngine(v): ViewEngine<TeraView>,
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
guard::current_admin(auth, &ctx).await?;
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let audience = read_audience(¶ms);
|
||||||
let list = products::Entity::find()
|
let list = products::Entity::find()
|
||||||
.order_by_asc(products::Column::Name)
|
.order_by_asc(products::Column::Name)
|
||||||
.all(&ctx.db)
|
.all(&ctx.db)
|
||||||
@@ -83,7 +125,7 @@ async fn index(
|
|||||||
format::view(
|
format::view(
|
||||||
&v,
|
&v,
|
||||||
"admin/catalog/discounts.html",
|
"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,
|
percent: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the single-product discount form, optionally with a validation error.
|
|
||||||
fn render_form(
|
fn render_form(
|
||||||
v: &TeraView,
|
v: &TeraView,
|
||||||
jar: &CookieJar,
|
jar: &CookieJar,
|
||||||
product: &products::Model,
|
product: &products::Model,
|
||||||
|
audience: &str,
|
||||||
prefill: &FormPrefill,
|
prefill: &FormPrefill,
|
||||||
error: Option<&str>,
|
error: Option<&str>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
@@ -115,9 +157,9 @@ fn render_form(
|
|||||||
"currency": product.currency,
|
"currency": product.currency,
|
||||||
"regular_price": format_price(product.price_cents),
|
"regular_price": format_price(product.price_cents),
|
||||||
"regular_cents": 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,
|
"mode": mode,
|
||||||
"fixed": prefill.fixed,
|
"fixed": prefill.fixed,
|
||||||
"percent": prefill.percent,
|
"percent": prefill.percent,
|
||||||
@@ -133,17 +175,21 @@ async fn edit(
|
|||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
ViewEngine(v): ViewEngine<TeraView>,
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
Path(id): Path<i32>,
|
Path(id): Path<i32>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
guard::current_admin(auth, &ctx).await?;
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let audience = read_audience(¶ms);
|
||||||
let product = product_by_id(&ctx, id).await?;
|
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 {
|
let prefill = FormPrefill {
|
||||||
mode: "fixed".to_string(),
|
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(),
|
percent: String::new(),
|
||||||
};
|
};
|
||||||
render_form(&v, &jar, &product, &prefill, None)
|
render_form(&v, &jar, &product, audience, &prefill, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
@@ -152,10 +198,12 @@ async fn update(
|
|||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
ViewEngine(v): ViewEngine<TeraView>,
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
Path(id): Path<i32>,
|
Path(id): Path<i32>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
Form(form): Form<DiscountForm>,
|
Form(form): Form<DiscountForm>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
guard::current_admin(auth, &ctx).await?;
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let audience = read_audience(¶ms);
|
||||||
let product = product_by_id(&ctx, id).await?;
|
let product = product_by_id(&ctx, id).await?;
|
||||||
|
|
||||||
let mode = match form.mode.as_deref() {
|
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 fixed = form.sale_price.unwrap_or_default().trim().to_string();
|
||||||
let percent = form.percent.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 {
|
let prefill = FormPrefill {
|
||||||
mode: mode.to_string(),
|
mode: mode.to_string(),
|
||||||
fixed: fixed.clone(),
|
fixed: fixed.clone(),
|
||||||
percent: percent.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
|
// 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).
|
// input in the active mode clears the discount (same as the Remove action).
|
||||||
let sale_cents = if mode == "percent" {
|
let sale_cents = if mode == "percent" {
|
||||||
if percent.is_empty() {
|
if percent.is_empty() {
|
||||||
return clear_discount(&ctx, product).await;
|
return clear_discount(&ctx, product, audience).await;
|
||||||
}
|
}
|
||||||
let pct = match parse_percent(&percent) {
|
let pct = match parse_percent(&percent) {
|
||||||
Some(pct) => pct,
|
Some(pct) => pct,
|
||||||
@@ -190,7 +236,7 @@ async fn update(
|
|||||||
percent_to_sale_cents(product.price_cents, pct)
|
percent_to_sale_cents(product.price_cents, pct)
|
||||||
} else {
|
} else {
|
||||||
if fixed.is_empty() {
|
if fixed.is_empty() {
|
||||||
return clear_discount(&ctx, product).await;
|
return clear_discount(&ctx, product, audience).await;
|
||||||
}
|
}
|
||||||
match parse_price_to_cents(&fixed) {
|
match parse_price_to_cents(&fixed) {
|
||||||
Ok(cents) => cents,
|
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 {
|
if sale_cents <= 0 {
|
||||||
return render_err("discount-must-be-positive");
|
return render_err("discount-must-be-positive");
|
||||||
}
|
}
|
||||||
@@ -208,27 +252,33 @@ async fn update(
|
|||||||
}
|
}
|
||||||
|
|
||||||
let mut active = product.into_active_model();
|
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?;
|
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();
|
let mut active = product.into_active_model();
|
||||||
active.sale_price_cents = Set(None);
|
set_value(&mut active, audience, None);
|
||||||
active.update(&ctx.db).await?;
|
active.update(&ctx.db).await?;
|
||||||
format::redirect("/admin/catalog/discounts")
|
list_redirect(audience)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn remove(
|
async fn remove(
|
||||||
auth: auth::JWT,
|
auth: auth::JWT,
|
||||||
Path(id): Path<i32>,
|
Path(id): Path<i32>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
guard::current_admin(auth, &ctx).await?;
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let audience = read_audience(¶ms);
|
||||||
let product = product_by_id(&ctx, id).await?;
|
let product = product_by_id(&ctx, id).await?;
|
||||||
clear_discount(&ctx, product).await
|
clear_discount(&ctx, product, audience).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn routes() -> Routes {
|
pub fn routes() -> Routes {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub struct Model {
|
|||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub price_cents: i64,
|
pub price_cents: i64,
|
||||||
pub sale_price_cents: Option<i64>,
|
pub sale_price_cents: Option<i64>,
|
||||||
|
pub business_sale_price_cents: Option<i64>,
|
||||||
pub currency: String,
|
pub currency: String,
|
||||||
pub sku: Option<String>,
|
pub sku: Option<String>,
|
||||||
pub stock: i32,
|
pub stock: i32,
|
||||||
|
|||||||
@@ -37,6 +37,13 @@ impl Model {
|
|||||||
self.price_cents
|
self.price_cents
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a baseline business discount (for all company accounts) is set and
|
||||||
|
/// actually below the regular price.
|
||||||
|
#[must_use]
|
||||||
|
pub fn business_on_sale(&self) -> bool {
|
||||||
|
matches!(self.business_sale_price_cents, Some(sale) if sale < self.price_cents)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// implement your write-oriented logic here
|
// implement your write-oriented logic here
|
||||||
|
|||||||
@@ -210,7 +210,13 @@ fn detail_for(product: &products::Model, b2b: Option<&B2bContext>) -> PriceDetai
|
|||||||
auto_cents = Some(apply_discount_bp(regular, chosen.percent_bp));
|
auto_cents = Some(apply_discount_bp(regular, chosen.percent_bp));
|
||||||
}
|
}
|
||||||
|
|
||||||
let business = [manual, auto_cents].into_iter().flatten().min();
|
// Baseline business discount for all company accounts (set on the discounts
|
||||||
|
// page), alongside the per-company automated and negotiated layers.
|
||||||
|
let business_global = product.business_sale_price_cents;
|
||||||
|
let business = [manual, auto_cents, business_global]
|
||||||
|
.into_iter()
|
||||||
|
.flatten()
|
||||||
|
.min();
|
||||||
let priced = decide(regular, public, business);
|
let priced = decide(regular, public, business);
|
||||||
|
|
||||||
PriceDetail {
|
PriceDetail {
|
||||||
|
|||||||
Reference in New Issue
Block a user