percentage discounts
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled

This commit is contained in:
Priec
2026-06-21 22:41:30 +02:00
parent 9ce1cb97f0
commit ed566b5347
5 changed files with 161 additions and 28 deletions

View File

@@ -213,12 +213,19 @@ 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.
on-sale = On sale on-sale = On sale
no-discount = No discount no-discount = No discount
discount = Discount
set-discount = Set discount set-discount = Set discount
remove-discount = Remove 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-invalid = Invalid price.
discount-must-be-positive = The sale price must be greater than zero. 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-below-regular = The sale price must be below the regular price.
discount-percent-range = The percentage must be between 0 and 100.
stock = Stock stock = Stock
sku = SKU sku = SKU
currency = Currency currency = Currency

View File

@@ -213,12 +213,19 @@ 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.
on-sale = V akcii on-sale = V akcii
no-discount = Bez zľavy no-discount = Bez zľavy
discount = Zľava
set-discount = Nastaviť zľavu set-discount = Nastaviť zľavu
remove-discount = Zrušiť 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-invalid = Neplatná cena.
discount-must-be-positive = Zľavnená cena musí byť väčšia ako nula. 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-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 stock = Sklad
sku = Kód (SKU) sku = Kód (SKU)
currency = Mena currency = Mena

View File

@@ -11,6 +11,24 @@
</div> </div>
<form method="post" action="/admin/catalog/discounts/{{ product.id }}" <form method="post" action="/admin/catalog/discounts/{{ product.id }}"
x-data="{
mode: '{{ mode }}',
fixed: '{{ fixed }}',
percent: '{{ percent }}',
regular: {{ product.regular_cents }},
num(v) { let n = parseFloat(String(v).replace(',', '.')); return isFinite(n) ? n : null; },
get afterCents() {
if (this.mode === 'percent') {
let p = this.num(this.percent); if (p === null) return null;
return this.regular - Math.round(this.regular * p / 100);
}
let f = this.num(this.fixed); if (f === null) return null;
return Math.round(f * 100);
},
money(c) { return (c / 100).toFixed(2); },
get valid() { let a = this.afterCents; return a !== null && a > 0 && a < this.regular; },
get percentOff() { let a = this.afterCents; return (a === null || this.regular <= 0) ? null : Math.round((this.regular - a) / this.regular * 100); }
}"
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"> 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() }} {{ ui::csrf_field() }}
@@ -23,10 +41,50 @@
<span class="font-semibold tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.regular_price }} {{ product.currency }}</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>
<div class="space-y-1.5"> <!-- mode toggle -->
<div class="grid grid-cols-2 gap-2">
<label class="flex cursor-pointer items-center justify-center gap-2 rounded-radius border px-3 py-2 text-sm transition"
:class="mode === 'fixed' ? 'border-primary bg-primary/10 text-on-surface-strong dark:border-primary-dark dark:bg-primary-dark/10 dark:text-on-surface-dark-strong' : 'border-outline text-on-surface dark:border-outline-dark dark:text-on-surface-dark'">
<input type="radio" name="mode" value="fixed" x-model="mode" class="sr-only">
{{ t(key="discount-mode-fixed", lang=lang | default(value='sk')) }}
</label>
<label class="flex cursor-pointer items-center justify-center gap-2 rounded-radius border px-3 py-2 text-sm transition"
:class="mode === 'percent' ? 'border-primary bg-primary/10 text-on-surface-strong dark:border-primary-dark dark:bg-primary-dark/10 dark:text-on-surface-dark-strong' : 'border-outline text-on-surface dark:border-outline-dark dark:text-on-surface-dark'">
<input type="radio" name="mode" value="percent" x-model="mode" class="sr-only">
{{ t(key="discount-mode-percent", lang=lang | default(value='sk')) }}
</label>
</div>
<!-- fixed price input -->
<div class="space-y-1.5" x-show="mode === 'fixed'">
<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> <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"') }} {{ ui::input(name="sale_price", id="sale_price", value=fixed, placeholder="0.00", attrs='inputmode="decimal" x-model="fixed"') }}
<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>
<!-- percentage input -->
<div class="space-y-1.5" x-show="mode === 'percent'">
<label for="percent" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="discount-percent", lang=lang | default(value='sk')) }}</label>
{{ ui::input(name="percent", id="percent", value=percent, placeholder="0", attrs='inputmode="decimal" min="0" max="100" x-model="percent"') }}
</div>
<!-- live preview -->
<div x-show="afterCents !== null" x-cloak
class="space-y-2 rounded-radius border border-outline bg-surface-alt px-4 py-3 dark:border-outline-dark dark:bg-surface-dark/40">
<div class="flex items-center justify-between gap-3 text-sm">
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="discount-preview-before", lang=lang | default(value='sk')) }}</span>
<span class="tabular-nums text-on-surface/60 line-through dark:text-on-surface-dark/60"><span x-text="money(regular)"></span> {{ product.currency }}</span>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="discount-preview-after", lang=lang | default(value='sk')) }}</span>
<span class="text-lg font-semibold tabular-nums" :class="valid ? 'text-danger' : 'text-on-surface/40 dark:text-on-surface-dark/40'">
<span x-text="money(afterCents)"></span> {{ product.currency }}
</span>
</div>
<div x-show="valid" class="flex items-center justify-between gap-3 text-xs text-on-surface/60 dark:text-on-surface-dark/60">
<span>{{ t(key="discount-preview-save", lang=lang | default(value='sk')) }}</span>
<span class="tabular-nums"><span x-text="money(regular - afterCents)"></span> {{ product.currency }} (<span x-text="percentOff"></span>%)</span>
</div>
<p x-show="!valid" class="text-xs text-danger">{{ t(key="discount-below-regular", lang=lang | default(value='sk')) }}</p>
</div> </div>
<div class="flex flex-wrap gap-3 pt-2"> <div class="flex flex-wrap gap-3 pt-2">

