dynamic prices with dicounts
This commit is contained in:
@@ -62,6 +62,7 @@
|
||||
{{ 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") }}
|
||||
</tr>
|
||||
@@ -83,6 +84,14 @@
|
||||
<span class="text-on-surface/40 dark:text-on-surface-dark/40">—</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3 tabular-nums">
|
||||
{% if product.effective_reduced %}
|
||||
<span class="font-medium text-primary dark:text-primary-dark">{{ product.effective_price }} {{ product.currency }}</span>
|
||||
<span class="ml-1 text-xs text-on-surface/60 dark:text-on-surface-dark/60">(−{{ product.effective_percent_off }}%)</span>
|
||||
{% else %}
|
||||
{{ product.effective_price }} {{ product.currency }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if on_sale %}
|
||||
{{ ui::badge(label=t(key="on-sale", lang=lang | default(value='sk')), variant="danger") }}
|
||||
|
||||
@@ -25,6 +25,7 @@ use crate::{
|
||||
shared::{
|
||||
guard,
|
||||
money::{format_bp, format_price, parse_percent, parse_price_to_cents},
|
||||
pricing,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -89,9 +90,9 @@ fn percent_off(regular_cents: i64, sale_cents: i64) -> i64 {
|
||||
off.round() as i64
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// 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,
|
||||
@@ -106,6 +107,11 @@ fn list_row(product: &products::Model) -> serde_json::Value {
|
||||
"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),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -123,7 +129,12 @@ async fn index(
|
||||
.order_by_asc(products::Column::Name)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
let rows: Vec<serde_json::Value> = list.iter().map(list_row).collect();
|
||||
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()
|
||||
|
||||
@@ -338,6 +338,38 @@ pub async fn detail_many(
|
||||
Ok(list.iter().map(|p| detail_for(p, &pc)).collect())
|
||||
}
|
||||
|
||||
/// Effective prices for a whole audience using only the global layers (the
|
||||
/// per-product sale + audience-assigned profiles + the business baseline), with
|
||||
/// no specific company's per-company deals. Used by the discounts admin page to
|
||||
/// preview what each tab's discounts produce. `audience` is "personal" or
|
||||
/// "business".
|
||||
pub async fn audience_price_many(
|
||||
ctx: &AppContext,
|
||||
list: &[products::Model],
|
||||
audience: &str,
|
||||
) -> Result<Vec<PricedProduct>> {
|
||||
let personal = load_audience(ctx, AUDIENCE_PERSONAL).await?;
|
||||
let (business, b2b) = if audience == AUDIENCE_BUSINESS {
|
||||
(
|
||||
load_audience(ctx, AUDIENCE_BUSINESS).await?,
|
||||
// A generic company with no per-company profiles/negotiated prices.
|
||||
Some(B2bContext {
|
||||
manual: HashMap::new(),
|
||||
profiles: LoadedProfiles::empty(),
|
||||
resolutions: HashMap::new(),
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
(LoadedProfiles::empty(), None)
|
||||
};
|
||||
let pc = PricingCtx {
|
||||
personal,
|
||||
business,
|
||||
b2b,
|
||||
};
|
||||
Ok(list.iter().map(|p| detail_for(p, &pc).priced()).collect())
|
||||
}
|
||||
|
||||
/// Price one product for `user` (`None` = anonymous/public).
|
||||
pub async fn price_for(
|
||||
ctx: &AppContext,
|
||||
|
||||
Reference in New Issue
Block a user