discounts

This commit is contained in:
Priec
2026-06-21 22:33:47 +02:00
parent 2ee87fbdd7
commit 9ce1cb97f0
20 changed files with 399 additions and 10 deletions

View File

@@ -208,6 +208,17 @@ edit-category = Edit category
product = Product
name = Name
price = Price
sale-price = Sale price
admin-discounts = Discounts
admin-discounts-desc = Set discounted product prices. A discount shows up as a sale in the shop.
on-sale = On sale
no-discount = No discount
set-discount = Set discount
remove-discount = Remove discount
discount-hint = Enter the discounted price (below the regular price). Leave empty to remove the discount.
discount-invalid = Invalid price.
discount-must-be-positive = The sale price must be greater than zero.
discount-below-regular = The sale price must be below the regular price.
stock = Stock
sku = SKU
currency = Currency

View File

@@ -208,6 +208,17 @@ edit-category = Upraviť kategóriu
product = Produkt
name = Názov
price = Cena
sale-price = Zľavnená cena
admin-discounts = Zľavy
admin-discounts-desc = Nastavte zľavnené ceny produktov. Zľava sa v obchode zobrazí ako akcia.
on-sale = V akcii
no-discount = Bez zľavy
set-discount = Nastaviť zľavu
remove-discount = Zrušiť zľavu
discount-hint = Zadajte zľavnenú cenu (nižšiu ako bežná cena). Nechajte prázdne pre zrušenie zľavy.
discount-invalid = Neplatná cena.
discount-must-be-positive = Zľavnená cena musí byť väčšia ako nula.
discount-below-regular = Zľavnená cena musí byť nižšia ako bežná cena.
stock = Sklad
sku = Kód (SKU)
currency = Mena

File diff suppressed because one or more lines are too long

View File

@@ -78,6 +78,10 @@
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')) }}
</a>
<a href="/admin/catalog/discounts" data-nav="/admin/catalog/discounts"
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-discounts", lang=lang | default(value='sk')) }}
</a>
<a href="/admin/catalog/categories" data-nav="/admin/catalog/categories"
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-categories", lang=lang | default(value='sk')) }}

View File

