22 Commits

Author SHA1 Message Date
Priec
ac31cdfbf3 eur czk can be disabled from now on
Some checks are pending
CI / Check Style (push) Waiting to run
CI / Run Clippy (push) Waiting to run
CI / Run Tests (push) Waiting to run
2026-06-23 21:54:09 +02:00
Priec
c409e85995 CZK implemented 2026-06-23 12:54:11 +02:00
Priec
6b7422806f whole eshop is now in euro 2026-06-23 12:31:52 +02:00
Priec
8085052b2b quill editor 2026-06-23 12:05:06 +02:00
Priec
1cf330e4e8 short and long description 2026-06-23 11:13:26 +02:00
Priec
031f86adb0 fixed front page product cards 2026-06-23 10:55:39 +02:00
Priec
96c428eadd discounts now work well
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-22 23:12:26 +02:00
Priec
5e6263e853 orders search query also working now
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-22 21:52:22 +02:00
Priec
5a474f3474 search in admin also 2026-06-22 21:38:03 +02:00
Priec
1e66bfd657 defaults for the search implemented 2026-06-22 21:18:13 +02:00
Priec
f512fbbb94 saerch query in the shop now works well 2026-06-22 21:12:47 +02:00
Priec
1ecfac2ad6 search with parameters 2026-06-22 21:01:02 +02:00
Priec
3b9c2f7d64 search implement
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-22 20:37:06 +02:00
Priec
e5cac27010 card can be vertical or horizontal
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-22 19:50:01 +02:00
Priec
a45f9ef030 fixing product card 2026-06-22 19:40:08 +02:00
Priec
51155f2fd2 I can move the images around now 2026-06-22 19:03:33 +02:00
Priec
2d2aa012ec multiple images in the edit product
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-22 18:20:50 +02:00
Priec
125be1798e muiltiple images in carousel 2026-06-22 17:40:55 +02:00
Priec
f724e9763f upload picture now working well 2026-06-22 16:56:14 +02:00
Priec
681c88f85d 0 is out of stock and nothing is available from now on
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-22 16:48:28 +02:00
Priec
6828854f24 the admin page now make sense for products
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-22 16:21:52 +02:00
Priec
3a1ea7cdb4 I can see the product with different options
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-22 16:14:04 +02:00
80 changed files with 3173 additions and 481 deletions

View File

@@ -77,3 +77,108 @@
/* Hide Alpine x-cloak elements until Alpine initializes. */
[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; }

View File

@@ -20,6 +20,7 @@ admin-audio-desc = upload songs, then group them into albums.
logout = Log out
settings = Settings
settings-language = Language
settings-currency = Currency
settings-theme = Theme
language-en = English
language-sk = Slovak
@@ -171,6 +172,8 @@ artist = Artist
release-date = Release date
cover-image = Cover image
description = Description
short-description = Short description
short-description-hint = Shown on product cards. Keep it short.
songs-in-album = Songs in this album
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.
@@ -212,6 +215,9 @@ sale-price = Sale price
variants-options = Variants / options
add-option = Add option
option-label = Option label
optional = optional
stock-untracked-hint = Leave blank = available without stock tracking
available = Available
choose-option = Choose an option
from-price = from { $price }
admin-discounts = Discounts
@@ -282,6 +288,10 @@ currency = Currency
category = Category
no-category = No category
image = Image
images = Images
main-image = Main
gallery-hint = The first image is the main one. Drag to reorder, click ✕ to remove.
add-images = Add images
slug = URL slug
slug-auto = generated automatically
position = Position
@@ -300,6 +310,32 @@ confirm-delete = Delete this for good?
shop-title = Shop
shop-subtitle = browse our products.
shop-empty = There are no products here yet.
search-placeholder = Search products…
order-search-placeholder = Search orders…
search-empty = Nothing matched your search:
results-count = { $count } products
sort-label = Sort
sort-relevance = Relevance
sort-newest = Newest
sort-price_asc = Price: low to high
sort-price_desc = Price: high to low
sort-name_asc = Name: AZ
sort-name_desc = Name: ZA
filter-category = Category
filter-all-categories = All categories
filter-uncategorized = Uncategorized
filter-price = Price
filter-price-from = Price from
filter-price-to = Price to
filter-in-stock = In stock only
filter-apply = Apply
filter-clear = Clear
pagination = Pagination
page-of = Page { $page } of { $pages }
prev = Previous
next = Next
view-grid = Grid view
view-list = List view
categories = Categories
all-products = All products
uncategorized = Uncategorized
@@ -440,6 +476,14 @@ bank-amount = Amount
admin-shipping = Shipping
admin-shipping-desc = set the price and availability of each delivery option.
shipping-enabled = Active
admin-currency = Exchange rate
admin-currency-desc = set the exchange rate for the currencies customers can switch between. You always enter prices in EUR.
currency-rate = Rate
exchange-rate = Exchange rate
exchange-rate-hint = { $code } prices are the { $base } price recalculated at this rate.
currency-enabled = Available to customers
currency-base = Base currency
currency-base-hint = the currency you enter prices in and settle payment in. Cannot be changed.
shipping-new = Add delivery option
shipping-add = Add
shipping-requires-pickup = Requires pickup point

View File

@@ -20,6 +20,7 @@ admin-audio-desc = nahrať skladby a potom ich zoskupiť do albumov.
logout = Odhlásiť sa
settings = Nastavenia
settings-language = Jazyk
settings-currency = Mena
settings-theme = Téma
language-en = Angličtina
language-sk = Slovenčina
@@ -171,6 +172,8 @@ artist = Interpret
release-date = Dátum vydania
cover-image = Obrázok obalu
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
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.
@@ -212,6 +215,9 @@ sale-price = Zľavnená cena
variants-options = Varianty / možnosti
add-option = Pridať možnosť
option-label = Označenie možnosti
optional = voliteľné
stock-untracked-hint = Nechajte prázdne = dostupné bez sledovania zásob
available = Dostupné
choose-option = Vyberte možnosť
from-price = od { $price }
admin-discounts = Zľavy
@@ -282,6 +288,10 @@ currency = Mena
category = Kategória
no-category = Bez kategórie
image = Obrázok
images = Obrázky
main-image = Hlavný
gallery-hint = Prvý obrázok je hlavný. Potiahnutím zmeníte poradie, krížikom obrázok odstránite.
add-images = Pridať obrázky
slug = URL adresa
slug-auto = vygeneruje sa automaticky
position = Poradie
@@ -300,6 +310,32 @@ confirm-delete = Naozaj zmazať?
shop-title = Obchod
shop-subtitle = prezrite si našu ponuku produktov.
shop-empty = Zatiaľ tu nie sú žiadne produkty.
search-placeholder = Hľadať produkty…
order-search-placeholder = Hľadať objednávky…
search-empty = Pre váš výraz sme nič nenašli:
results-count = { $count } produktov
sort-label = Zoradiť
sort-relevance = Relevancia
sort-newest = Najnovšie
sort-price_asc = Cena: od najnižšej
sort-price_desc = Cena: od najvyššej
sort-name_asc = Názov: AZ
sort-name_desc = Názov: ZA
filter-category = Kategória
filter-all-categories = Všetky kategórie
filter-uncategorized = Bez kategórie
filter-price = Cena
filter-price-from = Cena od
filter-price-to = Cena do
filter-in-stock = Len skladom
filter-apply = Použiť
filter-clear = Zrušiť
pagination = Stránkovanie
page-of = Strana { $page } z { $pages }
prev = Predchádzajúce
next = Ďalšie
view-grid = Zobrazenie v mriežke
view-list = Zobrazenie v zozname
categories = Kategórie
all-products = Všetky produkty
uncategorized = Bez kategórie
@@ -440,6 +476,14 @@ bank-amount = Suma
admin-shipping = Doprava
admin-shipping-desc = nastaviť cenu a dostupnosť jednotlivých možností dopravy.
shipping-enabled = Aktívne
admin-currency = Kurz
admin-currency-desc = nastaviť výmenný kurz pre meny, medzi ktorými môžu zákazníci prepínať. Ceny zadávate vždy v EUR.
currency-rate = Kurz
exchange-rate = Výmenný kurz
exchange-rate-hint = ceny v { $code } sa prepočítajú z ceny v { $base } týmto kurzom.
currency-enabled = Dostupná pre zákazníkov
currency-base = Základná mena
currency-base-hint = mena, v ktorej zadávate ceny a prebieha platba. Nedá sa zmeniť.
shipping-new = Pridať možnosť dopravy
shipping-add = Pridať
shipping-requires-pickup = Vyžaduje výdajné miesto

File diff suppressed because one or more lines are too long

View 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
View 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

File diff suppressed because one or more lines are too long

View 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
*/

File diff suppressed because one or more lines are too long

View File

@@ -30,18 +30,18 @@
{% for item in items %}
<li class="flex justify-between gap-2">
<span class="text-on-surface/80 dark:text-on-surface-dark/80">{{ item.product_name }}{% if item.variant_label %} · {{ item.variant_label }}{% endif %} × {{ item.quantity }}</span>
<span class="tabular-nums">{{ item.line_total }} {{ order.currency }}</span>
<span class="tabular-nums">{{ item.line_total }} </span>
</li>
{% endfor %}
</ul>
<div class="space-y-1 border-t border-outline py-3 text-sm dark:border-outline-dark">
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }}</span><span class="tabular-nums">{{ order.subtotal }} {{ order.currency }}</span></div>
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ order.carrier_name }}</span><span class="tabular-nums">{{ order.shipping }} {{ order.currency }}</span></div>
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }}</span><span class="tabular-nums">{{ order.subtotal }} </span></div>
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ order.carrier_name }}</span><span class="tabular-nums">{{ order.shipping }} </span></div>
{% if order.pickup_point_name %}<div class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ order.pickup_point_name }}</div>{% endif %}
</div>
<div class="flex justify-between border-t border-outline pt-3 font-bold dark:border-outline-dark">
<span>{{ t(key="order-total", lang=lang | default(value='sk')) }}</span>
<span class="tabular-nums text-primary dark:text-primary-dark">{{ order.total }} {{ order.currency }}</span>
<span class="tabular-nums text-primary dark:text-primary-dark">{{ order.total }} </span>
</div>
</div>
@@ -68,7 +68,7 @@
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-account-name", lang=lang | default(value='sk')) }}</span><span class="font-medium">{{ order.bank_account_name }}</span>
<span class="text-on-surface/70 dark:text-on-surface-dark/70">IBAN</span><span class="font-mono font-medium">{{ order.bank_iban }}</span>
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-variable-symbol", lang=lang | default(value='sk')) }}</span><span class="font-mono font-medium">{{ order.variable_symbol }}</span>
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-amount", lang=lang | default(value='sk')) }}</span><span class="font-medium tabular-nums">{{ order.total }} {{ order.currency }}</span>
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-amount", lang=lang | default(value='sk')) }}</span><span class="font-medium tabular-nums">{{ order.total }} </span>
</div>
</div>
{% endif %}

View File

@@ -22,7 +22,7 @@
</div>
<div class="flex items-center gap-4">
{{ self::status_badge(status=order.status) }}
<span class="tabular-nums text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.total }} {{ order.currency }}</span>
<span class="tabular-nums text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.total }} </span>
</div>
</a>
{% endmacro order_row %}

View File

@@ -105,6 +105,10 @@
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}
</a>
<a href="/admin/currencies" data-nav="/admin/currencies"
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
{{ t(key="admin-currency", lang=lang | default(value='sk')) }}
</a>
</div>
<div class="border-t border-outline p-4 dark:border-outline-dark">

View File

@@ -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>
</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) + ' €'"></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)) + ' €'"></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,35 @@
{% 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,
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 %}

View File

