discounts page removed, all migrated to the products page in admin
This commit is contained in:
@@ -78,10 +78,6 @@
|
|||||||
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/discount-profiles" data-nav="/admin/catalog/discount-profiles"
|
<a href="/admin/catalog/discount-profiles" data-nav="/admin/catalog/discount-profiles"
|
||||||
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-discount-profiles", lang=lang | default(value='sk')) }}
|
{{ t(key="admin-discount-profiles", lang=lang | default(value='sk')) }}
|
||||||
|
|||||||
@@ -12,10 +12,10 @@
|
|||||||
{% if audience == "business" %}{{ t(key="audience-business", lang=lang | default(value='sk')) }}{% else %}{{ t(key="audience-personal", lang=lang | default(value='sk')) }}{% endif %}
|
{% if audience == "business" %}{{ t(key="audience-business", lang=lang | default(value='sk')) }}{% else %}{{ t(key="audience-personal", lang=lang | default(value='sk')) }}{% endif %}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/discounts?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>
|
||||||
|
|
||||||
<form method="post" action="/admin/catalog/discounts/{{ product.id }}?audience={{ audience }}"
|
<form method="post" action="/admin/catalog/products/{{ product.id }}/discount?audience={{ audience }}"
|
||||||
x-data="{
|
x-data="{
|
||||||
mode: '{{ mode }}',
|
mode: '{{ mode }}',
|
||||||
fixed: '{{ fixed }}',
|
fixed: '{{ fixed }}',
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
<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')) ~ `')"`) }}
|
||||||
{% if has_discount %}
|
{% if has_discount %}
|
||||||
{{ 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?audience=` ~ audience ~ `" onclick="return confirm('` ~ t(key="discount-remove-confirm", lang=lang | default(value='sk')) ~ `')"`) }}
|
{{ ui::button(variant="outline-danger", label=t(key="remove-discount", lang=lang | default(value='sk')), type="submit", attrs=`formaction="/admin/catalog/products/` ~ product.id ~ `/discount/remove?audience=` ~ audience ~ `" onclick="return confirm('` ~ t(key="discount-remove-confirm", lang=lang | default(value='sk')) ~ `')"`) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,125 +0,0 @@
|
|||||||
{% 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 %}
|
|
||||||
{% set business = audience == "business" %}
|
|
||||||
<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">
|
|
||||||
{% if business %}{{ t(key="business-discount-desc", lang=lang | default(value='sk')) }}{% else %}{{ t(key="admin-discounts-desc", lang=lang | default(value='sk')) }}{% endif %}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- audience tabs -->
|
|
||||||
<div class="mt-4 inline-flex rounded-radius border border-outline p-1 dark:border-outline-dark">
|
|
||||||
<a href="/admin/catalog/discounts?audience=personal"
|
|
||||||
class="rounded-radius px-4 py-1.5 text-sm font-medium {% if not business %}bg-primary/10 text-on-surface-strong dark:bg-primary-dark/10 dark:text-on-surface-dark-strong{% else %}text-on-surface/70 dark:text-on-surface-dark/70{% endif %}">
|
|
||||||
{{ t(key="audience-personal", lang=lang | default(value='sk')) }}
|
|
||||||
</a>
|
|
||||||
<a href="/admin/catalog/discounts?audience=business"
|
|
||||||
class="rounded-radius px-4 py-1.5 text-sm font-medium {% if business %}bg-primary/10 text-on-surface-strong dark:bg-primary-dark/10 dark:text-on-surface-dark-strong{% else %}text-on-surface/70 dark:text-on-surface-dark/70{% endif %}">
|
|
||||||
{{ t(key="audience-business", lang=lang | default(value='sk')) }}
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- discount profiles applied to this audience -->
|
|
||||||
<section class="mt-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
|
||||||
<h2 class="text-lg font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="discount-profiles", lang=lang | default(value='sk')) }}</h2>
|
|
||||||
<p class="mt-1 text-sm text-on-surface/70 dark:text-on-surface-dark/70">
|
|
||||||
{% if business %}{{ t(key="apply-profiles-business-hint", lang=lang | default(value='sk')) }}{% else %}{{ t(key="apply-profiles-personal-hint", lang=lang | default(value='sk')) }}{% endif %}
|
|
||||||
</p>
|
|
||||||
{% if profiles | length > 0 %}
|
|
||||||
<form method="post" action="/admin/catalog/discounts/profiles?audience={{ audience }}" class="mt-3 space-y-3"
|
|
||||||
onsubmit="return confirm('{{ t(key="discount-apply-confirm", lang=lang | default(value='sk')) }}')">
|
|
||||||
{{ ui::csrf_field() }}
|
|
||||||
<div class="grid gap-2 sm:grid-cols-2">
|
|
||||||
{% for profile in profiles %}
|
|
||||||
<label class="flex items-center gap-2 text-sm text-on-surface dark:text-on-surface-dark">
|
|
||||||
<input type="checkbox" name="profile_ids" value="{{ profile.id }}" {% if profile.assigned %}checked{% endif %}>
|
|
||||||
<span>{{ profile.name }} <span class="text-on-surface/60 dark:text-on-surface-dark/60">(−{{ profile.percent }}%, {% if profile.scope_type == "all_except" %}{{ t(key="scope-all-except", lang=lang | default(value='sk')) }}{% else %}{{ t(key="scope-include", lang=lang | default(value='sk')) }}{% endif %})</span></span>
|
|
||||||
</label>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", size="px-4 py-2 text-sm") }}
|
|
||||||
</form>
|
|
||||||
{% else %}
|
|
||||||
<p class="mt-2 text-sm text-on-surface/70 dark:text-on-surface-dark/70">
|
|
||||||
{{ t(key="admin-no-profiles", lang=lang | default(value='sk')) }}
|
|
||||||
<a href="/admin/catalog/discount-profiles/new" class="text-primary dark:text-primary-dark">{{ t(key="new-profile", lang=lang | default(value='sk')) }}</a>
|
|
||||||
</p>
|
|
||||||
{% endif %}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<div class="mt-4 {{ 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="effective-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 %}
|
|
||||||
{% if business %}{% set on_sale = product.business_on_sale %}{% set sale_price = product.business_sale_price %}{% set pct = product.business_percent_off %}
|
|
||||||
{% else %}{% set on_sale = product.on_sale %}{% set sale_price = product.sale_price %}{% set pct = product.percent_off %}{% endif %}
|
|
||||||
<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 on_sale %}
|
|
||||||
<span class="font-medium text-danger">{{ sale_price }} {{ product.currency }}</span>
|
|
||||||
<span class="ml-1 text-xs text-on-surface/60 dark:text-on-surface-dark/60">(−{{ pct }}%)</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="text-on-surface/40 dark:text-on-surface-dark/40">—</span>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 tabular-nums">
|
|
||||||
{% if product.effective_reduced %}
|
|
||||||
<span class="font-medium text-primary dark:text-primary-dark">{{ product.effective_price }} {{ product.currency }}</span>
|
|
||||||
<span class="ml-1 text-xs text-on-surface/60 dark:text-on-surface-dark/60">(−{{ product.effective_percent_off }}%)</span>
|
|
||||||
{% else %}
|
|
||||||
{{ product.effective_price }} {{ product.currency }}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
{% if 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?audience=" ~ audience, size="px-3 py-1.5 text-xs") }}
|
|
||||||
{% if on_sale %}
|
|
||||||
<form method="post" action="/admin/catalog/discounts/{{ product.id }}/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 %}
|
|
||||||
</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 %}
|
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
{% block crumb %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
{% block crumb %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% set business = audience == "business" %}
|
||||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-products", lang=lang | default(value='sk')) }}</h1>
|
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-products", lang=lang | default(value='sk')) }}</h1>
|
||||||
@@ -13,13 +14,55 @@
|
|||||||
{{ ui::button(label=t(key="new-product", lang=lang | default(value='sk')), href="/admin/catalog/products/new") }}
|
{{ ui::button(label=t(key="new-product", lang=lang | default(value='sk')), href="/admin/catalog/products/new") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 {{ ui::table_wrap_cls() }}">
|
<!-- audience tabs -->
|
||||||
|
<div class="mt-4 inline-flex rounded-radius border border-outline p-1 dark:border-outline-dark">
|
||||||
|
<a href="/admin/catalog/products?audience=personal"
|
||||||
|
class="rounded-radius px-4 py-1.5 text-sm font-medium {% if not business %}bg-primary/10 text-on-surface-strong dark:bg-primary-dark/10 dark:text-on-surface-dark-strong{% else %}text-on-surface/70 dark:text-on-surface-dark/70{% endif %}">
|
||||||
|
{{ t(key="audience-personal", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
<a href="/admin/catalog/products?audience=business"
|
||||||
|
class="rounded-radius px-4 py-1.5 text-sm font-medium {% if business %}bg-primary/10 text-on-surface-strong dark:bg-primary-dark/10 dark:text-on-surface-dark-strong{% else %}text-on-surface/70 dark:text-on-surface-dark/70{% endif %}">
|
||||||
|
{{ t(key="audience-business", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- discount profiles applied to this audience -->
|
||||||
|
<section class="mt-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<h2 class="text-lg font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="discount-profiles", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
<p class="mt-1 text-sm text-on-surface/70 dark:text-on-surface-dark/70">
|
||||||
|
{% if business %}{{ t(key="apply-profiles-business-hint", lang=lang | default(value='sk')) }}{% else %}{{ t(key="apply-profiles-personal-hint", lang=lang | default(value='sk')) }}{% endif %}
|
||||||
|
</p>
|
||||||
|
{% if profiles | length > 0 %}
|
||||||
|
<form method="post" action="/admin/catalog/products/profiles?audience={{ audience }}" class="mt-3 space-y-3"
|
||||||
|
onsubmit="return confirm('{{ t(key="discount-apply-confirm", lang=lang | default(value='sk')) }}')">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
|
{% for profile in profiles %}
|
||||||
|
<label class="flex items-center gap-2 text-sm text-on-surface dark:text-on-surface-dark">
|
||||||
|
<input type="checkbox" name="profile_ids" value="{{ profile.id }}" {% if profile.assigned %}checked{% endif %}>
|
||||||
|
<span>{{ profile.name }} <span class="text-on-surface/60 dark:text-on-surface-dark/60">(−{{ profile.percent }}%, {% if profile.scope_type == "all_except" %}{{ t(key="scope-all-except", lang=lang | default(value='sk')) }}{% else %}{{ t(key="scope-include", lang=lang | default(value='sk')) }}{% endif %})</span></span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", size="px-4 py-2 text-sm") }}
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-2 text-sm text-on-surface/70 dark:text-on-surface-dark/70">
|
||||||
|
{{ t(key="admin-no-profiles", lang=lang | default(value='sk')) }}
|
||||||
|
<a href="/admin/catalog/discount-profiles/new" class="text-primary dark:text-primary-dark">{{ t(key="new-profile", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="mt-4 {{ ui::table_wrap_cls() }}">
|
||||||
{% if products | length > 0 %}
|
{% if products | length > 0 %}
|
||||||
<table class="{{ ui::table_cls() }}">
|
<table class="{{ ui::table_cls() }}">
|
||||||
<thead class="{{ ui::thead_cls() }}">
|
<thead class="{{ ui::thead_cls() }}">
|
||||||
<tr>
|
<tr>
|
||||||
{{ ui::th(label=t(key="product", lang=lang | default(value='sk'))) }}
|
{{ 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="price", lang=lang | default(value='sk'))) }}
|
||||||
|
{{ ui::th(label=t(key="sale-price", lang=lang | default(value='sk'))) }}
|
||||||
|
{{ ui::th(label=t(key="effective-price", lang=lang | default(value='sk'))) }}
|
||||||
{{ ui::th(label=t(key="stock", lang=lang | default(value='sk'))) }}
|
{{ ui::th(label=t(key="stock", lang=lang | default(value='sk'))) }}
|
||||||
{{ ui::th(label=t(key="status", 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") }}
|
{{ ui::th(label=t(key="actions", lang=lang | default(value='sk')), align="text-right") }}
|
||||||
@@ -41,12 +84,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
<td class="px-4 py-3 tabular-nums">{{ product.regular_price }} {{ product.currency }}</td>
|
||||||
<td class="px-4 py-3 tabular-nums">
|
<td class="px-4 py-3 tabular-nums">
|
||||||
{% if product.on_sale %}
|
{% if product.on_sale %}
|
||||||
<span class="font-medium text-danger">{{ product.price }} {{ product.currency }}</span>
|
<span class="font-medium text-danger">{{ product.sale_price }} {{ product.currency }}</span>
|
||||||
<span class="text-xs text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }}</span>
|
<span class="ml-1 text-xs text-on-surface/60 dark:text-on-surface-dark/60">(−{{ product.percent_off }}%)</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ product.price }} {{ product.currency }}
|
<span class="text-on-surface/40 dark:text-on-surface-dark/40">—</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 tabular-nums">
|
||||||
|
{% if product.effective_reduced %}
|
||||||
|
<span class="font-medium text-primary dark:text-primary-dark">{{ product.effective_price }} {{ product.currency }}</span>
|
||||||
|
<span class="ml-1 text-xs text-on-surface/60 dark:text-on-surface-dark/60">(−{{ product.effective_percent_off }}%)</span>
|
||||||
|
{% else %}
|
||||||
|
{{ product.effective_price }} {{ product.currency }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 tabular-nums">{{ product.stock }}</td>
|
<td class="px-4 py-3 tabular-nums">{{ product.stock }}</td>
|
||||||
@@ -60,11 +112,18 @@
|
|||||||
<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="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="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')) }}')">
|
||||||
{{ ui::csrf_field() }}
|
{{ ui::csrf_field() }}
|
||||||
{{ ui::button(variant="outline-danger", label=t(key="delete", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}
|
{{ ui::button(variant="outline-danger", label=t(key="delete", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ use std::{path::Path, sync::Arc};
|
|||||||
use crate::{
|
use crate::{
|
||||||
controllers::{
|
controllers::{
|
||||||
account, admin_categories, admin_customers, admin_dashboard, admin_discount_profiles,
|
account, admin_categories, admin_customers, admin_dashboard, admin_discount_profiles,
|
||||||
admin_discounts, admin_form, admin_orders, admin_products, admin_shipping, auth, auth_pages,
|
admin_form, admin_orders, admin_products, admin_shipping, auth, auth_pages,
|
||||||
cart, checkout, home, i18n, media, oauth2,
|
cart, checkout, home, i18n, media, oauth2,
|
||||||
shop,
|
shop,
|
||||||
},
|
},
|
||||||
@@ -105,7 +105,6 @@ 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_discount_profiles::routes())
|
.add_route(admin_discount_profiles::routes())
|
||||||
.add_route(admin_categories::routes())
|
.add_route(admin_categories::routes())
|
||||||
.add_route(admin_orders::routes())
|
.add_route(admin_orders::routes())
|
||||||
|
|||||||
@@ -1,370 +0,0 @@
|
|||||||
//! Admin management of per-product discounts, in a place of their own rather
|
|
||||||
//! than on the product editor.
|
|
||||||
//!
|
|
||||||
//! Two audiences, switched by an `?audience=` tab:
|
|
||||||
//! - **personal** (default): the public sale price (`products.sale_price_cents`)
|
|
||||||
//! everyone sees.
|
|
||||||
//! - **business**: a baseline discount for all company accounts
|
|
||||||
//! (`products.business_sale_price_cents`). Per-company profiles/negotiated
|
|
||||||
//! prices still layer on top (lowest price wins). Both are computed off the
|
|
||||||
//! regular price.
|
|
||||||
|
|
||||||
use std::collections::{HashMap, HashSet};
|
|
||||||
|
|
||||||
use axum_extra::extract::cookie::CookieJar;
|
|
||||||
use loco_rs::prelude::*;
|
|
||||||
use sea_orm::{
|
|
||||||
ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set, TransactionTrait,
|
|
||||||
};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
controllers::i18n::current_lang,
|
|
||||||
models::{audience_discount_profiles, discount_profiles, products},
|
|
||||||
shared::{
|
|
||||||
guard,
|
|
||||||
money::{format_bp, format_price, parse_percent, parse_price_to_cents},
|
|
||||||
pricing,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const BUSINESS: &str = "business";
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct DiscountForm {
|
|
||||||
/// "fixed" (enter the new price) or "percent" (enter % off). Defaults to
|
|
||||||
/// fixed for older/JSON callers.
|
|
||||||
mode: Option<String>,
|
|
||||||
sale_price: Option<String>,
|
|
||||||
percent: Option<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Which discount column an audience tab operates on.
|
|
||||||
fn read_audience(params: &HashMap<String, String>) -> &'static str {
|
|
||||||
match params.get("audience").map(String::as_str) {
|
|
||||||
Some(BUSINESS) => BUSINESS,
|
|
||||||
_ => "personal",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn current_value(product: &products::Model, audience: &str) -> Option<i64> {
|
|
||||||
if audience == BUSINESS {
|
|
||||||
product.business_sale_price_cents
|
|
||||||
} else {
|
|
||||||
product.sale_price_cents
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn set_value(active: &mut products::ActiveModel, audience: &str, value: Option<i64>) {
|
|
||||||
if audience == BUSINESS {
|
|
||||||
active.business_sale_price_cents = Set(value);
|
|
||||||
} else {
|
|
||||||
active.sale_price_cents = Set(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn list_redirect(audience: &str) -> Result<Response> {
|
|
||||||
format::redirect(&format!("/admin/catalog/discounts?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
|
|
||||||
}
|
|
||||||
|
|
||||||
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.
|
|
||||||
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, carrying both audiences' per-product values
|
|
||||||
/// plus the resolved effective price for the active tab (after profiles).
|
|
||||||
fn list_row(product: &products::Model, effective: &pricing::PricedProduct) -> 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)),
|
|
||||||
"business_on_sale": product.business_on_sale(),
|
|
||||||
"business_sale_price": product.business_sale_price_cents.map(format_price),
|
|
||||||
"business_percent_off": product
|
|
||||||
.business_sale_price_cents
|
|
||||||
.map(|sale| percent_off(product.price_cents, sale)),
|
|
||||||
// The price this audience actually pays after the per-product discount
|
|
||||||
// and any applied profiles.
|
|
||||||
"effective_price": format_price(effective.price_cents),
|
|
||||||
"effective_reduced": effective.is_reduced(),
|
|
||||||
"effective_percent_off": percent_off(product.price_cents, effective.price_cents),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn index(
|
|
||||||
auth: auth::JWT,
|
|
||||||
jar: CookieJar,
|
|
||||||
ViewEngine(v): ViewEngine<TeraView>,
|
|
||||||
Query(params): Query<HashMap<String, String>>,
|
|
||||||
State(ctx): State<AppContext>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
guard::current_admin(auth, &ctx).await?;
|
|
||||||
let audience = read_audience(¶ms);
|
|
||||||
let list = products::Entity::find()
|
|
||||||
.order_by_asc(products::Column::Name)
|
|
||||||
.all(&ctx.db)
|
|
||||||
.await?;
|
|
||||||
let effective = pricing::audience_price_many(&ctx, &list, audience).await?;
|
|
||||||
let rows: Vec<serde_json::Value> = list
|
|
||||||
.iter()
|
|
||||||
.zip(effective.iter())
|
|
||||||
.map(|(product, priced)| list_row(product, priced))
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Profiles applied globally to this audience, plus all profiles to choose from.
|
|
||||||
let assigned: HashSet<i32> = audience_discount_profiles::Entity::find()
|
|
||||||
.filter(audience_discount_profiles::Column::Audience.eq(audience))
|
|
||||||
.all(&ctx.db)
|
|
||||||
.await?
|
|
||||||
.into_iter()
|
|
||||||
.map(|a| a.discount_profile_id)
|
|
||||||
.collect();
|
|
||||||
let all_profiles = discount_profiles::Entity::find()
|
|
||||||
.order_by_asc(discount_profiles::Column::Name)
|
|
||||||
.all(&ctx.db)
|
|
||||||
.await?;
|
|
||||||
let profiles: Vec<serde_json::Value> = all_profiles
|
|
||||||
.iter()
|
|
||||||
.map(|p| {
|
|
||||||
json!({
|
|
||||||
"id": p.id,
|
|
||||||
"name": p.name,
|
|
||||||
"percent": format_bp(p.percent_bp),
|
|
||||||
"scope_type": p.scope_type,
|
|
||||||
"assigned": assigned.contains(&p.id),
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
format::view(
|
|
||||||
&v,
|
|
||||||
"admin/catalog/discounts.html",
|
|
||||||
json!({
|
|
||||||
"products": rows,
|
|
||||||
"profiles": profiles,
|
|
||||||
"audience": audience,
|
|
||||||
"lang": current_lang(&jar),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Replace the profiles applied to this audience with the submitted checkbox set
|
|
||||||
/// (`profile_ids`, a repeated field parsed directly from the body).
|
|
||||||
#[debug_handler]
|
|
||||||
async fn sync_profiles(
|
|
||||||
auth: auth::JWT,
|
|
||||||
Query(params): Query<HashMap<String, String>>,
|
|
||||||
State(ctx): State<AppContext>,
|
|
||||||
body: String,
|
|
||||||
) -> Result<Response> {
|
|
||||||
guard::current_admin(auth, &ctx).await?;
|
|
||||||
let audience = read_audience(¶ms);
|
|
||||||
|
|
||||||
let profile_ids: Vec<i32> = form_urlencoded::parse(body.as_bytes())
|
|
||||||
.filter(|(k, _)| k == "profile_ids")
|
|
||||||
.filter_map(|(_, value)| value.parse::<i32>().ok())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let txn = ctx.db.begin().await?;
|
|
||||||
audience_discount_profiles::Entity::delete_many()
|
|
||||||
.filter(audience_discount_profiles::Column::Audience.eq(audience))
|
|
||||||
.exec(&txn)
|
|
||||||
.await?;
|
|
||||||
for profile_id in profile_ids {
|
|
||||||
audience_discount_profiles::ActiveModel {
|
|
||||||
audience: Set(audience.to_string()),
|
|
||||||
discount_profile_id: Set(profile_id),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
.insert(&txn)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
txn.commit().await?;
|
|
||||||
list_redirect(audience)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// What to pre-fill the form with: the chosen input mode and the raw values for
|
|
||||||
/// each field, so a rejected submit (or a re-edit) shows what the admin had.
|
|
||||||
#[derive(Default)]
|
|
||||||
struct FormPrefill {
|
|
||||||
mode: String,
|
|
||||||
fixed: String,
|
|
||||||
percent: String,
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render_form(
|
|
||||||
v: &TeraView,
|
|
||||||
jar: &CookieJar,
|
|
||||||
product: &products::Model,
|
|
||||||
audience: &str,
|
|
||||||
prefill: &FormPrefill,
|
|
||||||
error: Option<&str>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
let mode = if prefill.mode == "percent" { "percent" } else { "fixed" };
|
|
||||||
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),
|
|
||||||
"regular_cents": product.price_cents,
|
|
||||||
},
|
|
||||||
"audience": audience,
|
|
||||||
"has_discount": current_value(product, audience).is_some(),
|
|
||||||
"mode": mode,
|
|
||||||
"fixed": prefill.fixed,
|
|
||||||
"percent": prefill.percent,
|
|
||||||
"error": error,
|
|
||||||
"lang": current_lang(jar),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn edit(
|
|
||||||
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(¶ms);
|
|
||||||
let product = product_by_id(&ctx, id).await?;
|
|
||||||
// Re-editing always opens in fixed mode showing the current price.
|
|
||||||
let prefill = FormPrefill {
|
|
||||||
mode: "fixed".to_string(),
|
|
||||||
fixed: current_value(&product, audience)
|
|
||||||
.map(format_price)
|
|
||||||
.unwrap_or_default(),
|
|
||||||
percent: String::new(),
|
|
||||||
};
|
|
||||||
render_form(&v, &jar, &product, audience, &prefill, None)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn update(
|
|
||||||
auth: auth::JWT,
|
|
||||||
jar: CookieJar,
|
|
||||||
ViewEngine(v): ViewEngine<TeraView>,
|
|
||||||
Path(id): Path<i32>,
|
|
||||||
Query(params): Query<HashMap<String, String>>,
|
|
||||||
State(ctx): State<AppContext>,
|
|
||||||
Form(form): Form<DiscountForm>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
guard::current_admin(auth, &ctx).await?;
|
|
||||||
let audience = read_audience(¶ms);
|
|
||||||
let product = product_by_id(&ctx, id).await?;
|
|
||||||
|
|
||||||
let mode = match form.mode.as_deref() {
|
|
||||||
Some("percent") => "percent",
|
|
||||||
_ => "fixed",
|
|
||||||
};
|
|
||||||
let fixed = form.sale_price.unwrap_or_default().trim().to_string();
|
|
||||||
let percent = form.percent.unwrap_or_default().trim().to_string();
|
|
||||||
|
|
||||||
let prefill = FormPrefill {
|
|
||||||
mode: mode.to_string(),
|
|
||||||
fixed: fixed.clone(),
|
|
||||||
percent: percent.clone(),
|
|
||||||
};
|
|
||||||
let render_err = |key: &str| render_form(&v, &jar, &product, audience, &prefill, Some(key));
|
|
||||||
|
|
||||||
// Resolve the entered discount into a fixed sale price in cents. An empty
|
|
||||||
// input in the active mode clears the discount (same as the Remove action).
|
|
||||||
let sale_cents = if mode == "percent" {
|
|
||||||
if percent.is_empty() {
|
|
||||||
return clear_discount(&ctx, product, audience).await;
|
|
||||||
}
|
|
||||||
let pct = match parse_percent(&percent) {
|
|
||||||
Some(pct) => pct,
|
|
||||||
None => return render_err("discount-invalid"),
|
|
||||||
};
|
|
||||||
if pct <= 0.0 || pct >= 100.0 {
|
|
||||||
return render_err("discount-percent-range");
|
|
||||||
}
|
|
||||||
percent_to_sale_cents(product.price_cents, pct)
|
|
||||||
} else {
|
|
||||||
if fixed.is_empty() {
|
|
||||||
return clear_discount(&ctx, product, audience).await;
|
|
||||||
}
|
|
||||||
match parse_price_to_cents(&fixed) {
|
|
||||||
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();
|
|
||||||
set_value(&mut active, audience, Some(sale_cents));
|
|
||||||
active.update(&ctx.db).await?;
|
|
||||||
list_redirect(audience)
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn clear_discount(
|
|
||||||
ctx: &AppContext,
|
|
||||||
product: products::Model,
|
|
||||||
audience: &str,
|
|
||||||
) -> Result<Response> {
|
|
||||||
let mut active = product.into_active_model();
|
|
||||||
set_value(&mut active, audience, None);
|
|
||||||
active.update(&ctx.db).await?;
|
|
||||||
list_redirect(audience)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn 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(¶ms);
|
|
||||||
let product = product_by_id(&ctx, id).await?;
|
|
||||||
clear_discount(&ctx, product, audience).await
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn routes() -> Routes {
|
|
||||||
Routes::new()
|
|
||||||
.add("/admin/catalog/discounts", get(index))
|
|
||||||
.add("/admin/catalog/discounts/profiles", post(sync_profiles))
|
|
||||||
.add("/admin/catalog/discounts/{id}/edit", get(edit))
|
|
||||||
.add("/admin/catalog/discounts/{id}", post(update))
|
|
||||||
.add("/admin/catalog/discounts/{id}/remove", post(remove))
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
//! Admin product CRUD.
|
//! Admin product CRUD.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
use axum::extract::{DefaultBodyLimit, Multipart};
|
use axum::extract::{DefaultBodyLimit, Multipart};
|
||||||
use axum_extra::extract::cookie::CookieJar;
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use sea_orm::{
|
use sea_orm::{
|
||||||
ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter,
|
ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter,
|
||||||
QueryOrder, Set,
|
QueryOrder, Set, TransactionTrait,
|
||||||
};
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
@@ -17,14 +20,19 @@ use crate::{
|
|||||||
},
|
},
|
||||||
shared::{
|
shared::{
|
||||||
guard,
|
guard,
|
||||||
money::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},
|
||||||
},
|
},
|
||||||
models::{categories, product_images, products},
|
models::{
|
||||||
|
audience_discount_profiles, categories, discount_profiles, product_images, products,
|
||||||
|
},
|
||||||
views::shop as view,
|
views::shop as view,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Which discount column an audience tab operates on.
|
||||||
|
const BUSINESS: &str = "business";
|
||||||
|
|
||||||
async fn product_by_id(ctx: &AppContext, id: i32) -> Result<products::Model> {
|
async fn product_by_id(ctx: &AppContext, id: i32) -> Result<products::Model> {
|
||||||
products::Entity::find_by_id(id)
|
products::Entity::find_by_id(id)
|
||||||
.one(&ctx.db)
|
.one(&ctx.db)
|
||||||
@@ -113,15 +121,20 @@ async fn index(
|
|||||||
auth: auth::JWT,
|
auth: auth::JWT,
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
ViewEngine(v): ViewEngine<TeraView>,
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
guard::current_admin(auth, &ctx).await?;
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let audience = read_audience(¶ms);
|
||||||
let list = products::Entity::find()
|
let list = products::Entity::find()
|
||||||
.order_by_desc(products::Column::CreatedAt)
|
.order_by_desc(products::Column::CreatedAt)
|
||||||
.all(&ctx.db)
|
.all(&ctx.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
// Effective price each product carries for the active audience, after the
|
||||||
|
// global per-product discount and any profiles assigned to that audience.
|
||||||
|
let effective = pricing::audience_price_many(&ctx, &list, audience).await?;
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
for product in list {
|
for (product, priced) in list.iter().zip(effective.iter()) {
|
||||||
let image = product_images::first_for(&ctx, product.id).await?;
|
let image = product_images::first_for(&ctx, product.id).await?;
|
||||||
let category_name = match product.category_id {
|
let category_name = match product.category_id {
|
||||||
Some(id) => categories::Entity::find_by_id(id)
|
Some(id) => categories::Entity::find_by_id(id)
|
||||||
@@ -130,16 +143,80 @@ async fn index(
|
|||||||
.map(|c| c.name),
|
.map(|c| c.name),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
let priced = pricing::price_for(&ctx, &product, None).await?;
|
rows.push(product_row(product, priced, image, category_name, audience));
|
||||||
rows.push(view::product_card(&product, &priced, image, category_name));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
format::view(
|
format::view(
|
||||||
&v,
|
&v,
|
||||||
"admin/catalog/products.html",
|
"admin/catalog/products.html",
|
||||||
json!({ "products": rows, "lang": current_lang(&jar) }),
|
json!({
|
||||||
|
"products": rows,
|
||||||
|
"profiles": load_audience_profiles(&ctx, audience).await?,
|
||||||
|
"audience": audience,
|
||||||
|
"lang": current_lang(&jar),
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List-row shape: the product card fields plus the active audience's per-product
|
||||||
|
/// discount and its resolved effective price (after profiles).
|
||||||
|
fn product_row(
|
||||||
|
product: &products::Model,
|
||||||
|
effective: &pricing::PricedProduct,
|
||||||
|
image: Option<String>,
|
||||||
|
category_name: Option<String>,
|
||||||
|
audience: &str,
|
||||||
|
) -> serde_json::Value {
|
||||||
|
let sale = current_value(product, audience);
|
||||||
|
json!({
|
||||||
|
"id": product.id,
|
||||||
|
"name": product.name,
|
||||||
|
"slug": product.slug,
|
||||||
|
"currency": product.currency,
|
||||||
|
"stock": product.stock,
|
||||||
|
"published": product.published,
|
||||||
|
"image": image,
|
||||||
|
"category_name": category_name,
|
||||||
|
"regular_price": format_price(product.price_cents),
|
||||||
|
"on_sale": sale.is_some(),
|
||||||
|
"sale_price": sale.map(format_price),
|
||||||
|
"percent_off": sale.map(|s| percent_off(product.price_cents, s)),
|
||||||
|
"effective_price": format_price(effective.price_cents),
|
||||||
|
"effective_reduced": effective.is_reduced(),
|
||||||
|
"effective_percent_off": percent_off(product.price_cents, effective.price_cents),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All discount profiles, flagged with whether they are assigned to `audience`.
|
||||||
|
async fn load_audience_profiles(
|
||||||
|
ctx: &AppContext,
|
||||||
|
audience: &str,
|
||||||
|
) -> Result<Vec<serde_json::Value>> {
|
||||||
|
let assigned: HashSet<i32> = audience_discount_profiles::Entity::find()
|
||||||
|
.filter(audience_discount_profiles::Column::Audience.eq(audience))
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| a.discount_profile_id)
|
||||||
|
.collect();
|
||||||
|
let all_profiles = discount_profiles::Entity::find()
|
||||||
|
.order_by_asc(discount_profiles::Column::Name)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
Ok(all_profiles
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
json!({
|
||||||
|
"id": p.id,
|
||||||
|
"name": p.name,
|
||||||
|
"percent": format_bp(p.percent_bp),
|
||||||
|
"scope_type": p.scope_type,
|
||||||
|
"assigned": assigned.contains(&p.id),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn new(
|
async fn new(
|
||||||
auth: auth::JWT,
|
auth: auth::JWT,
|
||||||
@@ -270,6 +347,255 @@ async fn delete(
|
|||||||
format::redirect("/admin/catalog/products")
|
format::redirect("/admin/catalog/products")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Discounts -------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// Two audiences, switched by an `?audience=` tab on the products page:
|
||||||
|
// - **personal** (default): the public sale price (`products.sale_price_cents`)
|
||||||
|
// everyone sees.
|
||||||
|
// - **business**: a baseline discount for all company accounts
|
||||||
|
// (`products.business_sale_price_cents`). Per-company profiles/negotiated
|
||||||
|
// prices still layer on top (lowest price wins). Both are off the regular price.
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct DiscountForm {
|
||||||
|
/// "fixed" (enter the new price) or "percent" (enter % off). Defaults to
|
||||||
|
/// fixed for older/JSON callers.
|
||||||
|
mode: Option<String>,
|
||||||
|
sale_price: Option<String>,
|
||||||
|
percent: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_audience(params: &HashMap<String, String>) -> &'static str {
|
||||||
|
match params.get("audience").map(String::as_str) {
|
||||||
|
Some(BUSINESS) => BUSINESS,
|
||||||
|
_ => "personal",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn current_value(product: &products::Model, audience: &str) -> Option<i64> {
|
||||||
|
if audience == BUSINESS {
|
||||||
|
product.business_sale_price_cents
|
||||||
|
} else {
|
||||||
|
product.sale_price_cents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_value(active: &mut products::ActiveModel, audience: &str, value: Option<i64>) {
|
||||||
|
if audience == BUSINESS {
|
||||||
|
active.business_sale_price_cents = Set(value);
|
||||||
|
} else {
|
||||||
|
active.sale_price_cents = Set(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_redirect(audience: &str) -> Result<Response> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Percent off the regular price, rounded to a whole number.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the profiles applied to this audience with the submitted checkbox set
|
||||||
|
/// (`profile_ids`, a repeated field parsed directly from the body).
|
||||||
|
#[debug_handler]
|
||||||
|
async fn sync_profiles(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
body: String,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let audience = read_audience(¶ms);
|
||||||
|
|
||||||
|
let profile_ids: Vec<i32> = form_urlencoded::parse(body.as_bytes())
|
||||||
|
.filter(|(k, _)| k == "profile_ids")
|
||||||
|
.filter_map(|(_, value)| value.parse::<i32>().ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let txn = ctx.db.begin().await?;
|
||||||
|
audience_discount_profiles::Entity::delete_many()
|
||||||
|
.filter(audience_discount_profiles::Column::Audience.eq(audience))
|
||||||
|
.exec(&txn)
|
||||||
|
.await?;
|
||||||
|
for profile_id in profile_ids {
|
||||||
|
audience_discount_profiles::ActiveModel {
|
||||||
|
audience: Set(audience.to_string()),
|
||||||
|
discount_profile_id: Set(profile_id),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&txn)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
txn.commit().await?;
|
||||||
|
list_redirect(audience)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What to pre-fill the discount form with: the chosen input mode and the raw
|
||||||
|
/// values for each field, so a rejected submit (or a re-edit) shows what the
|
||||||
|
/// admin had.
|
||||||
|
#[derive(Default)]
|
||||||
|
struct FormPrefill {
|
||||||
|
mode: String,
|
||||||
|
fixed: String,
|
||||||
|
percent: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_discount_form(
|
||||||
|
v: &TeraView,
|
||||||
|
jar: &CookieJar,
|
||||||
|
product: &products::Model,
|
||||||
|
audience: &str,
|
||||||
|
prefill: &FormPrefill,
|
||||||
|
error: Option<&str>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let mode = if prefill.mode == "percent" { "percent" } else { "fixed" };
|
||||||
|
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),
|
||||||
|
"regular_cents": product.price_cents,
|
||||||
|
},
|
||||||
|
"audience": audience,
|
||||||
|
"has_discount": current_value(product, audience).is_some(),
|
||||||
|
"mode": mode,
|
||||||
|
"fixed": prefill.fixed,
|
||||||
|
"percent": prefill.percent,
|
||||||
|
"error": error,
|
||||||
|
"lang": current_lang(jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn discount_edit(
|
||||||
|
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(¶ms);
|
||||||
|
let product = product_by_id(&ctx, id).await?;
|
||||||
|
// Re-editing always opens in fixed mode showing the current price.
|
||||||
|
let prefill = FormPrefill {
|
||||||
|
mode: "fixed".to_string(),
|
||||||
|
fixed: current_value(&product, audience)
|
||||||
|
.map(format_price)
|
||||||
|
.unwrap_or_default(),
|
||||||
|
percent: String::new(),
|
||||||
|
};
|
||||||
|
render_discount_form(&v, &jar, &product, audience, &prefill, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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>,
|
||||||
|
Form(form): Form<DiscountForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let audience = read_audience(¶ms);
|
||||||
|
let product = product_by_id(&ctx, id).await?;
|
||||||
|
|
||||||
|
let mode = match form.mode.as_deref() {
|
||||||
|
Some("percent") => "percent",
|
||||||
|
_ => "fixed",
|
||||||
|
};
|
||||||
|
let fixed = form.sale_price.unwrap_or_default().trim().to_string();
|
||||||
|
let percent = form.percent.unwrap_or_default().trim().to_string();
|
||||||
|
|
||||||
|
let prefill = FormPrefill {
|
||||||
|
mode: mode.to_string(),
|
||||||
|
fixed: fixed.clone(),
|
||||||
|
percent: percent.clone(),
|
||||||
|
};
|
||||||
|
let render_err =
|
||||||
|
|key: &str| render_discount_form(&v, &jar, &product, audience, &prefill, Some(key));
|
||||||
|
|
||||||
|
// Resolve the entered discount into a fixed sale price in cents. An empty
|
||||||
|
// input in the active mode clears the discount (same as the Remove action).
|
||||||
|
let sale_cents = if mode == "percent" {
|
||||||
|
if percent.is_empty() {
|
||||||
|
return clear_discount(&ctx, product, audience).await;
|
||||||
|
}
|
||||||
|
let pct = match parse_percent(&percent) {
|
||||||
|
Some(pct) => pct,
|
||||||
|
None => return render_err("discount-invalid"),
|
||||||
|
};
|
||||||
|
if pct <= 0.0 || pct >= 100.0 {
|
||||||
|
return render_err("discount-percent-range");
|
||||||
|
}
|
||||||
|
percent_to_sale_cents(product.price_cents, pct)
|
||||||
|
} else {
|
||||||
|
if fixed.is_empty() {
|
||||||
|
return clear_discount(&ctx, product, audience).await;
|
||||||
|
}
|
||||||
|
match parse_price_to_cents(&fixed) {
|
||||||
|
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();
|
||||||
|
set_value(&mut active, audience, Some(sale_cents));
|
||||||
|
active.update(&ctx.db).await?;
|
||||||
|
list_redirect(audience)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn clear_discount(
|
||||||
|
ctx: &AppContext,
|
||||||
|
product: products::Model,
|
||||||
|
audience: &str,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let mut active = product.into_active_model();
|
||||||
|
set_value(&mut active, audience, None);
|
||||||
|
active.update(&ctx.db).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(¶ms);
|
||||||
|
let product = product_by_id(&ctx, id).await?;
|
||||||
|
clear_discount(&ctx, product, audience).await
|
||||||
|
}
|
||||||
|
|
||||||
pub fn routes() -> Routes {
|
pub fn routes() -> Routes {
|
||||||
let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024);
|
let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024);
|
||||||
Routes::new()
|
Routes::new()
|
||||||
@@ -279,10 +605,23 @@ pub fn routes() -> Routes {
|
|||||||
"/admin/catalog/products",
|
"/admin/catalog/products",
|
||||||
post(create).layer(image_limit.clone()),
|
post(create).layer(image_limit.clone()),
|
||||||
)
|
)
|
||||||
|
.add("/admin/catalog/products/profiles", post(sync_profiles))
|
||||||
.add("/admin/catalog/products/{id}/edit", get(edit))
|
.add("/admin/catalog/products/{id}/edit", get(edit))
|
||||||
.add(
|
.add(
|
||||||
"/admin/catalog/products/{id}",
|
"/admin/catalog/products/{id}",
|
||||||
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_edit),
|
||||||
|
)
|
||||||
|
.add(
|
||||||
|
"/admin/catalog/products/{id}/discount",
|
||||||
|
post(discount_update),
|
||||||
|
)
|
||||||
|
.add(
|
||||||
|
"/admin/catalog/products/{id}/discount/remove",
|
||||||
|
post(discount_remove),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ pub mod admin_categories;
|
|||||||
pub mod admin_customers;
|
pub mod admin_customers;
|
||||||
pub mod admin_dashboard;
|
pub mod admin_dashboard;
|
||||||
pub mod admin_discount_profiles;
|
pub mod admin_discount_profiles;
|
||||||
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;
|
||||||
|
|||||||
Reference in New Issue
Block a user