Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8085052b2b | ||
|
|
1cf330e4e8 | ||
|
|
031f86adb0 | ||
|
|
96c428eadd |
@@ -77,3 +77,108 @@
|
|||||||
|
|
||||||
/* Hide Alpine x-cloak elements until Alpine initializes. */
|
/* Hide Alpine x-cloak elements until Alpine initializes. */
|
||||||
[x-cloak] { display: none !important; }
|
[x-cloak] { display: none !important; }
|
||||||
|
|
||||||
|
/* === Rich text editor (Quill "snow") =======================
|
||||||
|
* Vendored Quill (assets/static/vendor/quill) drives the admin
|
||||||
|
* product short/long description fields. Stock snow already suits
|
||||||
|
* the light theme; the admin panel is locked to data-theme="dark",
|
||||||
|
* so the rules below repaint the toolbar/editor for dark. Editor
|
||||||
|
* height is per-instance via the --rich-min-height custom prop set
|
||||||
|
* by the ui::rich_editor macro.
|
||||||
|
* ============================================================ */
|
||||||
|
.rich-editor .ql-editor {
|
||||||
|
min-height: var(--rich-min-height, 12rem);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.ql-editor.ql-blank::before { font-style: normal; }
|
||||||
|
|
||||||
|
[data-theme="dark"] .rich-editor { background: var(--color-surface-dark-alt); }
|
||||||
|
[data-theme="dark"] .ql-toolbar.ql-snow,
|
||||||
|
[data-theme="dark"] .ql-container.ql-snow { border-color: var(--color-outline-dark); }
|
||||||
|
[data-theme="dark"] .ql-toolbar.ql-snow { background: var(--color-surface-dark); }
|
||||||
|
[data-theme="dark"] .ql-container.ql-snow { color: var(--color-on-surface-dark); }
|
||||||
|
[data-theme="dark"] .ql-snow .ql-stroke,
|
||||||
|
[data-theme="dark"] .ql-snow .ql-stroke-miter { stroke: var(--color-on-surface-dark); }
|
||||||
|
[data-theme="dark"] .ql-snow .ql-fill,
|
||||||
|
[data-theme="dark"] .ql-snow .ql-stroke.ql-fill { fill: var(--color-on-surface-dark); }
|
||||||
|
[data-theme="dark"] .ql-snow .ql-picker { color: var(--color-on-surface-dark); }
|
||||||
|
[data-theme="dark"] .ql-snow .ql-picker-options {
|
||||||
|
background: var(--color-surface-dark);
|
||||||
|
border-color: var(--color-outline-dark);
|
||||||
|
}
|
||||||
|
/* active / hover toolbar state -> primary accent */
|
||||||
|
[data-theme="dark"] .ql-snow.ql-toolbar button:hover,
|
||||||
|
[data-theme="dark"] .ql-snow.ql-toolbar button.ql-active,
|
||||||
|
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-label:hover,
|
||||||
|
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-label.ql-active,
|
||||||
|
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-item:hover,
|
||||||
|
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-item.ql-selected { color: var(--color-primary-dark); }
|
||||||
|
[data-theme="dark"] .ql-snow.ql-toolbar button:hover .ql-stroke,
|
||||||
|
[data-theme="dark"] .ql-snow.ql-toolbar button.ql-active .ql-stroke,
|
||||||
|
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke,
|
||||||
|
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke { stroke: var(--color-primary-dark); }
|
||||||
|
[data-theme="dark"] .ql-snow.ql-toolbar button:hover .ql-fill,
|
||||||
|
[data-theme="dark"] .ql-snow.ql-toolbar button.ql-active .ql-fill { fill: var(--color-primary-dark); }
|
||||||
|
[data-theme="dark"] .ql-snow .ql-tooltip {
|
||||||
|
background-color: var(--color-surface-dark);
|
||||||
|
border-color: var(--color-outline-dark);
|
||||||
|
color: var(--color-on-surface-dark);
|
||||||
|
box-shadow: 0 2px 8px rgb(0 0 0 / 0.45);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .ql-snow .ql-tooltip input[type=text] {
|
||||||
|
background: var(--color-surface-dark-alt);
|
||||||
|
border-color: var(--color-outline-dark);
|
||||||
|
color: var(--color-on-surface-dark);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .ql-snow .ql-editor a,
|
||||||
|
[data-theme="dark"] .ql-snow .ql-tooltip a { color: var(--color-primary-dark); }
|
||||||
|
|
||||||
|
/* Image size controls under the editor. */
|
||||||
|
.rich-image-size-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
|
||||||
|
.rich-image-size-controls.hidden { display: none; }
|
||||||
|
.rich-image-size-controls button {
|
||||||
|
border: 1px solid var(--color-outline);
|
||||||
|
border-radius: var(--radius-radius);
|
||||||
|
padding: 0.3rem 0.65rem;
|
||||||
|
line-height: 1;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.rich-image-size-controls button:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||||
|
[data-theme="dark"] .rich-image-size-controls button { border-color: var(--color-outline-dark); }
|
||||||
|
[data-theme="dark"] .rich-image-size-controls button:hover {
|
||||||
|
border-color: var(--color-primary-dark);
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image sizing classes shared by the editor and rendered output. */
|
||||||
|
.rich-editor img, .rich-content img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin: 1rem auto;
|
||||||
|
border-radius: var(--radius-radius);
|
||||||
|
}
|
||||||
|
.rich-editor img { cursor: pointer; }
|
||||||
|
.rich-image-small { width: min(100%, 18rem); }
|
||||||
|
.rich-image-medium { width: min(100%, 34rem); }
|
||||||
|
.rich-image-full { width: 100%; }
|
||||||
|
|
||||||
|
/* === Rendered rich content (storefront product description) =
|
||||||
|
* Inherits text color from context so it works in both themes;
|
||||||
|
* only structural spacing + link/heading treatment is set here. */
|
||||||
|
.rich-content { line-height: 1.7; }
|
||||||
|
.rich-content h2 { margin: 1.25rem 0 0.6rem; font-size: 1.3rem; font-weight: 700; }
|
||||||
|
.rich-content h3 { margin: 1rem 0 0.5rem; font-size: 1.1rem; font-weight: 700; }
|
||||||
|
.rich-content p, .rich-content ul, .rich-content ol { margin: 0.6rem 0; }
|
||||||
|
.rich-content ul { list-style: disc; padding-left: 1.4rem; }
|
||||||
|
.rich-content ol { list-style: decimal; padding-left: 1.4rem; }
|
||||||
|
.rich-content a { color: var(--color-primary); text-decoration: underline; }
|
||||||
|
[data-theme="dark"] .rich-content a { color: var(--color-primary-dark); }
|
||||||
|
.rich-content :first-child { margin-top: 0; }
|
||||||
|
.rich-content :last-child { margin-bottom: 0; }
|
||||||
|
|
||||||
|
/* Compact rich blurb for product cards: neutralize block spacing so the
|
||||||
|
* line-clamp truncation stays tidy regardless of the authored markup. */
|
||||||
|
.rich-blurb :where(p, ul, ol, h2, h3) { margin: 0; }
|
||||||
|
.rich-blurb :where(ul, ol) { padding-left: 1.1rem; }
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ artist = Artist
|
|||||||
release-date = Release date
|
release-date = Release date
|
||||||
cover-image = Cover image
|
cover-image = Cover image
|
||||||
description = Description
|
description = Description
|
||||||
|
short-description = Short description
|
||||||
|
short-description-hint = Shown on product cards. Keep it short.
|
||||||
songs-in-album = Songs in this album
|
songs-in-album = Songs in this album
|
||||||
admin-new-album-desc = Fill in the details, then tick the songs to include.
|
admin-new-album-desc = Fill in the details, then tick the songs to include.
|
||||||
cover-help = Optional - png, jpg, webp or gif; shown on the album page.
|
cover-help = Optional - png, jpg, webp or gif; shown on the album page.
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ artist = Interpret
|
|||||||
release-date = Dátum vydania
|
release-date = Dátum vydania
|
||||||
cover-image = Obrázok obalu
|
cover-image = Obrázok obalu
|
||||||
description = Popis
|
description = Popis
|
||||||
|
short-description = Krátky popis
|
||||||
|
short-description-hint = Zobrazuje sa na kartách produktov. Najlepšie krátke.
|
||||||
songs-in-album = Skladby v albume
|
songs-in-album = Skladby v albume
|
||||||
admin-new-album-desc = Vyplň údaje a potom označ skladby, ktoré chceš zahrnúť.
|
admin-new-album-desc = Vyplň údaje a potom označ skladby, ktoré chceš zahrnúť.
|
||||||
cover-help = Voliteľné - png, jpg, webp alebo gif; zobrazí sa na stránke albumu.
|
cover-help = Voliteľné - png, jpg, webp alebo gif; zobrazí sa na stránke albumu.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
174
assets/static/js/rich-editor.js
Normal file
174
assets/static/js/rich-editor.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
// Quill-based rich text editor, ported from the universal_web blog editor and
|
||||||
|
// adapted to this shop: each editor lives in a `[data-rich-field]` wrapper so a
|
||||||
|
// single form can host several (e.g. short + long description); image uploads go
|
||||||
|
// to this app's /images/upload and carry the CSRF token the middleware expects.
|
||||||
|
(function () {
|
||||||
|
function setImageSize(image, size) {
|
||||||
|
image.classList.remove('rich-image-small', 'rich-image-medium', 'rich-image-full');
|
||||||
|
image.style.removeProperty('width');
|
||||||
|
image.style.removeProperty('height');
|
||||||
|
image.classList.add('rich-image-' + size);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setImageWidth(image, width) {
|
||||||
|
var px = parseInt(width, 10);
|
||||||
|
if (!Number.isFinite(px) || px < 40) return;
|
||||||
|
image.classList.remove('rich-image-small', 'rich-image-medium', 'rich-image-full');
|
||||||
|
image.style.width = Math.min(px, 1200) + 'px';
|
||||||
|
image.style.height = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEditorImages(root) {
|
||||||
|
root.querySelectorAll('img').forEach(function (image) {
|
||||||
|
if (
|
||||||
|
!image.classList.contains('rich-image-small')
|
||||||
|
&& !image.classList.contains('rich-image-medium')
|
||||||
|
&& !image.classList.contains('rich-image-full')
|
||||||
|
) {
|
||||||
|
image.classList.add('rich-image-full');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The CSRF middleware accepts the token as an X-CSRF-Token header; read it from
|
||||||
|
// the form's hidden _csrf field (rendered by ui::csrf_field()).
|
||||||
|
function csrfToken(field) {
|
||||||
|
var form = field.closest('form');
|
||||||
|
var input = form && form.querySelector('input[name="_csrf"]');
|
||||||
|
return input ? input.value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function initField(field) {
|
||||||
|
var editorEl = field.querySelector('[data-rich-editor]');
|
||||||
|
var contentInput = field.querySelector('[data-rich-content]');
|
||||||
|
var status = field.querySelector('[data-rich-status]');
|
||||||
|
var imageControls = field.querySelector('[data-image-size-controls]');
|
||||||
|
var imageWidthInput = field.querySelector('[data-image-width]');
|
||||||
|
if (!editorEl || !contentInput || !window.Quill) return;
|
||||||
|
|
||||||
|
var selectedImage = null;
|
||||||
|
var toolbar = [
|
||||||
|
[{ header: [2, 3, false] }],
|
||||||
|
['bold', 'italic'],
|
||||||
|
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||||
|
['link', 'image'],
|
||||||
|
['clean']
|
||||||
|
];
|
||||||
|
var editor = new Quill(editorEl, {
|
||||||
|
modules: { toolbar: toolbar },
|
||||||
|
placeholder: editorEl.dataset.placeholder || '',
|
||||||
|
theme: 'snow'
|
||||||
|
});
|
||||||
|
|
||||||
|
var initialContent = contentInput.value.trim();
|
||||||
|
if (initialContent) {
|
||||||
|
if (initialContent.indexOf('<') >= 0) editor.clipboard.dangerouslyPasteHTML(initialContent);
|
||||||
|
else editor.setText(initialContent);
|
||||||
|
normalizeEditorImages(editor.root);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncContent() {
|
||||||
|
normalizeEditorImages(editor.root);
|
||||||
|
// Quill leaves an empty editor as "<p><br></p>"; store empty instead so the
|
||||||
|
// server sees a blank (nullable) value rather than stray markup.
|
||||||
|
var html = editor.root.innerHTML;
|
||||||
|
contentInput.value = editor.getText().trim() === '' && !editor.root.querySelector('img')
|
||||||
|
? ''
|
||||||
|
: html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(message) {
|
||||||
|
if (status) status.textContent = message || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseImageFile() {
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = 'image/jpeg,image/png,image/webp,image/gif';
|
||||||
|
input.addEventListener('change', function () {
|
||||||
|
var file = input.files && input.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
uploadImage(file);
|
||||||
|
});
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadImage(file) {
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
setStatus(status ? status.dataset.uploading : '');
|
||||||
|
try {
|
||||||
|
var response = await fetch('/images/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'X-CSRF-Token': csrfToken(field) }
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('upload failed');
|
||||||
|
var result = await response.json();
|
||||||
|
var range = editor.getSelection(true);
|
||||||
|
editor.insertEmbed(range.index, 'image', result.url, 'user');
|
||||||
|
editor.setSelection(range.index + 1, 0, 'silent');
|
||||||
|
window.setTimeout(function () {
|
||||||
|
var images = editor.root.querySelectorAll('img');
|
||||||
|
var image = images[images.length - 1];
|
||||||
|
if (image) {
|
||||||
|
setImageSize(image, 'full');
|
||||||
|
selectedImage = image;
|
||||||
|
if (imageControls) imageControls.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
syncContent();
|
||||||
|
}, 0);
|
||||||
|
setStatus(status ? status.dataset.uploaded : '');
|
||||||
|
} catch (_error) {
|
||||||
|
setStatus(status ? status.dataset.error : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.getModule('toolbar').addHandler('image', chooseImageFile);
|
||||||
|
|
||||||
|
editor.root.addEventListener('click', function (event) {
|
||||||
|
if (event.target && event.target.tagName === 'IMG') {
|
||||||
|
selectedImage = event.target;
|
||||||
|
if (imageWidthInput) imageWidthInput.value = parseInt(selectedImage.style.width, 10) || '';
|
||||||
|
if (imageControls) imageControls.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (imageControls) {
|
||||||
|
imageControls.addEventListener('click', function (event) {
|
||||||
|
var button = event.target.closest('[data-image-size]');
|
||||||
|
if (button && selectedImage) {
|
||||||
|
setImageSize(selectedImage, button.dataset.imageSize);
|
||||||
|
if (imageWidthInput) imageWidthInput.value = '';
|
||||||
|
syncContent();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageWidthInput) {
|
||||||
|
imageWidthInput.addEventListener('change', function () {
|
||||||
|
if (!selectedImage) return;
|
||||||
|
setImageWidth(selectedImage, imageWidthInput.value);
|
||||||
|
syncContent();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.on('text-change', syncContent);
|
||||||
|
var form = field.closest('form');
|
||||||
|
if (form) form.addEventListener('submit', syncContent);
|
||||||
|
syncContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAll(root) {
|
||||||
|
(root || document).querySelectorAll('[data-rich-field]').forEach(function (field) {
|
||||||
|
if (field.dataset.richReady) return;
|
||||||
|
field.dataset.richReady = '1';
|
||||||
|
initField(field);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () { initAll(document); });
|
||||||
|
// Re-init after htmx swaps a fragment containing an editor into the page.
|
||||||
|
document.addEventListener('htmx:afterSwap', function (event) { initAll(event.target); });
|
||||||
|
})();
|
||||||
31
assets/static/vendor/quill/LICENSE
vendored
Normal file
31
assets/static/vendor/quill/LICENSE
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
Copyright (c) 2017-2024, Slab
|
||||||
|
Copyright (c) 2014, Jason Chen
|
||||||
|
Copyright (c) 2013, salesforce.com
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions
|
||||||
|
are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in the
|
||||||
|
documentation and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
3. Neither the name of the copyright holder nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
|
||||||
|
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
|
||||||
|
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||||
|
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||||
|
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||||
|
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
3
assets/static/vendor/quill/quill.js
vendored
Normal file
3
assets/static/vendor/quill/quill.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
assets/static/vendor/quill/quill.js.LICENSE.txt
vendored
Normal file
7
assets/static/vendor/quill/quill.js.LICENSE.txt
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/*!
|
||||||
|
* Quill Editor v2.0.3
|
||||||
|
* https://quilljs.com
|
||||||
|
* Copyright (c) 2017-2024, Slab
|
||||||
|
* Copyright (c) 2014, Jason Chen
|
||||||
|
* Copyright (c) 2013, salesforce.com
|
||||||
|
*/
|
||||||
10
assets/static/vendor/quill/quill.snow.css
vendored
Normal file
10
assets/static/vendor/quill/quill.snow.css
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -15,82 +15,80 @@
|
|||||||
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products?audience=" ~ audience, size="px-3 py-2 text-sm") }}
|
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products?audience=" ~ audience, size="px-3 py-2 text-sm") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# One discount row per option (variant). Each row picks a fixed sale price or a #}
|
||||||
|
{# percentage off its own regular price; a blank input clears that option's #}
|
||||||
|
{# discount. Both the fixed and percent inputs always submit (the server reads the #}
|
||||||
|
{# active mode); rows are pre-filled from `rows` (DB values, or submitted values #}
|
||||||
|
{# when repainting after a validation error) and indexed as v[<variant id>][...]. #}
|
||||||
|
<script id="discount-data" type="application/json">{{ rows | json_encode() | safe }}</script>
|
||||||
|
|
||||||
<form method="post" action="/admin/catalog/products/{{ product.id }}/discount?audience={{ audience }}"
|
<form method="post" action="/admin/catalog/products/{{ product.id }}/discount?audience={{ audience }}"
|
||||||
x-data="{
|
x-data="discountEditor(JSON.parse(document.getElementById('discount-data').textContent))"
|
||||||
mode: '{{ mode }}',
|
class="mt-6 max-w-2xl space-y-5">
|
||||||
fixed: '{{ fixed }}',
|
|
||||||
percent: '{{ percent }}',
|
|
||||||
regular: {{ product.regular_cents }},
|
|
||||||
num(v) { let n = parseFloat(String(v).replace(',', '.')); return isFinite(n) ? n : null; },
|
|
||||||
get afterCents() {
|
|
||||||
if (this.mode === 'percent') {
|
|
||||||
let p = this.num(this.percent); if (p === null) return null;
|
|
||||||
return this.regular - Math.round(this.regular * p / 100);
|
|
||||||
}
|
|
||||||
let f = this.num(this.fixed); if (f === null) return null;
|
|
||||||
return Math.round(f * 100);
|
|
||||||
},
|
|
||||||
money(c) { return (c / 100).toFixed(2); },
|
|
||||||
get valid() { let a = this.afterCents; return a !== null && a > 0 && a < this.regular; },
|
|
||||||
get percentOff() { let a = this.afterCents; return (a === null || this.regular <= 0) ? null : Math.round((this.regular - a) / this.regular * 100); }
|
|
||||||
}"
|
|
||||||
class="mt-6 max-w-md space-y-5 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
|
||||||
{{ ui::csrf_field() }}
|
{{ ui::csrf_field() }}
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
{{ ui::alert_danger(message=t(key=error, lang=lang | default(value='sk'))) }}
|
{{ ui::alert_danger(message=t(key=error, lang=lang | default(value='sk'))) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-3 rounded-radius bg-surface-alt px-4 py-3 dark:bg-surface-dark/40">
|
<template x-for="row in rows" :key="row.id">
|
||||||
<span class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="price", lang=lang | default(value='sk')) }}</span>
|
<div class="space-y-4 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
<span class="font-semibold tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.regular_price }} {{ product.currency }}</span>
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" :name="`v[${row.id}][mode]`" :value="row.mode">
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
<!-- mode toggle -->
|
<!-- mode toggle -->
|
||||||
<div class="grid grid-cols-2 gap-2">
|
<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"
|
<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'">
|
: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" value="fixed" x-model="mode" class="sr-only">
|
<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')) }}
|
{{ t(key="discount-mode-fixed", lang=lang | default(value='sk')) }}
|
||||||
</label>
|
</label>
|
||||||
<label class="flex cursor-pointer items-center justify-center gap-2 rounded-radius border px-3 py-2 text-sm transition"
|
<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'">
|
: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" value="percent" x-model="mode" class="sr-only">
|
<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')) }}
|
{{ t(key="discount-mode-percent", lang=lang | default(value='sk')) }}
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- fixed price input -->
|
<!-- value input: both fields stay in the DOM and submit; the server reads
|
||||||
<div class="space-y-1.5" x-show="mode === 'fixed'">
|
whichever matches the row's mode -->
|
||||||
<label for="sale_price" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="sale-price", lang=lang | default(value='sk')) }}</label>
|
<div class="space-y-1.5">
|
||||||
{{ ui::input(name="sale_price", id="sale_price", value=fixed, placeholder="0.00", attrs='inputmode="decimal" x-model="fixed"') }}
|
<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>
|
||||||
|
|
||||||
<!-- 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>
|
</div>
|
||||||
|
|
||||||
<!-- live preview -->
|
<!-- live preview -->
|
||||||
<div x-show="afterCents !== null" x-cloak
|
<div x-show="afterCents(row) !== null" x-cloak
|
||||||
class="space-y-2 rounded-radius border border-outline bg-surface-alt px-4 py-3 dark:border-outline-dark dark:bg-surface-dark/40">
|
class="flex flex-wrap items-center justify-between gap-3 rounded-radius border border-outline bg-surface-alt px-4 py-2.5 text-sm dark:border-outline-dark dark:bg-surface-dark/40">
|
||||||
<div class="flex items-center justify-between gap-3 text-sm">
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="discount-preview-after", lang=lang | default(value='sk')) }}</span>
|
||||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="discount-preview-before", lang=lang | default(value='sk')) }}</span>
|
<span class="flex items-center gap-2">
|
||||||
<span class="tabular-nums text-on-surface/60 line-through dark:text-on-surface-dark/60"><span x-text="money(regular)"></span> {{ product.currency }}</span>
|
<span class="tabular-nums text-on-surface/50 line-through dark:text-on-surface-dark/50" x-text="money(row.regular_cents) + ' ' + row.currency"></span>
|
||||||
</div>
|
<span class="text-base font-semibold tabular-nums" :class="valid(row) ? 'text-danger' : 'text-on-surface/40 dark:text-on-surface-dark/40'"
|
||||||
<div class="flex items-center justify-between gap-3">
|
x-text="money(afterCents(row)) + ' ' + row.currency"></span>
|
||||||
<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 x-show="valid(row)" class="text-xs text-on-surface/60 dark:text-on-surface-dark/60" x-text="'(−' + percentOff(row) + '%)'"></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>
|
</span>
|
||||||
</div>
|
</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">
|
<p x-show="afterCents(row) !== null && !valid(row)" class="text-xs text-danger">{{ t(key="discount-below-regular", lang=lang | default(value='sk')) }}</p>
|
||||||
<span>{{ t(key="discount-preview-save", lang=lang | default(value='sk')) }}</span>
|
|
||||||
<span class="tabular-nums"><span x-text="money(regular - afterCents)"></span> {{ product.currency }} (−<span x-text="percentOff"></span>%)</span>
|
|
||||||
</div>
|
|
||||||
<p x-show="!valid" class="text-xs text-danger">{{ t(key="discount-below-regular", lang=lang | default(value='sk')) }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3 pt-2">
|
<div class="flex flex-wrap gap-3 pt-2">
|
||||||
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", attrs=`onclick="return confirm('` ~ t(key="discount-apply-confirm", lang=lang | default(value='sk')) ~ `')"`) }}
|
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", attrs=`onclick="return confirm('` ~ t(key="discount-apply-confirm", lang=lang | default(value='sk')) ~ `')"`) }}
|
||||||
@@ -99,4 +97,36 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function discountEditor(initial) {
|
||||||
|
return {
|
||||||
|
rows: (initial || []).map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
label: r.label || '',
|
||||||
|
regular_cents: r.regular_cents,
|
||||||
|
regular_price: r.regular_price,
|
||||||
|
currency: r.currency,
|
||||||
|
mode: r.mode || 'fixed',
|
||||||
|
fixed: r.fixed || '',
|
||||||
|
percent: r.percent || '',
|
||||||
|
})),
|
||||||
|
num(v) { let n = parseFloat(String(v).replace(',', '.')); return isFinite(n) ? n : null; },
|
||||||
|
money(c) { return (c / 100).toFixed(2); },
|
||||||
|
afterCents(row) {
|
||||||
|
if (row.mode === 'percent') {
|
||||||
|
let p = this.num(row.percent); if (p === null) return null;
|
||||||
|
return row.regular_cents - Math.round(row.regular_cents * p / 100);
|
||||||
|
}
|
||||||
|
let f = this.num(row.fixed); if (f === null) return null;
|
||||||
|
return Math.round(f * 100);
|
||||||
|
},
|
||||||
|
valid(row) { let a = this.afterCents(row); return a !== null && a > 0 && a < row.regular_cents; },
|
||||||
|
percentOff(row) {
|
||||||
|
let a = this.afterCents(row);
|
||||||
|
return (a === null || row.regular_cents <= 0) ? null : Math.round((row.regular_cents - a) / row.regular_cents * 100);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
|
|
||||||
{% block title %}{% if product %}{{ t(key="edit-product", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-product", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
|
{% block title %}{% if product %}{{ t(key="edit-product", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-product", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
|
||||||
{% 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 head %}
|
||||||
|
<link href="/static/vendor/quill/quill.snow.css" rel="stylesheet" type="text/css">
|
||||||
|
{% endblock head %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
@@ -18,9 +21,9 @@
|
|||||||
{{ ui::csrf_field() }}
|
{{ ui::csrf_field() }}
|
||||||
|
|
||||||
{% if product %}
|
{% if product %}
|
||||||
{% set v_name = product.name %}{% set v_currency = product.currency %}{% set v_desc = product.description | default(value="") %}{% set v_pub = product.published %}
|
{% set v_name = product.name %}{% set v_currency = product.currency %}{% set v_desc = product.description | default(value="") %}{% set v_short = product.short_description | default(value="") %}{% set v_pub = product.published %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% set v_name = "" %}{% set v_currency = "EUR" %}{% set v_desc = "" %}{% set v_pub = false %}
|
{% set v_name = "" %}{% set v_currency = "EUR" %}{% set v_desc = "" %}{% set v_short = "" %}{% set v_pub = false %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% set inp = "w-full rounded-radius border border-outline bg-surface-alt px-3 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" %}
|
{% set inp = "w-full rounded-radius border border-outline bg-surface-alt px-3 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" %}
|
||||||
{% set sublabel = "text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70" %}
|
{% set sublabel = "text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70" %}
|
||||||
@@ -60,7 +63,7 @@
|
|||||||
{# items-end bottom-aligns every input regardless of how many lines each
|
{# items-end bottom-aligns every input regardless of how many lines each
|
||||||
label takes, so the row stays aligned even with the "(optional)" notes. #}
|
label takes, so the row stays aligned even with the "(optional)" notes. #}
|
||||||
<div class="grid flex-1 grid-cols-2 gap-3 sm:grid-cols-12 sm:items-end">
|
<div class="grid flex-1 grid-cols-2 gap-3 sm:grid-cols-12 sm:items-end">
|
||||||
<div class="space-y-1 col-span-2 sm:col-span-4">
|
<div class="space-y-1 col-span-2 sm:col-span-6">
|
||||||
<label class="{{ sublabel }} block truncate">{{ t(key="option-label", lang=lang | default(value='sk')) }}{{ opt }}</label>
|
<label class="{{ sublabel }} block truncate">{{ t(key="option-label", lang=lang | default(value='sk')) }}{{ opt }}</label>
|
||||||
<input :name="`variants[${i}][label]`" x-model="row.label" class="{{ inp }}" placeholder="napr. 10cm x 13cm">
|
<input :name="`variants[${i}][label]`" x-model="row.label" class="{{ inp }}" placeholder="napr. 10cm x 13cm">
|
||||||
</div>
|
</div>
|
||||||
@@ -76,10 +79,6 @@
|
|||||||
<label class="{{ sublabel }} block truncate">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
|
<label class="{{ sublabel }} block truncate">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
|
||||||
<input :name="`variants[${i}][price]`" x-model="row.price" inputmode="decimal" required class="{{ inp }}" placeholder="0.00">
|
<input :name="`variants[${i}][price]`" x-model="row.price" inputmode="decimal" required class="{{ inp }}" placeholder="0.00">
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1 sm:col-span-2">
|
|
||||||
<label class="{{ sublabel }} block truncate">{{ t(key="business-price", lang=lang | default(value='sk')) }}{{ opt }}</label>
|
|
||||||
<input :name="`variants[${i}][business_sale]`" x-model="row.business_sale" inputmode="decimal" class="{{ inp }}" placeholder="—">
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" @click="remove(i)"
|
<button type="button" @click="remove(i)"
|
||||||
@@ -90,7 +89,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
function variantEditor(initial) {
|
function variantEditor(initial) {
|
||||||
const blank = () => ({ id: '', label: '', sku: '', stock: '', price: '', business_sale: '' });
|
const blank = () => ({ id: '', label: '', sku: '', stock: '', price: '' });
|
||||||
return {
|
return {
|
||||||
rows: (initial || []).map(r => ({
|
rows: (initial || []).map(r => ({
|
||||||
id: r.id || '',
|
id: r.id || '',
|
||||||
@@ -98,7 +97,6 @@
|
|||||||
sku: r.sku || '',
|
sku: r.sku || '',
|
||||||
stock: (r.stock === null || r.stock === undefined) ? '' : r.stock,
|
stock: (r.stock === null || r.stock === undefined) ? '' : r.stock,
|
||||||
price: r.price || '',
|
price: r.price || '',
|
||||||
business_sale: r.business_sale || '',
|
|
||||||
})),
|
})),
|
||||||
init() { if (this.rows.length === 0) this.add(); },
|
init() { if (this.rows.length === 0) this.add(); },
|
||||||
add() { this.rows.push(blank()); },
|
add() { this.rows.push(blank()); },
|
||||||
@@ -122,8 +120,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="description" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="description", lang=lang | default(value='sk')) }}</label>
|
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="short-description", lang=lang | default(value='sk')) }}</span>
|
||||||
{{ ui::textarea(name="description", id="description", rows="5", value=v_desc) }}
|
<p class="{{ sublabel }}">{{ t(key="short-description-hint", lang=lang | default(value='sk')) }}</p>
|
||||||
|
{{ ui::rich_editor(name="short_description", lang=lang | default(value='sk'), value=v_short, min_height="6rem") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="description", lang=lang | default(value='sk')) }}</span>
|
||||||
|
{{ ui::rich_editor(name="description", lang=lang | default(value='sk'), value=v_desc, min_height="16rem") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{# --- Images gallery ------------------------------------------------------- #}
|
{# --- Images gallery ------------------------------------------------------- #}
|
||||||
@@ -215,4 +219,6 @@
|
|||||||
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products") }}
|
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products") }}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<script src="/static/vendor/quill/quill.js"></script>
|
||||||
|
<script src="/static/js/rich-editor.js"></script>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -138,6 +138,14 @@
|
|||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex flex-wrap justify-end gap-2">
|
<div class="flex flex-wrap justify-end gap-2">
|
||||||
{{ ui::button(variant="outline-secondary", label=t(key="edit", lang=lang | default(value='sk')), href="/admin/catalog/products/" ~ product.id ~ "/edit", size="px-3 py-1.5 text-xs") }}
|
{{ ui::button(variant="outline-secondary", label=t(key="edit", lang=lang | default(value='sk')), href="/admin/catalog/products/" ~ product.id ~ "/edit", size="px-3 py-1.5 text-xs") }}
|
||||||
|
{{ ui::button(variant="outline-secondary", label=t(key="set-discount", lang=lang | default(value='sk')), href="/admin/catalog/products/" ~ product.id ~ "/discount/edit?audience=" ~ audience, size="px-3 py-1.5 text-xs") }}
|
||||||
|
{% if product.on_sale %}
|
||||||
|
<form method="post" action="/admin/catalog/products/{{ product.id }}/discount/remove?audience={{ audience }}"
|
||||||
|
onsubmit="return confirm('{{ t(key="discount-remove-confirm", lang=lang | default(value='sk')) }}')">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
{{ ui::button(variant="outline-danger", label=t(key="remove-discount", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
{{ ui::button(variant="outline-secondary", label=t(key="view", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, size="px-3 py-1.5 text-xs") }}
|
{{ ui::button(variant="outline-secondary", label=t(key="view", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, size="px-3 py-1.5 text-xs") }}
|
||||||
<form method="post" action="/admin/catalog/products/{{ product.id }}/delete"
|
<form method="post" action="/admin/catalog/products/{{ product.id }}/delete"
|
||||||
onsubmit="return confirm('{{ t(key="confirm-delete", lang=lang | default(value='sk')) }}')">
|
onsubmit="return confirm('{{ t(key="confirm-delete", lang=lang | default(value='sk')) }}')">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<h2 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shop-title", lang=lang | default(value='sk')) }}</h2>
|
<h2 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shop-title", lang=lang | default(value='sk')) }}</h2>
|
||||||
<a href="/shop" class="text-sm font-medium text-primary dark:text-primary-dark">{{ t(key="cart-continue", lang=lang | default(value='sk')) }} →</a>
|
<a href="/shop" class="text-sm font-medium text-primary dark:text-primary-dark">{{ t(key="cart-continue", lang=lang | default(value='sk')) }} →</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
|
<div x-data="{ view: 'grid' }" class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
{% for product in products %}
|
{% for product in products %}
|
||||||
{% include "shop/_card.html" %}
|
{% include "shop/_card.html" %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -153,6 +153,34 @@
|
|||||||
<textarea {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" rows="{{ rows }}"{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if required %} required{% endif %} class="w-full rounded-radius border border-outline bg-surface-alt px-2.5 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 }}>{{ value }}</textarea>
|
<textarea {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" rows="{{ rows }}"{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if required %} required{% endif %} class="w-full rounded-radius border border-outline bg-surface-alt px-2.5 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 }}>{{ value }}</textarea>
|
||||||
{%- endmacro textarea %}
|
{%- endmacro textarea %}
|
||||||
|
|
||||||
|
{# Quill rich-text editor (see /static/js/rich-editor.js + /static/vendor/quill).
|
||||||
|
The real value rides in a hidden <textarea> the editor keeps in sync, so it
|
||||||
|
submits like any other field. `value` pre-fills (HTML or plain text). Several
|
||||||
|
editors may share one form — each is scoped to its own [data-rich-field].
|
||||||
|
Requires the page to load quill.js + quill.snow.css + rich-editor.js (the
|
||||||
|
product form does so) and a _csrf field in the form for image uploads. #}
|
||||||
|
{% macro rich_editor(name, lang, value="", placeholder="", min_height="12rem") -%}
|
||||||
|
<div data-rich-field>
|
||||||
|
<textarea name="{{ name }}" data-rich-content class="hidden">{{ value }}</textarea>
|
||||||
|
<div data-rich-editor class="rich-editor" style="--rich-min-height: {{ min_height }};"{% if placeholder %} data-placeholder="{{ placeholder }}"{% endif %}></div>
|
||||||
|
<div data-image-size-controls class="rich-image-size-controls mt-2 hidden">
|
||||||
|
<span class="text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="image-size", lang=lang) }}</span>
|
||||||
|
<button type="button" data-image-size="small">{{ t(key="image-size-small", lang=lang) }}</button>
|
||||||
|
<button type="button" data-image-size="medium">{{ t(key="image-size-medium", lang=lang) }}</button>
|
||||||
|
<button type="button" data-image-size="full">{{ t(key="image-size-full", lang=lang) }}</button>
|
||||||
|
<label class="inline-flex items-center gap-1.5">
|
||||||
|
<span class="text-xs text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="image-width-px", lang=lang) }}</span>
|
||||||
|
<input type="number" min="40" max="1200" step="10" data-image-width
|
||||||
|
class="w-20 rounded-radius border border-outline bg-surface-alt px-2 py-1 text-sm text-on-surface dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-on-surface/60 dark:text-on-surface-dark/60" data-rich-status
|
||||||
|
data-uploading="{{ t(key='image-uploading', lang=lang) }}"
|
||||||
|
data-uploaded="{{ t(key='image-uploaded', lang=lang) }}"
|
||||||
|
data-error="{{ t(key='image-upload-error', lang=lang) }}"></p>
|
||||||
|
</div>
|
||||||
|
{%- endmacro rich_editor %}
|
||||||
|
|
||||||
{# File input. #}
|
{# File input. #}
|
||||||
{% macro file_input(name, id="", accept="", attrs="", extra="") -%}
|
{% macro file_input(name, id="", accept="", attrs="", extra="") -%}
|
||||||
<input {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" type="file"{% if accept %} accept="{{ accept }}"{% endif %} class="w-full overflow-clip rounded-radius border border-outline bg-surface-alt/50 text-sm text-on-surface file:mr-4 file:border-none file:bg-surface-alt file:px-4 file:py-2 file:font-medium file:text-on-surface-strong 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:file:bg-surface-dark-alt dark:file:text-on-surface-dark-strong dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}/>
|
<input {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" type="file"{% if accept %} accept="{{ accept }}"{% endif %} class="w-full overflow-clip rounded-radius border border-outline bg-surface-alt/50 text-sm text-on-surface file:mr-4 file:border-none file:bg-surface-alt file:px-4 file:py-2 file:font-medium file:text-on-surface-strong 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:file:bg-surface-dark-alt dark:file:text-on-surface-dark-strong dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}/>
|
||||||
|
|||||||
@@ -27,6 +27,15 @@
|
|||||||
:class="view === 'list' ? 'p-4 sm:p-5' : 'p-6 pb-2'">
|
:class="view === 'list' ? 'p-4 sm:p-5' : 'p-6 pb-2'">
|
||||||
<!-- Header: Title & Price (stacked so neither overflows the narrow card) -->
|
<!-- Header: Title & Price (stacked so neither overflows the narrow card) -->
|
||||||
<h3 class="break-words text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
|
<h3 class="break-words text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
|
||||||
|
{# Short blurb for the card; falls back to the full description (clamped)
|
||||||
|
for products without a dedicated short one. Both are authored as rich
|
||||||
|
text (Quill), so render the stored HTML — `.rich-blurb` strips block
|
||||||
|
spacing so the line-clamp stays tidy. Overflow is truncated with an
|
||||||
|
ellipsis: 2 lines in the grid, 3 in the roomier list row. #}
|
||||||
|
{% if product.short_description or product.description %}
|
||||||
|
<div class="rich-blurb line-clamp-2 break-words text-sm text-on-surface/70 dark:text-on-surface-dark/70"
|
||||||
|
:class="view === 'list' && 'line-clamp-3'">{% if product.short_description %}{{ product.short_description | safe }}{% else %}{{ product.description | safe }}{% endif %}</div>
|
||||||
|
{% endif %}
|
||||||
{% if product.on_sale %}
|
{% if product.on_sale %}
|
||||||
<div class="flex flex-wrap items-baseline gap-x-2 leading-tight">
|
<div class="flex flex-wrap items-baseline gap-x-2 leading-tight">
|
||||||
<span class="text-xl font-semibold text-danger"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ product.currency }}</span>
|
<span class="text-xl font-semibold text-danger"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ product.currency }}</span>
|
||||||
|
|||||||
@@ -83,7 +83,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% 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>
|
{# Authored as rich text (Quill) in the admin; render the stored HTML. #}
|
||||||
|
<div class="rich-content text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description | safe }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<template x-if="current.in_stock">
|
<template x-if="current.in_stock">
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ mod m20260622_000003_variant_stock_nullable;
|
|||||||
mod m20260622_000004_product_search;
|
mod m20260622_000004_product_search;
|
||||||
mod m20260622_000005_product_search_aggregate;
|
mod m20260622_000005_product_search_aggregate;
|
||||||
mod m20260622_000006_order_search_indexes;
|
mod m20260622_000006_order_search_indexes;
|
||||||
|
mod m20260623_000001_add_short_description_to_products;
|
||||||
|
mod m20260623_000002_strip_html_from_product_search;
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -94,6 +96,8 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260622_000004_product_search::Migration),
|
Box::new(m20260622_000004_product_search::Migration),
|
||||||
Box::new(m20260622_000005_product_search_aggregate::Migration),
|
Box::new(m20260622_000005_product_search_aggregate::Migration),
|
||||||
Box::new(m20260622_000006_order_search_indexes::Migration),
|
Box::new(m20260622_000006_order_search_indexes::Migration),
|
||||||
|
Box::new(m20260623_000001_add_short_description_to_products::Migration),
|
||||||
|
Box::new(m20260623_000002_strip_html_from_product_search::Migration),
|
||||||
// inject-above (do not remove this comment)
|
// inject-above (do not remove this comment)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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> {
|
||||||
|
// A short blurb shown on product cards (grid/list), distinct from the full
|
||||||
|
// `description` rendered on the product detail page.
|
||||||
|
add_column(m, "products", "short_description", ColType::TextNull).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
remove_column(m, "products", "short_description").await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
//! Product descriptions are now authored as rich text (Quill) and stored as
|
||||||
|
//! HTML. The product search vector (see m20260622_000005) tokenizes the raw
|
||||||
|
//! description, so without this the markup itself (`p`, `strong`, `li`, `href`,
|
||||||
|
//! `class`, `ql`, …) would land in the index and pollute matches.
|
||||||
|
//!
|
||||||
|
//! Redefine `kompress_build_product_search` so the description is run through a
|
||||||
|
//! tag-stripping `regexp_replace` before `to_tsvector`, then backfill every
|
||||||
|
//! product's stored vector. Everything else about the function is unchanged.
|
||||||
|
|
||||||
|
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> {
|
||||||
|
let db = m.get_connection();
|
||||||
|
db.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
CREATE OR REPLACE FUNCTION kompress_build_product_search(
|
||||||
|
p_name text, p_description text, p_id integer
|
||||||
|
) RETURNS tsvector
|
||||||
|
LANGUAGE sql STABLE AS $func$
|
||||||
|
SELECT
|
||||||
|
setweight(to_tsvector('sk_unaccent', COALESCE(p_name, '')), 'A')
|
||||||
|
|| setweight(to_tsvector('sk_unaccent', COALESCE((
|
||||||
|
SELECT string_agg(t.name, ' ')
|
||||||
|
FROM product_product_tags ppt
|
||||||
|
JOIN product_tags t ON t.id = ppt.product_tag_id
|
||||||
|
WHERE ppt.product_id = p_id
|
||||||
|
), '')), 'B')
|
||||||
|
|| setweight(to_tsvector('sk_unaccent', COALESCE((
|
||||||
|
SELECT string_agg(v.label, ' ')
|
||||||
|
FROM product_variants v
|
||||||
|
WHERE v.product_id = p_id
|
||||||
|
), '')), 'B')
|
||||||
|
|| setweight(to_tsvector('sk_unaccent',
|
||||||
|
regexp_replace(COALESCE(p_description, ''), '<[^>]+>', ' ', 'g')
|
||||||
|
), 'C')
|
||||||
|
|| setweight(to_tsvector('sk_unaccent', COALESCE((
|
||||||
|
SELECT string_agg(v.sku, ' ')
|
||||||
|
FROM product_variants v
|
||||||
|
WHERE v.product_id = p_id AND v.sku IS NOT NULL
|
||||||
|
), '')), 'C');
|
||||||
|
$func$;
|
||||||
|
|
||||||
|
-- Backfill: recompute every product's vector with the new definition.
|
||||||
|
UPDATE products
|
||||||
|
SET search_vector = kompress_build_product_search(name, description, id);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// Restore the prior definition (raw, un-stripped description).
|
||||||
|
let db = m.get_connection();
|
||||||
|
db.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
CREATE OR REPLACE FUNCTION kompress_build_product_search(
|
||||||
|
p_name text, p_description text, p_id integer
|
||||||
|
) RETURNS tsvector
|
||||||
|
LANGUAGE sql STABLE AS $func$
|
||||||
|
SELECT
|
||||||
|
setweight(to_tsvector('sk_unaccent', COALESCE(p_name, '')), 'A')
|
||||||
|
|| setweight(to_tsvector('sk_unaccent', COALESCE((
|
||||||
|
SELECT string_agg(t.name, ' ')
|
||||||
|
FROM product_product_tags ppt
|
||||||
|
JOIN product_tags t ON t.id = ppt.product_tag_id
|
||||||
|
WHERE ppt.product_id = p_id
|
||||||
|
), '')), 'B')
|
||||||
|
|| setweight(to_tsvector('sk_unaccent', COALESCE((
|
||||||
|
SELECT string_agg(v.label, ' ')
|
||||||
|
FROM product_variants v
|
||||||
|
WHERE v.product_id = p_id
|
||||||
|
), '')), 'B')
|
||||||
|
|| setweight(to_tsvector('sk_unaccent', COALESCE(p_description, '')), 'C')
|
||||||
|
|| setweight(to_tsvector('sk_unaccent', COALESCE((
|
||||||
|
SELECT string_agg(v.sku, ' ')
|
||||||
|
FROM product_variants v
|
||||||
|
WHERE v.product_id = p_id AND v.sku IS NOT NULL
|
||||||
|
), '')), 'C');
|
||||||
|
$func$;
|
||||||
|
|
||||||
|
UPDATE products
|
||||||
|
SET search_vector = kompress_build_product_search(name, description, id);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ use crate::{
|
|||||||
},
|
},
|
||||||
shared::{
|
shared::{
|
||||||
guard,
|
guard,
|
||||||
money::{format_bp, format_price, parse_price_to_cents},
|
money::{format_bp, format_price, parse_percent, parse_price_to_cents},
|
||||||
pricing,
|
pricing,
|
||||||
slug::{slugify, unique_slug},
|
slug::{slugify, unique_slug},
|
||||||
},
|
},
|
||||||
@@ -52,6 +52,7 @@ struct ProductFields {
|
|||||||
name: String,
|
name: String,
|
||||||
slug: String,
|
slug: String,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
|
short_description: Option<String>,
|
||||||
currency: String,
|
currency: String,
|
||||||
category_id: Option<i32>,
|
category_id: Option<i32>,
|
||||||
published: bool,
|
published: bool,
|
||||||
@@ -67,6 +68,7 @@ async fn parse_product_fields(
|
|||||||
.ok_or_else(|| Error::BadRequest("product name is required".to_string()))?;
|
.ok_or_else(|| Error::BadRequest("product name is required".to_string()))?;
|
||||||
let currency = form.text("currency").unwrap_or_else(|| "EUR".to_string());
|
let currency = form.text("currency").unwrap_or_else(|| "EUR".to_string());
|
||||||
let description = form.text("description");
|
let description = form.text("description");
|
||||||
|
let short_description = form.text("short_description");
|
||||||
let category_id = form.text("category_id").and_then(|s| s.parse::<i32>().ok());
|
let category_id = form.text("category_id").and_then(|s| s.parse::<i32>().ok());
|
||||||
let published = form.checked("published");
|
let published = form.checked("published");
|
||||||
|
|
||||||
@@ -91,6 +93,7 @@ async fn parse_product_fields(
|
|||||||
name,
|
name,
|
||||||
slug,
|
slug,
|
||||||
description,
|
description,
|
||||||
|
short_description,
|
||||||
currency,
|
currency,
|
||||||
category_id,
|
category_id,
|
||||||
published,
|
published,
|
||||||
@@ -105,30 +108,9 @@ struct VariantInput {
|
|||||||
/// `None` = available but not inventory-tracked.
|
/// `None` = available but not inventory-tracked.
|
||||||
stock: Option<i32>,
|
stock: Option<i32>,
|
||||||
price_cents: i64,
|
price_cents: i64,
|
||||||
business_sale_cents: Option<i64>,
|
|
||||||
position: i32,
|
position: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The optional business-sale price field on a variant row: blank means "no
|
|
||||||
/// business quick-sale", a value must parse and be below the regular price.
|
|
||||||
fn parse_optional_sale(
|
|
||||||
form: &MultipartForm,
|
|
||||||
i: usize,
|
|
||||||
key: &str,
|
|
||||||
price_cents: i64,
|
|
||||||
) -> Result<Option<i64>> {
|
|
||||||
let Some(raw) = form.text(&format!("variants[{i}][{key}]")) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let cents = parse_price_to_cents(&raw)?;
|
|
||||||
if cents <= 0 || cents >= price_cents {
|
|
||||||
return Err(Error::BadRequest(
|
|
||||||
"a sale price must be positive and below the regular price".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(Some(cents))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse the repeated variant rows from the form, in submission order. Blank
|
/// Parse the repeated variant rows from the form, in submission order. Blank
|
||||||
/// rows (no price and no label) are skipped; at least one valid row is required.
|
/// rows (no price and no label) are skipped; at least one valid row is required.
|
||||||
fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
|
fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
|
||||||
@@ -168,7 +150,6 @@ fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
|
|||||||
.ok_or_else(|| Error::BadRequest("stock must be 0 or more".to_string()))?,
|
.ok_or_else(|| Error::BadRequest("stock must be 0 or more".to_string()))?,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
let business_sale_cents = parse_optional_sale(form, i, "business_sale", price_cents)?;
|
|
||||||
let id = form
|
let id = form
|
||||||
.text(&format!("variants[{i}][id]"))
|
.text(&format!("variants[{i}][id]"))
|
||||||
.and_then(|s| s.parse::<i32>().ok());
|
.and_then(|s| s.parse::<i32>().ok());
|
||||||
@@ -179,7 +160,6 @@ fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
|
|||||||
sku,
|
sku,
|
||||||
stock,
|
stock,
|
||||||
price_cents,
|
price_cents,
|
||||||
business_sale_cents,
|
|
||||||
position: out.len() as i32,
|
position: out.len() as i32,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -197,9 +177,9 @@ fn apply_variant(active: &mut product_variants::ActiveModel, input: &VariantInpu
|
|||||||
active.sku = Set(input.sku.clone());
|
active.sku = Set(input.sku.clone());
|
||||||
active.stock = Set(input.stock);
|
active.stock = Set(input.stock);
|
||||||
active.price_cents = Set(input.price_cents);
|
active.price_cents = Set(input.price_cents);
|
||||||
// The per-variant public sale price was removed from the UI; keep it cleared.
|
// Discounts (public + business sale) are owned by the discount page and keyed
|
||||||
active.sale_price_cents = Set(None);
|
// per option/audience; the product form must leave those columns untouched so
|
||||||
active.business_sale_price_cents = Set(input.business_sale_cents);
|
// it never clobbers a discount. New variants default them to NULL.
|
||||||
active.position = Set(input.position);
|
active.position = Set(input.position);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,7 +237,6 @@ fn variant_form_json(variant: &product_variants::Model) -> serde_json::Value {
|
|||||||
"sku": variant.sku,
|
"sku": variant.sku,
|
||||||
"stock": variant.stock,
|
"stock": variant.stock,
|
||||||
"price": format_price(variant.price_cents),
|
"price": format_price(variant.price_cents),
|
||||||
"business_sale": variant.business_sale_price_cents.map(format_price),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,9 +319,13 @@ async fn index(
|
|||||||
.sum::<i32>()
|
.sum::<i32>()
|
||||||
.to_string()
|
.to_string()
|
||||||
};
|
};
|
||||||
|
// The product is "on sale" for this audience if any option carries a
|
||||||
|
// discount; the per-option amounts live on the discount page.
|
||||||
|
let on_sale = variants.iter().any(|v| current_value(v, audience).is_some());
|
||||||
rows.push(product_row(
|
rows.push(product_row(
|
||||||
product,
|
product,
|
||||||
priced,
|
priced,
|
||||||
|
on_sale,
|
||||||
variants.len(),
|
variants.len(),
|
||||||
stock_display,
|
stock_display,
|
||||||
image,
|
image,
|
||||||
@@ -373,6 +356,7 @@ async fn index(
|
|||||||
fn product_row(
|
fn product_row(
|
||||||
product: &products::Model,
|
product: &products::Model,
|
||||||
effective: &pricing::PricedProduct,
|
effective: &pricing::PricedProduct,
|
||||||
|
on_sale: bool,
|
||||||
variant_count: usize,
|
variant_count: usize,
|
||||||
stock_display: String,
|
stock_display: String,
|
||||||
image: Option<String>,
|
image: Option<String>,
|
||||||
@@ -390,6 +374,7 @@ fn product_row(
|
|||||||
"image": image,
|
"image": image,
|
||||||
"category_name": category_name,
|
"category_name": category_name,
|
||||||
"regular_price": format_price(effective.regular_cents),
|
"regular_price": format_price(effective.regular_cents),
|
||||||
|
"on_sale": on_sale,
|
||||||
"effective_price": format_price(effective.price_cents),
|
"effective_price": format_price(effective.price_cents),
|
||||||
"effective_reduced": effective.is_reduced(),
|
"effective_reduced": effective.is_reduced(),
|
||||||
"effective_percent_off": percent_off(effective.regular_cents, effective.price_cents),
|
"effective_percent_off": percent_off(effective.regular_cents, effective.price_cents),
|
||||||
@@ -456,6 +441,7 @@ async fn create(
|
|||||||
name: Set(fields.name),
|
name: Set(fields.name),
|
||||||
slug: Set(fields.slug),
|
slug: Set(fields.slug),
|
||||||
description: Set(fields.description),
|
description: Set(fields.description),
|
||||||
|
short_description: Set(fields.short_description),
|
||||||
currency: Set(fields.currency),
|
currency: Set(fields.currency),
|
||||||
view_count: Set(0),
|
view_count: Set(0),
|
||||||
published: Set(fields.published),
|
published: Set(fields.published),
|
||||||
@@ -570,6 +556,7 @@ async fn update(
|
|||||||
product.name = Set(fields.name);
|
product.name = Set(fields.name);
|
||||||
product.slug = Set(fields.slug);
|
product.slug = Set(fields.slug);
|
||||||
product.description = Set(fields.description);
|
product.description = Set(fields.description);
|
||||||
|
product.short_description = Set(fields.short_description);
|
||||||
product.currency = Set(fields.currency);
|
product.currency = Set(fields.currency);
|
||||||
product.category_id = Set(fields.category_id);
|
product.category_id = Set(fields.category_id);
|
||||||
product.published = Set(fields.published);
|
product.published = Set(fields.published);
|
||||||
@@ -619,6 +606,30 @@ fn list_redirect(audience: &str) -> Result<Response> {
|
|||||||
format::redirect(&format!("/admin/catalog/products?audience={audience}"))
|
format::redirect(&format!("/admin/catalog/products?audience={audience}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a percentage off the regular price into a fixed sale price in cents.
|
||||||
|
fn percent_to_sale_cents(regular_cents: i64, percent: f64) -> i64 {
|
||||||
|
let off = (regular_cents as f64 * percent / 100.0).round() as i64;
|
||||||
|
regular_cents - off
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which discount value an audience tab sees on a variant.
|
||||||
|
fn current_value(variant: &product_variants::Model, audience: &str) -> Option<i64> {
|
||||||
|
if audience == BUSINESS {
|
||||||
|
variant.business_sale_price_cents
|
||||||
|
} else {
|
||||||
|
variant.sale_price_cents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the discount column on a variant for a given audience.
|
||||||
|
fn set_value(active: &mut product_variants::ActiveModel, audience: &str, value: Option<i64>) {
|
||||||
|
if audience == BUSINESS {
|
||||||
|
active.business_sale_price_cents = Set(value);
|
||||||
|
} else {
|
||||||
|
active.sale_price_cents = Set(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Percent off the regular price, rounded to a whole number.
|
/// Percent off the regular price, rounded to a whole number.
|
||||||
fn percent_off(regular_cents: i64, sale_cents: i64) -> i64 {
|
fn percent_off(regular_cents: i64, sale_cents: i64) -> i64 {
|
||||||
if regular_cents <= 0 {
|
if regular_cents <= 0 {
|
||||||
@@ -742,6 +753,231 @@ async fn sync_profiles(
|
|||||||
list_redirect(audience)
|
list_redirect(audience)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Per-variant discounts ---------------------------------------------------
|
||||||
|
//
|
||||||
|
// Each product is sold as one or more options (variants). A discount can be set
|
||||||
|
// on every option individually, for the active audience: personal writes the
|
||||||
|
// public `sale_price_cents`, business writes `business_sale_price_cents`. Per
|
||||||
|
// option the admin picks a fixed sale price or a percentage off the regular
|
||||||
|
// price; an empty value clears that option's discount.
|
||||||
|
|
||||||
|
/// One option row in the discount form. Carries enough to pre-fill the editor
|
||||||
|
/// and to survive a validation-error round-trip.
|
||||||
|
struct DiscountRow {
|
||||||
|
id: i32,
|
||||||
|
label: String,
|
||||||
|
regular_cents: i64,
|
||||||
|
mode: String,
|
||||||
|
fixed: String,
|
||||||
|
percent: String,
|
||||||
|
has_discount: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiscountRow {
|
||||||
|
/// Pre-fill from the discount stored for this audience.
|
||||||
|
fn from_db(v: &product_variants::Model, audience: &str) -> Self {
|
||||||
|
let sale = current_value(v, audience);
|
||||||
|
DiscountRow {
|
||||||
|
id: v.id,
|
||||||
|
label: v.label.clone(),
|
||||||
|
regular_cents: v.price_cents,
|
||||||
|
mode: "fixed".to_string(),
|
||||||
|
fixed: sale.map(format_price).unwrap_or_default(),
|
||||||
|
percent: String::new(),
|
||||||
|
has_discount: sale.is_some(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-fill from the submitted values, to repaint the form after an error.
|
||||||
|
fn from_submitted(
|
||||||
|
v: &product_variants::Model,
|
||||||
|
audience: &str,
|
||||||
|
pairs: &HashMap<String, String>,
|
||||||
|
) -> Self {
|
||||||
|
let get = |key: &str| {
|
||||||
|
pairs
|
||||||
|
.get(&format!("v[{}][{key}]", v.id))
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let mode = get("mode");
|
||||||
|
DiscountRow {
|
||||||
|
id: v.id,
|
||||||
|
label: v.label.clone(),
|
||||||
|
regular_cents: v.price_cents,
|
||||||
|
mode: if mode == "percent" {
|
||||||
|
mode
|
||||||
|
} else {
|
||||||
|
"fixed".to_string()
|
||||||
|
},
|
||||||
|
fixed: get("fixed"),
|
||||||
|
percent: get("percent"),
|
||||||
|
has_discount: current_value(v, audience).is_some(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_json(&self, currency: &str) -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"id": self.id,
|
||||||
|
"label": self.label,
|
||||||
|
"regular_cents": self.regular_cents,
|
||||||
|
"regular_price": format_price(self.regular_cents),
|
||||||
|
"currency": currency,
|
||||||
|
"mode": self.mode,
|
||||||
|
"fixed": self.fixed,
|
||||||
|
"percent": self.percent,
|
||||||
|
"has_discount": self.has_discount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve one submitted option into the sale price to store. `Ok(None)` clears
|
||||||
|
/// the discount; `Err` is an i18n key for the validation message.
|
||||||
|
fn resolve_row(
|
||||||
|
regular_cents: i64,
|
||||||
|
mode: &str,
|
||||||
|
fixed: &str,
|
||||||
|
percent: &str,
|
||||||
|
) -> std::result::Result<Option<i64>, &'static str> {
|
||||||
|
let sale_cents = if mode == "percent" {
|
||||||
|
if percent.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let pct = parse_percent(percent).ok_or("discount-invalid")?;
|
||||||
|
if pct <= 0.0 || pct >= 100.0 {
|
||||||
|
return Err("discount-percent-range");
|
||||||
|
}
|
||||||
|
percent_to_sale_cents(regular_cents, pct)
|
||||||
|
} else {
|
||||||
|
if fixed.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
parse_price_to_cents(fixed).map_err(|_| "discount-invalid")?
|
||||||
|
};
|
||||||
|
if sale_cents <= 0 {
|
||||||
|
return Err("discount-must-be-positive");
|
||||||
|
}
|
||||||
|
if sale_cents >= regular_cents {
|
||||||
|
return Err("discount-below-regular");
|
||||||
|
}
|
||||||
|
Ok(Some(sale_cents))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn discount_view(
|
||||||
|
v: &TeraView,
|
||||||
|
jar: &CookieJar,
|
||||||
|
product: &products::Model,
|
||||||
|
rows: &[DiscountRow],
|
||||||
|
audience: &str,
|
||||||
|
error: Option<&str>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let rows_json: Vec<_> = rows.iter().map(|r| r.to_json(&product.currency)).collect();
|
||||||
|
let has_discount = rows.iter().any(|r| r.has_discount);
|
||||||
|
format::view(
|
||||||
|
v,
|
||||||
|
"admin/catalog/discount_form.html",
|
||||||
|
json!({
|
||||||
|
"product": {
|
||||||
|
"id": product.id,
|
||||||
|
"name": product.name,
|
||||||
|
"currency": product.currency,
|
||||||
|
},
|
||||||
|
"rows": rows_json,
|
||||||
|
"audience": audience,
|
||||||
|
"has_discount": has_discount,
|
||||||
|
"error": error.map(|e| e.to_string()),
|
||||||
|
"lang": current_lang(jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn discount_show(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let audience = read_audience(¶ms);
|
||||||
|
let product = product_by_id(&ctx, id).await?;
|
||||||
|
let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
|
||||||
|
let rows: Vec<DiscountRow> = variants
|
||||||
|
.iter()
|
||||||
|
.map(|variant| DiscountRow::from_db(variant, audience))
|
||||||
|
.collect();
|
||||||
|
discount_view(&v, &jar, &product, &rows, audience, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn discount_update(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
body: String,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let audience = read_audience(¶ms);
|
||||||
|
let product = product_by_id(&ctx, id).await?;
|
||||||
|
let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
|
||||||
|
|
||||||
|
let pairs: HashMap<String, String> = form_urlencoded::parse(body.as_bytes())
|
||||||
|
.into_owned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Resolve every option before persisting anything, so one bad row can't leave
|
||||||
|
// the product half-discounted. On the first error, repaint with the inputs.
|
||||||
|
let mut resolved: Vec<(product_variants::Model, Option<i64>)> = Vec::new();
|
||||||
|
for variant in &variants {
|
||||||
|
let row = DiscountRow::from_submitted(variant, audience, &pairs);
|
||||||
|
match resolve_row(variant.price_cents, &row.mode, &row.fixed, &row.percent) {
|
||||||
|
Ok(value) => resolved.push((variant.clone(), value)),
|
||||||
|
Err(key) => {
|
||||||
|
let rows: Vec<DiscountRow> = variants
|
||||||
|
.iter()
|
||||||
|
.map(|v| DiscountRow::from_submitted(v, audience, &pairs))
|
||||||
|
.collect();
|
||||||
|
return discount_view(&v, &jar, &product, &rows, audience, Some(key)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let txn = ctx.db.begin().await?;
|
||||||
|
for (variant, value) in &resolved {
|
||||||
|
let mut active = variant.clone().into_active_model();
|
||||||
|
set_value(&mut active, audience, *value);
|
||||||
|
active.update(&txn).await?;
|
||||||
|
}
|
||||||
|
txn.commit().await?;
|
||||||
|
list_redirect(audience)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn discount_remove(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let audience = read_audience(¶ms);
|
||||||
|
let _product = product_by_id(&ctx, id).await?;
|
||||||
|
let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
|
||||||
|
let txn = ctx.db.begin().await?;
|
||||||
|
for variant in &variants {
|
||||||
|
let mut active = variant.clone().into_active_model();
|
||||||
|
set_value(&mut active, audience, None);
|
||||||
|
active.update(&txn).await?;
|
||||||
|
}
|
||||||
|
txn.commit().await?;
|
||||||
|
list_redirect(audience)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn routes() -> Routes {
|
pub fn routes() -> Routes {
|
||||||
// Several images may be uploaded in one submission; allow a generous total
|
// Several images may be uploaded in one submission; allow a generous total
|
||||||
// (per-file size is still capped at IMAGE_MAX_BYTES while reading).
|
// (per-file size is still capped at IMAGE_MAX_BYTES while reading).
|
||||||
@@ -764,4 +1000,16 @@ pub fn routes() -> Routes {
|
|||||||
post(update).layer(image_limit),
|
post(update).layer(image_limit),
|
||||||
)
|
)
|
||||||
.add("/admin/catalog/products/{id}/delete", post(delete))
|
.add("/admin/catalog/products/{id}/delete", post(delete))
|
||||||
|
.add(
|
||||||
|
"/admin/catalog/products/{id}/discount/edit",
|
||||||
|
get(discount_show),
|
||||||
|
)
|
||||||
|
.add(
|
||||||
|
"/admin/catalog/products/{id}/discount",
|
||||||
|
post(discount_update),
|
||||||
|
)
|
||||||
|
.add(
|
||||||
|
"/admin/catalog/products/{id}/discount/remove",
|
||||||
|
post(discount_remove),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ pub struct Model {
|
|||||||
pub slug: String,
|
pub slug: String,
|
||||||
#[sea_orm(column_type = "Text", nullable)]
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub short_description: Option<String>,
|
||||||
pub currency: String,
|
pub currency: String,
|
||||||
pub view_count: i32,
|
pub view_count: i32,
|
||||||
pub published: bool,
|
pub published: bool,
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ pub fn product_card(
|
|||||||
"name": product.name,
|
"name": product.name,
|
||||||
"slug": product.slug,
|
"slug": product.slug,
|
||||||
"description": product.description,
|
"description": product.description,
|
||||||
|
"short_description": product.short_description,
|
||||||
"price": format_price(priced.price_cents),
|
"price": format_price(priced.price_cents),
|
||||||
"on_sale": priced.is_reduced(),
|
"on_sale": priced.is_reduced(),
|
||||||
"is_business": priced.is_business,
|
"is_business": priced.is_business,
|
||||||
@@ -69,6 +70,7 @@ pub fn product_form(product: &products::Model, images: &[product_images::Model])
|
|||||||
"name": product.name,
|
"name": product.name,
|
||||||
"slug": product.slug,
|
"slug": product.slug,
|
||||||
"description": product.description,
|
"description": product.description,
|
||||||
|
"short_description": product.short_description,
|
||||||
"currency": product.currency,
|
"currency": product.currency,
|
||||||
"published": product.published,
|
"published": product.published,
|
||||||
"category_id": product.category_id,
|
"category_id": product.category_id,
|
||||||
|
|||||||
Reference in New Issue
Block a user