discounts now work well
This commit is contained in:
@@ -15,82 +15,80 @@
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products?audience=" ~ audience, size="px-3 py-2 text-sm") }}
|
||||
</div>
|
||||
|
||||
{# One discount row per option (variant). Each row picks a fixed sale price or a #}
|
||||
{# percentage off its own regular price; a blank input clears that option's #}
|
||||
{# discount. Both the fixed and percent inputs always submit (the server reads the #}
|
||||
{# active mode); rows are pre-filled from `rows` (DB values, or submitted values #}
|
||||
{# when repainting after a validation error) and indexed as v[<variant id>][...]. #}
|
||||
<script id="discount-data" type="application/json">{{ rows | json_encode() | safe }}</script>
|
||||
|
||||
<form method="post" action="/admin/catalog/products/{{ product.id }}/discount?audience={{ audience }}"
|
||||
x-data="{
|
||||
mode: '{{ mode }}',
|
||||
fixed: '{{ fixed }}',
|
||||
percent: '{{ percent }}',
|
||||
regular: {{ product.regular_cents }},
|
||||
num(v) { let n = parseFloat(String(v).replace(',', '.')); return isFinite(n) ? n : null; },
|
||||
get afterCents() {
|
||||
if (this.mode === 'percent') {
|
||||
let p = this.num(this.percent); if (p === null) return null;
|
||||
return this.regular - Math.round(this.regular * p / 100);
|
||||
}
|
||||
let f = this.num(this.fixed); if (f === null) return null;
|
||||
return Math.round(f * 100);
|
||||
},
|
||||
money(c) { return (c / 100).toFixed(2); },
|
||||
get valid() { let a = this.afterCents; return a !== null && a > 0 && a < this.regular; },
|
||||
get percentOff() { let a = this.afterCents; return (a === null || this.regular <= 0) ? null : Math.round((this.regular - a) / this.regular * 100); }
|
||||
}"
|
||||
class="mt-6 max-w-md space-y-5 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
x-data="discountEditor(JSON.parse(document.getElementById('discount-data').textContent))"
|
||||
class="mt-6 max-w-2xl space-y-5">
|
||||
{{ 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>
|
||||
<template x-for="row in rows" :key="row.id">
|
||||
<div class="space-y-4 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong"
|
||||
x-text="row.label || ('#' + row.id)"></span>
|
||||
<span class="text-sm tabular-nums text-on-surface/70 dark:text-on-surface-dark/70">
|
||||
{{ t(key="price", lang=lang | default(value='sk')) }}:
|
||||
<span x-text="row.regular_price"></span> <span x-text="row.currency"></span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- mode toggle -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="flex cursor-pointer items-center justify-center gap-2 rounded-radius border px-3 py-2 text-sm transition"
|
||||
:class="mode === 'fixed' ? 'border-primary bg-primary/10 text-on-surface-strong dark:border-primary-dark dark:bg-primary-dark/10 dark:text-on-surface-dark-strong' : 'border-outline text-on-surface dark:border-outline-dark dark:text-on-surface-dark'">
|
||||
<input type="radio" name="mode" value="fixed" x-model="mode" class="sr-only">
|
||||
{{ t(key="discount-mode-fixed", lang=lang | default(value='sk')) }}
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center justify-center gap-2 rounded-radius border px-3 py-2 text-sm transition"
|
||||
:class="mode === 'percent' ? 'border-primary bg-primary/10 text-on-surface-strong dark:border-primary-dark dark:bg-primary-dark/10 dark:text-on-surface-dark-strong' : 'border-outline text-on-surface dark:border-outline-dark dark:text-on-surface-dark'">
|
||||
<input type="radio" name="mode" value="percent" x-model="mode" class="sr-only">
|
||||
{{ t(key="discount-mode-percent", lang=lang | default(value='sk')) }}
|
||||
</label>
|
||||
</div>
|
||||
<input type="hidden" :name="`v[${row.id}][mode]`" :value="row.mode">
|
||||
|
||||
<!-- fixed price input -->
|
||||
<div class="space-y-1.5" x-show="mode === 'fixed'">
|
||||
<label for="sale_price" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="sale-price", lang=lang | default(value='sk')) }}</label>
|
||||
{{ ui::input(name="sale_price", id="sale_price", value=fixed, placeholder="0.00", attrs='inputmode="decimal" x-model="fixed"') }}
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<!-- mode toggle -->
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label class="flex cursor-pointer items-center justify-center gap-2 rounded-radius border px-3 py-2 text-sm transition"
|
||||
:class="row.mode === 'fixed' ? 'border-primary bg-primary/10 text-on-surface-strong dark:border-primary-dark dark:bg-primary-dark/10 dark:text-on-surface-dark-strong' : 'border-outline text-on-surface dark:border-outline-dark dark:text-on-surface-dark'">
|
||||
<input type="radio" :name="`mode-ui-${row.id}`" value="fixed" x-model="row.mode" class="sr-only">
|
||||
{{ t(key="discount-mode-fixed", lang=lang | default(value='sk')) }}
|
||||
</label>
|
||||
<label class="flex cursor-pointer items-center justify-center gap-2 rounded-radius border px-3 py-2 text-sm transition"
|
||||
:class="row.mode === 'percent' ? 'border-primary bg-primary/10 text-on-surface-strong dark:border-primary-dark dark:bg-primary-dark/10 dark:text-on-surface-dark-strong' : 'border-outline text-on-surface dark:border-outline-dark dark:text-on-surface-dark'">
|
||||
<input type="radio" :name="`mode-ui-${row.id}`" value="percent" x-model="row.mode" class="sr-only">
|
||||
{{ t(key="discount-mode-percent", lang=lang | default(value='sk')) }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- percentage input -->
|
||||
<div class="space-y-1.5" x-show="mode === 'percent'">
|
||||
<label for="percent" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="discount-percent", lang=lang | default(value='sk')) }}</label>
|
||||
{{ ui::input(name="percent", id="percent", value=percent, placeholder="0", attrs='inputmode="decimal" min="0" max="100" x-model="percent"') }}
|
||||
</div>
|
||||
<!-- value input: both fields stay in the DOM and submit; the server reads
|
||||
whichever matches the row's mode -->
|
||||
<div class="space-y-1.5">
|
||||
<div x-show="row.mode === 'fixed'">
|
||||
<label class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="sale-price", lang=lang | default(value='sk')) }}</label>
|
||||
<input :name="`v[${row.id}][fixed]`" x-model="row.fixed" inputmode="decimal" placeholder="0.00"
|
||||
class="w-full rounded-radius border border-outline bg-surface-alt px-4 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark">
|
||||
</div>
|
||||
<div x-show="row.mode === 'percent'">
|
||||
<label class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="discount-percent", lang=lang | default(value='sk')) }}</label>
|
||||
<input :name="`v[${row.id}][percent]`" x-model="row.percent" inputmode="decimal" min="0" max="100" placeholder="0"
|
||||
class="w-full rounded-radius border border-outline bg-surface-alt px-4 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- live preview -->
|
||||
<div x-show="afterCents !== null" x-cloak
|
||||
class="space-y-2 rounded-radius border border-outline bg-surface-alt px-4 py-3 dark:border-outline-dark dark:bg-surface-dark/40">
|
||||
<div class="flex items-center justify-between gap-3 text-sm">
|
||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="discount-preview-before", lang=lang | default(value='sk')) }}</span>
|
||||
<span class="tabular-nums text-on-surface/60 line-through dark:text-on-surface-dark/60"><span x-text="money(regular)"></span> {{ product.currency }}</span>
|
||||
<!-- live preview -->
|
||||
<div x-show="afterCents(row) !== null" x-cloak
|
||||
class="flex flex-wrap items-center justify-between gap-3 rounded-radius border border-outline bg-surface-alt px-4 py-2.5 text-sm dark:border-outline-dark dark:bg-surface-dark/40">
|
||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="discount-preview-after", lang=lang | default(value='sk')) }}</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="tabular-nums text-on-surface/50 line-through dark:text-on-surface-dark/50" x-text="money(row.regular_cents) + ' ' + row.currency"></span>
|
||||
<span class="text-base font-semibold tabular-nums" :class="valid(row) ? 'text-danger' : 'text-on-surface/40 dark:text-on-surface-dark/40'"
|
||||
x-text="money(afterCents(row)) + ' ' + row.currency"></span>
|
||||
<span x-show="valid(row)" class="text-xs text-on-surface/60 dark:text-on-surface-dark/60" x-text="'(−' + percentOff(row) + '%)'"></span>
|
||||
</span>
|
||||
</div>
|
||||
<p x-show="afterCents(row) !== null && !valid(row)" class="text-xs text-danger">{{ t(key="discount-below-regular", lang=lang | default(value='sk')) }}</p>
|
||||
</div>
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<span class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="discount-preview-after", lang=lang | default(value='sk')) }}</span>
|
||||
<span class="text-lg font-semibold tabular-nums" :class="valid ? 'text-danger' : 'text-on-surface/40 dark:text-on-surface-dark/40'">
|
||||
<span x-text="money(afterCents)"></span> {{ product.currency }}
|
||||
</span>
|
||||
</div>
|
||||
<div x-show="valid" class="flex items-center justify-between gap-3 text-xs text-on-surface/60 dark:text-on-surface-dark/60">
|
||||
<span>{{ t(key="discount-preview-save", lang=lang | default(value='sk')) }}</span>
|
||||
<span class="tabular-nums"><span x-text="money(regular - afterCents)"></span> {{ product.currency }} (−<span x-text="percentOff"></span>%)</span>
|
||||
</div>
|
||||
<p x-show="!valid" class="text-xs text-danger">{{ t(key="discount-below-regular", lang=lang | default(value='sk')) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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')) ~ `')"`) }}
|
||||
@@ -99,4 +97,36 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<script>
|
||||
function discountEditor(initial) {
|
||||
return {
|
||||
rows: (initial || []).map(r => ({
|
||||
id: r.id,
|
||||
label: r.label || '',
|
||||
regular_cents: r.regular_cents,
|
||||
regular_price: r.regular_price,
|
||||
currency: r.currency,
|
||||
mode: r.mode || 'fixed',
|
||||
fixed: r.fixed || '',
|
||||
percent: r.percent || '',
|
||||
})),
|
||||
num(v) { let n = parseFloat(String(v).replace(',', '.')); return isFinite(n) ? n : null; },
|
||||
money(c) { return (c / 100).toFixed(2); },
|
||||
afterCents(row) {
|
||||
if (row.mode === 'percent') {
|
||||
let p = this.num(row.percent); if (p === null) return null;
|
||||
return row.regular_cents - Math.round(row.regular_cents * p / 100);
|
||||
}
|
||||
let f = this.num(row.fixed); if (f === null) return null;
|
||||
return Math.round(f * 100);
|
||||
},
|
||||
valid(row) { let a = this.afterCents(row); return a !== null && a > 0 && a < row.regular_cents; },
|
||||
percentOff(row) {
|
||||
let a = this.afterCents(row);
|
||||
return (a === null || row.regular_cents <= 0) ? null : Math.round((row.regular_cents - a) / row.regular_cents * 100);
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock content %}
|
||||
|
||||
@@ -60,7 +60,7 @@
|
||||
{# items-end bottom-aligns every input regardless of how many lines each
|
||||
label takes, so the row stays aligned even with the "(optional)" notes. #}
|
||||
<div class="grid flex-1 grid-cols-2 gap-3 sm:grid-cols-12 sm:items-end">
|
||||
<div class="space-y-1 col-span-2 sm:col-span-4">
|
||||
<div class="space-y-1 col-span-2 sm:col-span-6">
|
||||
<label class="{{ sublabel }} block truncate">{{ t(key="option-label", lang=lang | default(value='sk')) }}{{ opt }}</label>
|
||||
<input :name="`variants[${i}][label]`" x-model="row.label" class="{{ inp }}" placeholder="napr. 10cm x 13cm">
|
||||
</div>
|
||||
@@ -76,10 +76,6 @@
|
||||
<label class="{{ sublabel }} block truncate">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
|
||||
<input :name="`variants[${i}][price]`" x-model="row.price" inputmode="decimal" required class="{{ inp }}" placeholder="0.00">
|
||||
</div>
|
||||
<div class="space-y-1 sm:col-span-2">
|
||||
<label class="{{ sublabel }} block truncate">{{ t(key="business-price", lang=lang | default(value='sk')) }}{{ opt }}</label>
|
||||
<input :name="`variants[${i}][business_sale]`" x-model="row.business_sale" inputmode="decimal" class="{{ inp }}" placeholder="—">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" @click="remove(i)"
|
||||
@@ -90,7 +86,7 @@
|
||||
|
||||
<script>
|
||||
function variantEditor(initial) {
|
||||
const blank = () => ({ id: '', label: '', sku: '', stock: '', price: '', business_sale: '' });
|
||||
const blank = () => ({ id: '', label: '', sku: '', stock: '', price: '' });
|
||||
return {
|
||||
rows: (initial || []).map(r => ({
|
||||
id: r.id || '',
|
||||
@@ -98,7 +94,6 @@
|
||||
sku: r.sku || '',
|
||||
stock: (r.stock === null || r.stock === undefined) ? '' : r.stock,
|
||||
price: r.price || '',
|
||||
business_sale: r.business_sale || '',
|
||||
})),
|
||||
init() { if (this.rows.length === 0) this.add(); },
|
||||
add() { this.rows.push(blank()); },
|
||||
|
||||
@@ -138,6 +138,14 @@
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="edit", lang=lang | default(value='sk')), href="/admin/catalog/products/" ~ product.id ~ "/edit", size="px-3 py-1.5 text-xs") }}
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="set-discount", lang=lang | default(value='sk')), href="/admin/catalog/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") }}
|
||||
<form method="post" action="/admin/catalog/products/{{ product.id }}/delete"
|
||||
onsubmit="return confirm('{{ t(key="confirm-delete", lang=lang | default(value='sk')) }}')">
|
||||
|
||||
Reference in New Issue
Block a user