discounts
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -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')) }}
|
||||
|
||||
39
assets/views/admin/catalog/discount_form.html
Normal file
39
assets/views/admin/catalog/discount_form.html
Normal 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 %}
|
||||
70
assets/views/admin/catalog/discounts.html
Normal file
70
assets/views/admin/catalog/discounts.html
Normal 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 %}
|
||||
@@ -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')) }}')">
|
||||
|
||||
@@ -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="") -%}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
]
|
||||
}
|
||||
|
||||
19
migration/src/m20260621_000001_add_sale_price_to_products.rs
Normal file
19
migration/src/m20260621_000001_add_sale_price_to_products.rs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
185
src/controllers/admin_discounts.rs
Normal file
185
src/controllers/admin_discounts.rs
Normal 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))
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user