@@ -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 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 %}
<div class="flex items-center justify-between gap-3">
@@ -18,9 +21,9 @@
{{ ui::csrf_field() }}
{% 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_desc = product.description | default(value="") %}{% set v_short = product.short_description | default(value="") %}{% set v_pub = product.published %}
{% else %}
{% set v_name = "" %}{% set v_currency = "EUR" %}{% set v_desc = "" %}{% set v_pub = false %}
{% set v_name = "" %}{% set v_desc = "" %}{% set v_short = "" %}{% set v_pub = false %}
{% 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 sublabel = "text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70" %}
@@ -30,16 +33,14 @@
{{ ui::input(name="name", id="name", required=true, value=v_name) }}
</div>
<div class="space-y-1.5 sm:max-w-[10rem]">
<label for="currency" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="currency", lang=lang | default(value='sk')) }}</label>
{{ ui::input(name="currency", id="currency", value=v_currency, attrs='maxlength="3"', extra="uppercase") }}
</div>
{# --- Variants / options editor ------------------------------------------- #}
{# Each product is sold as one or more variants (a free-text label such as #}
{# "10cm x 13cm" or "5ml" plus its own price/stock/sku, and optional public & #}
{# business sale prices). Rows are managed client-side; names are indexed #}
{# (variants[i][...]) and read back by the controller. #}
{# "10cm x 13cm" or "5ml" plus its own price). Price is required. Stock is #}
{# optional — leave it blank ("∞") to mark the option simply available (not #}
{# inventory-tracked). SKU and business price are optional too. Rows are #}
{# managed client-side; names are indexed (variants[i][…]) and read back by #}
{# the controller. #}
{% set opt = " (" ~ t(key="optional", lang=lang | default(value='sk')) ~ ")" %}
<script id="variants-data" type="application/json">{{ variants | json_encode() | safe }}</script>
<div class="space-y-3" x-data="variantEditor(JSON.parse(document.getElementById('variants-data').textContent))">
<div class="flex items-center justify-between">
@@ -51,54 +52,46 @@
</div>
<template x-for="(row, i) in rows" :key="i">
<div class="grid gap-3 rounded-radius border border-outline bg-surface-alt/40 p-3 sm:grid-cols-12 dark:border-outline-dark dark:bg-surface-dark-alt/30">
<div class="flex items-end gap-3 rounded-radius border border-outline bg-surface-alt/40 p-3 dark:border-outline-dark dark:bg-surface-dark-alt/30">
<input type="hidden" :name="`variants[${i}][id]`" :value="row.id">
<div class="space-y-1 sm:col-span-5">
<label class="{{ sublabel }}">{{ t(key="option-label", lang=lang | default(value='sk')) }}</label>
<input :name="`variants[${i}][label]`" x-model="row.label" class="{{ inp }}" placeholder="napr. 10cm x 13cm">
</div>
<div class="space-y-1 sm:col-span-3">
<label class="{{ sublabel }}">{{ t(key="sku", lang=lang | default(value='sk')) }}</label>
<input :name="`variants[${i}][sku]`" x-model="row.sku" class="{{ inp }}">
</div>
<div class="space-y-1 sm:col-span-2">
<label class="{{ sublabel }}">{{ t(key="stock", lang=lang | default(value='sk')) }}</label>
<input type="number" min="0" :name="`variants[${i}][stock]`" x-model="row.stock" class="{{ inp }}">
</div>
<div class="flex items-end sm:col-span-2">
<button type="button" @click="remove(i)"
class="ml-auto rounded-radius px-2 py-2 text-sm text-danger hover:bg-danger/10" title="{{ t(key='delete', lang=lang | default(value='sk')) }}"></button>
{# 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-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>
<div class="space-y-1 sm:col-span-2">
<label class="{{ sublabel }} block truncate">{{ t(key="sku", lang=lang | default(value='sk')) }}{{ opt }}</label>
<input :name="`variants[${i}][sku]`" x-model="row.sku" class="{{ inp }}">
</div>
<div class="space-y-1 sm:col-span-2">
<label class="{{ sublabel }} block truncate">{{ t(key="stock", lang=lang | default(value='sk')) }}{{ opt }}</label>
<input type="number" min="0" :name="`variants[${i}][stock]`" x-model="row.stock" class="{{ inp }}" placeholder="∞" title="{{ t(key='stock-untracked-hint', lang=lang | default(value='sk')) }}">
</div>
<div class="space-y-1 sm:col-span-2">
<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>
<div class="space-y-1 sm:col-span-4">
<label class="{{ sublabel }}">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
<input :name="`variants[${i}][price]`" x-model="row.price" inputmode="decimal" class="{{ inp }}" placeholder="0.00">
</div>
<div class="space-y-1 sm:col-span-4">
<label class="{{ sublabel }}">{{ t(key="sale-price", lang=lang | default(value='sk')) }}</label>
<input :name="`variants[${i}][sale]`" x-model="row.sale" inputmode="decimal" class="{{ inp }}" placeholder="—">
</div>
<div class="space-y-1 sm:col-span-4">
<label class="{{ sublabel }}">{{ t(key="business-price", lang=lang | default(value='sk')) }}</label>
<input :name="`variants[${i}][business_sale]`" x-model="row.business_sale" inputmode="decimal" class="{{ inp }}" placeholder="—">
</div>
<button type="button" @click="remove(i)"
class="mb-1 shrink-0 rounded-radius px-2 py-2 text-sm text-danger hover:bg-danger/10" title="{{ t(key='delete', lang=lang | default(value='sk')) }}"></button>
</div>
</template>
</div>
<script>
function variantEditor(initial) {
const blank = () => ({ id: '', label: '', sku: '', stock: 0, price: '', sale: '', business_sale: '' });
const blank = () => ({ id: '', label: '', sku: '', stock: '', price: '' });
return {
rows: (initial || []).map(r => ({
id: r.id || '',
label: r.label || '',
sku: r.sku || '',
stock: (r.stock === null || r.stock === undefined) ? 0 : r.stock,
stock: (r.stock === null || r.stock === undefined) ? '' : r.stock,
price: r.price || '',
sale: r.sale || '',
business_sale: r.business_sale || '',
})),
init() { if (this.rows.length === 0) this.add(); },
add() { this.rows.push(blank()); },
@@ -122,16 +115,96 @@
</div>
<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>
{{ ui::textarea(name="description", id="description", rows="5", value=v_desc) }}
<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>
<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">
<label for="image" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="image", lang=lang | default(value='sk')) }}</label>
{% if product and product.image %}
<img src="/images/{{ product.image }}" alt="" class="size-24 rounded-radius object-cover">
{% endif %}
{{ ui::file_input(name="image", id="image", accept="image/*") }}
<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>
{# --- Images gallery ------------------------------------------------------- #}
{# Unified drag-orderable gallery: existing images (with id) and new uploads #}
{# (placeholder blobs) live in a single list. The full order is submitted as #}
{# repeated `image_order` fields — an integer id for kept images or `new` for #}
{# each uploaded file. The DataTransfer backing the hidden `image` file input #}
{# is rebuilt after every reorder / add / remove so the file-part order matches #}
{# the relative order of `new` slots in `image_order`. #}
<script id="images-data" type="application/json">{% if product %}{{ product.images | json_encode() | safe }}{% else %}[]{% endif %}</script>
<div class="space-y-2" x-data="{
init() {
const existing = JSON.parse(document.getElementById('images-data').textContent);
this.items = existing.map(im => ({ type: 'existing', id: im.id, image_id: im.image_id }));
},
items: [],
dt: new DataTransfer(),
dragIndex: null,
rebuildDt() {
this.dt = new DataTransfer();
for (const it of this.items) {
if (it.type === 'new') this.dt.items.add(it.file);
}
this.$refs.holder.files = this.dt.files;
},
onDrop(i) {
if (this.dragIndex === null || this.dragIndex === i) { this.dragIndex = null; return; }
this.items.splice(i, 0, this.items.splice(this.dragIndex, 1)[0]);
this.dragIndex = null;
this.rebuildDt();
},
addFiles(e) {
for (const f of e.target.files) {
this.items.push({ type: 'new', file: f, url: URL.createObjectURL(f) });
}
this.rebuildDt();
e.target.value = '';
},
remove(i) {
const it = this.items[i];
if (it.type === 'new') URL.revokeObjectURL(it.url);
this.items.splice(i, 1);
this.rebuildDt();
},
}">
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="images", lang=lang | default(value='sk')) }}</span>
<p class="{{ sublabel }}">{{ t(key="gallery-hint", lang=lang | default(value='sk')) }}</p>
<div class="flex flex-wrap gap-3" x-show="items.length">
<template x-for="(it, i) in items" :key="it.type === 'existing' ? it.id : it.url">
<div draggable="true"
@dragstart="dragIndex = i"
@dragover.prevent
@drop.prevent="onDrop(i)"
:class="dragIndex === i ? 'opacity-50' : ''"
class="group relative size-24 cursor-move overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
<input type="hidden" name="image_order" :value="it.type === 'existing' ? it.id : 'new'">
<img :src="it.type === 'existing' ? `/images/${it.image_id}` : it.url" alt="" class="size-full object-cover">
<span x-show="i === 0"
class="absolute left-1 top-1 rounded-radius bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-on-primary dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="main-image", lang=lang | default(value='sk')) }}</span>
<button type="button" @click="remove(i)"
class="absolute right-1 top-1 flex size-5 items-center justify-center rounded-full bg-surface/70 text-xs text-danger opacity-0 transition group-hover:opacity-100 dark:bg-surface-dark/70"
title="{{ t(key='delete', lang=lang | default(value='sk')) }}"></button>
</div>
</template>
</div>
{# Hidden input carries the accumulated files on submit; the visible picker #}
{# only feeds addFiles() and is reset after each pick so selections stack. #}
<input type="file" name="image" multiple class="hidden" x-ref="holder">
<input type="file" accept="image/*" multiple class="hidden" x-ref="picker" @change="addFiles($event)">
<button type="button" @click="$refs.picker.click()"
class="rounded-radius border border-outline px-3 py-1.5 text-sm font-medium text-on-surface hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt/50">
+ {{ t(key="add-images", lang=lang | default(value='sk')) }}
</button>
</div>
{{ ui::checkbox(name="published", id="published", label=t(key="published", lang=lang | default(value='sk')), checked=v_pub) }}
@@ -141,4 +214,6 @@
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products") }}
</div>
</form>
<script src="/static/vendor/quill/quill.js"></script>
<script src="/static/js/rich-editor.js"></script>
{% endblock content %}

View File

@@ -7,6 +7,8 @@
{% block content %}
{% set business = audience == "business" %}
{% set L = lang | default(value='sk') %}
{% set q_enc = query | default(value='') | urlencode %}
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-products", lang=lang | default(value='sk')) }}</h1>
@@ -15,20 +17,34 @@
{{ ui::button(label=t(key="new-product", lang=lang | default(value='sk')), href="/admin/catalog/products/new") }}
</div>
<!-- audience tabs -->
<div class="mt-4 inline-flex rounded-radius border border-outline p-1 dark:border-outline-dark">
<a href="/admin/catalog/products?audience=personal"
class="rounded-radius px-4 py-1.5 text-sm font-medium {% if not business %}bg-primary/10 text-on-surface-strong dark:bg-primary-dark/10 dark:text-on-surface-dark-strong{% else %}text-on-surface/70 dark:text-on-surface-dark/70{% endif %}">
{{ t(key="audience-personal", lang=lang | default(value='sk')) }}
</a>
<a href="/admin/catalog/products?audience=business"
class="rounded-radius px-4 py-1.5 text-sm font-medium {% if business %}bg-primary/10 text-on-surface-strong dark:bg-primary-dark/10 dark:text-on-surface-dark-strong{% else %}text-on-surface/70 dark:text-on-surface-dark/70{% endif %}">
{{ t(key="audience-business", lang=lang | default(value='sk')) }}
</a>
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
<!-- audience tabs -->
<div class="inline-flex rounded-radius border border-outline p-1 dark:border-outline-dark">
<a href="/admin/catalog/products?audience=personal&q={{ q_enc }}"
class="rounded-radius px-4 py-1.5 text-sm font-medium {% if not business %}bg-primary/10 text-on-surface-strong dark:bg-primary-dark/10 dark:text-on-surface-dark-strong{% else %}text-on-surface/70 dark:text-on-surface-dark/70{% endif %}">
{{ t(key="audience-personal", lang=L) }}
</a>
<a href="/admin/catalog/products?audience=business&q={{ q_enc }}"
class="rounded-radius px-4 py-1.5 text-sm font-medium {% if business %}bg-primary/10 text-on-surface-strong dark:bg-primary-dark/10 dark:text-on-surface-dark-strong{% else %}text-on-surface/70 dark:text-on-surface-dark/70{% endif %}">
{{ t(key="audience-business", lang=L) }}
</a>
</div>
<!-- product search (drafts included); keeps the active audience + category -->
<form method="get" action="/admin/catalog/products" role="search" class="relative w-full max-w-xs">
<input type="hidden" name="audience" value="{{ audience }}">
<input type="hidden" name="category" value="{{ selected_category }}">
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-on-surface/50 dark:text-on-surface-dark/50">
{{ ui::icon(name="search", size="size-5") }}
</span>
<input type="search" name="q" value="{{ query | default(value='') }}" autocomplete="off"
placeholder="{{ t(key='search-placeholder', lang=L) }}" aria-label="{{ t(key='search-placeholder', lang=L) }}"
class="w-full rounded-radius border border-outline bg-surface py-2 pl-10 pr-3 text-sm text-on-surface placeholder:text-on-surface/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50 dark:focus-visible:outline-primary-dark">
</form>
</div>
{% set category_base = "/admin/catalog/products" %}
{% set category_suffix = "&audience=" ~ audience %}
{% set category_suffix = "&audience=" ~ audience ~ "&q=" ~ q_enc %}
<div class="mt-4 flex flex-col gap-6 md:flex-row md:items-start">
{% include "admin/partials/category_filter.html" %}
@@ -106,7 +122,7 @@
</div>
</div>
</td>
<td class="px-4 py-3 tabular-nums">{% if product.has_options %}{{ t(key="from-price", price=product.regular_price, lang=lang | default(value='sk')) }}{% else %}{{ product.regular_price }}{% endif %} {{ product.currency }}</td>
<td class="px-4 py-3 tabular-nums">{% if product.has_options %}{{ t(key="from-price", price=product.regular_price, lang=lang | default(value='sk')) }}{% else %}{{ product.regular_price }}{% endif %} </td>
<td class="px-4 py-3 tabular-nums">{{ product.variant_count }}</td>
<td class="px-4 py-3 tabular-nums">
<span id="eff-{{ product.id }}">{{ ui::eff_price(p=product) }}</span>
@@ -122,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')) }}')">

View File

@@ -0,0 +1,44 @@
{% extends "admin/base.html" %}
{% import "macros/ui.html" as ui %}
{% block title %}{{ t(key="admin-currency", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}{{ t(key="admin-currency", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% block content %}
<header class="space-y-1">
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-currency", lang=lang | default(value='sk')) }}</h1>
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-currency-desc", lang=lang | default(value='sk')) }}</p>
</header>
<div class="mt-6 space-y-4">
<!-- base currency, read-only for context -->
<div class="flex flex-wrap items-center gap-4 rounded-radius border border-outline bg-surface-alt/40 p-5 dark:border-outline-dark dark:bg-surface-dark-alt/30">
<div class="min-w-40">
<p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ base_code }} ({{ base_symbol }})</p>
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="currency-base-hint", lang=lang | default(value='sk')) }}</p>
</div>
{{ ui::badge(label=t(key="currency-base", lang=lang | default(value='sk')), variant="neutral") }}
</div>
{% for c in currencies %}
<form method="post" action="/admin/currencies/{{ c.id }}"
class="flex flex-wrap items-end gap-4 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
{{ ui::csrf_field() }}
<div class="min-w-40">
<p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ c.code }} ({{ c.symbol }})</p>
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="exchange-rate-hint", code=c.code, base=base_code, lang=lang | default(value='sk')) }}</p>
</div>
<div class="space-y-1.5">
<label for="rate-{{ c.id }}" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="exchange-rate", lang=lang | default(value='sk')) }}</label>
<span class="flex items-center gap-2">
<span class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">1 {{ base_code }} =</span>
{{ ui::input(name="rate", id="rate-" ~ c.id, value=c.rate, width="w-28", attrs='inputmode="decimal"') }}
<span class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ c.code }}</span>
</span>
</div>
<div class="pb-2">{{ ui::checkbox(name="enabled", label=t(key="currency-enabled", lang=lang | default(value='sk')), checked=c.enabled) }}</div>
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", extra="ml-auto") }}
</form>
{% endfor %}
</div>
{% endblock content %}

View File

@@ -38,15 +38,15 @@
<div class="space-y-2 rounded-radius bg-surface-alt px-4 py-3 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="price", lang=lang | default(value='sk')) }}</span>
<span class="tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.regular_price }} {{ product.currency }}</span>
<span class="tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.regular_price }} </span>
</div>
<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="business-price", lang=lang | default(value='sk')) }}</span>
<span class="tabular-nums {% if product.business_reduced %}font-medium text-danger{% else %}text-on-surface-strong dark:text-on-surface-dark-strong{% endif %}">{{ product.business_price }} {{ product.currency }}</span>
<span class="tabular-nums {% if product.business_reduced %}font-medium text-danger{% else %}text-on-surface-strong dark:text-on-surface-dark-strong{% endif %}">{{ product.business_price }} </span>
</div>
<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="effective-price", lang=lang | default(value='sk')) }}</span>
<span class="tabular-nums font-medium {% if product.effective_differs %}text-primary dark:text-primary-dark{% else %}text-on-surface-strong dark:text-on-surface-dark-strong{% endif %}">{{ product.effective_price }} {{ product.currency }}</span>
<span class="tabular-nums font-medium {% if product.effective_differs %}text-primary dark:text-primary-dark{% else %}text-on-surface-strong dark:text-on-surface-dark-strong{% endif %}">{{ product.effective_price }} </span>
</div>
</div>
@@ -62,7 +62,7 @@
<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="negotiated-price", lang=lang | default(value='sk')) }}</span>
<span class="text-lg font-semibold tabular-nums" :class="valid ? 'text-secondary dark:text-secondary-dark' : 'text-on-surface/40 dark:text-on-surface-dark/40'">
<span x-text="money(afterCents)"></span> {{ product.currency }}
<span x-text="money(afterCents)"></span>
</span>
</div>
<p x-show="!valid" class="text-xs text-danger">{{ t(key="discount-must-be-positive", lang=lang | default(value='sk')) }}</p>

View File

@@ -5,6 +5,8 @@
{% block crumb %}{{ t(key="admin-customers", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% block content %}
{% set L = lang | default(value='sk') %}
{% set q_enc = query | default(value='') | urlencode %}
<div class="flex items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ customer.name }}</h1>
@@ -41,10 +43,23 @@
{% endif %}
</section>
<p class="mt-6 text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="negotiated-prices-hint", lang=lang | default(value='sk')) }}</p>
<div class="mt-6 flex flex-wrap items-center justify-between gap-3">
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="negotiated-prices-hint", lang=L) }}</p>
<!-- product search (drafts included); keeps the active category -->
<form method="get" action="/admin/customers/{{ customer.id }}" role="search" class="relative w-full max-w-xs">
<input type="hidden" name="category" value="{{ selected_category }}">
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-on-surface/50 dark:text-on-surface-dark/50">
{{ ui::icon(name="search", size="size-5") }}
</span>
<input type="search" name="q" value="{{ query | default(value='') }}" autocomplete="off"
placeholder="{{ t(key='search-placeholder', lang=L) }}" aria-label="{{ t(key='search-placeholder', lang=L) }}"
class="w-full rounded-radius border border-outline bg-surface py-2 pl-10 pr-3 text-sm text-on-surface placeholder:text-on-surface/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50 dark:focus-visible:outline-primary-dark">
</form>
</div>
{% set category_base = "/admin/customers/" ~ customer.id %}
{% set category_suffix = "" %}
{% set category_suffix = "&q=" ~ q_enc %}
<div class="mt-3 flex flex-col gap-6 md:flex-row md:items-start">
{% include "admin/partials/category_filter.html" %}
<div class="min-w-0 flex-1 {{ ui::table_wrap_cls() }}">
@@ -67,14 +82,14 @@
</td>
<td class="px-4 py-3 tabular-nums">
{% if product.business_reduced %}
<span class="font-medium text-danger">{{ product.business_price }} {{ product.currency }}</span>
<span class="font-medium text-danger">{{ product.business_price }} </span>
<span class="ml-1 text-xs text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }}</span>
{% else %}
{{ product.business_price }} {{ product.currency }}
{{ product.business_price }}
{% endif %}
</td>
<td class="px-4 py-3 tabular-nums">
<span class="font-medium {% if product.effective_differs %}text-primary dark:text-primary-dark{% else %}text-on-surface-strong dark:text-on-surface-dark-strong{% endif %}">{{ product.effective_price }} {{ product.currency }}</span>
<span class="font-medium {% if product.effective_differs %}text-primary dark:text-primary-dark{% else %}text-on-surface-strong dark:text-on-surface-dark-strong{% endif %}">{{ product.effective_price }} </span>
{% if product.collision %}<span class="ml-1">{{ ui::badge(label=t(key="collision", lang=lang | default(value='sk')), variant="warning") }}</span>{% endif %}
</td>
<td class="px-4 py-3">

View File

@@ -5,7 +5,24 @@
{% block crumb %}{{ t(key="admin-orders", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% block content %}
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-orders", lang=lang | default(value='sk')) }}</h1>
{% set L = lang | default(value='sk') %}
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-orders", lang=L) }}</h1>
<!-- order search: order number, customer, email, company, phone, tracking -->
<form method="get" action="/admin/orders" role="search" class="relative w-full max-w-xs">
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-on-surface/50 dark:text-on-surface-dark/50">
{{ ui::icon(name="search", size="size-5") }}
</span>
<input type="search" name="q" value="{{ query | default(value='') }}" autocomplete="off"
placeholder="{{ t(key='order-search-placeholder', lang=L) }}" aria-label="{{ t(key='order-search-placeholder', lang=L) }}"
class="w-full rounded-radius border border-outline bg-surface py-2 pl-10 pr-3 text-sm text-on-surface placeholder:text-on-surface/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50 dark:focus-visible:outline-primary-dark">
</form>
</div>
{% if query and query != "" %}
<p class="mt-2 text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="results-count", lang=L, count=total) }} · “{{ query }}”</p>
{% endif %}
<div class="mt-6 {{ ui::table_wrap_cls() }}">
{% if orders | length > 0 %}
@@ -27,7 +44,7 @@
<td class="px-4 py-3">
{{ ui::badge(label=t(key="order-status-" ~ order.status, lang=lang | default(value='sk')), variant="neutral") }}
</td>
<td class="px-4 py-3 text-right tabular-nums">{{ order.total }} {{ order.currency }}</td>
<td class="px-4 py-3 text-right tabular-nums">{{ order.total }} </td>
<td class="px-4 py-3 text-right">
{{ ui::button(variant="outline-secondary", label=t(key="view", lang=lang | default(value='sk')), href="/admin/orders/" ~ order.id, size="px-3 py-1.5 text-xs") }}
</td>

View File

@@ -38,14 +38,14 @@
<tr>
<td class="px-4 py-3">{{ item.product_name }}{% if item.variant_label %} <span class="text-on-surface/60 dark:text-on-surface-dark/60">· {{ item.variant_label }}</span>{% endif %}</td>
<td class="px-4 py-3 tabular-nums">{{ item.quantity }}</td>
<td class="px-4 py-3 text-right tabular-nums">{{ item.line_total }} {{ order.currency }}</td>
<td class="px-4 py-3 text-right tabular-nums">{{ item.line_total }} </td>
</tr>
{% endfor %}
</tbody>
<tfoot class="{{ ui::tfoot_cls() }}">
<tr>
<td colspan="2" class="px-4 py-3 text-right font-semibold">{{ t(key="order-total", lang=lang | default(value='sk')) }}</td>
<td class="px-4 py-3 text-right font-bold tabular-nums text-primary dark:text-primary-dark">{{ order.total }} {{ order.currency }}</td>
<td class="px-4 py-3 text-right font-bold tabular-nums text-primary dark:text-primary-dark">{{ order.total }} </td>
</tr>
</tfoot>
</table>
@@ -75,7 +75,7 @@
</div>
<div>
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-carrier", lang=lang | default(value='sk')) }}</p>
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.carrier_name }} — {{ order.shipping }} {{ order.currency }}</p>
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.carrier_name }} — {{ order.shipping }} </p>
{% if order.pickup_point_name %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.pickup_point_name }}</p>{% endif %}
</div>
<div>

View File

@@ -104,8 +104,19 @@
{% endif %}
</ul>
<!-- right side: cart + settings + mobile toggle -->
<!-- right side: kurz + cart + settings + mobile toggle -->
<div class="ml-auto flex items-center gap-3">
<!-- exchange-rate ("kurz") display: the admin-set EUR→alt rate(s).
Hidden when the store is EUR-only (no enabled alternatives). -->
{% set nav_cc = currencies() %}
{% if nav_cc.alts | length > 0 %}
<div class="hidden items-center gap-2 text-xs text-on-surface/70 dark:text-on-surface-dark/70 sm:flex">
<span class="font-semibold uppercase tracking-wide">{{ t(key="currency-rate", lang=lang | default(value='sk')) }}</span>
{% for a in nav_cc.alts %}
<span class="tabular-nums">1 {{ nav_cc.base.symbol }} = {{ a.rate }} {{ a.symbol }}</span>
{% endfor %}
</div>
{% endif %}
<!-- customer profile dropdown (avatar + name + account type) -->
{% if logged_in_customer %}
{% include "partials/profile_menu.html" %}

View File

@@ -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>
<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 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 %}
{% include "shop/_card.html" %}
{% endfor %}

View File

@@ -83,6 +83,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>
{%- elif name == "chevron-double-left" -%}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><path stroke-linecap="round" stroke-linejoin="round" d="m18.75 4.5-7.5 7.5 7.5 7.5m-6-15L5.25 12l7.5 7.5" /></svg>
{%- elif name == "search" -%}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
{%- else -%}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /></svg>
{%- endif -%}
@@ -130,10 +132,10 @@
{% macro eff_price(p, preview=false) -%}
{%- if preview -%}{% set strong = "text-info" %}{%- else -%}{% set strong = "text-primary dark:text-primary-dark" %}{%- endif -%}
{% if p.effective_reduced %}
<span class="font-medium {{ strong }}">{{ p.effective_price }} {{ p.currency }}</span>
<span class="font-medium {{ strong }}">{{ p.effective_price }} </span>
<span class="ml-1 text-xs text-on-surface/60 dark:text-on-surface-dark/60">({{ p.effective_percent_off }}%)</span>
{% else %}
{{ p.effective_price }} {{ p.currency }}
{{ p.effective_price }}
{% endif %}
{%- endmacro eff_price %}
@@ -151,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>
{%- 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. #}
{% 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 }}/>

View File