@@ -0,0 +1,39 @@
{% extends "admin/base.html" %}
{% import "macros/ui.html" as ui %}
{% block title %}{{ t(key="set-discount", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}{{ t(key="admin-discounts", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% block content %}
<div class="flex items-center justify-between gap-3">
<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") }}
</div>
<form method="post" action="/admin/catalog/discounts/{{ product.id }}"
class="mt-6 max-w-md space-y-5 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
{{ ui::csrf_field() }}
{% if error %}
{{ ui::alert_danger(message=t(key=error, lang=lang | default(value='sk'))) }}
{% endif %}
<div class="flex items-center justify-between gap-3 rounded-radius bg-surface-alt px-4 py-3 dark:bg-surface-dark/40">
<span class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="price", lang=lang | default(value='sk')) }}</span>
<span class="font-semibold tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.regular_price }} {{ product.currency }}</span>
</div>
<div class="space-y-1.5">
<label for="sale_price" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="sale-price", lang=lang | default(value='sk')) }}</label>
{{ ui::input(name="sale_price", id="sale_price", value=value, placeholder="0.00", attrs='inputmode="decimal"') }}
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="discount-hint", lang=lang | default(value='sk')) }}</p>
</div>
<div class="flex flex-wrap gap-3 pt-2">
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit") }}
{% if product.on_sale %}
{{ 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"') }}
{% endif %}
</div>
</form>
{% endblock content %}

View File

@@ -0,0 +1,70 @@
{% 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 %}
<div class="flex flex-wrap items-end justify-between gap-3">
<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>
<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>
</div>
</div>
<div class="mt-6 {{ ui::table_wrap_cls() }}">
{% if products | length > 0 %}
<table class="{{ ui::table_cls() }}">
<thead class="{{ ui::thead_cls() }}">
<tr>
{{ 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="status", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="actions", lang=lang | default(value='sk')), align="text-right") }}
</tr>
</thead>
<tbody class="{{ ui::tbody_cls() }}">
{% for product in products %}
<tr class="{{ ui::row_cls() }}">
<td class="px-4 py-3">
<div class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</div>
</td>
<td class="px-4 py-3 tabular-nums">{{ product.regular_price }} {{ product.currency }}</td>
<td class="px-4 py-3 tabular-nums">
{% if product.on_sale %}
<span class="font-medium text-danger">{{ product.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>
{% else %}
<span class="text-on-surface/40 dark:text-on-surface-dark/40"></span>
{% endif %}
</td>
<td class="px-4 py-3">
{% if product.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 %}
</td>
<td class="px-4 py-3">
<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") }}
{% if product.on_sale %}
<form method="post" action="/admin/catalog/discounts/{{ product.id }}/remove">
{{ 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") }}
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="flex flex-col items-center gap-3 px-4 py-16 text-center">
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-no-products", lang=lang | default(value='sk')) }}</p>
</div>
{% endif %}
</div>
{% endblock content %}

View File

@@ -41,7 +41,14 @@
</div>
</div>
</td>
<td class="px-4 py-3 tabular-nums">{{ product.price }} {{ product.currency }}</td>
<td class="px-4 py-3 tabular-nums">
{% if product.on_sale %}
<span class="font-medium text-danger">{{ product.price }} {{ product.currency }}</span>
<span class="text-xs text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }}</span>
{% else %}
{{ product.price }} {{ product.currency }}
{% endif %}
</td>
<td class="px-4 py-3 tabular-nums">{{ product.stock }}</td>
<td class="px-4 py-3">
{% if product.published %}
@@ -53,6 +60,7 @@
<td class="px-4 py-3">
<div class="flex flex-wrap justify-end gap-2">
{{ 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="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="view", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, size="px-3 py-1.5 text-xs") }}
<form method="post" action="/admin/catalog/products/{{ product.id }}/delete"
onsubmit="return confirm('{{ t(key="confirm-delete", lang=lang | default(value='sk')) }}')">

View File

@@ -127,7 +127,7 @@
{# Text/email/number/password input. #}
{% macro input(name, type="text", id="", value="", placeholder="", required=false, autocomplete="", attrs="", extra="", width="w-full") -%}
<input {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" type="{{ type }}"{% if value != "" %} value="{{ value }}"{% endif %}{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if required %} required{% endif %}{% if autocomplete %} autocomplete="{{ autocomplete }}"{% endif %} class="{{ width }} rounded-radius border border-outline bg-surface-alt px-2 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}/>
<input {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" type="{{ type }}"{% if value is number or value != "" %} value="{{ value }}"{% endif %}{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if required %} required{% endif %}{% if autocomplete %} autocomplete="{{ autocomplete }}"{% endif %} class="{{ width }} rounded-radius border border-outline bg-surface-alt px-2 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}/>
{%- endmacro input %}
{% macro textarea(name, id="", value="", rows="3", placeholder="", required=false, attrs="", extra="") -%}

View File

@@ -17,7 +17,14 @@
<!-- Header: Title & Price -->
<div class="flex justify-between gap-4">
<h3 class="text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
{% if product.on_sale %}
<span class="flex flex-col items-end whitespace-nowrap leading-tight">
<span class="text-sm text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }} {{ product.currency }}</span>
<span class="text-xl font-semibold text-danger"><span class="sr-only">Price</span>{{ product.price }} {{ product.currency }}</span>
</span>
{% else %}
<span class="whitespace-nowrap text-xl"><span class="sr-only">Price</span>{{ product.price }} {{ product.currency }}</span>
{% endif %}
</div>
</div>
</a>

View File

@@ -54,7 +54,14 @@
<a href="/category/{{ category.slug }}" class="text-sm font-medium text-primary dark:text-primary-dark">{{ category.name }}</a>
{% endif %}
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h1>
{% if product.on_sale %}
<div class="flex items-baseline gap-3">
<p class="text-2xl font-semibold text-danger">{{ product.price }} {{ product.currency }}</p>
<p class="text-lg text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }} {{ product.currency }}</p>
</div>
{% else %}
<p class="text-2xl font-semibold text-primary dark:text-primary-dark">{{ product.price }} {{ product.currency }}</p>
{% endif %}
{% if product.description %}
<div class="whitespace-pre-line leading-relaxed text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description }}</div>

View File

@@ -35,6 +35,7 @@ mod m20260618_000002_customer_profiles;
mod m20260618_000003_account_type;
mod m20260618_000004_account_ownership;
mod m20260620_000001_add_totp_to_users;
mod m20260621_000001_add_sale_price_to_products;
pub struct Migrator;
#[async_trait::async_trait]
@@ -74,6 +75,7 @@ impl MigratorTrait for Migrator {
Box::new(m20260618_000003_account_type::Migration),
Box::new(m20260618_000004_account_ownership::Migration),
Box::new(m20260620_000001_add_totp_to_users::Migration),
Box::new(m20260621_000001_add_sale_price_to_products::Migration),
// inject-above (do not remove this comment)
]
}

View File

@@ -0,0 +1,19 @@
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 discounted price in minor units. When set (and below
// `price_cents`) the product is on sale; the regular price is shown
// struck through and this is the effective price everywhere.
add_column(m, "products", "sale_price_cents", ColType::BigIntegerNull).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
remove_column(m, "products", "sale_price_cents").await
}
}

View File

@@ -17,7 +17,7 @@ use std::{path::Path, sync::Arc};
#[allow(unused_imports)]
use crate::{
controllers::{
account, admin_categories, admin_dashboard, admin_form, admin_orders,
account, admin_categories, admin_dashboard, admin_discounts, admin_form, admin_orders,
admin_products, admin_shipping, auth, auth_pages, cart, checkout, home, i18n, media, oauth2,
shop,
},
@@ -104,6 +104,7 @@ impl Hooks for App {
// admin
.add_route(admin_dashboard::routes())
.add_route(admin_products::routes())
.add_route(admin_discounts::routes())
.add_route(admin_categories::routes())
.add_route(admin_orders::routes())
.add_route(admin_shipping::routes())

View File

@@ -0,0 +1,185 @@
//! Admin management of per-product discounts.
//!
//! Discounts live on the product (`sale_price_cents`) but are set here, in a
//! place of their own, rather than on the product editor: an admin picks a
//! product, enters a discounted price, and the storefront then shows it on sale.
//! Editing a product never touches its discount, and vice versa.
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, EntityTrait, QueryOrder, Set};
use serde::Deserialize;
use serde_json::json;
use crate::{
controllers::i18n::current_lang,
models::products,
shared::{
guard,
money::{format_price, parse_price_to_cents},
},
};
#[derive(Debug, Deserialize)]
struct DiscountForm {
sale_price: String,
}
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. `0` when there is
/// no positive regular price to discount from.
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.
fn list_row(product: &products::Model) -> 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)),
})
}
#[debug_handler]
async fn index(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let list = products::Entity::find()
.order_by_asc(products::Column::Name)
.all(&ctx.db)
.await?;
let rows: Vec<serde_json::Value> = list.iter().map(list_row).collect();
format::view(
&v,
"admin/catalog/discounts.html",
json!({ "products": rows, "lang": current_lang(&jar) }),
)
}
/// Render the single-product discount form, optionally with a validation error
/// and the value the admin just typed (so a rejected submit isn't lost).
fn render_form(
v: &TeraView,
jar: &CookieJar,
product: &products::Model,
entered: Option<String>,
error: Option<&str>,
) -> Result<Response> {
let current = product.sale_price_cents.map(format_price);
let value = entered.or_else(|| current.clone()).unwrap_or_default();
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),
"on_sale": product.on_sale(),
"sale_price": current,
},
"value": value,
"error": error,
"lang": current_lang(jar),
}),
)
}
#[debug_handler]
async fn edit(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let product = product_by_id(&ctx, id).await?;
render_form(&v, &jar, &product, None, None)
}
#[debug_handler]
async fn update(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
Form(form): Form<DiscountForm>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let product = product_by_id(&ctx, id).await?;
let entered = form.sale_price.trim().to_string();
// An empty value clears the discount (same as the Remove action).
if entered.is_empty() {
return clear_discount(&ctx, product).await;
}
// A discount must be a valid, positive price strictly below the regular
// price — otherwise it isn't a discount. Reject inline, keeping the input.
let render_err = |key: &str| render_form(&v, &jar, &product, Some(entered.clone()), Some(key));
let sale_cents = match parse_price_to_cents(&entered) {
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();
active.sale_price_cents = Set(Some(sale_cents));
active.update(&ctx.db).await?;
format::redirect("/admin/catalog/discounts")
}
async fn clear_discount(ctx: &AppContext, product: products::Model) -> Result<Response> {
let mut active = product.into_active_model();
active.sale_price_cents = Set(None);
active.update(&ctx.db).await?;
format::redirect("/admin/catalog/discounts")
}
#[debug_handler]
async fn remove(
auth: auth::JWT,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let product = product_by_id(&ctx, id).await?;
clear_discount(&ctx, product).await
}
pub fn routes() -> Routes {
Routes::new()
.add("/admin/catalog/discounts", get(index))
.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

@@ -201,14 +201,15 @@ pub(crate) async fn resolve_cart(
if qty == 0 {
continue;
}
let line_total = product.price_cents * i64::from(qty);
let unit_price = product.effective_price_cents();
let line_total = unit_price * i64::from(qty);
total += line_total;
valid.push((product.id, qty));
lines.push(json!({
"id": product.id,
"name": product.name,
"slug": product.slug,
"price": format_price(product.price_cents),
"price": format_price(unit_price),
"currency": product.currency,
"quantity": qty,
"stock": product.stock,

View File

@@ -4,6 +4,7 @@ pub mod auth_pages;
pub mod oauth2;
pub mod admin_categories;
pub mod admin_dashboard;
pub mod admin_discounts;
pub mod admin_form;
pub mod admin_orders;
pub mod admin_products;

View File

@@ -16,6 +16,7 @@ pub struct Model {
#[sea_orm(column_type = "Text", nullable)]
pub description: Option<String>,
pub price_cents: i64,
pub sale_price_cents: Option<i64>,
pub currency: String,
pub sku: Option<String>,
pub stock: i32,

View File

@@ -61,13 +61,15 @@ pub async fn place(ctx: &AppContext, items: &[(i32, i32)], details: Checkout) ->
)));
}
currency = product.currency.clone();
subtotal += product.price_cents * i64::from(*qty);
// Snapshot the effective price (honouring any active discount).
let unit_price_cents = product.effective_price_cents();
subtotal += unit_price_cents * i64::from(*qty);
let mut active = product.clone().into_active_model();
active.stock = Set(product.stock - *qty);
active.update(&txn).await?;
snapshots.push((product.id, product.name, product.price_cents, *qty));
snapshots.push((product.id, product.name, unit_price_cents, *qty));
}
let order = ActiveModel {

View File

@@ -19,7 +19,25 @@ impl ActiveModelBehavior for ActiveModel {
}
// implement your read-oriented logic here
impl Model {}
impl Model {
/// Whether a discount is currently active: a sale price is set and is
/// strictly below the regular price.
#[must_use]
pub fn on_sale(&self) -> bool {
matches!(self.sale_price_cents, Some(sale) if sale < self.price_cents)
}
/// The price actually charged: the sale price when [`Model::on_sale`],
/// otherwise the regular price.
#[must_use]
pub fn effective_price_cents(&self) -> i64 {
if self.on_sale() {
self.sale_price_cents.unwrap_or(self.price_cents)
} else {
self.price_cents
}
}
}
// implement your write-oriented logic here
impl ActiveModel {}

View File

@@ -17,7 +17,9 @@ pub fn product_card(
"name": product.name,
"slug": product.slug,
"description": product.description,
"price": format_price(product.price_cents),
"price": format_price(product.effective_price_cents()),
"on_sale": product.on_sale(),
"regular_price": format_price(product.price_cents),
"currency": product.currency,
"sku": product.sku,
"stock": product.stock,