1 Commits

Author SHA1 Message Date
Priec
96c428eadd discounts now work well
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-22 23:12:26 +02:00
5 changed files with 376 additions and 100 deletions

File diff suppressed because one or more lines are too long

View File

@@ -15,82 +15,80 @@
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products?audience=" ~ audience, size="px-3 py-2 text-sm") }} {{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products?audience=" ~ audience, size="px-3 py-2 text-sm") }}
</div> </div>
{# One discount row per option (variant). Each row picks a fixed sale price or a #}
{# percentage off its own regular price; a blank input clears that option's #}
{# discount. Both the fixed and percent inputs always submit (the server reads the #}
{# active mode); rows are pre-filled from `rows` (DB values, or submitted values #}
{# when repainting after a validation error) and indexed as v[<variant id>][...]. #}
<script id="discount-data" type="application/json">{{ rows | json_encode() | safe }}</script>
<form method="post" action="/admin/catalog/products/{{ product.id }}/discount?audience={{ audience }}" <form method="post" action="/admin/catalog/products/{{ product.id }}/discount?audience={{ audience }}"
x-data="{ x-data="discountEditor(JSON.parse(document.getElementById('discount-data').textContent))"
mode: '{{ mode }}', class="mt-6 max-w-2xl space-y-5">
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">
{{ ui::csrf_field() }} {{ ui::csrf_field() }}
{% if error %} {% if error %}
{{ ui::alert_danger(message=t(key=error, lang=lang | default(value='sk'))) }} {{ ui::alert_danger(message=t(key=error, lang=lang | default(value='sk'))) }}
{% endif %} {% endif %}
<div class="flex items-center justify-between gap-3 rounded-radius bg-surface-alt px-4 py-3 dark:bg-surface-dark/40"> <template x-for="row in rows" :key="row.id">
<span class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="price", lang=lang | default(value='sk')) }}</span> <div class="space-y-4 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
<span class="font-semibold tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.regular_price }} {{ product.currency }}</span> <div class="flex items-center justify-between gap-3">
</div> <span class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong"
x-text="row.label || ('#' + row.id)"></span>
<span class="text-sm tabular-nums text-on-surface/70 dark:text-on-surface-dark/70">
{{ t(key="price", lang=lang | default(value='sk')) }}:
<span x-text="row.regular_price"></span> <span x-text="row.currency"></span>
</span>
</div>
<!-- mode toggle --> <input type="hidden" :name="`v[${row.id}][mode]`" :value="row.mode">
<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="grid gap-4 sm:grid-cols-2">
<div class="space-y-1.5" x-show="mode === 'fixed'"> <!-- mode toggle -->
<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> <div class="grid grid-cols-2 gap-2">
{{ ui::input(name="sale_price", id="sale_price", value=fixed, placeholder="0.00", attrs='inputmode="decimal" x-model="fixed"') }} <label class="flex cursor-pointer items-center justify-center gap-2 rounded-radius border px-3 py-2 text-sm transition"
</div> :class="row.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-ui-${row.id}`" value="fixed" x-model="row.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="row.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-ui-${row.id}`" value="percent" x-model="row.mode" class="sr-only">
{{ t(key="discount-mode-percent", lang=lang | default(value='sk')) }}
</label>
</div>
<!-- percentage input --> <!-- value input: both fields stay in the DOM and submit; the server reads
<div class="space-y-1.5" x-show="mode === 'percent'"> whichever matches the row's mode -->
<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> <div class="space-y-1.5">
{{ ui::input(name="percent", id="percent", value=percent, placeholder="0", attrs='inputmode="decimal" min="0" max="100" x-model="percent"') }} <div x-show="row.mode === 'fixed'">
</div> <label 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>
<input :name="`v[${row.id}][fixed]`" x-model="row.fixed" inputmode="decimal" placeholder="0.00"
class="w-full rounded-radius border border-outline bg-surface-alt px-4 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark">
</div>
<div x-show="row.mode === 'percent'">
<label 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>
<input :name="`v[${row.id}][percent]`" x-model="row.percent" inputmode="decimal" min="0" max="100" placeholder="0"
class="w-full rounded-radius border border-outline bg-surface-alt px-4 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark">
</div>
</div>
</div>
<!-- live preview --> <!-- live preview -->
<div x-show="afterCents !== null" x-cloak <div x-show="afterCents(row) !== 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"> class="flex flex-wrap items-center justify-between gap-3 rounded-radius border border-outline bg-surface-alt px-4 py-2.5 text-sm 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-after", lang=lang | default(value='sk')) }}</span>
<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="flex items-center gap-2">
<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> <span class="tabular-nums text-on-surface/50 line-through dark:text-on-surface-dark/50" x-text="money(row.regular_cents) + ' ' + row.currency"></span>
<span class="text-base font-semibold tabular-nums" :class="valid(row) ? 'text-danger' : 'text-on-surface/40 dark:text-on-surface-dark/40'"
x-text="money(afterCents(row)) + ' ' + row.currency"></span>
<span x-show="valid(row)" class="text-xs text-on-surface/60 dark:text-on-surface-dark/60" x-text="'(' + percentOff(row) + '%)'"></span>
</span>
</div>
<p x-show="afterCents(row) !== null && !valid(row)" class="text-xs text-danger">{{ t(key="discount-below-regular", lang=lang | default(value='sk')) }}</p>
</div> </div>
<div class="flex items-center justify-between gap-3"> </template>
<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 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", attrs=`onclick="return confirm('` ~ t(key="discount-apply-confirm", lang=lang | default(value='sk')) ~ `')"`) }} {{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", attrs=`onclick="return confirm('` ~ t(key="discount-apply-confirm", lang=lang | default(value='sk')) ~ `')"`) }}
@@ -99,4 +97,36 @@
{% endif %} {% endif %}
</div> </div>
</form> </form>
<script>
function discountEditor(initial) {
return {
rows: (initial || []).map(r => ({
id: r.id,
label: r.label || '',
regular_cents: r.regular_cents,
regular_price: r.regular_price,
currency: r.currency,
mode: r.mode || 'fixed',
fixed: r.fixed || '',
percent: r.percent || '',
})),
num(v) { let n = parseFloat(String(v).replace(',', '.')); return isFinite(n) ? n : null; },
money(c) { return (c / 100).toFixed(2); },
afterCents(row) {
if (row.mode === 'percent') {
let p = this.num(row.percent); if (p === null) return null;
return row.regular_cents - Math.round(row.regular_cents * p / 100);
}
let f = this.num(row.fixed); if (f === null) return null;
return Math.round(f * 100);
},
valid(row) { let a = this.afterCents(row); return a !== null && a > 0 && a < row.regular_cents; },
percentOff(row) {
let a = this.afterCents(row);
return (a === null || row.regular_cents <= 0) ? null : Math.round((row.regular_cents - a) / row.regular_cents * 100);
},
};
}
</script>
{% endblock content %} {% endblock content %}

View File

@@ -60,7 +60,7 @@
{# items-end bottom-aligns every input regardless of how many lines each {# items-end bottom-aligns every input regardless of how many lines each
label takes, so the row stays aligned even with the "(optional)" notes. #} label takes, so the row stays aligned even with the "(optional)" notes. #}
<div class="grid flex-1 grid-cols-2 gap-3 sm:grid-cols-12 sm:items-end"> <div class="grid flex-1 grid-cols-2 gap-3 sm:grid-cols-12 sm:items-end">
<div class="space-y-1 col-span-2 sm:col-span-4"> <div class="space-y-1 col-span-2 sm:col-span-6">
<label class="{{ sublabel }} block truncate">{{ t(key="option-label", lang=lang | default(value='sk')) }}{{ opt }}</label> <label class="{{ sublabel }} block truncate">{{ t(key="option-label", lang=lang | default(value='sk')) }}{{ opt }}</label>
<input :name="`variants[${i}][label]`" x-model="row.label" class="{{ inp }}" placeholder="napr. 10cm x 13cm"> <input :name="`variants[${i}][label]`" x-model="row.label" class="{{ inp }}" placeholder="napr. 10cm x 13cm">
</div> </div>
@@ -76,10 +76,6 @@
<label class="{{ sublabel }} block truncate">{{ t(key="price", lang=lang | default(value='sk')) }}</label> <label class="{{ sublabel }} block truncate">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
<input :name="`variants[${i}][price]`" x-model="row.price" inputmode="decimal" required class="{{ inp }}" placeholder="0.00"> <input :name="`variants[${i}][price]`" x-model="row.price" inputmode="decimal" required class="{{ inp }}" placeholder="0.00">
</div> </div>
<div class="space-y-1 sm:col-span-2">
<label class="{{ sublabel }} block truncate">{{ t(key="business-price", lang=lang | default(value='sk')) }}{{ opt }}</label>
<input :name="`variants[${i}][business_sale]`" x-model="row.business_sale" inputmode="decimal" class="{{ inp }}" placeholder="—">
</div>
</div> </div>
<button type="button" @click="remove(i)" <button type="button" @click="remove(i)"
@@ -90,7 +86,7 @@
<script> <script>
function variantEditor(initial) { function variantEditor(initial) {
const blank = () => ({ id: '', label: '', sku: '', stock: '', price: '', business_sale: '' }); const blank = () => ({ id: '', label: '', sku: '', stock: '', price: '' });
return { return {
rows: (initial || []).map(r => ({ rows: (initial || []).map(r => ({
id: r.id || '', id: r.id || '',
@@ -98,7 +94,6 @@
sku: r.sku || '', sku: r.sku || '',
stock: (r.stock === null || r.stock === undefined) ? '' : r.stock, stock: (r.stock === null || r.stock === undefined) ? '' : r.stock,
price: r.price || '', price: r.price || '',
business_sale: r.business_sale || '',
})), })),
init() { if (this.rows.length === 0) this.add(); }, init() { if (this.rows.length === 0) this.add(); },
add() { this.rows.push(blank()); }, add() { this.rows.push(blank()); },

View File

@@ -138,6 +138,14 @@
<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/products/" ~ product.id ~ "/discount/edit?audience=" ~ audience, size="px-3 py-1.5 text-xs") }}
{% if product.on_sale %}
<form method="post" action="/admin/catalog/products/{{ product.id }}/discount/remove?audience={{ audience }}"
onsubmit="return confirm('{{ t(key="discount-remove-confirm", lang=lang | default(value='sk')) }}')">
{{ 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 %}
{{ 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

@@ -26,7 +26,7 @@ use crate::{
}, },
shared::{ shared::{
guard, guard,
money::{format_bp, format_price, parse_price_to_cents}, money::{format_bp, format_price, parse_percent, parse_price_to_cents},
pricing, pricing,
slug::{slugify, unique_slug}, slug::{slugify, unique_slug},
}, },
@@ -105,30 +105,9 @@ struct VariantInput {
/// `None` = available but not inventory-tracked. /// `None` = available but not inventory-tracked.
stock: Option<i32>, stock: Option<i32>,
price_cents: i64, price_cents: i64,
business_sale_cents: Option<i64>,
position: i32, position: i32,
} }
/// The optional business-sale price field on a variant row: blank means "no
/// business quick-sale", a value must parse and be below the regular price.
fn parse_optional_sale(
form: &MultipartForm,
i: usize,
key: &str,
price_cents: i64,
) -> Result<Option<i64>> {
let Some(raw) = form.text(&format!("variants[{i}][{key}]")) else {
return Ok(None);
};
let cents = parse_price_to_cents(&raw)?;
if cents <= 0 || cents >= price_cents {
return Err(Error::BadRequest(
"a sale price must be positive and below the regular price".to_string(),
));
}
Ok(Some(cents))
}
/// Parse the repeated variant rows from the form, in submission order. Blank /// Parse the repeated variant rows from the form, in submission order. Blank
/// rows (no price and no label) are skipped; at least one valid row is required. /// rows (no price and no label) are skipped; at least one valid row is required.
fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> { fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
@@ -168,7 +147,6 @@ fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
.ok_or_else(|| Error::BadRequest("stock must be 0 or more".to_string()))?, .ok_or_else(|| Error::BadRequest("stock must be 0 or more".to_string()))?,
), ),
}; };
let business_sale_cents = parse_optional_sale(form, i, "business_sale", price_cents)?;
let id = form let id = form
.text(&format!("variants[{i}][id]")) .text(&format!("variants[{i}][id]"))
.and_then(|s| s.parse::<i32>().ok()); .and_then(|s| s.parse::<i32>().ok());
@@ -179,7 +157,6 @@ fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
sku, sku,
stock, stock,
price_cents, price_cents,
business_sale_cents,
position: out.len() as i32, position: out.len() as i32,
}); });
} }
@@ -197,9 +174,9 @@ fn apply_variant(active: &mut product_variants::ActiveModel, input: &VariantInpu
active.sku = Set(input.sku.clone()); active.sku = Set(input.sku.clone());
active.stock = Set(input.stock); active.stock = Set(input.stock);
active.price_cents = Set(input.price_cents); active.price_cents = Set(input.price_cents);
// The per-variant public sale price was removed from the UI; keep it cleared. // Discounts (public + business sale) are owned by the discount page and keyed
active.sale_price_cents = Set(None); // per option/audience; the product form must leave those columns untouched so
active.business_sale_price_cents = Set(input.business_sale_cents); // it never clobbers a discount. New variants default them to NULL.
active.position = Set(input.position); active.position = Set(input.position);
} }
@@ -257,7 +234,6 @@ fn variant_form_json(variant: &product_variants::Model) -> serde_json::Value {
"sku": variant.sku, "sku": variant.sku,
"stock": variant.stock, "stock": variant.stock,
"price": format_price(variant.price_cents), "price": format_price(variant.price_cents),
"business_sale": variant.business_sale_price_cents.map(format_price),
}) })
} }
@@ -340,9 +316,13 @@ async fn index(
.sum::<i32>() .sum::<i32>()
.to_string() .to_string()
}; };
// The product is "on sale" for this audience if any option carries a
// discount; the per-option amounts live on the discount page.
let on_sale = variants.iter().any(|v| current_value(v, audience).is_some());
rows.push(product_row( rows.push(product_row(
product, product,
priced, priced,
on_sale,
variants.len(), variants.len(),
stock_display, stock_display,
image, image,
@@ -373,6 +353,7 @@ async fn index(
fn product_row( fn product_row(
product: &products::Model, product: &products::Model,
effective: &pricing::PricedProduct, effective: &pricing::PricedProduct,
on_sale: bool,
variant_count: usize, variant_count: usize,
stock_display: String, stock_display: String,
image: Option<String>, image: Option<String>,
@@ -390,6 +371,7 @@ fn product_row(
"image": image, "image": image,
"category_name": category_name, "category_name": category_name,
"regular_price": format_price(effective.regular_cents), "regular_price": format_price(effective.regular_cents),
"on_sale": on_sale,
"effective_price": format_price(effective.price_cents), "effective_price": format_price(effective.price_cents),
"effective_reduced": effective.is_reduced(), "effective_reduced": effective.is_reduced(),
"effective_percent_off": percent_off(effective.regular_cents, effective.price_cents), "effective_percent_off": percent_off(effective.regular_cents, effective.price_cents),
@@ -619,6 +601,30 @@ fn list_redirect(audience: &str) -> Result<Response> {
format::redirect(&format!("/admin/catalog/products?audience={audience}")) format::redirect(&format!("/admin/catalog/products?audience={audience}"))
} }
/// Resolve a percentage off the regular price into a fixed sale price in cents.
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
}
/// Which discount value an audience tab sees on a variant.
fn current_value(variant: &product_variants::Model, audience: &str) -> Option<i64> {
if audience == BUSINESS {
variant.business_sale_price_cents
} else {
variant.sale_price_cents
}
}
/// Set the discount column on a variant for a given audience.
fn set_value(active: &mut product_variants::ActiveModel, audience: &str, value: Option<i64>) {
if audience == BUSINESS {
active.business_sale_price_cents = Set(value);
} else {
active.sale_price_cents = Set(value);
}
}
/// Percent off the regular price, rounded to a whole number. /// Percent off the regular price, rounded to a whole number.
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 {
@@ -742,6 +748,231 @@ async fn sync_profiles(
list_redirect(audience) list_redirect(audience)
} }
// --- Per-variant discounts ---------------------------------------------------
//
// Each product is sold as one or more options (variants). A discount can be set
// on every option individually, for the active audience: personal writes the
// public `sale_price_cents`, business writes `business_sale_price_cents`. Per
// option the admin picks a fixed sale price or a percentage off the regular
// price; an empty value clears that option's discount.
/// One option row in the discount form. Carries enough to pre-fill the editor
/// and to survive a validation-error round-trip.
struct DiscountRow {
id: i32,
label: String,
regular_cents: i64,
mode: String,
fixed: String,
percent: String,
has_discount: bool,
}
impl DiscountRow {
/// Pre-fill from the discount stored for this audience.
fn from_db(v: &product_variants::Model, audience: &str) -> Self {
let sale = current_value(v, audience);
DiscountRow {
id: v.id,
label: v.label.clone(),
regular_cents: v.price_cents,
mode: "fixed".to_string(),
fixed: sale.map(format_price).unwrap_or_default(),
percent: String::new(),
has_discount: sale.is_some(),
}
}
/// Pre-fill from the submitted values, to repaint the form after an error.
fn from_submitted(
v: &product_variants::Model,
audience: &str,
pairs: &HashMap<String, String>,
) -> Self {
let get = |key: &str| {
pairs
.get(&format!("v[{}][{key}]", v.id))
.map(|s| s.trim().to_string())
.unwrap_or_default()
};
let mode = get("mode");
DiscountRow {
id: v.id,
label: v.label.clone(),
regular_cents: v.price_cents,
mode: if mode == "percent" {
mode
} else {
"fixed".to_string()
},
fixed: get("fixed"),
percent: get("percent"),
has_discount: current_value(v, audience).is_some(),
}
}
fn to_json(&self, currency: &str) -> serde_json::Value {
json!({
"id": self.id,
"label": self.label,
"regular_cents": self.regular_cents,
"regular_price": format_price(self.regular_cents),
"currency": currency,
"mode": self.mode,
"fixed": self.fixed,
"percent": self.percent,
"has_discount": self.has_discount,
})
}
}
/// Resolve one submitted option into the sale price to store. `Ok(None)` clears
/// the discount; `Err` is an i18n key for the validation message.
fn resolve_row(
regular_cents: i64,
mode: &str,
fixed: &str,
percent: &str,
) -> std::result::Result<Option<i64>, &'static str> {
let sale_cents = if mode == "percent" {
if percent.is_empty() {
return Ok(None);
}
let pct = parse_percent(percent).ok_or("discount-invalid")?;
if pct <= 0.0 || pct >= 100.0 {
return Err("discount-percent-range");
}
percent_to_sale_cents(regular_cents, pct)
} else {
if fixed.is_empty() {
return Ok(None);
}
parse_price_to_cents(fixed).map_err(|_| "discount-invalid")?
};
if sale_cents <= 0 {
return Err("discount-must-be-positive");
}
if sale_cents >= regular_cents {
return Err("discount-below-regular");
}
Ok(Some(sale_cents))
}
async fn discount_view(
v: &TeraView,
jar: &CookieJar,
product: &products::Model,
rows: &[DiscountRow],
audience: &str,
error: Option<&str>,
) -> Result<Response> {
let rows_json: Vec<_> = rows.iter().map(|r| r.to_json(&product.currency)).collect();
let has_discount = rows.iter().any(|r| r.has_discount);
format::view(
v,
"admin/catalog/discount_form.html",
json!({
"product": {
"id": product.id,
"name": product.name,
"currency": product.currency,
},
"rows": rows_json,
"audience": audience,
"has_discount": has_discount,
"error": error.map(|e| e.to_string()),
"lang": current_lang(jar),
}),
)
}
#[debug_handler]
async fn discount_show(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<i32>,
Query(params): Query<HashMap<String, String>>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let audience = read_audience(&params);
let product = product_by_id(&ctx, id).await?;
let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
let rows: Vec<DiscountRow> = variants
.iter()
.map(|variant| DiscountRow::from_db(variant, audience))
.collect();
discount_view(&v, &jar, &product, &rows, audience, None).await
}
#[debug_handler]
async fn discount_update(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<i32>,
Query(params): Query<HashMap<String, String>>,
State(ctx): State<AppContext>,
body: String,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let audience = read_audience(&params);
let product = product_by_id(&ctx, id).await?;
let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
let pairs: HashMap<String, String> = form_urlencoded::parse(body.as_bytes())
.into_owned()
.collect();
// Resolve every option before persisting anything, so one bad row can't leave
// the product half-discounted. On the first error, repaint with the inputs.
let mut resolved: Vec<(product_variants::Model, Option<i64>)> = Vec::new();
for variant in &variants {
let row = DiscountRow::from_submitted(variant, audience, &pairs);
match resolve_row(variant.price_cents, &row.mode, &row.fixed, &row.percent) {
Ok(value) => resolved.push((variant.clone(), value)),
Err(key) => {
let rows: Vec<DiscountRow> = variants
.iter()
.map(|v| DiscountRow::from_submitted(v, audience, &pairs))
.collect();
return discount_view(&v, &jar, &product, &rows, audience, Some(key)).await;
}
}
}
let txn = ctx.db.begin().await?;
for (variant, value) in &resolved {
let mut active = variant.clone().into_active_model();
set_value(&mut active, audience, *value);
active.update(&txn).await?;
}
txn.commit().await?;
list_redirect(audience)
}
#[debug_handler]
async fn discount_remove(
auth: auth::JWT,
Path(id): Path<i32>,
Query(params): Query<HashMap<String, String>>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let audience = read_audience(&params);
let _product = product_by_id(&ctx, id).await?;
let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
let txn = ctx.db.begin().await?;
for variant in &variants {
let mut active = variant.clone().into_active_model();
set_value(&mut active, audience, None);
active.update(&txn).await?;
}
txn.commit().await?;
list_redirect(audience)
}
pub fn routes() -> Routes { pub fn routes() -> Routes {
// Several images may be uploaded in one submission; allow a generous total // Several images may be uploaded in one submission; allow a generous total
// (per-file size is still capped at IMAGE_MAX_BYTES while reading). // (per-file size is still capped at IMAGE_MAX_BYTES while reading).
@@ -764,4 +995,16 @@ pub fn routes() -> Routes {
post(update).layer(image_limit), post(update).layer(image_limit),
) )
.add("/admin/catalog/products/{id}/delete", post(delete)) .add("/admin/catalog/products/{id}/delete", post(delete))
.add(
"/admin/catalog/products/{id}/discount/edit",
get(discount_show),
)
.add(
"/admin/catalog/products/{id}/discount",
post(discount_update),
)
.add(
"/admin/catalog/products/{id}/discount/remove",
post(discount_remove),
)
} }