@@ -35,6 +35,32 @@
{% if lang | default(value='sk') == "sk" %}<span class="text-primary dark:text-primary-dark"></span>{% endif %}
</button>
</form>
{# Currency switcher. Only enabled (buyer-available) currencies are listed,
from the `currencies()` snapshot; the whole section is hidden when the store
is EUR-only (no enabled alternatives). The active code is read from the
`currency` cookie client-side (Alpine); posting to /currency sets it. #}
{% set cc = currencies() %}
{% if cc.alts | length > 0 %}
<p class="mt-1 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
{{ t(key="settings-currency", lang=lang | default(value='sk')) }}
</p>
<form method="post" action="/currency" hx-boost="false"
x-data="{ cur: ((document.cookie.split('; ').find(function (c) { return c.indexOf('currency=') === 0 }) || 'currency={{ cc.base.code }}').split('=')[1]) }">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<button type="submit" name="currency" value="{{ cc.base.code }}" role="menuitem"
class="flex w-full items-center justify-between px-4 py-2 text-sm text-on-surface transition hover:bg-primary/5 hover:text-on-surface-strong focus-visible:bg-primary/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
<span>{{ cc.base.code }} ({{ cc.base.symbol }})</span>
<span x-cloak x-show="cur === '{{ cc.base.code }}'" class="text-primary dark:text-primary-dark"></span>
</button>
{% for a in cc.alts %}
<button type="submit" name="currency" value="{{ a.code }}" role="menuitem"
class="flex w-full items-center justify-between px-4 py-2 text-sm text-on-surface transition hover:bg-primary/5 hover:text-on-surface-strong focus-visible:bg-primary/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
<span>{{ a.code }} ({{ a.symbol }})</span>
<span x-cloak x-show="cur === '{{ a.code }}'" class="text-primary dark:text-primary-dark"></span>
</button>
{% endfor %}
</form>
{% endif %}
<p class="mt-1 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
{{ t(key="settings-theme", lang=lang | default(value='sk')) }}
</p>

View File

@@ -1,39 +1,58 @@
{# Imported locally (not just inherited from base.html) so the card also renders
inside standalone htmx fragments like shop/_results.html, where Tera's import
chain from the layout isn't present. #}
{% import "macros/ui.html" as ui %}
{# Adapted from the vendored Penguin UI component
(penguinui-components/card/ecommerce-product-card.html):
wired to our product data + i18n + htmx add-to-cart + toast. The demo rating
stars, hardcoded title/price/description/image and the `max-w-sm` (which fights
the shop grid) are dropped; the whole card links to the product page. #}
{# Layout adapts to the `view` Alpine state set by _product_grid.html:
'grid' (default) → vertical card; 'list' → horizontal row. On pages without
that state (e.g. home) `view` is undefined, so the grid layout applies. #}
<article
class="group flex flex-col overflow-hidden rounded-radius border border-outline bg-surface-alt text-on-surface transition hover:border-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:border-primary-dark">
<a href="/shop/{{ product.slug }}" class="flex flex-1 flex-col">
class="group flex overflow-hidden rounded-radius border border-outline bg-surface-alt text-on-surface transition hover:border-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:border-primary-dark"
:class="view === 'list' ? 'flex-row flex-wrap' : 'flex-col'">
<a href="/shop/{{ product.slug }}" class="flex min-w-0 flex-1"
:class="view === 'list' ? 'flex-row' : 'flex-col'">
<!-- Image -->
<div class="h-44 overflow-hidden bg-surface-alt md:h-64 dark:bg-surface-dark">
<div class="overflow-hidden bg-surface-alt dark:bg-surface-dark"
:class="view === 'list' ? 'size-28 shrink-0 sm:size-40' : 'h-44 md:h-64'">
{% if product.image %}
<img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover transition duration-700 ease-out group-hover:scale-105">
{% endif %}
</div>
<!-- Content -->
<div class="flex flex-1 flex-col gap-1 p-6 pb-2">
<!-- Header: Title & Price -->
<div class="flex justify-between gap-4">
<h3 class="text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
{% if product.on_sale %}
<span class="flex flex-col items-end whitespace-nowrap leading-tight">
<span class="text-sm text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }} {{ product.currency }}</span>
<span class="text-xl font-semibold text-danger"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ product.currency }}</span>
</span>
{% else %}
<span class="whitespace-nowrap text-xl"><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>
{% endif %}
<div class="flex min-w-0 flex-1 flex-col gap-1"
:class="view === 'list' ? 'p-4 sm:p-5' : 'p-6 pb-2'">
<!-- 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>
{# 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 %}
<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 %} {{ currency_symbol }}</span>
<span class="text-sm text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }} {{ currency_symbol }}</span>
</div>
{% else %}
<span class="break-words text-xl"><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 %} {{ currency_symbol }}</span>
{% endif %}
</div>
</a>
<div class="flex flex-col gap-2 p-6 pt-0">
<div class="flex flex-col gap-2"
:class="view === 'list' ? 'w-full justify-center p-4 sm:w-56 sm:p-5' : 'p-6 pt-0'">
{% if product.has_options %}
{# Multiple variants: customer must pick on the product page. #}
{{ ui::button(label=t(key="choose-option", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, extra="w-full") }}
{% elif product.stock > 0 %}
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}</p>
{% elif product.in_stock %}
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{% if product.tracked %}{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}{% else %}{{ t(key="available", lang=lang | default(value='sk')) }}{% endif %}</p>
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none"
hx-on::after-request="if (event.detail.successful) toast('{{ t(key='cart-added', lang=lang | default(value='sk')) }}')">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">

View File

@@ -23,10 +23,10 @@
</td>
<td class="px-4 py-3 tabular-nums">
{% if item.on_sale %}
<span class="font-medium text-danger">{{ item.price }} {{ item.currency }}</span>
<span class="font-medium text-danger">{{ item.price }} {{ currency_symbol }}</span>
<span class="ml-1 text-xs text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ item.regular_price }}</span>
{% else %}
{{ item.price }} {{ item.currency }}
{{ item.price }} {{ currency_symbol }}
{% endif %}
</td>
<td class="px-4 py-3">
@@ -37,7 +37,7 @@
hx-post="/cart/update" hx-trigger="cartchange" hx-target="#cart-body" hx-swap="innerHTML">
{{ ui::csrf_field() }}
<input type="hidden" name="variant_id" value="{{ item.id }}">
<input type="number" name="quantity" min="0" max="{{ item.stock }}" value="{{ item.quantity }}"
<input type="number" name="quantity" min="0" {% if item.stock %}max="{{ item.stock }}"{% endif %} value="{{ item.quantity }}"
@change="
if (parseInt($el.value || '0') <= 0 && !window.confirm('{{ t(key='cart-remove-confirm', lang=lang | default(value='sk')) }}')) {
$el.value = '{{ item.quantity }}';
@@ -48,7 +48,7 @@
class="w-20 rounded-radius border border-outline bg-surface-alt px-2 py-1 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">
</form>
</td>
<td class="px-4 py-3 text-right font-medium tabular-nums">{{ item.line_total }} {{ item.currency }}</td>
<td class="px-4 py-3 text-right font-medium tabular-nums">{{ item.line_total }} {{ currency_symbol }}</td>
<td class="px-4 py-3 text-right">
<form method="post" action="/cart/remove"
hx-post="/cart/remove" hx-target="#cart-body" hx-swap="innerHTML">
@@ -63,7 +63,7 @@
<tfoot class="{{ ui::tfoot_cls() }}">
<tr>
<td colspan="3" class="px-4 py-3 text-right font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="cart-total", lang=lang | default(value='sk')) }}</td>
<td class="px-4 py-3 text-right text-lg font-bold tabular-nums text-primary dark:text-primary-dark">{{ total }} {{ currency }}</td>
<td class="px-4 py-3 text-right text-lg font-bold tabular-nums text-primary dark:text-primary-dark">{{ total }} {{ currency_symbol }}</td>
<td></td>
</tr>
</tfoot>

View File

@@ -1,6 +1,6 @@
{# Mini-cart preview shown on hover over the navbar cart (Alza-style).
Lazy-loaded via htmx from /partials/cart into the hover dropdown panel in
base.html. Receives: items[], total, currency, lang. #}
base.html. Receives: items[], total, lang. #}
{% import "macros/ui.html" as ui %}
{% if items | length > 0 %}
<div class="max-h-80 divide-y divide-outline overflow-y-auto dark:divide-outline-dark">
@@ -9,16 +9,16 @@
<div class="min-w-0 flex-1">
<a href="/shop/{{ item.slug }}" class="block truncate text-sm font-medium text-on-surface-strong hover:text-primary dark:text-on-surface-dark-strong dark:hover:text-primary-dark">{{ item.name }}</a>
{% if item.variant_label %}<span class="block truncate text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ item.variant_label }}</span>{% endif %}
<p class="mt-0.5 text-xs tabular-nums text-on-surface dark:text-on-surface-dark">{{ item.quantity }} × {{ item.price }} {{ item.currency }}</p>
<p class="mt-0.5 text-xs tabular-nums text-on-surface dark:text-on-surface-dark">{{ item.quantity }} × {{ item.price }} {{ currency_symbol }}</p>
</div>
<span class="shrink-0 text-sm font-semibold tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{{ item.line_total }} {{ item.currency }}</span>
<span class="shrink-0 text-sm font-semibold tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{{ item.line_total }} {{ currency_symbol }}</span>
</div>
{% endfor %}
</div>
<div class="border-t border-outline px-4 py-3 dark:border-outline-dark">
<div class="mb-3 flex items-center justify-between">
<span class="text-sm text-on-surface dark:text-on-surface-dark">{{ t(key="cart-total", lang=lang | default(value='sk')) }}</span>
<span class="text-base font-bold tabular-nums text-primary dark:text-primary-dark">{{ total }} {{ currency }}</span>
<span class="text-base font-bold tabular-nums text-primary dark:text-primary-dark">{{ total }} {{ currency_symbol }}</span>
</div>
<div class="flex gap-2">
{{ ui::button(href="/cart", variant="outline-primary", label=t(key="cart-title", lang=lang | default(value='sk')), extra="flex-1", attrs='hx-boost="false"') }}

View File

@@ -0,0 +1,39 @@
{# Product collection with a grid / list view toggle.
The chosen view is held in Alpine and persisted to localStorage so it
survives navigation; `_card.html` reads the same `view` state to switch
its own layout between a vertical card and a horizontal row. #}
<div x-data="{ view: localStorage.getItem('shopView') === 'list' ? 'list' : 'grid' }"
x-init="$watch('view', v => localStorage.setItem('shopView', v))"
class="space-y-4">
<!-- View toggle -->
<div class="flex justify-end">
<div class="inline-flex gap-0.5 rounded-radius border border-outline p-0.5 dark:border-outline-dark" role="group"
aria-label="{{ t(key='view-grid', lang=lang | default(value='sk')) }} / {{ t(key='view-list', lang=lang | default(value='sk')) }}">
<button type="button" @click="view = 'grid'" :aria-pressed="view === 'grid'"
class="inline-flex size-8 items-center justify-center rounded-radius transition"
:class="view === 'grid' ? 'bg-primary text-on-primary dark:bg-primary-dark dark:text-on-primary-dark' : 'text-on-surface hover:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark'"
aria-label="{{ t(key='view-grid', lang=lang | default(value='sk')) }}"
title="{{ t(key='view-grid', lang=lang | default(value='sk')) }}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-4">
<path d="M3 3h6v6H3V3Zm8 0h6v6h-6V3ZM3 11h6v6H3v-6Zm8 0h6v6h-6v-6Z" />
</svg>
</button>
<button type="button" @click="view = 'list'" :aria-pressed="view === 'list'"
class="inline-flex size-8 items-center justify-center rounded-radius transition"
:class="view === 'list' ? 'bg-primary text-on-primary dark:bg-primary-dark dark:text-on-primary-dark' : 'text-on-surface hover:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark'"
aria-label="{{ t(key='view-list', lang=lang | default(value='sk')) }}"
title="{{ t(key='view-list', lang=lang | default(value='sk')) }}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-4">
<path d="M3 4h14v2.5H3V4Zm0 4.75h14v2.5H3v-2.5ZM3 13.5h14V16H3v-2.5Z" />
</svg>
</button>
</div>
</div>
<!-- Products -->
<div :class="view === 'list' ? 'flex flex-col gap-5' : 'grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4'">
{% for product in products %}
{% include "shop/_card.html" %}
{% endfor %}
</div>
</div>

View File

@@ -0,0 +1,46 @@
{# Results region: swapped in by htmx on each query/filter change and rendered
server-side on first load. Holds the result summary, the product grid and
pagination. #}
{% set L = lang | default(value='sk') %}
<div class="space-y-4">
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70" aria-live="polite">
{{ t(key="results-count", lang=L, count=total) }}{% if query and query != "" %} · “{{ query }}”{% endif %}
</p>
{% if products | length > 0 %}
{% include "shop/_product_grid.html" %}
{% if pages > 1 %}
<nav class="flex items-center justify-center gap-2 pt-2" aria-label="{{ t(key='pagination', lang=L) }}">
{% if has_prev %}
<button type="button"
hx-get="/search?{% if query_base %}{{ query_base }}&{% endif %}page={{ prev_page }}"
hx-target="#shop-results" hx-swap="innerHTML" hx-push-url="true"
class="rounded-radius border border-outline px-3 py-1.5 text-sm font-medium text-on-surface transition hover:bg-primary/5 hover:text-primary dark:border-outline-dark dark:text-on-surface-dark dark:hover:text-primary-dark">
{{ t(key="prev", lang=L) }}
</button>
{% endif %}
<span class="px-2 text-sm text-on-surface/70 dark:text-on-surface-dark/70">
{{ t(key="page-of", lang=L, page=page, pages=pages) }}
</span>
{% if has_next %}
<button type="button"
hx-get="/search?{% if query_base %}{{ query_base }}&{% endif %}page={{ next_page }}"
hx-target="#shop-results" hx-swap="innerHTML" hx-push-url="true"
class="rounded-radius border border-outline px-3 py-1.5 text-sm font-medium text-on-surface transition hover:bg-primary/5 hover:text-primary dark:border-outline-dark dark:text-on-surface-dark dark:hover:text-primary-dark">
{{ t(key="next", lang=L) }}
</button>
{% endif %}
</nav>
{% endif %}
{% elif query and query != "" %}
<div class="rounded-radius border border-outline px-6 py-16 text-center text-on-surface/70 dark:border-outline-dark dark:text-on-surface-dark/70">
{{ t(key="search-empty", lang=L) }} <span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ query }}</span>
</div>
{% else %}
<div class="rounded-radius border border-outline px-6 py-16 text-center text-on-surface/70 dark:border-outline-dark dark:text-on-surface-dark/70">
{{ t(key="shop-empty", lang=L) }}
</div>
{% endif %}
</div>

View File

@@ -0,0 +1,102 @@
{# Shared storefront search + filter toolbar and results region, used by the shop
index and every category page. One form drives the whole listing: htmx re-runs
/search and swaps only #shop-results; the toolbar keeps its own DOM state.
Triggers: live (debounced) typing in the search box, immediate on any
select/checkbox change, and submit (Enter / Apply) for the price band. Degrades
to a plain GET form without JS.
Expects: query, category_groups, selected_category, selected_category_id,
uncategorized_count, sort, min_price, max_price, price_floor, price_ceil,
in_stock, plus the result vars consumed by _results.html. #}
{% set L = lang | default(value='sk') %}
<div class="space-y-6">
<form action="/search" method="get" role="search"
hx-get="/search" hx-target="#shop-results" hx-swap="innerHTML"
hx-push-url="true" hx-indicator="#search-spinner"
hx-trigger="submit, change, keyup changed delay:350ms from:input[name='q']"
class="space-y-3">
<!-- search box -->
<div class="relative max-w-xl">
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-on-surface/50 dark:text-on-surface-dark/50">
{{ ui::icon(name="search", size="size-5") }}
</span>
<input type="search" name="q" value="{{ query | default(value='') }}" autocomplete="off"
placeholder="{{ t(key='search-placeholder', lang=L) }}"
aria-label="{{ t(key='search-placeholder', lang=L) }}"
class="w-full rounded-radius border border-outline bg-surface py-2 pl-10 pr-10 text-sm text-on-surface placeholder:text-on-surface/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50 dark:focus-visible:outline-primary-dark" />
<span id="search-spinner" class="htmx-indicator pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-on-surface/50 dark:text-on-surface-dark/50">
<svg class="size-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.4 0 0 5.4 0 12h4Z"></path>
</svg>
</span>
</div>
<!-- filter toolbar -->
<div class="flex flex-wrap items-end gap-3 rounded-radius border border-outline bg-surface-alt p-3 dark:border-outline-dark dark:bg-surface-dark-alt">
<!-- category -->
<label class="flex flex-col gap-1 text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70">
{{ t(key="filter-category", lang=L) }}
<select name="category"
class="rounded-radius border border-outline bg-surface px-2 py-1.5 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 dark:text-on-surface-dark">
<option value="all"{% if selected_category == "all" %} selected{% endif %}>{{ t(key="filter-all-categories", lang=L) }}</option>
{% for g in category_groups %}
<option value="{{ g.id }}"{% if selected_category_id == g.id %} selected{% endif %}>{{ g.name }} ({{ g.count }})</option>
{% for ch in g.children %}
<option value="{{ ch.id }}"{% if selected_category_id == ch.id %} selected{% endif %}>&nbsp;&nbsp;— {{ ch.name }} ({{ ch.count }})</option>
{% endfor %}
{% endfor %}
{% if uncategorized_count > 0 %}
<option value="none"{% if selected_category == "none" %} selected{% endif %}>{{ t(key="filter-uncategorized", lang=L) }} ({{ uncategorized_count }})</option>
{% endif %}
</select>
</label>
<!-- sort -->
<label class="flex flex-col gap-1 text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70">
{{ t(key="sort-label", lang=L) }}
<select name="sort"
class="rounded-radius border border-outline bg-surface px-2 py-1.5 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 dark:text-on-surface-dark">
{% for opt in ["newest", "relevance", "price_asc", "price_desc", "name_asc", "name_desc"] %}
<option value="{{ opt }}"{% if sort == opt %} selected{% endif %}>{{ t(key="sort-" ~ opt, lang=L) }}</option>
{% endfor %}
</select>
</label>
<!-- price band -->
<label class="flex flex-col gap-1 text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70">
{{ t(key="filter-price", lang=L) }}{% if currency_symbol %} ({{ currency_symbol }}){% endif %}
<span class="flex items-center gap-1">
<input type="number" name="min_price" min="0" step="0.01" inputmode="decimal"
value="{{ min_price | default(value='') }}" placeholder="{{ price_floor }}"
aria-label="{{ t(key='filter-price-from', lang=L) }}"
class="w-24 rounded-radius border border-outline bg-surface px-2 py-1.5 text-sm text-on-surface dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark" />
<span class="text-on-surface/50 dark:text-on-surface-dark/50"></span>
<input type="number" name="max_price" min="0" step="0.01" inputmode="decimal"
value="{{ max_price | default(value='') }}" placeholder="{{ price_ceil }}"
aria-label="{{ t(key='filter-price-to', lang=L) }}"
class="w-24 rounded-radius border border-outline bg-surface px-2 py-1.5 text-sm text-on-surface dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark" />
</span>
</label>
<!-- in stock -->
<label class="flex items-center gap-2 pb-1.5 text-sm text-on-surface dark:text-on-surface-dark">
<input type="checkbox" name="in_stock" value="1"{% if in_stock %} checked{% endif %}
class="size-4 rounded border-outline text-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:text-primary-dark" />
{{ t(key="filter-in-stock", lang=L) }}
</label>
<div class="ml-auto flex items-end gap-2">
{{ ui::button(label=t(key="filter-apply", lang=L), type="submit", variant="secondary") }}
<a href="/shop" hx-get="/search" hx-target="#shop-results" hx-push-url="true"
class="self-end pb-1.5 text-sm font-medium text-on-surface/70 underline-offset-2 transition hover:text-primary hover:underline dark:text-on-surface-dark/70 dark:hover:text-primary-dark">
{{ t(key="filter-clear", lang=L) }}
</a>
</div>
</div>
</form>
<div id="shop-results">
{% include "shop/_results.html" %}
</div>
</div>

View File

@@ -4,10 +4,11 @@
{% block title %}{{ category.name }}{% endblock title %}
{% block content %}
<div class="space-y-8">
{% set L = lang | default(value='sk') %}
<div class="space-y-6">
<header class="space-y-2">
<nav class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">
<a href="/shop" class="hover:text-primary dark:hover:text-primary-dark">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</a>
<a href="/shop" class="hover:text-primary dark:hover:text-primary-dark">{{ t(key="nav-shop", lang=L) }}</a>
{% for crumb in breadcrumbs %}
<span class="px-1">/</span>
<a href="/category/{{ crumb.slug }}" class="hover:text-primary dark:hover:text-primary-dark">{{ crumb.name }}</a>
@@ -28,16 +29,7 @@
{% endif %}
</header>
{% if products | length > 0 %}
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4">
{% for product in products %}
{% include "shop/_card.html" %}
{% endfor %}
</div>
{% else %}
<div class="rounded-radius border border-outline px-6 py-16 text-center text-on-surface/70 dark:border-outline-dark dark:text-on-surface-dark/70">
{{ t(key="shop-empty", lang=lang | default(value='sk')) }}
</div>
{% endif %}
{# Same search + filters as the shop, with this category preselected. #}
{% include "shop/_search.html" %}
</div>
{% endblock content %}

View File

@@ -188,7 +188,7 @@
class="before:content[''] relative h-4 w-4 appearance-none rounded-full border border-outline bg-surface before:invisible before:absolute before:left-1/2 before:top-1/2 before:h-1.5 before:w-1.5 before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:bg-on-primary checked:border-primary checked:bg-primary checked:before:visible focus:outline-2 focus:outline-offset-2 focus:outline-outline-strong checked:focus:outline-primary disabled:cursor-not-allowed dark:border-outline-dark dark:bg-surface-dark dark:before:bg-on-primary-dark dark:checked:border-primary-dark dark:checked:bg-primary-dark dark:focus:outline-outline-dark-strong dark:checked:focus:outline-primary-dark">
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ m.name }}</span>
</span>
<span class="tabular-nums text-on-surface/80 dark:text-on-surface-dark/80">{{ m.price }} {{ currency }}</span>
<span class="tabular-nums text-on-surface/80 dark:text-on-surface-dark/80">{{ m.price }} </span>
</label>
{% endfor %}
@@ -252,23 +252,23 @@
{% for item in items %}
<li class="flex justify-between gap-2">
<span class="text-on-surface/80 dark:text-on-surface-dark/80">{{ item.name }} × {{ item.quantity }}</span>
<span class="tabular-nums">{{ item.line_total }} {{ item.currency }}</span>
<span class="tabular-nums">{{ item.line_total }} </span>
</li>
{% endfor %}
</ul>
<div class="space-y-1 border-t border-outline pt-3 text-sm dark:border-outline-dark">
<div class="flex justify-between">
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }}</span>
<span class="tabular-nums">{{ subtotal }} {{ currency }}</span>
<span class="tabular-nums">{{ subtotal }} </span>
</div>
<div class="flex justify-between">
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-shipping-cost", lang=lang | default(value='sk')) }}</span>
<span class="tabular-nums" x-text="fmt(carrierPrice) + ' {{ currency }}'"></span>
<span class="tabular-nums" x-text="fmt(carrierPrice) + ' '"></span>
</div>
</div>
<div class="flex justify-between border-t border-outline pt-3 text-base font-bold dark:border-outline-dark">
<span>{{ t(key="cart-total", lang=lang | default(value='sk')) }}</span>
<span class="tabular-nums text-primary dark:text-primary-dark" x-text="fmt(subtotal + carrierPrice) + ' {{ currency }}'"></span>
<span class="tabular-nums text-primary dark:text-primary-dark" x-text="fmt(subtotal + carrierPrice) + ' '"></span>
</div>
{{ ui::button(label=t(key="checkout-place-order", lang=lang | default(value='sk')), type="submit", attrs=':disabled="!canSubmit"', extra="w-full", size="px-6 py-2.5 text-sm") }}
</aside>

View File

@@ -4,22 +4,13 @@
{% block title %}{{ t(key="nav-shop", lang=lang | default(value='sk')) }}{% endblock title %}
{% block content %}
<div class="space-y-8">
<header class="space-y-2">
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shop-title", lang=lang | default(value='sk')) }}</h1>
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-subtitle", lang=lang | default(value='sk')) }}</p>
{% set L = lang | default(value='sk') %}
<div class="space-y-6">
<header class="space-y-1">
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shop-title", lang=L) }}</h1>
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-subtitle", lang=L) }}</p>
</header>
{% if products | length > 0 %}
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4">
{% for product in products %}
{% include "shop/_card.html" %}
{% endfor %}
</div>
{% else %}
<div class="rounded-radius border border-outline px-6 py-16 text-center text-on-surface/70 dark:border-outline-dark dark:text-on-surface-dark/70">
{{ t(key="shop-empty", lang=lang | default(value='sk')) }}
</div>
{% endif %}
{% include "shop/_search.html" %}
</div>
{% endblock content %}

View File

@@ -30,18 +30,18 @@
{% for item in items %}
<li class="flex justify-between gap-2">
<span class="text-on-surface/80 dark:text-on-surface-dark/80">{{ item.product_name }}{% if item.variant_label %} · {{ item.variant_label }}{% endif %} × {{ item.quantity }}</span>
<span class="tabular-nums">{{ item.line_total }} {{ order.currency }}</span>
<span class="tabular-nums">{{ item.line_total }} </span>
</li>
{% endfor %}
</ul>
<div class="space-y-1 border-t border-outline py-3 text-sm dark:border-outline-dark">
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }}</span><span class="tabular-nums">{{ order.subtotal }} {{ order.currency }}</span></div>
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ order.carrier_name }}</span><span class="tabular-nums">{{ order.shipping }} {{ order.currency }}</span></div>
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }}</span><span class="tabular-nums">{{ order.subtotal }} </span></div>
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ order.carrier_name }}</span><span class="tabular-nums">{{ order.shipping }} </span></div>
{% if order.pickup_point_name %}<div class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ order.pickup_point_name }}</div>{% endif %}
</div>
<div class="flex justify-between border-t border-outline pt-3 font-bold dark:border-outline-dark">
<span>{{ t(key="order-total", lang=lang | default(value='sk')) }}</span>
<span class="tabular-nums text-primary dark:text-primary-dark">{{ order.total }} {{ order.currency }}</span>
<span class="tabular-nums text-primary dark:text-primary-dark">{{ order.total }} </span>
</div>
</div>
@@ -52,7 +52,7 @@
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-account-name", lang=lang | default(value='sk')) }}</span><span class="font-medium">{{ order.bank_account_name }}</span>
<span class="text-on-surface/70 dark:text-on-surface-dark/70">IBAN</span><span class="font-mono font-medium">{{ order.bank_iban }}</span>
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-variable-symbol", lang=lang | default(value='sk')) }}</span><span class="font-mono font-medium">{{ order.variable_symbol }}</span>
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-amount", lang=lang | default(value='sk')) }}</span><span class="font-medium tabular-nums">{{ order.total }} {{ order.currency }}</span>
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-amount", lang=lang | default(value='sk')) }}</span><span class="font-medium tabular-nums">{{ order.total }} </span>
</div>
</div>
{% else %}