View File

@@ -60,7 +60,7 @@
<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="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="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") }} {{ 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" <form method="post" action="/admin/catalog/products/{{ product.id }}/delete"
onsubmit="return confirm('{{ t(key="confirm-delete", lang=lang | default(value='sk')) }}')"> onsubmit="return confirm('{{ t(key="confirm-delete", lang=lang | default(value='sk')) }}')">

View File

@@ -22,7 +22,24 @@ use crate::{
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct DiscountForm { struct DiscountForm {
sale_price: String, /// "fixed" (enter the new price) or "percent" (enter % off). Defaults to
/// fixed for older/JSON callers.
mode: Option<String>,
sale_price: Option<String>,
percent: Option<String>,
}
/// Parse a percentage typed as "20", "20.5" or "20,5" into an `f64`.
fn parse_percent(value: &str) -> Option<f64> {
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<products::Model> { async fn product_by_id(ctx: &AppContext, id: i32) -> Result<products::Model> {
@@ -76,17 +93,24 @@ async fn index(
) )
} }
/// Render the single-product discount form, optionally with a validation error /// What to pre-fill the form with: the chosen input mode and the raw values for
/// and the value the admin just typed (so a rejected submit isn't lost). /// 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( fn render_form(
v: &TeraView, v: &TeraView,
jar: &CookieJar, jar: &CookieJar,
product: &products::Model, product: &products::Model,
entered: Option<String>, prefill: &FormPrefill,
error: Option<&str>, error: Option<&str>,
) -> Result<Response> { ) -> Result<Response> {
let current = product.sale_price_cents.map(format_price); let mode = if prefill.mode == "percent" { "percent" } else { "fixed" };
let value = entered.or_else(|| current.clone()).unwrap_or_default();
format::view( format::view(
v, v,
"admin/catalog/discount_form.html", "admin/catalog/discount_form.html",
@@ -96,10 +120,13 @@ fn render_form(
"name": product.name, "name": product.name,
"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,
"on_sale": product.on_sale(), "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, "error": error,
"lang": current_lang(jar), "lang": current_lang(jar),
}), }),
@@ -116,7 +143,13 @@ async fn edit(
) -> Result<Response> { ) -> Result<Response> {
guard::current_admin(auth, &ctx).await?; guard::current_admin(auth, &ctx).await?;
let product = product_by_id(&ctx, id).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] #[debug_handler]
@@ -131,20 +164,48 @@ async fn update(
guard::current_admin(auth, &ctx).await?; guard::current_admin(auth, &ctx).await?;
let product = product_by_id(&ctx, id).await?; let product = product_by_id(&ctx, id).await?;
let entered = form.sale_price.trim().to_string(); 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();
// An empty value clears the discount (same as the Remove action). // Whatever the mode, both raw inputs are echoed back on error so neither tab
if entered.is_empty() { // 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; return clear_discount(&ctx, product).await;
} }
let pct = match parse_percent(&percent) {
// A discount must be a valid, positive price strictly below the regular Some(pct) => pct,
// price — otherwise it isn't a discount. Reject inline, keeping the input. None => return render_err("discount-invalid"),
let render_err = |key: &str| render_form(&v, &jar, &product, Some(entered.clone()), Some(key)); };
let sale_cents = match parse_price_to_cents(&entered) { 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, Ok(cents) => cents,
Err(_) => return render_err("discount-invalid"), 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 { if sale_cents <= 0 {
return render_err("discount-must-be-positive"); return render_err("discount-must-be-positive");
} }