From ed566b53472ca59dd9724034fb8fe483c7e1f311 Mon Sep 17 00:00:00 2001 From: Priec Date: Sun, 21 Jun 2026 22:41:30 +0200 Subject: [PATCH] percentage discounts --- assets/i18n/en/main.ftl | 9 +- assets/i18n/sk/main.ftl | 9 +- assets/views/admin/catalog/discount_form.html | 64 ++++++++++- assets/views/admin/catalog/products.html | 2 +- src/controllers/admin_discounts.rs | 105 ++++++++++++++---- 5 files changed, 161 insertions(+), 28 deletions(-) diff --git a/assets/i18n/en/main.ftl b/assets/i18n/en/main.ftl index 3d49519..9b3e1f6 100644 --- a/assets/i18n/en/main.ftl +++ b/assets/i18n/en/main.ftl @@ -213,12 +213,19 @@ 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 +discount = 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-mode-fixed = Fixed price +discount-mode-percent = Percentage +discount-percent = Discount (%) +discount-preview-before = Original price +discount-preview-after = New price +discount-preview-save = You save 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. +discount-percent-range = The percentage must be between 0 and 100. stock = Stock sku = SKU currency = Currency diff --git a/assets/i18n/sk/main.ftl b/assets/i18n/sk/main.ftl index e594a2a..fb64273 100644 --- a/assets/i18n/sk/main.ftl +++ b/assets/i18n/sk/main.ftl @@ -213,12 +213,19 @@ 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 +discount = Zľava 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-mode-fixed = Pevná cena +discount-mode-percent = Percentá +discount-percent = Zľava (%) +discount-preview-before = Pôvodná cena +discount-preview-after = Nová cena +discount-preview-save = Ušetríte 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. +discount-percent-range = Percento musí byť medzi 0 a 100. stock = Sklad sku = Kód (SKU) currency = Mena diff --git a/assets/views/admin/catalog/discount_form.html b/assets/views/admin/catalog/discount_form.html index 761d197..d87f748 100644 --- a/assets/views/admin/catalog/discount_form.html +++ b/assets/views/admin/catalog/discount_form.html @@ -11,6 +11,24 @@
{{ ui::csrf_field() }} @@ -23,10 +41,50 @@ {{ product.regular_price }} {{ product.currency }} -
+ +
+ + +
+ + +
- {{ ui::input(name="sale_price", id="sale_price", value=value, placeholder="0.00", attrs='inputmode="decimal"') }} -

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

+ {{ ui::input(name="sale_price", id="sale_price", value=fixed, placeholder="0.00", attrs='inputmode="decimal" x-model="fixed"') }} +
+ + +
+ + {{ ui::input(name="percent", id="percent", value=percent, placeholder="0", attrs='inputmode="decimal" min="0" max="100" x-model="percent"') }} +
+ + +
+
+ {{ t(key="discount-preview-before", lang=lang | default(value='sk')) }} + {{ product.currency }} +
+
+ {{ t(key="discount-preview-after", lang=lang | default(value='sk')) }} + + {{ product.currency }} + +
+
+ {{ t(key="discount-preview-save", lang=lang | default(value='sk')) }} + {{ product.currency }} (−%) +
+

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

diff --git a/assets/views/admin/catalog/products.html b/assets/views/admin/catalog/products.html index 449df66..94f0827 100644 --- a/assets/views/admin/catalog/products.html +++ b/assets/views/admin/catalog/products.html @@ -60,7 +60,7 @@
{{ 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="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") }} diff --git a/src/controllers/admin_discounts.rs b/src/controllers/admin_discounts.rs index babe74d..2c149fa 100644 --- a/src/controllers/admin_discounts.rs +++ b/src/controllers/admin_discounts.rs @@ -22,7 +22,24 @@ use crate::{ #[derive(Debug, Deserialize)] struct DiscountForm { - sale_price: String, + /// "fixed" (enter the new price) or "percent" (enter % off). Defaults to + /// fixed for older/JSON callers. + mode: Option, + sale_price: Option, + percent: Option, +} + +/// Parse a percentage typed as "20", "20.5" or "20,5" into an `f64`. +fn parse_percent(value: &str) -> Option { + let parsed: f64 = value.trim().replace(',', ".").parse().ok()?; + parsed.is_finite().then_some(parsed) +} + +/// 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 { + 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 { @@ -76,17 +93,24 @@ async fn index( ) } -/// 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). +/// 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, +} + +/// Render the single-product discount form, optionally with a validation error. fn render_form( v: &TeraView, jar: &CookieJar, product: &products::Model, - entered: Option, + prefill: &FormPrefill, error: Option<&str>, ) -> Result { - let current = product.sale_price_cents.map(format_price); - let value = entered.or_else(|| current.clone()).unwrap_or_default(); + let mode = if prefill.mode == "percent" { "percent" } else { "fixed" }; format::view( v, "admin/catalog/discount_form.html", @@ -96,10 +120,13 @@ fn render_form( "name": product.name, "currency": product.currency, "regular_price": format_price(product.price_cents), + "regular_cents": product.price_cents, "on_sale": product.on_sale(), - "sale_price": current, + "sale_price": product.sale_price_cents.map(format_price), }, - "value": value, + "mode": mode, + "fixed": prefill.fixed, + "percent": prefill.percent, "error": error, "lang": current_lang(jar), }), @@ -116,7 +143,13 @@ async fn edit( ) -> Result { guard::current_admin(auth, &ctx).await?; let product = product_by_id(&ctx, id).await?; - render_form(&v, &jar, &product, None, None) + // Re-editing always opens in fixed mode showing the current sale price. + let prefill = FormPrefill { + mode: "fixed".to_string(), + fixed: product.sale_price_cents.map(format_price).unwrap_or_default(), + percent: String::new(), + }; + render_form(&v, &jar, &product, &prefill, None) } #[debug_handler] @@ -131,20 +164,48 @@ async fn update( 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"), + 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(); + + // Whatever the mode, both raw inputs are echoed back on error so neither tab + // loses what was typed. + let prefill = FormPrefill { + mode: mode.to_string(), + fixed: fixed.clone(), + percent: percent.clone(), + }; + let render_err = |key: &str| render_form(&v, &jar, &product, &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).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).await; + } + match parse_price_to_cents(&fixed) { + Ok(cents) => cents, + Err(_) => return render_err("discount-invalid"), + } + }; + + // A discount must be a positive price strictly below the regular price — + // otherwise it isn't a discount. if sale_cents <= 0 { return render_err("discount-must-be-positive"); }