View File

@@ -60,31 +60,33 @@
<template x-if="current">
<div class="space-y-6">
<div class="flex items-baseline gap-3">
<p class="text-2xl font-semibold" :class="current.on_sale ? 'text-danger' : 'text-primary dark:text-primary-dark'">
<span x-text="current.price"></span> {{ product.currency }}
</p>
<template x-if="current.on_sale">
<p class="text-lg text-on-surface/50 line-through dark:text-on-surface-dark/50"><span x-text="current.regular_price"></span> {{ product.currency }}</p>
</template>
</div>
{% if product.description %}
<div class="whitespace-pre-line leading-relaxed text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description }}</div>
{% endif %}
<!-- variant picker (only when there's a real choice) -->
<!-- option picker (only when there's a real choice); first option is
selected by default and switching it updates the price + buy form -->
<template x-if="variants.length > 1">
<div class="max-w-sm space-y-1.5">
<label for="variant-select" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="choose-option", lang=lang | default(value='sk')) }}</label>
<select id="variant-select" x-model.number="sel" class="{{ fld }}">
<template x-for="(v, i) in variants" :key="v.id">
<option :value="i" x-text="(v.label || '—') + ' · ' + v.price + ' {{ product.currency }}' + (v.in_stock ? '' : ' ({{ t(key='out-of-stock', lang=lang | default(value='sk')) }})')"></option>
<option :value="i" x-text="(v.label || '—') + ' · ' + v.price + ' {{ currency_symbol }}' + (v.in_stock ? '' : ' ({{ t(key='out-of-stock', lang=lang | default(value='sk')) }})')"></option>
</template>
</select>
</div>
</template>
<div class="flex items-baseline gap-3">
<p class="text-2xl font-semibold" :class="current.on_sale ? 'text-danger' : 'text-primary dark:text-primary-dark'">
<span x-text="current.price"></span> {{ currency_symbol }}
</p>
<template x-if="current.on_sale">
<p class="text-lg text-on-surface/50 line-through dark:text-on-surface-dark/50"><span x-text="current.regular_price"></span> {{ currency_symbol }}</p>
</template>
</div>
{% if product.description %}
{# 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 %}
<template x-if="current.in_stock">
<div class="space-y-2">
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none" class="flex flex-wrap items-end gap-3"
@@ -97,7 +99,14 @@
</div>
<button type="submit" class="{{ btn }}">{{ t(key="add-to-cart", lang=lang | default(value='sk')) }}</button>
</form>
<p class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="in-stock", lang=lang | default(value='sk')) }}: <span x-text="current.stock"></span></p>
<p class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">
<template x-if="current.tracked">
<span>{{ t(key="in-stock", lang=lang | default(value='sk')) }}: <span x-text="current.stock"></span></span>
</template>
<template x-if="!current.tracked">
<span>{{ t(key="available", lang=lang | default(value='sk')) }}</span>
</template>
</p>
</div>
</template>
<template x-if="!current.in_stock">

View File

@@ -41,6 +41,14 @@ mod m20260621_000003_discount_profiles;
mod m20260621_000004_add_business_sale_price_to_products;
mod m20260622_000001_audience_discount_profiles;
mod m20260622_000002_product_variants;
mod m20260622_000003_variant_stock_nullable;
mod m20260622_000004_product_search;
mod m20260622_000005_product_search_aggregate;
mod m20260622_000006_order_search_indexes;
mod m20260623_000001_add_short_description_to_products;
mod m20260623_000002_strip_html_from_product_search;
mod m20260623_000003_drop_currency;
mod m20260623_000004_currencies;
pub struct Migrator;
#[async_trait::async_trait]
@@ -86,6 +94,14 @@ impl MigratorTrait for Migrator {
Box::new(m20260621_000004_add_business_sale_price_to_products::Migration),
Box::new(m20260622_000001_audience_discount_profiles::Migration),
Box::new(m20260622_000002_product_variants::Migration),
Box::new(m20260622_000003_variant_stock_nullable::Migration),
Box::new(m20260622_000004_product_search::Migration),
Box::new(m20260622_000005_product_search_aggregate::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),
Box::new(m20260623_000003_drop_currency::Migration),
Box::new(m20260623_000004_currencies::Migration),
// inject-above (do not remove this comment)
]
}

View File

@@ -0,0 +1,36 @@
//! Make `product_variants.stock` nullable: a NULL stock means the variant is
//! "available" but not inventory-tracked — always purchasable, no quantity cap,
//! and never decremented on order. A numeric stock is tracked/capped as before.
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> {
m.get_connection()
.execute_unprepared(
r#"
ALTER TABLE product_variants ALTER COLUMN stock DROP DEFAULT;
ALTER TABLE product_variants ALTER COLUMN stock DROP NOT NULL;
"#,
)
.await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.get_connection()
.execute_unprepared(
r#"
UPDATE product_variants SET stock = 0 WHERE stock IS NULL;
ALTER TABLE product_variants ALTER COLUMN stock SET DEFAULT 0;
ALTER TABLE product_variants ALTER COLUMN stock SET NOT NULL;
"#,
)
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,92 @@
//! Full-text + fuzzy search over the product catalog.
//!
//! Storefront search has to cope with Slovak text (diacritics, ad-hoc spelling)
//! and customer typos, while staying entirely inside Postgres — the catalog is
//! small (hundreds of products), so a separate search engine would be pure
//! operational overhead. This migration sets up:
//!
//! 1. `unaccent` + `pg_trgm` extensions, and an IMMUTABLE `f_unaccent` wrapper
//! (the stock `unaccent` is only STABLE, so it can't be used in an index
//! expression without wrapping it).
//! 2. a `sk_unaccent` text-search configuration: the `simple` dictionary
//! (no English stemming, which would mangle Slovak) folded through
//! `unaccent` so "kompresor" and "kompresór" tokenize identically.
//! 3. a STORED generated `products.search_vector`, weighting the name above
//! the description, with a GIN index for `@@` matching.
//! 4. a trigram GIN index on the (unaccented) name for fuzzy matching.
//!
//! The matching query itself lives in `products::Entity::search`.
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 EXTENSION IF NOT EXISTS unaccent;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- IMMUTABLE wrapper so unaccent() can be used in generated columns
-- and index expressions (the extension's own unaccent() is STABLE).
CREATE OR REPLACE FUNCTION f_unaccent(text)
RETURNS text
LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT AS
$func$ SELECT public.unaccent('public.unaccent', $1) $func$;
-- 'simple' (no stemming) + unaccent: a good fit for Slovak, where
-- English stemming is wrong and accents are typed inconsistently.
DROP TEXT SEARCH CONFIGURATION IF EXISTS sk_unaccent;
CREATE TEXT SEARCH CONFIGURATION sk_unaccent ( COPY = simple );
ALTER TEXT SEARCH CONFIGURATION sk_unaccent
ALTER MAPPING FOR hword, hword_part, word
WITH unaccent, simple;
"#,
)
.await?;
db.execute_unprepared(
r#"
ALTER TABLE products
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('sk_unaccent', COALESCE(name, '')), 'A') ||
setweight(to_tsvector('sk_unaccent', COALESCE(description, '')), 'B')
) STORED;
CREATE INDEX idx_products_search_vector
ON products USING GIN (search_vector);
CREATE INDEX idx_products_name_trgm
ON products USING GIN (f_unaccent(name) gin_trgm_ops);
"#,
)
.await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
let db = m.get_connection();
// Drop the trigram index (it depends on f_unaccent) before the function;
// dropping the column takes its own GIN index with it.
db.execute_unprepared(
r#"
DROP INDEX IF EXISTS idx_products_name_trgm;
ALTER TABLE products DROP COLUMN IF EXISTS search_vector;
DROP TEXT SEARCH CONFIGURATION IF EXISTS sk_unaccent;
DROP FUNCTION IF EXISTS f_unaccent(text);
"#,
)
.await?;
// The unaccent / pg_trgm extensions are left installed: other objects may
// rely on them and they are harmless on their own.
Ok(())
}
}

View File

@@ -0,0 +1,232 @@
//! Broaden product search to the whole purchasable surface.
//!
//! The `product_search` migration could only index columns living on `products`
//! itself (name, description), because a STORED generated column may not read
//! other tables. To also match by tag, variant label and SKU, `search_vector`
//! becomes a plain column maintained by triggers:
//!
//! * `kompress_build_product_search(name, description, id)` builds the weighted
//! vector for one product, pulling tags + variant labels + SKUs by id
//! (name = A, tags + labels = B, description + SKU = C).
//! * a BEFORE trigger on `products` keeps a product's own row in sync, and
//! * AFTER triggers on `product_variants`, `product_product_tags` and tag
//! renames refresh the affected product(s).
//!
//! The result is one `products.search_vector` that every search query can reuse,
//! always consistent with the catalog.
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();
// Swap the generated column (name + description only) for a plain column
// the triggers can own. Dropping it takes its GIN index with it.
db.execute_unprepared(
r#"
ALTER TABLE products DROP COLUMN IF EXISTS search_vector;
ALTER TABLE products ADD COLUMN search_vector tsvector;
"#,
)
.await?;
// Single source of truth for a product's search document.
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$;
-- Refresh one product's stored vector (used by the satellite triggers).
CREATE OR REPLACE FUNCTION kompress_refresh_product_search(p_id integer)
RETURNS void LANGUAGE sql AS $func$
UPDATE products
SET search_vector = kompress_build_product_search(name, description, id)
WHERE id = p_id;
$func$;
"#,
)
.await?;
// BEFORE trigger on products: recompute on its own writes. When a refresh
// only touches search_vector (name + description unchanged) it skips the
// recompute and keeps the supplied value — which also breaks recursion.
db.execute_unprepared(
r#"
CREATE OR REPLACE FUNCTION kompress_products_search_tg() RETURNS trigger
LANGUAGE plpgsql AS $func$
BEGIN
IF TG_OP = 'UPDATE'
AND NEW.name IS NOT DISTINCT FROM OLD.name
AND NEW.description IS NOT DISTINCT FROM OLD.description
AND NEW.search_vector IS DISTINCT FROM OLD.search_vector THEN
RETURN NEW;
END IF;
NEW.search_vector :=
kompress_build_product_search(NEW.name, NEW.description, NEW.id);
RETURN NEW;
END;
$func$;
DROP TRIGGER IF EXISTS products_search_tg ON products;
CREATE TRIGGER products_search_tg
BEFORE INSERT OR UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION kompress_products_search_tg();
"#,
)
.await?;
// Variants: any change refreshes the owning product (both, on reparent).
db.execute_unprepared(
r#"
CREATE OR REPLACE FUNCTION kompress_variants_search_tg() RETURNS trigger
LANGUAGE plpgsql AS $func$
BEGIN
IF TG_OP = 'DELETE' THEN
PERFORM kompress_refresh_product_search(OLD.product_id);
RETURN OLD;
END IF;
PERFORM kompress_refresh_product_search(NEW.product_id);
IF TG_OP = 'UPDATE' AND NEW.product_id IS DISTINCT FROM OLD.product_id THEN
PERFORM kompress_refresh_product_search(OLD.product_id);
END IF;
RETURN NEW;
END;
$func$;
DROP TRIGGER IF EXISTS product_variants_search_tg ON product_variants;
CREATE TRIGGER product_variants_search_tg
AFTER INSERT OR UPDATE OR DELETE ON product_variants
FOR EACH ROW EXECUTE FUNCTION kompress_variants_search_tg();
"#,
)
.await?;
// Tag links: attaching/detaching a tag refreshes the product.
db.execute_unprepared(
r#"
CREATE OR REPLACE FUNCTION kompress_product_tags_link_search_tg() RETURNS trigger
LANGUAGE plpgsql AS $func$
BEGIN
IF TG_OP = 'DELETE' THEN
PERFORM kompress_refresh_product_search(OLD.product_id);
RETURN OLD;
END IF;
PERFORM kompress_refresh_product_search(NEW.product_id);
RETURN NEW;
END;
$func$;
DROP TRIGGER IF EXISTS product_product_tags_search_tg ON product_product_tags;
CREATE TRIGGER product_product_tags_search_tg
AFTER INSERT OR UPDATE OR DELETE ON product_product_tags
FOR EACH ROW EXECUTE FUNCTION kompress_product_tags_link_search_tg();
"#,
)
.await?;
// Renaming a tag refreshes every product carrying it.
db.execute_unprepared(
r#"
CREATE OR REPLACE FUNCTION kompress_tag_rename_search_tg() RETURNS trigger
LANGUAGE plpgsql AS $func$
BEGIN
UPDATE products p
SET search_vector =
kompress_build_product_search(p.name, p.description, p.id)
WHERE p.id IN (
SELECT ppt.product_id FROM product_product_tags ppt
WHERE ppt.product_tag_id = NEW.id
);
RETURN NEW;
END;
$func$;
DROP TRIGGER IF EXISTS product_tags_rename_search_tg ON product_tags;
CREATE TRIGGER product_tags_rename_search_tg
AFTER UPDATE OF name ON product_tags
FOR EACH ROW EXECUTE FUNCTION kompress_tag_rename_search_tg();
"#,
)
.await?;
// Backfill existing rows, then (re)create the GIN index for `@@`.
db.execute_unprepared(
r#"
UPDATE products
SET search_vector = kompress_build_product_search(name, description, id);
CREATE INDEX idx_products_search_vector
ON products USING GIN (search_vector);
"#,
)
.await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
let db = m.get_connection();
db.execute_unprepared(
r#"
DROP TRIGGER IF EXISTS product_tags_rename_search_tg ON product_tags;
DROP TRIGGER IF EXISTS product_product_tags_search_tg ON product_product_tags;
DROP TRIGGER IF EXISTS product_variants_search_tg ON product_variants;
DROP TRIGGER IF EXISTS products_search_tg ON products;
DROP FUNCTION IF EXISTS kompress_tag_rename_search_tg();
DROP FUNCTION IF EXISTS kompress_product_tags_link_search_tg();
DROP FUNCTION IF EXISTS kompress_variants_search_tg();
DROP FUNCTION IF EXISTS kompress_products_search_tg();
DROP FUNCTION IF EXISTS kompress_refresh_product_search(integer);
DROP FUNCTION IF EXISTS kompress_build_product_search(text, text, integer);
ALTER TABLE products DROP COLUMN IF EXISTS search_vector;
"#,
)
.await?;
// Restore the name + description generated column from the prior migration.
db.execute_unprepared(
r#"
ALTER TABLE products
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('sk_unaccent', COALESCE(name, '')), 'A') ||
setweight(to_tsvector('sk_unaccent', COALESCE(description, '')), 'B')
) STORED;
CREATE INDEX idx_products_search_vector
ON products USING GIN (search_vector);
"#,
)
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,48 @@
//! Trigram indexes so the admin order search stays fast as orders pile up.
//!
//! Order search is a plain substring (`ILIKE`) match over the high-signal,
//! free-text order fields — order number, email, customer/company name — run
//! through `f_unaccent` so diacritics and case never matter (see
//! `orders::Entity::search`). These `pg_trgm` GIN indexes let those `ILIKE`
//! lookups use an index instead of scanning every row. `pg_trgm` + `f_unaccent`
//! already exist from the product-search migration.
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> {
m.get_connection()
.execute_unprepared(
r#"
CREATE INDEX idx_orders_number_trgm
ON orders USING GIN (f_unaccent(order_number) gin_trgm_ops);
CREATE INDEX idx_orders_email_trgm
ON orders USING GIN (f_unaccent(email) gin_trgm_ops);
CREATE INDEX idx_orders_customer_name_trgm
ON orders USING GIN (f_unaccent(COALESCE(customer_name, '')) gin_trgm_ops);
CREATE INDEX idx_orders_company_name_trgm
ON orders USING GIN (f_unaccent(COALESCE(company_name, '')) gin_trgm_ops);
"#,
)
.await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.get_connection()
.execute_unprepared(
r#"
DROP INDEX IF EXISTS idx_orders_company_name_trgm;
DROP INDEX IF EXISTS idx_orders_customer_name_trgm;
DROP INDEX IF EXISTS idx_orders_email_trgm;
DROP INDEX IF EXISTS idx_orders_number_trgm;
"#,
)
.await?;
Ok(())
}
}

