discounts
This commit is contained in:
@@ -208,6 +208,17 @@ edit-category = Edit category
|
|||||||
product = Product
|
product = Product
|
||||||
name = Name
|
name = Name
|
||||||
price = Price
|
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
|
stock = Stock
|
||||||
sku = SKU
|
sku = SKU
|
||||||
currency = Currency
|
currency = Currency
|
||||||
|
|||||||
@@ -208,6 +208,17 @@ edit-category = Upraviť kategóriu
|
|||||||
product = Produkt
|
product = Produkt
|
||||||
name = Názov
|
name = Názov
|
||||||
price = Cena
|
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
|
stock = Sklad
|
||||||
sku = Kód (SKU)
|
sku = Kód (SKU)
|
||||||
currency = Mena
|
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">
|
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')) }}
|
{{ t(key="admin-products", lang=lang | default(value='sk')) }}
|
||||||
</a>
|
</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"
|
<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">
|
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')) }}
|
{{ 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>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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 tabular-nums">{{ product.stock }}</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
{% if product.published %}
|
{% if product.published %}
|
||||||
@@ -53,6 +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="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')) }}')">
|
||||||
|
|||||||
@@ -127,7 +127,7 @@
|
|||||||
|
|
||||||
{# Text/email/number/password input. #}
|
{# Text/email/number/password input. #}
|
||||||
{% macro input(name, type="text", id="", value="", placeholder="", required=false, autocomplete="", attrs="", extra="", width="w-full") -%}
|
{% 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 %}
|
{%- endmacro input %}
|
||||||
|
|
||||||
{% macro textarea(name, id="", value="", rows="3", placeholder="", required=false, attrs="", extra="") -%}
|
{% macro textarea(name, id="", value="", rows="3", placeholder="", required=false, attrs="", extra="") -%}
|
||||||
|
|||||||
@@ -17,7 +17,14 @@
|
|||||||
<!-- Header: Title & Price -->
|
<!-- Header: Title & Price -->
|
||||||
<div class="flex justify-between gap-4">
|
<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>
|
<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>
|
<span class="whitespace-nowrap text-xl"><span class="sr-only">Price</span>{{ product.price }} {{ product.currency }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -54,7 +54,14 @@
|
|||||||
<a href="/category/{{ category.slug }}" class="text-sm font-medium text-primary dark:text-primary-dark">{{ category.name }}</a>
|
<a href="/category/{{ category.slug }}" class="text-sm font-medium text-primary dark:text-primary-dark">{{ category.name }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h1>
|
<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>
|
<p class="text-2xl font-semibold text-primary dark:text-primary-dark">{{ product.price }} {{ product.currency }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% if product.description %}
|
{% if product.description %}
|
||||||
<div class="whitespace-pre-line leading-relaxed text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description }}</div>
|
<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_000003_account_type;
|
||||||
mod m20260618_000004_account_ownership;
|
mod m20260618_000004_account_ownership;
|
||||||
mod m20260620_000001_add_totp_to_users;
|
mod m20260620_000001_add_totp_to_users;
|
||||||
|
mod m20260621_000001_add_sale_price_to_products;
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -74,6 +75,7 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260618_000003_account_type::Migration),
|
Box::new(m20260618_000003_account_type::Migration),
|
||||||
Box::new(m20260618_000004_account_ownership::Migration),
|
Box::new(m20260618_000004_account_ownership::Migration),
|
||||||
Box::new(m20260620_000001_add_totp_to_users::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)
|
// 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)]
|
#[allow(unused_imports)]
|
||||||
use crate::{
|
use crate::{
|
||||||
controllers::{
|
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,
|
admin_products, admin_shipping, auth, auth_pages, cart, checkout, home, i18n, media, oauth2,
|
||||||
shop,
|
shop,
|
||||||
},
|
},
|
||||||
@@ -104,6 +104,7 @@ impl Hooks for App {
|
|||||||
// admin
|
// admin
|
||||||
.add_route(admin_dashboard::routes())
|
.add_route(admin_dashboard::routes())
|
||||||
.add_route(admin_products::routes())
|
.add_route(admin_products::routes())
|
||||||
|
.add_route(admin_discounts::routes())
|
||||||
.add_route(admin_categories::routes())
|
.add_route(admin_categories::routes())
|
||||||
.add_route(admin_orders::routes())
|
.add_route(admin_orders::routes())
|
||||||
.add_route(admin_shipping::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 {
|
if qty == 0 {
|
||||||
continue;
|
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;
|
total += line_total;
|
||||||
valid.push((product.id, qty));
|
valid.push((product.id, qty));
|
||||||
lines.push(json!({
|
lines.push(json!({
|
||||||
"id": product.id,
|
"id": product.id,
|
||||||
"name": product.name,
|
"name": product.name,
|
||||||
"slug": product.slug,
|
"slug": product.slug,
|
||||||
"price": format_price(product.price_cents),
|
"price": format_price(unit_price),
|
||||||
"currency": product.currency,
|
"currency": product.currency,
|
||||||
"quantity": qty,
|
"quantity": qty,
|
||||||
"stock": product.stock,
|
"stock": product.stock,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ pub mod auth_pages;
|
|||||||
pub mod oauth2;
|
pub mod oauth2;
|
||||||
pub mod admin_categories;
|
pub mod admin_categories;
|
||||||
pub mod admin_dashboard;
|
pub mod admin_dashboard;
|
||||||
|
pub mod admin_discounts;
|
||||||
pub mod admin_form;
|
pub mod admin_form;
|
||||||
pub mod admin_orders;
|
pub mod admin_orders;
|
||||||
pub mod admin_products;
|
pub mod admin_products;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ pub struct Model {
|
|||||||
#[sea_orm(column_type = "Text", nullable)]
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub price_cents: i64,
|
pub price_cents: i64,
|
||||||
|
pub sale_price_cents: Option<i64>,
|
||||||
pub currency: String,
|
pub currency: String,
|
||||||
pub sku: Option<String>,
|
pub sku: Option<String>,
|
||||||
pub stock: i32,
|
pub stock: i32,
|
||||||
|
|||||||
@@ -61,13 +61,15 @@ pub async fn place(ctx: &AppContext, items: &[(i32, i32)], details: Checkout) ->
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
currency = product.currency.clone();
|
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();
|
let mut active = product.clone().into_active_model();
|
||||||
active.stock = Set(product.stock - *qty);
|
active.stock = Set(product.stock - *qty);
|
||||||
active.update(&txn).await?;
|
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 {
|
let order = ActiveModel {
|
||||||
|
|||||||
@@ -19,7 +19,25 @@ impl ActiveModelBehavior for ActiveModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// implement your read-oriented logic here
|
// 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
|
// implement your write-oriented logic here
|
||||||
impl ActiveModel {}
|
impl ActiveModel {}
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ pub fn product_card(
|
|||||||
"name": product.name,
|
"name": product.name,
|
||||||
"slug": product.slug,
|
"slug": product.slug,
|
||||||
"description": product.description,
|
"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,
|
"currency": product.currency,
|
||||||
"sku": product.sku,
|
"sku": product.sku,
|
||||||
"stock": product.stock,
|
"stock": product.stock,
|
||||||
|
|||||||
Reference in New Issue
Block a user