View File

@@ -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
}
}

View File

@@ -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(())
}
}

View File

@@ -0,0 +1,20 @@
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> {
// The store is EUR-only. Currency is no longer stored per product/order;
// the euro symbol is rendered everywhere in the UI.
remove_column(m, "products", "currency").await?;
remove_column(m, "orders", "currency").await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
add_column(m, "products", "currency", ColType::StringWithDefault("EUR".to_string())).await?;
add_column(m, "orders", "currency", ColType::StringWithDefault("EUR".to_string())).await
}
}

View File

@@ -0,0 +1,31 @@
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> {
// Buyer-selectable display currencies. EUR is the base/transaction
// currency and is NOT stored here; each row is an alternative the buyer
// can switch to, whose prices are the EUR price recalculated at
// `rate_e4` (units of this currency per 1 EUR, scaled ×10000). For now
// the only row is CZK, seeded by `initializers::currency_seeder`.
create_table(m, "currencies",
&[
("id", ColType::PkAuto),
("code", ColType::StringUniq),
("symbol", ColType::String),
("rate_e4", ColType::BigIntegerWithDefault(10_000)),
("enabled", ColType::BooleanWithDefault(true)),
],
&[
]
).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "currencies").await
}
}

View File

@@ -17,9 +17,9 @@ use std::{path::Path, sync::Arc};
#[allow(unused_imports)]
use crate::{
controllers::{
account, admin_categories, admin_customers, admin_dashboard, admin_discount_profiles,
admin_form, admin_orders, admin_products, admin_shipping, auth, auth_pages,
cart, checkout, home, i18n, media, oauth2,
account, admin_categories, admin_currencies, admin_customers, admin_dashboard,
admin_discount_profiles, admin_form, admin_orders, admin_products, admin_shipping,
auth, auth_pages, cart, checkout, currency, home, i18n, media, oauth2,
shop,
},
initializers,
@@ -83,6 +83,7 @@ impl Hooks for App {
Box::new(initializers::view_engine::ViewEngineInitializer),
Box::new(initializers::admin_seeder::AdminSeeder),
Box::new(initializers::shipping_seeder::ShippingSeeder),
Box::new(initializers::currency_seeder::CurrencySeeder),
Box::new(initializers::oauth2::OAuth2StoreInitializer),
Box::new(initializers::oauth2_session::OAuth2SessionInitializer),
])
@@ -95,6 +96,7 @@ impl Hooks for App {
.add_route(shop::routes())
.add_route(cart::routes())
.add_route(checkout::routes())
.add_route(currency::routes())
// cross-cutting
.add_route(auth::routes())
.add_route(auth_pages::routes())
@@ -110,6 +112,7 @@ impl Hooks for App {
.add_route(admin_orders::routes())
.add_route(admin_customers::routes())
.add_route(admin_shipping::routes())
.add_route(admin_currencies::routes())
}
async fn after_context(ctx: AppContext) -> Result<AppContext> {

View File

@@ -198,7 +198,7 @@ async fn create(
guard::current_admin(auth, &ctx).await?;
let form = read_multipart_form(multipart).await?;
let fields = parse_category_fields(&ctx, &form, None).await?;
let image_id = match form.image {
let image_id = match form.single_image() {
Some(data) => Some(store_image(&ctx, data).await?),
None => None,
};
@@ -252,7 +252,7 @@ async fn update(
category.position = Set(fields.position);
category.published = Set(fields.published);
category.parent_id = Set(fields.parent_id);
if let Some(data) = form.image {
if let Some(data) = form.single_image() {
category.image_id = Set(Some(store_image(&ctx, data).await?));
}
category.update(&ctx.db).await?;

View File

@@ -0,0 +1,94 @@
//! Admin management of the alternative display currencies.
//!
//! EUR is the base/transaction currency and is shown read-only for context. The
//! admin sets each alternative currency's exchange rate (units per 1 EUR) and
//! toggles whether buyers may switch to it. The currencies themselves are fixed
//! and seeded by `initializers::currency_seeder`.
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, EntityTrait, QueryOrder, Set};
use serde::Deserialize;
use serde_json::json;
use crate::{
controllers::i18n::current_lang,
models::currencies,
shared::{
currency::{self, BASE_CODE, BASE_SYMBOL},
guard,
},
};
#[derive(Debug, Deserialize)]
struct CurrencyForm {
rate: String,
enabled: Option<String>,
}
fn is_checked(value: &Option<String>) -> bool {
matches!(value.as_deref(), Some("on" | "true" | "1"))
}
#[debug_handler]
async fn index(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let rows = currencies::Entity::find()
.order_by_asc(currencies::Column::Code)
.all(&ctx.db)
.await?;
let currencies_json: Vec<serde_json::Value> = rows
.iter()
.map(|c| {
json!({
"id": c.id,
"code": c.code,
"symbol": c.symbol,
"rate": currency::format_rate(c.rate_e4),
"enabled": c.enabled,
})
})
.collect();
format::view(
&v,
"admin/currencies/index.html",
json!({
"base_code": BASE_CODE,
"base_symbol": BASE_SYMBOL,
"currencies": currencies_json,
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn update(
auth: auth::JWT,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
Form(form): Form<CurrencyForm>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let row = currencies::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
let mut active = row.into_active_model();
active.rate_e4 = Set(currency::parse_rate(&form.rate)?);
active.enabled = Set(is_checked(&form.enabled));
active.update(&ctx.db).await?;
// Keep the navbar/settings chrome snapshot in sync with the new rate/state.
currency::refresh_snapshot(&ctx.db).await?;
format::redirect("/admin/currencies")
}
pub fn routes() -> Routes {
Routes::new()
.add("/admin/currencies", get(index))
.add("/admin/currencies/{id}", post(update))
}

View File

@@ -138,10 +138,17 @@ async fn show(
.all(&ctx.db)
.await?;
let list = products::Entity::find()
.order_by_asc(products::Column::Name)
.all(&ctx.db)
.await?;
// Optional text search (drafts included), otherwise the whole catalog by
// name. Reuses the storefront's hybrid full-text + fuzzy product search.
let query = params.get("q").map(String::as_str).unwrap_or("").trim().to_string();
let list = if query.is_empty() {
products::Entity::find()
.order_by_asc(products::Column::Name)
.all(&ctx.db)
.await?
} else {
products::Entity::search(&ctx.db, &query, 1000, false).await?
};
// Category sidebar tree (counts over the full, unfiltered product list) plus
// the active `?category=` filter applied to the rows.
@@ -191,7 +198,6 @@ async fn show(
"variant_id": variant.id,
"name": product.name,
"variant_label": variant.label,
"currency": product.currency,
"regular_price": format_price(d.regular_cents),
"business_price": format_price(b.price_cents),
"business_reduced": b.price_cents < d.regular_cents,
@@ -212,6 +218,7 @@ async fn show(
"products": rows,
"category_groups": category_groups,
"selected_category": selected_category,
"query": query,
"total_count": list.len(),
"uncategorized_count": category_ids.iter().filter(|c| c.is_none()).count(),
"error": params.get("error"),
@@ -277,7 +284,6 @@ async fn price_edit(
"variant_id": variant.id,
"name": product.name,
"variant_label": variant.label,
"currency": product.currency,
"regular_price": format_price(d.regular_cents),
"regular_cents": d.regular_cents,
"business_price": format_price(business_cents),

View File

@@ -1,8 +1,11 @@
//! Multipart form handling shared by the product and category admin forms.
//!
//! Both forms submit a mix of text fields and an optional `image` file part;
//! this collects them into an easy-to-query [`MultipartForm`] and stores any
//! uploaded image through the configured storage driver.
//! Both forms submit a mix of text fields and `image` file part(s); this
//! collects them into an easy-to-query [`MultipartForm`] and stores any
//! uploaded image through the configured storage driver. The product form can
//! upload several images at once and submits a unified gallery order as
//! repeated `image_order` fields — each either an existing image's id or the
//! literal `new` (a placeholder consumed, in order, from the uploaded files).
use std::collections::HashMap;
@@ -18,11 +21,24 @@ fn normalize_empty(value: Option<String>) -> Option<String> {
})
}
/// Collected multipart form: text fields keyed by name, plus the raw bytes of
/// an `image` file part if one was uploaded (an empty file input is ignored).
/// One slot in the unified gallery order submitted by the product form.
#[derive(Debug, Clone)]
pub(crate) enum ImageSlot {
/// An existing image kept in the gallery.
Existing(i32),
/// A placeholder for one newly-uploaded file, consumed from [`MultipartForm::images`]
/// in the order these slots appear.
New,
}
/// Collected multipart form: text fields keyed by name, the raw bytes of every
/// `image` file part uploaded (empty file inputs are ignored, submission order
/// preserved), and the full gallery order as repeated `image_order` fields —
/// each either an existing image's id or the literal `new`.
pub(crate) struct MultipartForm {
fields: HashMap<String, String>,
pub(crate) image: Option<Vec<u8>>,
pub(crate) images: Vec<Vec<u8>>,
pub(crate) image_order: Vec<ImageSlot>,
}
impl MultipartForm {
@@ -31,6 +47,12 @@ impl MultipartForm {
normalize_empty(self.fields.get(key).cloned())
}
/// The single uploaded image, for forms (like categories) that accept only
/// one. Consumes the first uploaded part; any extras are ignored.
pub(crate) fn single_image(self) -> Option<Vec<u8>> {
self.images.into_iter().next()
}
/// Whether a checkbox-style field is checked.
pub(crate) fn checked(&self, key: &str) -> bool {
matches!(
@@ -59,7 +81,8 @@ impl MultipartForm {
pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<MultipartForm> {
let mut fields = HashMap::new();
let mut image = None;
let mut images = Vec::new();
let mut image_order = Vec::new();
while let Some(mut field) = multipart
.next_field()
@@ -82,8 +105,20 @@ pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<Mult
)));
}
}
// An empty file part (no file chosen in a slot) is ignored.
if !data.is_empty() {
image = Some(data);
images.push(data);
}
} else if name == "image_order" {
let value = field
.text()
.await
.map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?;
let trimmed = value.trim();
if trimmed == "new" {
image_order.push(ImageSlot::New);
} else if let Ok(id) = trimmed.parse::<i32>() {
image_order.push(ImageSlot::Existing(id));
}
} else {
let value = field
@@ -94,7 +129,11 @@ pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<Mult
}
}
Ok(MultipartForm { fields, image })
Ok(MultipartForm {
fields,
images,
image_order,
})
}
/// Store an uploaded image's bytes and return its generated filename.

View File

@@ -1,5 +1,8 @@
//! Admin order list, detail, status updates, and manual carrier dispatch.
use std::collections::HashMap;
use axum::extract::Query;
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
@@ -30,18 +33,31 @@ async fn index(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Query(params): Query<HashMap<String, String>>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let list = orders::Entity::find()
.order_by_desc(orders::Column::CreatedAt)
.all(&ctx.db)
.await?;
// Optional search over order number / customer / email / etc., otherwise the
// full list newest first.
let query = params.get("q").map(String::as_str).unwrap_or("").trim().to_string();
let list = if query.is_empty() {
orders::Entity::find()
.order_by_desc(orders::Column::CreatedAt)
.all(&ctx.db)
.await?
} else {
orders::Entity::search(&ctx.db, &query, 500).await?
};
let rows: Vec<serde_json::Value> = list.iter().map(view::summary).collect();
format::view(
&v,
"admin/orders/index.html",
json!({ "orders": rows, "lang": current_lang(&jar) }),
json!({
"orders": rows,
"query": query,
"total": list.len(),
"lang": current_lang(&jar),
}),
)
}
@@ -186,7 +202,6 @@ async fn ship(
country: order.country.as_deref(),
pickup_point_id: order.pickup_point_id.as_deref(),
cod_cents,
currency: &order.currency,
value_cents: goods_value,
weight_grams: DEFAULT_PARCEL_WEIGHT_GRAMS,
};

View File

@@ -20,13 +20,13 @@ use serde_json::json;
use crate::{
controllers::{
admin_form::{read_multipart_form, store_image, MultipartForm},
admin_form::{read_multipart_form, store_image, ImageSlot, MultipartForm},
i18n::current_lang,
media::IMAGE_MAX_BYTES,
},
shared::{
guard,
money::{format_bp, format_price, parse_price_to_cents},
money::{format_bp, format_price, parse_percent, parse_price_to_cents},
pricing,
slug::{slugify, unique_slug},
},
@@ -52,7 +52,7 @@ struct ProductFields {
name: String,
slug: String,
description: Option<String>,
currency: String,
short_description: Option<String>,
category_id: Option<i32>,
published: bool,
}
@@ -65,8 +65,8 @@ async fn parse_product_fields(
let name = form
.text("name")
.ok_or_else(|| Error::BadRequest("product name is required".to_string()))?;
let currency = form.text("currency").unwrap_or_else(|| "EUR".to_string());
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 published = form.checked("published");
@@ -91,7 +91,7 @@ async fn parse_product_fields(
name,
slug,
description,
currency,
short_description,
category_id,
published,
})
@@ -102,33 +102,12 @@ struct VariantInput {
id: Option<i32>,
label: String,
sku: Option<String>,
stock: i32,
/// `None` = available but not inventory-tracked.
stock: Option<i32>,
price_cents: i64,
sale_cents: Option<i64>,
business_sale_cents: Option<i64>,
position: i32,
}
/// An optional price field on a variant row (sale / business sale): blank means
/// "no 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
/// rows (no price and no label) are skipped; at least one valid row is required.
fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
@@ -157,13 +136,17 @@ fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
}
let sku = form.text(&format!("variants[{i}][sku]"));
let stock = form
.text(&format!("variants[{i}][stock]"))
.and_then(|s| s.parse::<i32>().ok())
.filter(|n| *n >= 0)
.unwrap_or(0);
let sale_cents = parse_optional_sale(form, i, "sale", price_cents)?;
let business_sale_cents = parse_optional_sale(form, i, "business_sale", price_cents)?;
// Stock is optional: blank means "available, not tracked". A value must
// be a non-negative integer.
let stock = match form.text(&format!("variants[{i}][stock]")) {
None => None,
Some(raw) => Some(
raw.parse::<i32>()
.ok()
.filter(|n| *n >= 0)
.ok_or_else(|| Error::BadRequest("stock must be 0 or more".to_string()))?,
),
};
let id = form
.text(&format!("variants[{i}][id]"))
.and_then(|s| s.parse::<i32>().ok());
@@ -174,8 +157,6 @@ fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
sku,
stock,
price_cents,
sale_cents,
business_sale_cents,
position: out.len() as i32,
});
}
@@ -193,8 +174,9 @@ fn apply_variant(active: &mut product_variants::ActiveModel, input: &VariantInpu
active.sku = Set(input.sku.clone());
active.stock = Set(input.stock);
active.price_cents = Set(input.price_cents);
active.sale_price_cents = Set(input.sale_cents);
active.business_sale_price_cents = Set(input.business_sale_cents);
// Discounts (public + business sale) are owned by the discount page and keyed
// per option/audience; the product form must leave those columns untouched so
// it never clobbers a discount. New variants default them to NULL.
active.position = Set(input.position);
}
@@ -252,8 +234,6 @@ fn variant_form_json(variant: &product_variants::Model) -> serde_json::Value {
"sku": variant.sku,
"stock": variant.stock,
"price": format_price(variant.price_cents),
"sale": variant.sale_price_cents.map(format_price),
"business_sale": variant.business_sale_price_cents.map(format_price),
})
}
@@ -277,10 +257,17 @@ async fn index(
.map(|c| (c.id, c.name.clone()))
.collect();
let list = products::Entity::find()
.order_by_desc(products::Column::CreatedAt)
.all(&ctx.db)
.await?;
// Optional text search (drafts included), otherwise the full catalog newest
// first. Reuses the storefront's hybrid full-text + fuzzy product search.
let query = params.get("q").map(String::as_str).unwrap_or("").trim().to_string();
let list = if query.is_empty() {
products::Entity::find()
.order_by_desc(products::Column::CreatedAt)
.all(&ctx.db)
.await?
} else {
products::Entity::search(&ctx.db, &query, 1000, false).await?
};
let ids: Vec<i32> = list.iter().map(|p| p.id).collect();
let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &ids).await?;
@@ -318,12 +305,26 @@ async fn index(
let category_name = product
.category_id
.and_then(|id| category_name.get(&id).cloned());
let total_stock: i32 = variants.iter().map(|v| v.stock).sum();
// Stock column: total across tracked variants, or "∞" when any option is
// untracked (always available).
let stock_display = if variants.iter().any(|v| !v.tracked()) {
"".to_string()
} else {
variants
.iter()
.filter_map(|v| v.stock)
.sum::<i32>()
.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(
product,
priced,
on_sale,
variants.len(),
total_stock,
stock_display,
image,
category_name,
));
@@ -338,6 +339,7 @@ async fn index(
"audience": audience,
"category_groups": category_groups,
"selected_category": selected_category,
"query": query,
"total_count": list.len(),
"uncategorized_count": category_ids.iter().filter(|c| c.is_none()).count(),
"lang": current_lang(&jar),
@@ -351,8 +353,9 @@ async fn index(
fn product_row(
product: &products::Model,
effective: &pricing::PricedProduct,
on_sale: bool,
variant_count: usize,
total_stock: i32,
stock_display: String,
image: Option<String>,
category_name: Option<String>,
) -> serde_json::Value {
@@ -360,14 +363,14 @@ fn product_row(
"id": product.id,
"name": product.name,
"slug": product.slug,
"currency": product.currency,
"stock": total_stock,
"stock": stock_display,
"variant_count": variant_count,
"has_options": variant_count > 1,
"published": product.published,
"image": image,
"category_name": category_name,
"regular_price": format_price(effective.regular_cents),
"on_sale": on_sale,
"effective_price": format_price(effective.price_cents),
"effective_reduced": effective.is_reduced(),
"effective_percent_off": percent_off(effective.regular_cents, effective.price_cents),
@@ -434,7 +437,7 @@ async fn create(
name: Set(fields.name),
slug: Set(fields.slug),
description: Set(fields.description),
currency: Set(fields.currency),
short_description: Set(fields.short_description),
view_count: Set(0),
published: Set(fields.published),
published_at: Set(fields.published.then(|| chrono::Utc::now().into())),
@@ -444,24 +447,73 @@ async fn create(
.insert(&txn)
.await?;
sync_variants(&txn, product.id, &variants).await?;
if let Some(data) = form.image {
let filename = store_image(&ctx, data).await?;
product_images::ActiveModel {
product_id: Set(product.id),
image_id: Set(filename),
position: Set(0),
alt: Set(None),
..Default::default()
}
.insert(&txn)
.await?;
}
sync_images(&ctx, &txn, product.id, &form.image_order, &form.images).await?;
txn.commit().await?;
format::redirect("/admin/catalog/products")
}
/// Reconcile a product's images inside `txn` with the submitted unified
/// `image_order`: for each [`ImageSlot::Existing`] entry re-number the
/// corresponding image to its slot position; for each [`ImageSlot::New`]
/// consume the next freshly uploaded file from `new_images`, storing and
/// inserting it at that position. Any existing image not referenced in
/// `image_order` is deleted.
async fn sync_images<C: ConnectionTrait>(
ctx: &AppContext,
txn: &C,
product_id: i32,
image_order: &[ImageSlot],
new_images: &[Vec<u8>],
) -> Result<()> {
let existing = product_images::for_product(txn, product_id).await?;
let by_id: HashMap<i32, product_images::Model> =
existing.iter().map(|m| (m.id, m.clone())).collect();
let keep: HashSet<i32> = image_order
.iter()
.filter_map(|slot| match slot {
ImageSlot::Existing(id) => Some(*id),
ImageSlot::New => None,
})
.collect();
for image in &existing {
if !keep.contains(&image.id) {
image.clone().delete(txn).await?;
}
}
let mut new_iter = new_images.iter();
let mut position = 0i32;
for slot in image_order {
match slot {
ImageSlot::Existing(id) => {
if let Some(model) = by_id.get(id) {
let mut active = model.clone().into_active_model();
active.position = Set(position);
active.update(txn).await?;
}
}
ImageSlot::New => {
if let Some(data) = new_iter.next() {
let filename = store_image(ctx, data.clone()).await?;
product_images::ActiveModel {
product_id: Set(product_id),
image_id: Set(filename),
position: Set(position),
alt: Set(None),
..Default::default()
}
.insert(txn)
.await?;
}
}
}
position += 1;
}
Ok(())
}
#[debug_handler]
async fn edit(
auth: auth::JWT,
@@ -472,10 +524,10 @@ async fn edit(
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let product = product_by_id(&ctx, id).await?;
let image = product_images::first_for(&ctx, id).await?;
let images = product_images::for_product(&ctx.db, id).await?;
let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
let mut context = form_context(&ctx, &jar).await?;
context["product"] = view::product_form(&product, image);
context["product"] = view::product_form(&product, &images);
context["variants"] = json!(variants.iter().map(variant_form_json).collect::<Vec<_>>());
format::view(&v, "admin/catalog/product_form.html", context)
}
@@ -499,7 +551,7 @@ async fn update(
product.name = Set(fields.name);
product.slug = Set(fields.slug);
product.description = Set(fields.description);
product.currency = Set(fields.currency);
product.short_description = Set(fields.short_description);
product.category_id = Set(fields.category_id);
product.published = Set(fields.published);
if fields.published && !was_published {
@@ -509,20 +561,7 @@ async fn update(
}
product.update(&txn).await?;
sync_variants(&txn, id, &variants).await?;
if let Some(data) = form.image {
let filename = store_image(&ctx, data).await?;
let next_position = product_images::count_for(&ctx, id).await?;
product_images::ActiveModel {
product_id: Set(id),
image_id: Set(filename),
position: Set(next_position),
alt: Set(None),
..Default::default()
}
.insert(&txn)
.await?;
}
sync_images(&ctx, &txn, id, &form.image_order, &form.images).await?;
txn.commit().await?;
format::redirect("/admin/catalog/products")
@@ -561,6 +600,30 @@ fn list_redirect(audience: &str) -> Result<Response> {
format::redirect(&format!("/admin/catalog/products?audience={audience}"))
}
/// Resolve a percentage off the regular price into a fixed sale price in cents.
fn percent_to_sale_cents(regular_cents: i64, percent: f64) -> i64 {
let off = (regular_cents as f64 * percent / 100.0).round() as i64;
regular_cents - off
}
/// 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.
fn percent_off(regular_cents: i64, sale_cents: i64) -> i64 {
if regular_cents <= 0 {
@@ -635,7 +698,6 @@ async fn profiles_preview(
}
rows.push(json!({
"id": product.id,
"currency": product.currency,
"effective_price": format_price(priced.price_cents),
"effective_reduced": priced.is_reduced(),
"effective_percent_off": percent_off(priced.regular_cents, priced.price_cents),
@@ -684,8 +746,233 @@ async fn sync_profiles(
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) -> serde_json::Value {
json!({
"id": self.id,
"label": self.label,
"regular_cents": self.regular_cents,
"regular_price": format_price(self.regular_cents),
"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(DiscountRow::to_json).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,
},
"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(&params);
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(&params);
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(&params);
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 {
let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024);
// Several images may be uploaded in one submission; allow a generous total
// (per-file size is still capped at IMAGE_MAX_BYTES while reading).
let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES * 12 + 1024 * 1024);
Routes::new()
.add("/admin/catalog/products", get(index))
.add("/admin/catalog/products/new", get(new))
@@ -704,4 +991,16 @@ pub fn routes() -> Routes {
post(update).layer(image_limit),
)
.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),
)
}

View File

@@ -1,4 +1,4 @@
use crate::{controllers::i18n::current_lang, shared::{guard, money::format_price, pricing}, models::{product_variants, products}};
use crate::{controllers::i18n::current_lang, shared::{currency::{self, Currency}, guard, pricing}, models::{product_variants, products}};
use axum::{
http::{HeaderMap, StatusCode},
response::Redirect,
@@ -65,7 +65,7 @@ fn cart_cookie(value: String) -> Cookie<'static> {
}
/// Look up a variant whose product is published, returning the variant together
/// with its parent product (for name/slug/currency).
/// with its parent product (for name/slug).
async fn published_variant(
ctx: &AppContext,
variant_id: i32,
@@ -97,9 +97,9 @@ async fn add(
let mut items = parse_cart(&jar);
let add_qty = form.quantity.unwrap_or(1).max(1);
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == variant.id) {
entry.1 = (entry.1 + add_qty).min(variant.stock);
entry.1 = variant.cap(entry.1 + add_qty);
} else {
items.push((variant.id, add_qty.min(variant.stock)));
items.push((variant.id, variant.cap(add_qty)));
}
items.retain(|(_, qty)| *qty > 0);
@@ -128,13 +128,14 @@ async fn update(
headers: HeaderMap,
Form(form): Form<UpdateForm>,
) -> Result<Response> {
let stock = published_variant(&ctx, form.variant_id)
.await?
.map(|(v, _)| v.stock)
.unwrap_or(0);
// Clamp the requested quantity to what's available (no cap for untracked
// variants); a removed variant clamps to 0 and drops out below.
let clamped = match published_variant(&ctx, form.variant_id).await? {
Some((variant, _)) => variant.cap(form.quantity),
None => 0,
};
let mut items = parse_cart(&jar);
let clamped = form.quantity.clamp(0, stock);
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.variant_id) {
entry.1 = clamped;
}
@@ -172,12 +173,8 @@ async fn cart_response(
return Ok((jar, Redirect::to("/cart")).into_response());
}
let (lines, valid, total) = resolve_cart(ctx, &jar).await?;
let currency = lines
.first()
.and_then(|line| line["currency"].as_str())
.unwrap_or("EUR")
.to_string();
let cur = currency::resolve(ctx, &jar).await;
let (lines, valid, total) = resolve_cart(ctx, &jar, &cur).await?;
// Persist the re-validated cookie (drops now-invalid lines).
let jar = jar.add(cart_cookie(serialize_cart(&valid)));
let response = format::view(
@@ -185,8 +182,8 @@ async fn cart_response(
"shop/_cart_body.html",
json!({
"items": lines,
"total": format_price(total),
"currency": currency,
"total": cur.format(total),
"currency_symbol": cur.symbol,
"lang": current_lang(&jar),
}),
)?;
@@ -199,6 +196,7 @@ async fn cart_response(
pub(crate) async fn resolve_cart(
ctx: &AppContext,
jar: &CookieJar,
cur: &Currency,
) -> Result<(Vec<serde_json::Value>, Vec<(i32, i32)>, i64)> {
// Resolve the cart entries to in-stock products first, then price them all
// for the current viewer in one batch (the price depends on who's logged in).
@@ -208,7 +206,7 @@ pub(crate) async fn resolve_cart(
let Some((variant, product)) = published_variant(ctx, id).await? else {
continue;
};
let qty = qty.clamp(0, variant.stock);
let qty = variant.cap(qty);
if qty == 0 {
continue;
}
@@ -231,13 +229,12 @@ pub(crate) async fn resolve_cart(
"name": product.name,
"variant_label": variant.label,
"slug": product.slug,
"price": format_price(unit_price),
"regular_price": format_price(priced.regular_cents),
"price": cur.format(unit_price),
"regular_price": cur.format(priced.regular_cents),
"on_sale": priced.is_reduced(),
"currency": product.currency,
"quantity": qty,
"stock": variant.stock,
"line_total": format_price(line_total),
"line_total": cur.format(line_total),
}));
}
@@ -250,12 +247,8 @@ async fn show(
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let (lines, valid, total) = resolve_cart(&ctx, &jar).await?;
let currency = lines
.first()
.and_then(|line| line["currency"].as_str())
.unwrap_or("EUR")
.to_string();
let cur = currency::resolve(&ctx, &jar).await;
let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?;
// Drop any now-invalid lines from the cookie so the badge stays accurate.
let rebuilt = serialize_cart(&valid);
@@ -265,8 +258,8 @@ async fn show(
"shop/cart.html",
json!({
"items": lines,
"total": format_price(total),
"currency": currency,
"total": cur.format(total),
"currency_symbol": cur.symbol,
"logged_in_admin": c.logged_in_admin,
"logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
@@ -286,20 +279,16 @@ async fn preview(
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let (lines, valid, total) = resolve_cart(&ctx, &jar).await?;
let currency = lines
.first()
.and_then(|line| line["currency"].as_str())
.unwrap_or("EUR")
.to_string();
let cur = currency::resolve(&ctx, &jar).await;
let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?;
let rebuilt = serialize_cart(&valid);
let response = format::view(
&v,
"shop/_cart_preview.html",
json!({
"items": lines,
"total": format_price(total),
"currency": currency,
"total": cur.format(total),
"currency_symbol": cur.symbol,
"lang": current_lang(&jar),
}),
)?;

View File

@@ -18,7 +18,7 @@ use crate::{
users::{self, normalize_account_type},
},
controllers::i18n::current_lang,
shared::{guard, money::format_price, settings},
shared::{currency::Currency, guard, money::format_price, settings},
views::checkout as view,
};
@@ -77,15 +77,12 @@ async fn checkout_page(
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar).await?;
// Checkout and everything past it (orders, confirmation) stay in the EUR
// base — the settlement currency — even when the buyer browsed in another.
let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
if lines.is_empty() {
return format::redirect("/cart");
}
let currency = lines
.first()
.and_then(|line| line["currency"].as_str())
.unwrap_or("EUR")
.to_string();
let methods: Vec<serde_json::Value> = enabled_shipping_methods(&ctx)
.await?
@@ -127,7 +124,6 @@ async fn checkout_page(
"items": lines,
"subtotal": format_price(subtotal),
"subtotal_cents": subtotal,
"currency": currency,
"shipping_methods": methods,
"packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""),
"logged_in_admin": is_admin,
@@ -165,7 +161,7 @@ async fn place_order(
State(ctx): State<AppContext>,
Form(form): Form<CheckoutForm>,
) -> Result<Response> {
let (_lines, valid, _total) = resolve_cart(&ctx, &jar).await?;
let (_lines, valid, _total) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
if valid.is_empty() {
return format::redirect("/cart");
}

View File

@@ -0,0 +1,39 @@
//! Storefront display-currency switcher.
//!
//! Sets the `currency` cookie to the buyer's chosen display currency, then sends
//! them back where they were. EUR is the base; any other code must name an
//! enabled row in `currencies` or it falls back to EUR on the next render.
use axum::{
http::{header, HeaderMap},
response::Redirect,
};
use loco_rs::prelude::*;
use serde::Deserialize;
use crate::controllers::i18n::back_path;
use crate::shared::currency::{BASE_CODE, COOKIE};
#[derive(Debug, Deserialize)]
pub struct CurrencyForm {
pub currency: String,
}
#[debug_handler]
async fn set_currency(headers: HeaderMap, Form(form): Form<CurrencyForm>) -> Result<Response> {
// Store the code uppercased; validation against the enabled set happens at
// render time (shared::currency::resolve), which falls back to EUR.
let code = form.currency.trim().to_uppercase();
let code = if code.is_empty() { BASE_CODE.to_string() } else { code };
let cookie = format!("{COOKIE}={code}; Path=/; Max-Age=31536000; SameSite=Lax");
Ok((
[(header::SET_COOKIE, cookie)],
Redirect::to(&back_path(&headers)),
)
.into_response())
}
pub fn routes() -> Routes {
Routes::new().add("/currency", post(set_currency))
}

View File

@@ -4,7 +4,9 @@ use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use serde_json::json;
use crate::{controllers::i18n::current_lang, shared::guard, controllers::shop};
use crate::{
controllers::i18n::current_lang, controllers::shop, shared::currency, shared::guard,
};
#[debug_handler]
async fn index(
@@ -13,7 +15,8 @@ async fn index(
State(ctx): State<AppContext>,
) -> Result<Response> {
let user = guard::current_user(&ctx, &jar).await;
let products = shop::featured_products(&ctx, user.as_ref(), 8).await?;
let cur = currency::resolve(&ctx, &jar).await;
let products = shop::featured_products(&ctx, user.as_ref(), 8, &cur).await?;
let c = guard::chrome_from(&ctx, user.as_ref());
format::view(
@@ -25,6 +28,7 @@ async fn index(
"logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
"customer_account_type": c.customer_account_type,
"currency_symbol": cur.symbol,
"lang": current_lang(&jar),
}),
)

View File

@@ -34,7 +34,7 @@ async fn set_lang(headers: HeaderMap, Form(form): Form<LangForm>) -> Result<Resp
.into_response())
}
fn back_path(headers: &HeaderMap) -> String {
pub(crate) fn back_path(headers: &HeaderMap) -> String {
let raw = headers
.get(header::REFERER)
.and_then(|value| value.to_str().ok())

View File

@@ -3,6 +3,7 @@ pub mod auth;
pub mod auth_pages;
pub mod oauth2;
pub mod admin_categories;
pub mod admin_currencies;
pub mod admin_customers;
pub mod admin_dashboard;
pub mod admin_discount_profiles;
@@ -12,6 +13,7 @@ pub mod admin_products;
pub mod admin_shipping;
pub mod cart;
pub mod checkout;
pub mod currency;
pub mod home;
pub mod i18n;
pub mod media;

View File

@@ -1,6 +1,10 @@
//! Public storefront: product listings, product detail, category pages and the
//! lazily-loaded category sidebar.
use std::collections::HashMap;
use axum::extract::Query;
use axum::http::HeaderMap;
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set};
@@ -8,11 +12,240 @@ use serde_json::json;
use crate::{
controllers::i18n::current_lang,
shared::{guard, pricing},
shared::{
currency::{self, Currency},
guard,
money::parse_price_to_cents,
pricing,
},
models::{categories, product_images, product_variants, products, users},
views::shop as view,
};
/// Results per page in the storefront listing/search.
const PER_PAGE: usize = 24;
/// Hard cap on candidates a single text search considers before faceting; well
/// above any realistic page of results for this catalog.
const SEARCH_CAP: u64 = 1000;
/// All storefront listing controls: free-text query, category, price band,
/// stock, sort and page. Everything is optional so `/shop` and `/search` share
/// one shape.
#[derive(Debug, Default, serde::Deserialize)]
struct SearchParams {
q: Option<String>,
category: Option<String>,
min_price: Option<String>,
max_price: Option<String>,
in_stock: Option<String>,
sort: Option<String>,
page: Option<u32>,
}
/// A candidate product with everything the listing needs to filter, sort and
/// render it: its representative (first) variant, the resolved price for the
/// viewer, stock, variant count and original search rank (for relevance order).
struct Candidate {
product: products::Model,
rep: product_variants::Model,
priced: pricing::PricedProduct,
in_stock: bool,
count: usize,
rank: usize,
}
/// Whether a checkbox-style param is on (present and not an explicit "off"/"0").
fn is_on(v: &Option<String>) -> bool {
matches!(v.as_deref(), Some(s) if !s.is_empty() && s != "0" && s != "false" && s != "off")
}
/// Rebuild the query string from `params` minus `page`, so pagination links can
/// preserve the active query + filters + sort.
fn query_base(params: &SearchParams) -> String {
let mut ser = form_urlencoded::Serializer::new(String::new());
if let Some(q) = params.q.as_deref().filter(|s| !s.is_empty()) {
ser.append_pair("q", q);
}
if let Some(c) = params.category.as_deref().filter(|s| !s.is_empty() && *s != "all") {
ser.append_pair("category", c);
}
if let Some(p) = params.min_price.as_deref().filter(|s| !s.is_empty()) {
ser.append_pair("min_price", p);
}
if let Some(p) = params.max_price.as_deref().filter(|s| !s.is_empty()) {
ser.append_pair("max_price", p);
}
if is_on(&params.in_stock) {
ser.append_pair("in_stock", "1");
}
if let Some(s) = params.sort.as_deref().filter(|s| !s.is_empty()) {
ser.append_pair("sort", s);
}
ser.finish()
}
/// Run the full faceted listing pipeline for `params` and shape the template
/// context (results page + facet data + pagination). Reused by `/shop` and
/// `/search`; the caller adds chrome and picks the template.
async fn run_search(
ctx: &AppContext,
user: Option<&users::Model>,
params: &SearchParams,
cur: &Currency,
) -> Result<serde_json::Value> {
let q = params.q.clone().unwrap_or_default();
let q_trim = q.trim().to_string();
// 1. Base candidates: ranked search hits, or the full published listing.
let base: Vec<products::Model> = if q_trim.is_empty() {
products::Entity::find()
.filter(products::Column::Published.eq(true))
.order_by_desc(products::Column::PublishedAt)
.all(&ctx.db)
.await?
} else {
products::Entity::search(&ctx.db, &q_trim, SEARCH_CAP, true).await?
};
// 2. Attach representative variant + resolved price to each (drop products
// with no purchasable variant).
let ids: Vec<i32> = base.iter().map(|p| p.id).collect();
let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &ids).await?;
let mut staged: Vec<(products::Model, product_variants::Model, usize, usize)> = Vec::new();
for (rank, product) in base.into_iter().enumerate() {
if let Some(vs) = grouped.get(&product.id) {
if let Some(rep) = vs.first() {
staged.push((product, rep.clone(), vs.len(), rank));
}
}
}
let reps: Vec<product_variants::Model> = staged.iter().map(|(_, r, _, _)| r.clone()).collect();
let priced = pricing::price_variants(ctx, &reps, user).await?;
let mut items: Vec<Candidate> = staged
.into_iter()
.zip(priced.iter())
.map(|((product, rep, count, rank), p)| Candidate {
in_stock: rep.in_stock(),
product,
rep,
priced: *p,
count,
rank,
})
.collect();
// Price band bounds across all matches, to hint the filter UI.
let price_floor = items.iter().map(|i| i.priced.price_cents).min().unwrap_or(0);
let price_ceil = items.iter().map(|i| i.priced.price_cents).max().unwrap_or(0);
// 3. Non-category filters: price band + in-stock. The typed bounds are in
// the buyer's display currency; convert them back to EUR cents to compare
// against the (EUR) resolved prices.
let min_c = params
.min_price
.as_deref()
.and_then(|s| parse_price_to_cents(s).ok())
.map(|c| cur.to_eur_cents(c));
let max_c = params
.max_price
.as_deref()
.and_then(|s| parse_price_to_cents(s).ok())
.map(|c| cur.to_eur_cents(c));
let in_stock_only = is_on(&params.in_stock);
items.retain(|i| {
min_c.is_none_or(|m| i.priced.price_cents >= m)
&& max_c.is_none_or(|m| i.priced.price_cents <= m)
&& (!in_stock_only || i.in_stock)
});
// 4. Category facets: counts computed over the price/stock-filtered set
// (i.e. before applying the category choice itself).
let all_categories = categories::published(ctx).await?;
let cat_ids: Vec<Option<i32>> = items.iter().map(|i| i.product.category_id).collect();
let category_groups = view::admin_category_groups(&all_categories, &cat_ids);
let uncategorized_count = cat_ids.iter().filter(|c| c.is_none()).count();
let category_name: HashMap<i32, String> =
all_categories.iter().map(|c| (c.id, c.name.clone())).collect();
// 5. Apply the category filter.
let selected_category = params
.category
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "all".to_string());
let filter = view::category_filter_ids(&all_categories, &selected_category);
items.retain(|i| view::category_filter_keep(&filter, i.product.category_id));
// 6. Sort. Newest-first is the default; relevance (the ranked search order)
// is available explicitly via the sort control.
let sort = params
.sort
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "newest".to_string());
match sort.as_str() {
"price_asc" => items.sort_by(|a, b| a.priced.price_cents.cmp(&b.priced.price_cents)),
"price_desc" => items.sort_by(|a, b| b.priced.price_cents.cmp(&a.priced.price_cents)),
"name_asc" => items.sort_by(|a, b| {
a.product.name.to_lowercase().cmp(&b.product.name.to_lowercase())
}),
"name_desc" => items.sort_by(|a, b| {
b.product.name.to_lowercase().cmp(&a.product.name.to_lowercase())
}),
"newest" => items.sort_by(|a, b| b.product.published_at.cmp(&a.product.published_at)),
// "relevance" and anything unknown: original search rank.
_ => items.sort_by_key(|i| i.rank),
}
// 7. Paginate.
let total = items.len();
let pages = total.div_ceil(PER_PAGE).max(1);
let page = params.page.unwrap_or(1).clamp(1, pages as u32);
let start = (page as usize - 1) * PER_PAGE;
// 8. Render only the current page's cards (images fetched per row).
let mut rows = Vec::new();
for item in items.iter().skip(start).take(PER_PAGE) {
let image = product_images::first_for(ctx, item.product.id).await?;
let cat_name = item.product.category_id.and_then(|id| category_name.get(&id).cloned());
rows.push(view::product_card(
&item.product,
&item.rep,
&item.priced,
item.count,
image,
cat_name,
cur,
));
}
Ok(json!({
"products": rows,
"query": q,
"category_groups": category_groups,
"selected_category": selected_category,
// Numeric form so the <select> can mark the active option (Tera can't
// compare a string param against a numeric category id).
"selected_category_id": selected_category.parse::<i32>().unwrap_or(-1),
"uncategorized_count": uncategorized_count,
"sort": sort,
"in_stock": in_stock_only,
"min_price": params.min_price.clone().unwrap_or_default(),
"max_price": params.max_price.clone().unwrap_or_default(),
"price_floor": cur.format(price_floor),
"price_ceil": cur.format(price_ceil),
"currency_symbol": cur.symbol,
"total": total,
"page": page,
"pages": pages,
"has_prev": page > 1,
"has_next": (page as usize) < pages,
"prev_page": page.saturating_sub(1).max(1),
"next_page": page + 1,
"query_base": query_base(params),
}))
}
/// Shape a list of products into card rows for `user` (None = public). Each card
/// shows the resolved price of the product's representative (first) variant; the
/// `variant_count` lets the template render "from {price}" for multi-variant
@@ -21,6 +254,7 @@ async fn product_rows(
ctx: &AppContext,
user: Option<&users::Model>,
list: Vec<products::Model>,
cur: &Currency,
) -> Result<Vec<serde_json::Value>> {
let ids: Vec<i32> = list.iter().map(|p| p.id).collect();
let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &ids).await?;
@@ -42,7 +276,7 @@ async fn product_rows(
let mut rows = Vec::with_capacity(entries.len());
for ((product, rep, count), priced) in entries.iter().zip(priced.iter()) {
let image = product_images::first_for(ctx, product.id).await?;
rows.push(view::product_card(product, rep, priced, *count, image, None));
rows.push(view::product_card(product, rep, priced, *count, image, None, cur));
}
Ok(rows)
}
@@ -53,6 +287,7 @@ pub(crate) async fn featured_products(
ctx: &AppContext,
user: Option<&users::Model>,
limit: u64,
cur: &Currency,
) -> Result<Vec<serde_json::Value>> {
let list = products::Entity::find()
.filter(products::Column::Published.eq(true))
@@ -60,7 +295,7 @@ pub(crate) async fn featured_products(
.limit(limit)
.all(&ctx.db)
.await?;
product_rows(ctx, user, list).await
product_rows(ctx, user, list, cur).await
}
/// The site-wide category sidebar, loaded lazily via htmx by the base layout so
@@ -82,32 +317,61 @@ async fn category_sidebar(
)
}
/// Fold the page chrome (login state, names) and language into a `run_search`
/// context so the full page can render the layout.
fn add_chrome(ctx_value: &mut serde_json::Value, c: &guard::Chrome, lang: &str) {
if let Some(map) = ctx_value.as_object_mut() {
map.insert("logged_in_admin".into(), json!(c.logged_in_admin));
map.insert("logged_in_customer".into(), json!(c.logged_in_customer));
map.insert("customer_name".into(), json!(c.customer_name));
map.insert("customer_account_type".into(), json!(c.customer_account_type));
map.insert("lang".into(), json!(lang));
}
}
#[debug_handler]
async fn index(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let list = products::Entity::find()
.filter(products::Column::Published.eq(true))
.order_by_desc(products::Column::PublishedAt)
.all(&ctx.db)
.await?;
let user = guard::current_user(&ctx, &jar).await;
let cur = currency::resolve(&ctx, &jar).await;
let mut context = run_search(&ctx, user.as_ref(), &SearchParams::default(), &cur).await?;
let c = guard::chrome_from(&ctx, user.as_ref());
format::view(
&v,
"shop/index.html",
json!({
"products": product_rows(&ctx, user.as_ref(), list).await?,
"logged_in_admin": c.logged_in_admin,
"logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
"customer_account_type": c.customer_account_type,
"lang": current_lang(&jar),
}),
)
add_chrome(&mut context, &c, &current_lang(&jar));
format::view(&v, "shop/index.html", context)
}
/// Storefront search + faceted browse. Combines the hybrid full-text/fuzzy query
/// ([`products::Entity::search`]) with category, price-band, in-stock and sort
/// filters, ranked and paginated by [`run_search`]. A blank query falls back to
/// the full published listing, so the same endpoint powers both "browse" and
/// "search". htmx requests get just the results fragment (for live updates);
/// direct navigation (or no-JS) renders the whole page.
#[debug_handler]
async fn search(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
headers: HeaderMap,
Query(params): Query<SearchParams>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let user = guard::current_user(&ctx, &jar).await;
let cur = currency::resolve(&ctx, &jar).await;
let mut context = run_search(&ctx, user.as_ref(), &params, &cur).await?;
let lang = current_lang(&jar);
if headers.contains_key("HX-Request") {
if let Some(map) = context.as_object_mut() {
map.insert("lang".into(), json!(lang));
}
return format::view(&v, "shop/_results.html", context);
}
let c = guard::chrome_from(&ctx, user.as_ref());
add_chrome(&mut context, &c, &lang);
format::view(&v, "shop/index.html", context)
}
#[debug_handler]
@@ -139,12 +403,13 @@ async fn show(
};
let user = guard::current_user(&ctx, &jar).await;
let cur = currency::resolve(&ctx, &jar).await;
let variants = product_variants::Entity::for_product(&ctx.db, product.id).await?;
let variant_prices = pricing::price_variants(&ctx, &variants, user.as_ref()).await?;
let options: Vec<serde_json::Value> = variants
.iter()
.zip(variant_prices.iter())
.map(|(variant, priced)| view::variant_option(variant, priced))
.map(|(variant, priced)| view::variant_option(variant, priced, &cur))
.collect();
// The card header uses the representative (first) variant for its headline
// price; the picker below lets the customer switch.
@@ -158,6 +423,7 @@ async fn show(
variants.len(),
None,
category.as_ref().map(|c| c.name.clone()),
&cur,
),
// A product with no variants isn't purchasable; show it without a price.
_ => serde_json::json!({
@@ -165,7 +431,6 @@ async fn show(
"name": product.name,
"slug": product.slug,
"description": product.description,
"currency": product.currency,
"variant_count": 0,
"has_options": false,
}),
@@ -183,16 +448,23 @@ async fn show(
"logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
"customer_account_type": c.customer_account_type,
"currency_symbol": cur.symbol,
"lang": current_lang(&jar),
}),
)
}
/// Category page: the same faceted search as the shop, but with this category
/// preselected as the default filter (plus breadcrumbs and subcategory chips).
/// Any other filters/sort/query on the URL are honoured; the category itself is
/// always forced to this page's category. Interacting with the toolbar navigates
/// to `/search` (the category stays selected there too).
#[debug_handler]
async fn category(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(slug): Path<String>,
Query(params): Query<SearchParams>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let published = categories::published(&ctx).await?;
@@ -205,41 +477,31 @@ async fn category(
let breadcrumbs = categories::ancestors(&published, category.parent_id);
let children = categories::children_of(&published, category.id);
// Products listed here span this category and all of its descendants, so a
// parent category is never empty just because its products live in leaves.
let mut category_ids: Vec<i32> = categories::descendant_ids(&published, category.id)
.into_iter()
.collect();
category_ids.push(category.id);
let list = products::Entity::find()
.filter(products::Column::CategoryId.is_in(category_ids))
.filter(products::Column::Published.eq(true))
.order_by_desc(products::Column::PublishedAt)
.all(&ctx.db)
.await?;
// Force the category filter to this page's category, keeping any other params.
let params = SearchParams {
category: Some(category.id.to_string()),
..params
};
let user = guard::current_user(&ctx, &jar).await;
let cur = currency::resolve(&ctx, &jar).await;
let mut context = run_search(&ctx, user.as_ref(), &params, &cur).await?;
if let Some(map) = context.as_object_mut() {
map.insert("category".into(), serde_json::to_value(&category)?);
map.insert("breadcrumbs".into(), serde_json::to_value(&breadcrumbs)?);
map.insert("children".into(), serde_json::to_value(&children)?);
}
let c = guard::chrome_from(&ctx, user.as_ref());
format::view(
&v,
"shop/category.html",
json!({
"category": category,
"breadcrumbs": breadcrumbs,
"children": children,
"products": product_rows(&ctx, user.as_ref(), list).await?,
"logged_in_admin": c.logged_in_admin,
"logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
"customer_account_type": c.customer_account_type,
"lang": current_lang(&jar),
}),
)
add_chrome(&mut context, &c, &current_lang(&jar));
format::view(&v, "shop/category.html", context)
}
pub fn routes() -> Routes {
Routes::new()
.add("/shop", get(index))
// Top-level path (not /shop/search) so it never collides with the
// /shop/{slug} product route.
.add("/search", get(search))
.add("/shop/{slug}", get(show))
.add("/category/{slug}", get(category))
.add("/partials/categories", get(category_sidebar))

View File

@@ -0,0 +1,52 @@
//! Ensures the built-in alternative display currencies always exist.
//!
//! EUR is the base currency and is never stored. For now the only alternative is
//! the Czech koruna (CZK); the admin sets its exchange rate and can disable it.
//! We insert each one only when its `code` is missing, so an admin's rate/enabled
//! changes are never overwritten on the next boot.
use async_trait::async_trait;
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
use crate::models::currencies;
use crate::shared::currency::{self, SCALE};
/// `(code, symbol, default_rate_e4)` — default rate is a placeholder the admin
/// is expected to update from the live FX rate.
const BUILTINS: [(&str, &str, i64); 1] = [("CZK", "", 25 * SCALE)];
pub struct CurrencySeeder;
#[async_trait]
impl Initializer for CurrencySeeder {
fn name(&self) -> String {
"currency-seeder".to_string()
}
async fn before_run(&self, ctx: &AppContext) -> Result<()> {
for (code, symbol, rate_e4) in BUILTINS {
let exists = currencies::Entity::find()
.filter(currencies::Column::Code.eq(code))
.count(&ctx.db)
.await?
> 0;
if exists {
continue;
}
currencies::ActiveModel {
code: Set(code.to_string()),
symbol: Set(symbol.to_string()),
rate_e4: Set(rate_e4),
enabled: Set(true),
..Default::default()
}
.insert(&ctx.db)
.await?;
tracing::info!(currency = code, "seeded display currency");
}
// Prime the process-wide snapshot used by the navbar/settings chrome.
currency::refresh_snapshot(&ctx.db).await?;
Ok(())
}
}

View File

@@ -1,4 +1,5 @@
pub mod admin_seeder;
pub mod currency_seeder;
pub mod oauth2;
pub mod oauth2_session;
pub mod shipping_seeder;

View File

@@ -54,6 +54,12 @@ impl Initializer for ViewEngineInitializer {
crate::shared::csrf::current_token().unwrap_or_default(),
))
});
// `currencies()`: the EUR base plus enabled alternative currencies
// (from the process-wide snapshot), used by the global chrome — the
// settings-menu switcher and the navbar exchange-rate display.
tera.register_function("currencies", |_args: &HashMap<String, serde_json::Value>| {
Ok(crate::shared::currency::selectable_json())
});
Ok(())
})?;

View File

@@ -28,7 +28,6 @@ pub struct ShipmentRequest<'a> {
pub pickup_point_id: Option<&'a str>,
/// Cash-on-delivery amount in cents; `0` when payment is not COD.
pub cod_cents: i64,
pub currency: &'a str,
/// Total order value in cents (for insurance / customs declarations).
pub value_cents: i64,
pub weight_grams: i32,

View File

@@ -77,7 +77,7 @@ pub async fn create_shipment(ctx: &AppContext, req: ShipmentRequest<'_>) -> Resu
xml_escape(address_id),
value,
cod,
xml_escape(req.currency),
"EUR",
weight_kg,
xml_escape(sender_label),
);

View File

@@ -0,0 +1,22 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "currencies")]
pub struct Model {
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub code: String,
pub symbol: String,
/// Units of this currency per 1 EUR, scaled ×10000 (e.g. 25.30 → 253000).
pub rate_e4: i64,
pub enabled: bool,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

View File

@@ -8,6 +8,7 @@ pub mod account_product_resolutions;
pub mod audience_discount_profiles;
pub mod audit_logs;
pub mod categories;
pub mod currencies;
pub mod customer_profiles;
pub mod discount_profile_products;
pub mod discount_profiles;

View File

@@ -16,7 +16,6 @@ pub struct Model {
pub customer_name: Option<String>,
pub status: String,
pub total_cents: i64,
pub currency: String,
pub address: Option<String>,
pub city: Option<String>,
pub zip: Option<String>,

View File

@@ -6,6 +6,7 @@ pub use super::account_product_resolutions::Entity as AccountProductResolutions;
pub use super::audience_discount_profiles::Entity as AudienceDiscountProfiles;
pub use super::audit_logs::Entity as AuditLogs;
pub use super::categories::Entity as Categories;
pub use super::currencies::Entity as Currencies;
pub use super::customer_profiles::Entity as CustomerProfiles;
pub use super::discount_profile_products::Entity as DiscountProfileProducts;
pub use super::discount_profiles::Entity as DiscountProfiles;

View File

@@ -14,7 +14,7 @@ pub struct Model {
pub label: String,
pub position: i32,
pub sku: Option<String>,
pub stock: i32,
pub stock: Option<i32>,
pub price_cents: i64,
pub sale_price_cents: Option<i64>,
pub business_sale_price_cents: Option<i64>,

View File

@@ -15,7 +15,8 @@ pub struct Model {
pub slug: String,
#[sea_orm(column_type = "Text", nullable)]
pub description: Option<String>,
pub currency: String,
#[sea_orm(column_type = "Text", nullable)]
pub short_description: Option<String>,
pub view_count: i32,
pub published: bool,
pub published_at: Option<DateTimeWithTimeZone>,

40
src/models/currencies.rs Normal file
View File

@@ -0,0 +1,40 @@
use sea_orm::entity::prelude::*;
pub use crate::models::_entities::currencies::{ActiveModel, Column, Entity, Model};
pub type Currencies = Entity;
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
where
C: ConnectionTrait,
{
if !insert && self.updated_at.is_unchanged() {
let mut this = self;
this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
Ok(this)
} else {
Ok(self)
}
}
}
// implement your read-oriented logic here
impl Model {}
// implement your write-oriented logic here
impl ActiveModel {}
// implement your custom finders, selectors oriented logic here
impl Entity {
/// An enabled currency by its ISO code (case-insensitive), or `None`.
pub async fn find_enabled_by_code<C: ConnectionTrait>(
db: &C,
code: &str,
) -> Result<Option<Model>, DbErr> {
Entity::find()
.filter(Column::Code.eq(code.to_uppercase()))
.filter(Column::Enabled.eq(true))
.one(db)
.await
}
}

View File

@@ -12,6 +12,7 @@ pub mod account_product_resolutions;
pub mod audience_discount_profiles;
pub mod audit_logs;
pub mod categories;
pub mod currencies;
pub mod discount_profile_products;
pub mod discount_profiles;
pub mod customer_profiles;

View File

@@ -53,7 +53,6 @@ pub async fn place(
let txn = ctx.db.begin().await?;
let mut subtotal: i64 = 0;
let mut currency = "EUR".to_string();
let mut snapshots = Vec::new();
for (variant_id, qty) in items {
let variant = product_variants::Entity::find_by_id(*variant_id)
@@ -65,22 +64,27 @@ pub async fn place(
.one(&txn)
.await?
.ok_or_else(|| Error::BadRequest("a product is no longer available".to_string()))?;
if variant.stock < *qty {
return Err(Error::BadRequest(format!(
"not enough stock for {}",
product.name
)));
// Tracked variants can't oversell; untracked ones (stock = None) are
// always available and never decremented.
if let Some(on_hand) = variant.stock {
if on_hand < *qty {
return Err(Error::BadRequest(format!(
"not enough stock for {}",
product.name
)));
}
}
currency = product.currency.clone();
// Snapshot the price the buyer actually pays — public sale or, for a
// business account, their negotiated/lowest price (same resolver the
// cart and storefront use).
let unit_price_cents = pricing::price_variant(ctx, &variant, user).await?.price_cents;
subtotal += unit_price_cents * i64::from(*qty);
let mut active = variant.clone().into_active_model();
active.stock = Set(variant.stock - *qty);
active.update(&txn).await?;
if let Some(on_hand) = variant.stock {
let mut active = variant.clone().into_active_model();
active.stock = Set(Some(on_hand - *qty));
active.update(&txn).await?;
}
snapshots.push((product.id, variant.id, product.name, variant.label, unit_price_cents, *qty));
}
@@ -92,7 +96,6 @@ pub async fn place(
customer_name: Set(details.customer_name),
status: Set("pending".to_string()),
total_cents: Set(subtotal + details.method.price_cents),
currency: Set(currency),
user_id: Set(details.user_id),
account_type: Set(details.account_type),
company_name: Set(details.company_name),
@@ -157,4 +160,45 @@ impl Model {}
impl ActiveModel {}
// implement your custom finders, selectors oriented logic here
impl Entity {}
impl Entity {
/// Admin order search: a diacritic- and case-insensitive substring match over
/// the free-text order fields an admin would actually type — order number,
/// email, customer name, company name, phone and tracking number. Backed by
/// the trigram indexes from the `order_search_indexes` migration. Newest
/// first, capped at `limit`. A blank query returns nothing (callers fall back
/// to the full list).
pub async fn search<C: sea_orm::ConnectionTrait>(
db: &C,
query: &str,
limit: u64,
) -> Result<Vec<Model>, DbErr> {
let q = query.trim();
if q.is_empty() {
return Ok(Vec::new());
}
// Treat the query literally: escape LIKE wildcards, then wrap in %…%.
let escaped = q.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
let pattern = format!("%{escaped}%");
let sql = r#"
SELECT * FROM orders o
WHERE f_unaccent(o.order_number) ILIKE f_unaccent($1)
OR f_unaccent(o.email) ILIKE f_unaccent($1)
OR f_unaccent(COALESCE(o.customer_name,'')) ILIKE f_unaccent($1)
OR f_unaccent(COALESCE(o.company_name,'')) ILIKE f_unaccent($1)
OR f_unaccent(COALESCE(o.phone,'')) ILIKE f_unaccent($1)
OR f_unaccent(COALESCE(o.tracking_number,'')) ILIKE f_unaccent($1)
ORDER BY o.created_at DESC
LIMIT $2
"#;
Entity::find()
.from_raw_sql(sea_orm::Statement::from_sql_and_values(
db.get_database_backend(),
sql,
[pattern.into(), (limit as i64).into()],
))
.all(db)
.await
}
}

View File

@@ -37,11 +37,15 @@ pub async fn first_for(ctx: &AppContext, product_id: i32) -> Result<Option<Strin
.map(|image| image.image_id))
}
/// Number of images already attached to a product, used to position new uploads.
pub async fn count_for(ctx: &AppContext, product_id: i32) -> Result<i32> {
use sea_orm::PaginatorTrait;
/// All of a product's images in display order (lowest position first). Takes a
/// connection so it can run inside the update transaction.
pub async fn for_product<C: ConnectionTrait>(
db: &C,
product_id: i32,
) -> Result<Vec<Model>> {
Ok(Entity::find()
.filter(Column::ProductId.eq(product_id))
.count(&ctx.db)
.await? as i32)
.order_by_asc(Column::Position)
.all(db)
.await?)
}

View File

@@ -45,6 +45,30 @@ impl Model {
pub fn business_on_sale(&self) -> bool {
matches!(self.business_sale_price_cents, Some(sale) if sale < self.price_cents)
}
/// Whether the variant's inventory is tracked. A `None` stock means
/// "available, not tracked" (always purchasable, unlimited).
#[must_use]
pub fn tracked(&self) -> bool {
self.stock.is_some()
}
/// Whether the variant can currently be bought: untracked variants are always
/// available; tracked ones need a positive quantity on hand.
#[must_use]
pub fn in_stock(&self) -> bool {
self.stock.map_or(true, |s| s > 0)
}
/// Clamp a desired quantity to what's available: capped at the tracked stock,
/// or left as-is (only floored at 0) when untracked.
#[must_use]
pub fn cap(&self, qty: i32) -> i32 {
match self.stock {
Some(s) => qty.clamp(0, s),
None => qty.max(0),
}
}
}
// implement your write-oriented logic here

View File

@@ -1,4 +1,5 @@
use sea_orm::entity::prelude::*;
use sea_orm::{ConnectionTrait, Statement};
pub use crate::models::_entities::products::{ActiveModel, Column, Entity, Model};
pub type Products = Entity;
@@ -25,4 +26,59 @@ impl Model {}
impl ActiveModel {}
// implement your custom finders, selectors oriented logic here
impl Entity {}
impl Entity {
/// Published products matching a free-text query, best matches first.
///
/// Hybrid Postgres search. The `tsvector` full-text match covers the whole
/// purchasable surface — name, description, tags, variant labels and SKUs
/// (kept in sync by triggers; see the `product_search_aggregate` migration) —
/// OR a `pg_trgm` `word_similarity` match on name/description for typo
/// tolerance ("komprsor" still finds "kompresor"). Both sides run through
/// `f_unaccent`, so diacritics never matter. Results are ranked by full-text
/// rank, then trigram closeness of the name, then recency. An empty/blank
/// query returns nothing — callers fall back to the plain listing.
/// `published_only` filters to the storefront-visible set; pass `false` for
/// admin tools that also need to find drafts.
pub async fn search<C: ConnectionTrait>(
db: &C,
query: &str,
limit: u64,
published_only: bool,
) -> Result<Vec<Model>, DbErr> {
let q = query.trim();
if q.is_empty() {
return Ok(Vec::new());
}
// Only the model's own columns are selected; the generated `search_vector`
// is left out so the row maps cleanly back onto `Model`. `$1` is reused
// for every occurrence of the query term; `$2` caps the result set.
let published_clause = if published_only { "p.published = TRUE AND" } else { "" };
let sql = format!(
r#"
SELECT p.created_at, p.updated_at, p.id, p.name, p.slug, p.description,
p.short_description, p.view_count, p.published, p.published_at, p.category_id
FROM products p
WHERE {published_clause} (
p.search_vector @@ websearch_to_tsquery('sk_unaccent', $1)
OR word_similarity(f_unaccent($1), f_unaccent(p.name)) > 0.3
OR word_similarity(f_unaccent($1), f_unaccent(COALESCE(p.description, ''))) > 0.3
)
ORDER BY
ts_rank(p.search_vector, websearch_to_tsquery('sk_unaccent', $1)) DESC,
word_similarity(f_unaccent($1), f_unaccent(p.name)) DESC,
p.published_at DESC NULLS LAST
LIMIT $2
"#
);
Entity::find()
.from_raw_sql(Statement::from_sql_and_values(
db.get_database_backend(),
&sql,
[q.into(), (limit as i64).into()],
))
.all(db)
.await
}
}

View File

@@ -161,7 +161,6 @@ pub async fn seed_catalog(ctx: &AppContext) -> Result<()> {
name: Set(item.name.to_string()),
slug: Set(product_slug),
description: Set(Some(item.description.to_string())),
currency: Set("EUR".to_string()),
published: Set(true),
published_at: Set(Some(now.into())),
category_id: Set(category.map(|c| c.id)),
@@ -177,7 +176,7 @@ pub async fn seed_catalog(ctx: &AppContext) -> Result<()> {
label: Set(String::new()),
position: Set(0),
sku: Set(item.sku.map(|s| s.to_string())),
stock: Set(item.stock),
stock: Set(Some(item.stock)),
price_cents: Set(item.price_cents),
..Default::default()
}

206
src/shared/currency.rs Normal file
View File

@@ -0,0 +1,206 @@
//! Buyer-selectable display currency.
//!
//! EUR is the base/transaction currency: every price is stored and reasoned
//! about in EUR minor units (cents). A buyer may switch their *display* currency
//! (cookie [`COOKIE`]); non-base currencies live in the `currencies` table with
//! an admin-set exchange `rate_e4` (units per 1 EUR, scaled ×10000). The
//! [`Currency`] resolved per request converts EUR cents into the chosen currency
//! for display only — the cart logic, orders and admin stay in EUR.
use std::sync::RwLock;
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
use crate::models::currencies;
use crate::shared::money::format_price;
/// Cookie holding the buyer's chosen display-currency code.
pub const COOKIE: &str = "currency";
/// The base/transaction currency code.
pub const BASE_CODE: &str = "EUR";
/// The base currency symbol.
pub const BASE_SYMBOL: &str = "";
/// Fixed-point scale for exchange rates (`rate_e4` = rate × 10000).
pub const SCALE: i64 = 10_000;
/// A resolved display currency: how to label prices and how to convert them
/// from the EUR base.
#[derive(Debug, Clone)]
pub struct Currency {
pub code: String,
pub symbol: String,
/// Units of this currency per 1 EUR, scaled ×10000. `SCALE` for the base.
pub rate_e4: i64,
}
impl Currency {
/// The base currency (EUR), the identity conversion.
#[must_use]
pub fn eur() -> Self {
Self {
code: BASE_CODE.to_string(),
symbol: BASE_SYMBOL.to_string(),
rate_e4: SCALE,
}
}
#[must_use]
pub fn is_base(&self) -> bool {
self.code == BASE_CODE
}
/// Convert EUR minor units into this currency's minor units (half-up).
#[must_use]
pub fn convert_cents(&self, eur_cents: i64) -> i64 {
if self.is_base() {
return eur_cents;
}
let scale = i128::from(SCALE);
((i128::from(eur_cents) * i128::from(self.rate_e4) + scale / 2) / scale) as i64
}
/// Inverse of [`convert_cents`]: this currency's minor units back to EUR
/// minor units (half-up). Used to interpret price-filter bounds typed in the
/// display currency.
#[must_use]
pub fn to_eur_cents(&self, cents: i64) -> i64 {
if self.is_base() || self.rate_e4 == 0 {
return cents;
}
let rate = i128::from(self.rate_e4);
((i128::from(cents) * i128::from(SCALE) + rate / 2) / rate) as i64
}
/// Render EUR minor units as a plain decimal string in this currency (no
/// symbol). The symbol is appended by templates via `currency_symbol`.
#[must_use]
pub fn format(&self, eur_cents: i64) -> String {
format_price(self.convert_cents(eur_cents))
}
}
/// Resolve the buyer's display currency from the `currency` cookie, falling back
/// to EUR when the cookie is absent, names the base, or names a currency that is
/// missing or disabled.
pub async fn resolve(ctx: &AppContext, jar: &CookieJar) -> Currency {
let code = jar
.get(COOKIE)
.map(|c| c.value().to_string())
.unwrap_or_default();
if code.is_empty() || code.eq_ignore_ascii_case(BASE_CODE) {
return Currency::eur();
}
match currencies::Entity::find_enabled_by_code(&ctx.db, &code).await {
Ok(Some(m)) => Currency {
code: m.code,
symbol: m.symbol,
rate_e4: m.rate_e4,
},
_ => Currency::eur(),
}
}
/// One enabled, buyer-selectable alternative currency in the process-wide
/// snapshot below.
#[derive(Clone)]
struct Selectable {
code: String,
symbol: String,
rate_e4: i64,
}
/// Process-wide snapshot of the enabled alternative currencies, so the global
/// chrome (the settings-menu switcher and the navbar rate) can be rendered via a
/// Tera function without a per-request DB hit. Loaded at boot by
/// `initializers::currency_seeder` and refreshed by the admin on every edit (see
/// [`refresh_snapshot`]). EUR (the base) is implicit and never listed here.
static ENABLED: RwLock<Vec<Selectable>> = RwLock::new(Vec::new());
/// Reload the [`ENABLED`] snapshot from the database. Call at boot and after any
/// admin change to a currency's rate/enabled state.
pub async fn refresh_snapshot<C: sea_orm::ConnectionTrait>(db: &C) -> Result<()> {
let rows = currencies::Entity::find()
.filter(currencies::Column::Enabled.eq(true))
.order_by_asc(currencies::Column::Code)
.all(db)
.await?;
let list = rows
.into_iter()
.map(|m| Selectable {
code: m.code,
symbol: m.symbol,
rate_e4: m.rate_e4,
})
.collect();
*ENABLED.write().unwrap() = list;
Ok(())
}
/// The selectable currencies for templates (the Tera `currencies()` function):
/// the EUR base plus every enabled alternative, each with a human rate string.
/// `alts` is empty when the store is effectively EUR-only.
#[must_use]
pub fn selectable_json() -> serde_json::Value {
let alts: Vec<serde_json::Value> = ENABLED
.read()
.unwrap()
.iter()
.map(|s| {
serde_json::json!({
"code": s.code,
"symbol": s.symbol,
"rate": format_rate(s.rate_e4),
})
})
.collect();
serde_json::json!({
"base": { "code": BASE_CODE, "symbol": BASE_SYMBOL },
"alts": alts,
})
}
/// Parse an exchange rate typed in major units ("25", "25.3", "25,30",
/// "25.3045") into `rate_e4` (×10000). Rejects negatives and >4 decimals.
pub fn parse_rate(value: &str) -> Result<i64> {
let value = value.trim().replace(',', ".");
let invalid = || Error::BadRequest("invalid exchange rate".to_string());
let (whole, frac) = match value.split_once('.') {
Some((w, f)) => (w, f),
None => (value.as_str(), ""),
};
if frac.len() > 4 || whole.is_empty() || !whole.chars().all(|c| c.is_ascii_digit()) {
return Err(invalid());
}
if !frac.chars().all(|c| c.is_ascii_digit()) {
return Err(invalid());
}
let whole: i64 = whole.parse().map_err(|_| invalid())?;
// Right-pad the fractional part to exactly 4 digits.
let padded = format!("{frac:0<4}");
let frac: i64 = if padded.is_empty() {
0
} else {
padded.parse().map_err(|_| invalid())?
};
let rate = whole * SCALE + frac;
if rate <= 0 {
return Err(invalid());
}
Ok(rate)
}
/// Render `rate_e4` as a human string, trimming trailing zeros (253000 → "25.3",
/// 250000 → "25").
#[must_use]
pub fn format_rate(rate_e4: i64) -> String {
let whole = rate_e4 / SCALE;
let frac = (rate_e4 % SCALE).abs();
if frac == 0 {
return whole.to_string();
}
let frac = format!("{frac:04}");
let trimmed = frac.trim_end_matches('0');
format!("{whole}.{trimmed}")
}

View File

@@ -1,6 +1,7 @@
//! Cross-cutting helpers used across feature slices.
pub mod csrf;
pub mod currency;
pub mod guard;
pub mod money;
pub mod pricing;

View File

@@ -40,7 +40,6 @@ pub fn detail(order: &orders::Model, bank_iban: &str, bank_account_name: &str) -
"subtotal": format_price(order.total_cents - order.shipping_cents),
"shipping": format_price(order.shipping_cents),
"total": format_price(order.total_cents),
"currency": order.currency,
"address": order.address,
"city": order.city,
"zip": order.zip,
@@ -68,7 +67,6 @@ pub fn summary(order: &orders::Model) -> Value {
"email": order.email,
"status": order.status,
"total": format_price(order.total_cents),
"currency": order.currency,
"created_at": order.created_at.to_rfc3339(),
})
}

View File

@@ -2,8 +2,8 @@
use serde_json::{json, Value};
use crate::models::_entities::{categories, product_variants, products};
use crate::shared::money::format_price;
use crate::models::_entities::{categories, product_images, product_variants, products};
use crate::shared::currency::Currency;
use crate::shared::pricing::PricedProduct;
/// Card/list shape for a product: model fields plus the viewer's resolved price
@@ -20,6 +20,7 @@ pub fn product_card(
variant_count: usize,
image: Option<String>,
category_name: Option<String>,
cur: &Currency,
) -> Value {
json!({
"id": product.id,
@@ -27,13 +28,15 @@ pub fn product_card(
"name": product.name,
"slug": product.slug,
"description": product.description,
"price": format_price(priced.price_cents),
"short_description": product.short_description,
"price": cur.format(priced.price_cents),
"on_sale": priced.is_reduced(),
"is_business": priced.is_business,
"regular_price": format_price(priced.regular_cents),
"currency": product.currency,
"regular_price": cur.format(priced.regular_cents),
"sku": representative.sku,
"stock": representative.stock,
"tracked": representative.tracked(),
"in_stock": representative.in_stock(),
"variant_count": variant_count,
"has_options": variant_count > 1,
"published": product.published,
@@ -43,33 +46,41 @@ pub fn product_card(
}
/// One priced variant row for the product detail page's option picker.
pub fn variant_option(variant: &product_variants::Model, priced: &PricedProduct) -> Value {
pub fn variant_option(
variant: &product_variants::Model,
priced: &PricedProduct,
cur: &Currency,
) -> Value {
json!({
"id": variant.id,
"label": variant.label,
"sku": variant.sku,
"stock": variant.stock,
"in_stock": variant.stock > 0,
"price": format_price(priced.price_cents),
"tracked": variant.tracked(),
"in_stock": variant.in_stock(),
"price": cur.format(priced.price_cents),
"on_sale": priced.is_reduced(),
"regular_price": format_price(priced.regular_cents),
"regular_price": cur.format(priced.regular_cents),
"is_business": priced.is_business,
})
}
/// Shape used to pre-fill the admin product form (exposes `category_id` rather
/// than a resolved name, and the current primary image). Variants are supplied
/// separately by the controller.
pub fn product_form(product: &products::Model, image: Option<String>) -> Value {
/// than a resolved name, and the current images in display order — first is the
/// main one). Variants are supplied separately by the controller.
pub fn product_form(product: &products::Model, images: &[product_images::Model]) -> Value {
json!({
"id": product.id,
"name": product.name,
"slug": product.slug,
"description": product.description,
"currency": product.currency,
"short_description": product.short_description,
"published": product.published,
"category_id": product.category_id,
"image": image,
"images": images
.iter()
.map(|im| json!({ "id": im.id, "image_id": im.image_id }))
.collect::<Vec<_>>(),
})
}