6 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
67 changed files with 1274 additions and 154 deletions

View File

@@ -77,3 +77,108 @@
/* Hide Alpine x-cloak elements until Alpine initializes. */ /* Hide Alpine x-cloak elements until Alpine initializes. */
[x-cloak] { display: none !important; } [x-cloak] { display: none !important; }
/* === Rich text editor (Quill "snow") =======================
* Vendored Quill (assets/static/vendor/quill) drives the admin
* product short/long description fields. Stock snow already suits
* the light theme; the admin panel is locked to data-theme="dark",
* so the rules below repaint the toolbar/editor for dark. Editor
* height is per-instance via the --rich-min-height custom prop set
* by the ui::rich_editor macro.
* ============================================================ */
.rich-editor .ql-editor {
min-height: var(--rich-min-height, 12rem);
font-size: 0.95rem;
line-height: 1.6;
}
.ql-editor.ql-blank::before { font-style: normal; }
[data-theme="dark"] .rich-editor { background: var(--color-surface-dark-alt); }
[data-theme="dark"] .ql-toolbar.ql-snow,
[data-theme="dark"] .ql-container.ql-snow { border-color: var(--color-outline-dark); }
[data-theme="dark"] .ql-toolbar.ql-snow { background: var(--color-surface-dark); }
[data-theme="dark"] .ql-container.ql-snow { color: var(--color-on-surface-dark); }
[data-theme="dark"] .ql-snow .ql-stroke,
[data-theme="dark"] .ql-snow .ql-stroke-miter { stroke: var(--color-on-surface-dark); }
[data-theme="dark"] .ql-snow .ql-fill,
[data-theme="dark"] .ql-snow .ql-stroke.ql-fill { fill: var(--color-on-surface-dark); }
[data-theme="dark"] .ql-snow .ql-picker { color: var(--color-on-surface-dark); }
[data-theme="dark"] .ql-snow .ql-picker-options {
background: var(--color-surface-dark);
border-color: var(--color-outline-dark);
}
/* active / hover toolbar state -> primary accent */
[data-theme="dark"] .ql-snow.ql-toolbar button:hover,
[data-theme="dark"] .ql-snow.ql-toolbar button.ql-active,
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-label:hover,
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-label.ql-active,
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-item:hover,
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-item.ql-selected { color: var(--color-primary-dark); }
[data-theme="dark"] .ql-snow.ql-toolbar button:hover .ql-stroke,
[data-theme="dark"] .ql-snow.ql-toolbar button.ql-active .ql-stroke,
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke,
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke { stroke: var(--color-primary-dark); }
[data-theme="dark"] .ql-snow.ql-toolbar button:hover .ql-fill,
[data-theme="dark"] .ql-snow.ql-toolbar button.ql-active .ql-fill { fill: var(--color-primary-dark); }
[data-theme="dark"] .ql-snow .ql-tooltip {
background-color: var(--color-surface-dark);
border-color: var(--color-outline-dark);
color: var(--color-on-surface-dark);
box-shadow: 0 2px 8px rgb(0 0 0 / 0.45);
}
[data-theme="dark"] .ql-snow .ql-tooltip input[type=text] {
background: var(--color-surface-dark-alt);
border-color: var(--color-outline-dark);
color: var(--color-on-surface-dark);
}
[data-theme="dark"] .ql-snow .ql-editor a,
[data-theme="dark"] .ql-snow .ql-tooltip a { color: var(--color-primary-dark); }
/* Image size controls under the editor. */
.rich-image-size-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
.rich-image-size-controls.hidden { display: none; }
.rich-image-size-controls button {
border: 1px solid var(--color-outline);
border-radius: var(--radius-radius);
padding: 0.3rem 0.65rem;
line-height: 1;
font-size: 0.8rem;
}
.rich-image-size-controls button:hover { border-color: var(--color-primary); color: var(--color-primary); }
[data-theme="dark"] .rich-image-size-controls button { border-color: var(--color-outline-dark); }
[data-theme="dark"] .rich-image-size-controls button:hover {
border-color: var(--color-primary-dark);
color: var(--color-primary-dark);
}
/* Image sizing classes shared by the editor and rendered output. */
.rich-editor img, .rich-content img {
display: block;
max-width: 100%;
height: auto;
margin: 1rem auto;
border-radius: var(--radius-radius);
}
.rich-editor img { cursor: pointer; }
.rich-image-small { width: min(100%, 18rem); }
.rich-image-medium { width: min(100%, 34rem); }
.rich-image-full { width: 100%; }
/* === Rendered rich content (storefront product description) =
* Inherits text color from context so it works in both themes;
* only structural spacing + link/heading treatment is set here. */
.rich-content { line-height: 1.7; }
.rich-content h2 { margin: 1.25rem 0 0.6rem; font-size: 1.3rem; font-weight: 700; }
.rich-content h3 { margin: 1rem 0 0.5rem; font-size: 1.1rem; font-weight: 700; }
.rich-content p, .rich-content ul, .rich-content ol { margin: 0.6rem 0; }
.rich-content ul { list-style: disc; padding-left: 1.4rem; }
.rich-content ol { list-style: decimal; padding-left: 1.4rem; }
.rich-content a { color: var(--color-primary); text-decoration: underline; }
[data-theme="dark"] .rich-content a { color: var(--color-primary-dark); }
.rich-content :first-child { margin-top: 0; }
.rich-content :last-child { margin-bottom: 0; }
/* Compact rich blurb for product cards: neutralize block spacing so the
* line-clamp truncation stays tidy regardless of the authored markup. */
.rich-blurb :where(p, ul, ol, h2, h3) { margin: 0; }
.rich-blurb :where(ul, ol) { padding-left: 1.1rem; }

View File

@@ -20,6 +20,7 @@ admin-audio-desc = upload songs, then group them into albums.
logout = Log out logout = Log out
settings = Settings settings = Settings
settings-language = Language settings-language = Language
settings-currency = Currency
settings-theme = Theme settings-theme = Theme
language-en = English language-en = English
language-sk = Slovak language-sk = Slovak
@@ -171,6 +172,8 @@ artist = Artist
release-date = Release date release-date = Release date
cover-image = Cover image cover-image = Cover image
description = Description description = Description
short-description = Short description
short-description-hint = Shown on product cards. Keep it short.
songs-in-album = Songs in this album songs-in-album = Songs in this album
admin-new-album-desc = Fill in the details, then tick the songs to include. admin-new-album-desc = Fill in the details, then tick the songs to include.
cover-help = Optional - png, jpg, webp or gif; shown on the album page. cover-help = Optional - png, jpg, webp or gif; shown on the album page.
@@ -473,6 +476,14 @@ bank-amount = Amount
admin-shipping = Shipping admin-shipping = Shipping
admin-shipping-desc = set the price and availability of each delivery option. admin-shipping-desc = set the price and availability of each delivery option.
shipping-enabled = Active 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-new = Add delivery option
shipping-add = Add shipping-add = Add
shipping-requires-pickup = Requires pickup point 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 logout = Odhlásiť sa
settings = Nastavenia settings = Nastavenia
settings-language = Jazyk settings-language = Jazyk
settings-currency = Mena
settings-theme = Téma settings-theme = Téma
language-en = Angličtina language-en = Angličtina
language-sk = Slovenčina language-sk = Slovenčina
@@ -171,6 +172,8 @@ artist = Interpret
release-date = Dátum vydania release-date = Dátum vydania
cover-image = Obrázok obalu cover-image = Obrázok obalu
description = Popis description = Popis
short-description = Krátky popis
short-description-hint = Zobrazuje sa na kartách produktov. Najlepšie krátke.
songs-in-album = Skladby v albume songs-in-album = Skladby v albume
admin-new-album-desc = Vyplň údaje a potom označ skladby, ktoré chceš zahrnúť. admin-new-album-desc = Vyplň údaje a potom označ skladby, ktoré chceš zahrnúť.
cover-help = Voliteľné - png, jpg, webp alebo gif; zobrazí sa na stránke albumu. cover-help = Voliteľné - png, jpg, webp alebo gif; zobrazí sa na stránke albumu.
@@ -473,6 +476,14 @@ bank-amount = Suma
admin-shipping = Doprava admin-shipping = Doprava
admin-shipping-desc = nastaviť cenu a dostupnosť jednotlivých možností dopravy. admin-shipping-desc = nastaviť cenu a dostupnosť jednotlivých možností dopravy.
shipping-enabled = Aktívne 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-new = Pridať možnosť dopravy
shipping-add = Pridať shipping-add = Pridať
shipping-requires-pickup = Vyžaduje výdajné miesto 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 %} {% for item in items %}
<li class="flex justify-between gap-2"> <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="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> </li>
{% endfor %} {% endfor %}
</ul> </ul>
<div class="space-y-1 border-t border-outline py-3 text-sm dark:border-outline-dark"> <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">{{ 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 }} {{ 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 }} </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 %} {% 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>
<div class="flex justify-between border-t border-outline pt-3 font-bold dark:border-outline-dark"> <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>{{ 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>
</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">{{ 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">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-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>
</div> </div>
{% endif %} {% endif %}

View File

@@ -22,7 +22,7 @@
</div> </div>
<div class="flex items-center gap-4"> <div class="flex items-center gap-4">
{{ self::status_badge(status=order.status) }} {{ 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> </div>
</a> </a>
{% endmacro order_row %} {% 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"> 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')) }} {{ t(key="admin-shipping", lang=lang | default(value='sk')) }}
</a> </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>
<div class="border-t border-outline p-4 dark:border-outline-dark"> <div class="border-t border-outline p-4 dark:border-outline-dark">

View File

@@ -38,7 +38,7 @@
x-text="row.label || ('#' + row.id)"></span> x-text="row.label || ('#' + row.id)"></span>
<span class="text-sm tabular-nums text-on-surface/70 dark:text-on-surface-dark/70"> <span class="text-sm tabular-nums text-on-surface/70 dark:text-on-surface-dark/70">
{{ t(key="price", lang=lang | default(value='sk')) }}: {{ t(key="price", lang=lang | default(value='sk')) }}:
<span x-text="row.regular_price"></span> <span x-text="row.currency"></span> <span x-text="row.regular_price"></span>
</span> </span>
</div> </div>
@@ -80,9 +80,9 @@
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"> 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="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="flex items-center gap-2">
<span class="tabular-nums text-on-surface/50 line-through dark:text-on-surface-dark/50" x-text="money(row.regular_cents) + ' ' + row.currency"></span> <span class="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'" <span class="text-base font-semibold tabular-nums" :class="valid(row) ? 'text-danger' : 'text-on-surface/40 dark:text-on-surface-dark/40'"
x-text="money(afterCents(row)) + ' ' + row.currency"></span> 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 x-show="valid(row)" class="text-xs text-on-surface/60 dark:text-on-surface-dark/60" x-text="'(' + percentOff(row) + '%)'"></span>
</span> </span>
</div> </div>
@@ -106,7 +106,6 @@
label: r.label || '', label: r.label || '',
regular_cents: r.regular_cents, regular_cents: r.regular_cents,
regular_price: r.regular_price, regular_price: r.regular_price,
currency: r.currency,
mode: r.mode || 'fixed', mode: r.mode || 'fixed',
fixed: r.fixed || '', fixed: r.fixed || '',
percent: r.percent || '', percent: r.percent || '',

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 title %}{% if product %}{{ t(key="edit-product", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-product", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
{% block crumb %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock crumb %} {% block crumb %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% block head %}
<link href="/static/vendor/quill/quill.snow.css" rel="stylesheet" type="text/css">
{% endblock head %}
{% block content %} {% block content %}
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
@@ -18,9 +21,9 @@
{{ ui::csrf_field() }} {{ ui::csrf_field() }}
{% if product %} {% if product %}
{% set v_name = product.name %}{% set v_currency = product.currency %}{% set v_desc = product.description | default(value="") %}{% set v_pub = product.published %} {% set v_name = product.name %}{% set v_desc = product.description | default(value="") %}{% set v_short = product.short_description | default(value="") %}{% set v_pub = product.published %}
{% else %} {% else %}
{% set v_name = "" %}{% set v_currency = "EUR" %}{% set v_desc = "" %}{% set v_pub = false %} {% set v_name = "" %}{% set v_desc = "" %}{% set v_short = "" %}{% set v_pub = false %}
{% endif %} {% endif %}
{% set inp = "w-full rounded-radius border border-outline bg-surface-alt px-3 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark" %} {% set inp = "w-full rounded-radius border border-outline bg-surface-alt px-3 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark" %}
{% set sublabel = "text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70" %} {% set sublabel = "text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70" %}
@@ -30,11 +33,6 @@
{{ ui::input(name="name", id="name", required=true, value=v_name) }} {{ ui::input(name="name", id="name", required=true, value=v_name) }}
</div> </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 ------------------------------------------- #} {# --- Variants / options editor ------------------------------------------- #}
{# Each product is sold as one or more variants (a free-text label such as #} {# Each product is sold as one or more variants (a free-text label such as #}
{# "10cm x 13cm" or "5ml" plus its own price). Price is required. Stock is #} {# "10cm x 13cm" or "5ml" plus its own price). Price is required. Stock is #}
@@ -73,7 +71,7 @@
<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')) }}"> <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>
<div class="space-y-1 sm:col-span-2"> <div class="space-y-1 sm:col-span-2">
<label class="{{ sublabel }} block truncate">{{ t(key="price", lang=lang | default(value='sk')) }}</label> <label class="{{ sublabel }} block truncate">{{ t(key="price", lang=lang | default(value='sk')) }} (€)</label>
<input :name="`variants[${i}][price]`" x-model="row.price" inputmode="decimal" required class="{{ inp }}" placeholder="0.00"> <input :name="`variants[${i}][price]`" x-model="row.price" inputmode="decimal" required class="{{ inp }}" placeholder="0.00">
</div> </div>
</div> </div>
@@ -117,8 +115,14 @@
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="description" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="description", lang=lang | default(value='sk')) }}</label> <span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="short-description", lang=lang | default(value='sk')) }}</span>
{{ ui::textarea(name="description", id="description", rows="5", value=v_desc) }} <p class="{{ sublabel }}">{{ t(key="short-description-hint", lang=lang | default(value='sk')) }}</p>
{{ ui::rich_editor(name="short_description", lang=lang | default(value='sk'), value=v_short, min_height="6rem") }}
</div>
<div class="space-y-1.5">
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="description", lang=lang | default(value='sk')) }}</span>
{{ ui::rich_editor(name="description", lang=lang | default(value='sk'), value=v_desc, min_height="16rem") }}
</div> </div>
{# --- Images gallery ------------------------------------------------------- #} {# --- Images gallery ------------------------------------------------------- #}
@@ -210,4 +214,6 @@
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products") }} {{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products") }}
</div> </div>
</form> </form>
<script src="/static/vendor/quill/quill.js"></script>
<script src="/static/js/rich-editor.js"></script>
{% endblock content %} {% endblock content %}

View File

@@ -122,7 +122,7 @@
</div> </div>
</div> </div>
</td> </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">{{ product.variant_count }}</td>
<td class="px-4 py-3 tabular-nums"> <td class="px-4 py-3 tabular-nums">
<span id="eff-{{ product.id }}">{{ ui::eff_price(p=product) }}</span> <span id="eff-{{ product.id }}">{{ ui::eff_price(p=product) }}</span>

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="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"> <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="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>
<div class="flex items-center justify-between gap-3 text-sm"> <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="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>
<div class="flex items-center justify-between gap-3 text-sm"> <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="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>
</div> </div>
@@ -62,7 +62,7 @@
<div class="flex items-center justify-between gap-3"> <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-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 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> </span>
</div> </div>
<p x-show="!valid" class="text-xs text-danger">{{ t(key="discount-must-be-positive", lang=lang | default(value='sk')) }}</p> <p x-show="!valid" class="text-xs text-danger">{{ t(key="discount-must-be-positive", lang=lang | default(value='sk')) }}</p>

View File

@@ -82,14 +82,14 @@
</td> </td>
<td class="px-4 py-3 tabular-nums"> <td class="px-4 py-3 tabular-nums">
{% if product.business_reduced %} {% 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> <span class="ml-1 text-xs text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }}</span>
{% else %} {% else %}
{{ product.business_price }} {{ product.currency }} {{ product.business_price }}
{% endif %} {% endif %}
</td> </td>
<td class="px-4 py-3 tabular-nums"> <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 %} {% if product.collision %}<span class="ml-1">{{ ui::badge(label=t(key="collision", lang=lang | default(value='sk')), variant="warning") }}</span>{% endif %}
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">

View File

@@ -44,7 +44,7 @@
<td class="px-4 py-3"> <td class="px-4 py-3">
{{ ui::badge(label=t(key="order-status-" ~ order.status, lang=lang | default(value='sk')), variant="neutral") }} {{ ui::badge(label=t(key="order-status-" ~ order.status, lang=lang | default(value='sk')), variant="neutral") }}
</td> </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"> <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") }} {{ 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> </td>

View File

@@ -38,14 +38,14 @@
<tr> <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">{{ 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 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> </tr>
{% endfor %} {% endfor %}
</tbody> </tbody>
<tfoot class="{{ ui::tfoot_cls() }}"> <tfoot class="{{ ui::tfoot_cls() }}">
<tr> <tr>
<td colspan="2" class="px-4 py-3 text-right font-semibold">{{ t(key="order-total", lang=lang | default(value='sk')) }}</td> <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> </tr>
</tfoot> </tfoot>
</table> </table>
@@ -75,7 +75,7 @@
</div> </div>
<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-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 %} {% 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>
<div> <div>

View File

@@ -104,8 +104,19 @@
{% endif %} {% endif %}
</ul> </ul>
<!-- right side: cart + settings + mobile toggle --> <!-- right side: kurz + cart + settings + mobile toggle -->
<div class="ml-auto flex items-center gap-3"> <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) --> <!-- customer profile dropdown (avatar + name + account type) -->
{% if logged_in_customer %} {% if logged_in_customer %}
{% include "partials/profile_menu.html" %} {% 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> <h2 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shop-title", lang=lang | default(value='sk')) }}</h2>
<a href="/shop" class="text-sm font-medium text-primary dark:text-primary-dark">{{ t(key="cart-continue", lang=lang | default(value='sk')) }} →</a> <a href="/shop" class="text-sm font-medium text-primary dark:text-primary-dark">{{ t(key="cart-continue", lang=lang | default(value='sk')) }} →</a>
</div> </div>
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4"> <div x-data="{ view: 'grid' }" class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
{% for product in products %} {% for product in products %}
{% include "shop/_card.html" %} {% include "shop/_card.html" %}
{% endfor %} {% endfor %}

View File

@@ -132,10 +132,10 @@
{% macro eff_price(p, preview=false) -%} {% macro eff_price(p, preview=false) -%}
{%- if preview -%}{% set strong = "text-info" %}{%- else -%}{% set strong = "text-primary dark:text-primary-dark" %}{%- endif -%} {%- if preview -%}{% set strong = "text-info" %}{%- else -%}{% set strong = "text-primary dark:text-primary-dark" %}{%- endif -%}
{% if p.effective_reduced %} {% 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> <span class="ml-1 text-xs text-on-surface/60 dark:text-on-surface-dark/60">({{ p.effective_percent_off }}%)</span>
{% else %} {% else %}
{{ p.effective_price }} {{ p.currency }} {{ p.effective_price }}
{% endif %} {% endif %}
{%- endmacro eff_price %} {%- endmacro eff_price %}
@@ -153,6 +153,34 @@
<textarea {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" rows="{{ rows }}"{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if required %} required{% endif %} class="w-full rounded-radius border border-outline bg-surface-alt px-2.5 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}>{{ value }}</textarea> <textarea {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" rows="{{ rows }}"{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if required %} required{% endif %} class="w-full rounded-radius border border-outline bg-surface-alt px-2.5 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}>{{ value }}</textarea>
{%- endmacro textarea %} {%- endmacro textarea %}
{# Quill rich-text editor (see /static/js/rich-editor.js + /static/vendor/quill).
The real value rides in a hidden <textarea> the editor keeps in sync, so it
submits like any other field. `value` pre-fills (HTML or plain text). Several
editors may share one form — each is scoped to its own [data-rich-field].
Requires the page to load quill.js + quill.snow.css + rich-editor.js (the
product form does so) and a _csrf field in the form for image uploads. #}
{% macro rich_editor(name, lang, value="", placeholder="", min_height="12rem") -%}
<div data-rich-field>
<textarea name="{{ name }}" data-rich-content class="hidden">{{ value }}</textarea>
<div data-rich-editor class="rich-editor" style="--rich-min-height: {{ min_height }};"{% if placeholder %} data-placeholder="{{ placeholder }}"{% endif %}></div>
<div data-image-size-controls class="rich-image-size-controls mt-2 hidden">
<span class="text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="image-size", lang=lang) }}</span>
<button type="button" data-image-size="small">{{ t(key="image-size-small", lang=lang) }}</button>
<button type="button" data-image-size="medium">{{ t(key="image-size-medium", lang=lang) }}</button>
<button type="button" data-image-size="full">{{ t(key="image-size-full", lang=lang) }}</button>
<label class="inline-flex items-center gap-1.5">
<span class="text-xs text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="image-width-px", lang=lang) }}</span>
<input type="number" min="40" max="1200" step="10" data-image-width
class="w-20 rounded-radius border border-outline bg-surface-alt px-2 py-1 text-sm text-on-surface dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark">
</label>
</div>
<p class="mt-1 text-xs text-on-surface/60 dark:text-on-surface-dark/60" data-rich-status
data-uploading="{{ t(key='image-uploading', lang=lang) }}"
data-uploaded="{{ t(key='image-uploaded', lang=lang) }}"
data-error="{{ t(key='image-upload-error', lang=lang) }}"></p>
</div>
{%- endmacro rich_editor %}
{# File input. #} {# File input. #}
{% macro file_input(name, id="", accept="", attrs="", extra="") -%} {% macro file_input(name, id="", accept="", attrs="", extra="") -%}
<input {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" type="file"{% if accept %} accept="{{ accept }}"{% endif %} class="w-full overflow-clip rounded-radius border border-outline bg-surface-alt/50 text-sm text-on-surface file:mr-4 file:border-none file:bg-surface-alt file:px-4 file:py-2 file:font-medium file:text-on-surface-strong focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:file:bg-surface-dark-alt dark:file:text-on-surface-dark-strong dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}/> <input {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" type="file"{% if accept %} accept="{{ accept }}"{% endif %} class="w-full overflow-clip rounded-radius border border-outline bg-surface-alt/50 text-sm text-on-surface file:mr-4 file:border-none file:bg-surface-alt file:px-4 file:py-2 file:font-medium file:text-on-surface-strong focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:file:bg-surface-dark-alt dark:file:text-on-surface-dark-strong dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}/>

View File

@@ -35,6 +35,32 @@
{% if lang | default(value='sk') == "sk" %}<span class="text-primary dark:text-primary-dark"></span>{% endif %} {% if lang | default(value='sk') == "sk" %}<span class="text-primary dark:text-primary-dark"></span>{% endif %}
</button> </button>
</form> </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"> <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')) }} {{ t(key="settings-theme", lang=lang | default(value='sk')) }}
</p> </p>

View File

@@ -27,13 +27,22 @@
:class="view === 'list' ? 'p-4 sm:p-5' : 'p-6 pb-2'"> :class="view === 'list' ? 'p-4 sm:p-5' : 'p-6 pb-2'">
<!-- Header: Title & Price (stacked so neither overflows the narrow card) --> <!-- Header: Title & Price (stacked so neither overflows the narrow card) -->
<h3 class="break-words text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3> <h3 class="break-words text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
{# Short blurb for the card; falls back to the full description (clamped)
for products without a dedicated short one. Both are authored as rich
text (Quill), so render the stored HTML — `.rich-blurb` strips block
spacing so the line-clamp stays tidy. Overflow is truncated with an
ellipsis: 2 lines in the grid, 3 in the roomier list row. #}
{% if product.short_description or product.description %}
<div class="rich-blurb line-clamp-2 break-words text-sm text-on-surface/70 dark:text-on-surface-dark/70"
:class="view === 'list' && 'line-clamp-3'">{% if product.short_description %}{{ product.short_description | safe }}{% else %}{{ product.description | safe }}{% endif %}</div>
{% endif %}
{% if product.on_sale %} {% if product.on_sale %}
<div class="flex flex-wrap items-baseline gap-x-2 leading-tight"> <div class="flex flex-wrap items-baseline gap-x-2 leading-tight">
<span class="text-xl font-semibold text-danger"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ product.currency }}</span> <span class="text-xl font-semibold text-danger"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ currency_symbol }}</span>
<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-sm text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }} {{ currency_symbol }}</span>
</div> </div>
{% else %} {% 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 %} {{ product.currency }}</span> <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 %} {% endif %}
</div> </div>
</a> </a>

View File

@@ -23,10 +23,10 @@
</td> </td>
<td class="px-4 py-3 tabular-nums"> <td class="px-4 py-3 tabular-nums">
{% if item.on_sale %} {% 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> <span class="ml-1 text-xs text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ item.regular_price }}</span>
{% else %} {% else %}
{{ item.price }} {{ item.currency }} {{ item.price }} {{ currency_symbol }}
{% endif %} {% endif %}
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
@@ -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"> 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> </form>
</td> </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"> <td class="px-4 py-3 text-right">
<form method="post" action="/cart/remove" <form method="post" action="/cart/remove"
hx-post="/cart/remove" hx-target="#cart-body" hx-swap="innerHTML"> hx-post="/cart/remove" hx-target="#cart-body" hx-swap="innerHTML">
@@ -63,7 +63,7 @@
<tfoot class="{{ ui::tfoot_cls() }}"> <tfoot class="{{ ui::tfoot_cls() }}">
<tr> <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 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> <td></td>
</tr> </tr>
</tfoot> </tfoot>

View File

@@ -1,6 +1,6 @@
{# Mini-cart preview shown on hover over the navbar cart (Alza-style). {# 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 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 %} {% import "macros/ui.html" as ui %}
{% if items | length > 0 %} {% if items | length > 0 %}
<div class="max-h-80 divide-y divide-outline overflow-y-auto dark:divide-outline-dark"> <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"> <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> <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 %} {% 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> </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> </div>
{% endfor %} {% endfor %}
</div> </div>
<div class="border-t border-outline px-4 py-3 dark:border-outline-dark"> <div class="border-t border-outline px-4 py-3 dark:border-outline-dark">
<div class="mb-3 flex items-center justify-between"> <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-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>
<div class="flex gap-2"> <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"') }} {{ 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

@@ -65,7 +65,7 @@
<!-- price band --> <!-- price band -->
<label class="flex flex-col gap-1 text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70"> <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) }} {{ t(key="filter-price", lang=L) }}{% if currency_symbol %} ({{ currency_symbol }}){% endif %}
<span class="flex items-center gap-1"> <span class="flex items-center gap-1">
<input type="number" name="min_price" min="0" step="0.01" inputmode="decimal" <input type="number" name="min_price" min="0" step="0.01" inputmode="decimal"
value="{{ min_price | default(value='') }}" placeholder="{{ price_floor }}" value="{{ min_price | default(value='') }}" placeholder="{{ price_floor }}"

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"> 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 class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ m.name }}</span>
</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> </label>
{% endfor %} {% endfor %}
@@ -252,23 +252,23 @@
{% for item in items %} {% for item in items %}
<li class="flex justify-between gap-2"> <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="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> </li>
{% endfor %} {% endfor %}
</ul> </ul>
<div class="space-y-1 border-t border-outline pt-3 text-sm dark:border-outline-dark"> <div class="space-y-1 border-t border-outline pt-3 text-sm dark:border-outline-dark">
<div class="flex justify-between"> <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="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>
<div class="flex justify-between"> <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="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> </div>
<div class="flex justify-between border-t border-outline pt-3 text-base font-bold dark:border-outline-dark"> <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>{{ 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> </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") }} {{ 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> </aside>

View File

@@ -30,18 +30,18 @@
{% for item in items %} {% for item in items %}
<li class="flex justify-between gap-2"> <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="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> </li>
{% endfor %} {% endfor %}
</ul> </ul>
<div class="space-y-1 border-t border-outline py-3 text-sm dark:border-outline-dark"> <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">{{ 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 }} {{ 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 }} </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 %} {% 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>
<div class="flex justify-between border-t border-outline pt-3 font-bold dark:border-outline-dark"> <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>{{ 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>
</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">{{ 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">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-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>
</div> </div>
{% else %} {% else %}

View File

@@ -67,7 +67,7 @@
<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> <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 }}"> <select id="variant-select" x-model.number="sel" class="{{ fld }}">
<template x-for="(v, i) in variants" :key="v.id"> <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> </template>
</select> </select>
</div> </div>
@@ -75,15 +75,16 @@
<div class="flex items-baseline gap-3"> <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'"> <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 }} <span x-text="current.price"></span> {{ currency_symbol }}
</p> </p>
<template x-if="current.on_sale"> <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> <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> </template>
</div> </div>
{% if product.description %} {% if product.description %}
<div class="whitespace-pre-line leading-relaxed text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description }}</div> {# Authored as rich text (Quill) in the admin; render the stored HTML. #}
<div class="rich-content text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description | safe }}</div>
{% endif %} {% endif %}
<template x-if="current.in_stock"> <template x-if="current.in_stock">

View File

@@ -45,6 +45,10 @@ mod m20260622_000003_variant_stock_nullable;
mod m20260622_000004_product_search; mod m20260622_000004_product_search;
mod m20260622_000005_product_search_aggregate; mod m20260622_000005_product_search_aggregate;
mod m20260622_000006_order_search_indexes; mod m20260622_000006_order_search_indexes;
mod m20260623_000001_add_short_description_to_products;
mod m20260623_000002_strip_html_from_product_search;
mod m20260623_000003_drop_currency;
mod m20260623_000004_currencies;
pub struct Migrator; pub struct Migrator;
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -94,6 +98,10 @@ impl MigratorTrait for Migrator {
Box::new(m20260622_000004_product_search::Migration), Box::new(m20260622_000004_product_search::Migration),
Box::new(m20260622_000005_product_search_aggregate::Migration), Box::new(m20260622_000005_product_search_aggregate::Migration),
Box::new(m20260622_000006_order_search_indexes::Migration), Box::new(m20260622_000006_order_search_indexes::Migration),
Box::new(m20260623_000001_add_short_description_to_products::Migration),
Box::new(m20260623_000002_strip_html_from_product_search::Migration),
Box::new(m20260623_000003_drop_currency::Migration),
Box::new(m20260623_000004_currencies::Migration),
// inject-above (do not remove this comment) // inject-above (do not remove this comment)
] ]
} }

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

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

@@ -198,7 +198,6 @@ async fn show(
"variant_id": variant.id, "variant_id": variant.id,
"name": product.name, "name": product.name,
"variant_label": variant.label, "variant_label": variant.label,
"currency": product.currency,
"regular_price": format_price(d.regular_cents), "regular_price": format_price(d.regular_cents),
"business_price": format_price(b.price_cents), "business_price": format_price(b.price_cents),
"business_reduced": b.price_cents < d.regular_cents, "business_reduced": b.price_cents < d.regular_cents,
@@ -285,7 +284,6 @@ async fn price_edit(
"variant_id": variant.id, "variant_id": variant.id,
"name": product.name, "name": product.name,
"variant_label": variant.label, "variant_label": variant.label,
"currency": product.currency,
"regular_price": format_price(d.regular_cents), "regular_price": format_price(d.regular_cents),
"regular_cents": d.regular_cents, "regular_cents": d.regular_cents,
"business_price": format_price(business_cents), "business_price": format_price(business_cents),

View File

@@ -202,7 +202,6 @@ async fn ship(
country: order.country.as_deref(), country: order.country.as_deref(),
pickup_point_id: order.pickup_point_id.as_deref(), pickup_point_id: order.pickup_point_id.as_deref(),
cod_cents, cod_cents,
currency: &order.currency,
value_cents: goods_value, value_cents: goods_value,
weight_grams: DEFAULT_PARCEL_WEIGHT_GRAMS, weight_grams: DEFAULT_PARCEL_WEIGHT_GRAMS,
}; };

View File

@@ -52,7 +52,7 @@ struct ProductFields {
name: String, name: String,
slug: String, slug: String,
description: Option<String>, description: Option<String>,
currency: String, short_description: Option<String>,
category_id: Option<i32>, category_id: Option<i32>,
published: bool, published: bool,
} }
@@ -65,8 +65,8 @@ async fn parse_product_fields(
let name = form let name = form
.text("name") .text("name")
.ok_or_else(|| Error::BadRequest("product name is required".to_string()))?; .ok_or_else(|| Error::BadRequest("product name is required".to_string()))?;
let currency = form.text("currency").unwrap_or_else(|| "EUR".to_string());
let description = form.text("description"); let description = form.text("description");
let short_description = form.text("short_description");
let category_id = form.text("category_id").and_then(|s| s.parse::<i32>().ok()); let category_id = form.text("category_id").and_then(|s| s.parse::<i32>().ok());
let published = form.checked("published"); let published = form.checked("published");
@@ -91,7 +91,7 @@ async fn parse_product_fields(
name, name,
slug, slug,
description, description,
currency, short_description,
category_id, category_id,
published, published,
}) })
@@ -363,7 +363,6 @@ fn product_row(
"id": product.id, "id": product.id,
"name": product.name, "name": product.name,
"slug": product.slug, "slug": product.slug,
"currency": product.currency,
"stock": stock_display, "stock": stock_display,
"variant_count": variant_count, "variant_count": variant_count,
"has_options": variant_count > 1, "has_options": variant_count > 1,
@@ -438,7 +437,7 @@ async fn create(
name: Set(fields.name), name: Set(fields.name),
slug: Set(fields.slug), slug: Set(fields.slug),
description: Set(fields.description), description: Set(fields.description),
currency: Set(fields.currency), short_description: Set(fields.short_description),
view_count: Set(0), view_count: Set(0),
published: Set(fields.published), published: Set(fields.published),
published_at: Set(fields.published.then(|| chrono::Utc::now().into())), published_at: Set(fields.published.then(|| chrono::Utc::now().into())),
@@ -552,7 +551,7 @@ async fn update(
product.name = Set(fields.name); product.name = Set(fields.name);
product.slug = Set(fields.slug); product.slug = Set(fields.slug);
product.description = Set(fields.description); product.description = Set(fields.description);
product.currency = Set(fields.currency); product.short_description = Set(fields.short_description);
product.category_id = Set(fields.category_id); product.category_id = Set(fields.category_id);
product.published = Set(fields.published); product.published = Set(fields.published);
if fields.published && !was_published { if fields.published && !was_published {
@@ -699,7 +698,6 @@ async fn profiles_preview(
} }
rows.push(json!({ rows.push(json!({
"id": product.id, "id": product.id,
"currency": product.currency,
"effective_price": format_price(priced.price_cents), "effective_price": format_price(priced.price_cents),
"effective_reduced": priced.is_reduced(), "effective_reduced": priced.is_reduced(),
"effective_percent_off": percent_off(priced.regular_cents, priced.price_cents), "effective_percent_off": percent_off(priced.regular_cents, priced.price_cents),
@@ -811,13 +809,12 @@ impl DiscountRow {
} }
} }
fn to_json(&self, currency: &str) -> serde_json::Value { fn to_json(&self) -> serde_json::Value {
json!({ json!({
"id": self.id, "id": self.id,
"label": self.label, "label": self.label,
"regular_cents": self.regular_cents, "regular_cents": self.regular_cents,
"regular_price": format_price(self.regular_cents), "regular_price": format_price(self.regular_cents),
"currency": currency,
"mode": self.mode, "mode": self.mode,
"fixed": self.fixed, "fixed": self.fixed,
"percent": self.percent, "percent": self.percent,
@@ -866,7 +863,7 @@ async fn discount_view(
audience: &str, audience: &str,
error: Option<&str>, error: Option<&str>,
) -> Result<Response> { ) -> Result<Response> {
let rows_json: Vec<_> = rows.iter().map(|r| r.to_json(&product.currency)).collect(); let rows_json: Vec<_> = rows.iter().map(DiscountRow::to_json).collect();
let has_discount = rows.iter().any(|r| r.has_discount); let has_discount = rows.iter().any(|r| r.has_discount);
format::view( format::view(
v, v,
@@ -875,7 +872,6 @@ async fn discount_view(
"product": { "product": {
"id": product.id, "id": product.id,
"name": product.name, "name": product.name,
"currency": product.currency,
}, },
"rows": rows_json, "rows": rows_json,
"audience": audience, "audience": audience,

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::{ use axum::{
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::Redirect, 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 /// 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( async fn published_variant(
ctx: &AppContext, ctx: &AppContext,
variant_id: i32, variant_id: i32,
@@ -173,12 +173,8 @@ async fn cart_response(
return Ok((jar, Redirect::to("/cart")).into_response()); return Ok((jar, Redirect::to("/cart")).into_response());
} }
let (lines, valid, total) = resolve_cart(ctx, &jar).await?; let cur = currency::resolve(ctx, &jar).await;
let currency = lines let (lines, valid, total) = resolve_cart(ctx, &jar, &cur).await?;
.first()
.and_then(|line| line["currency"].as_str())
.unwrap_or("EUR")
.to_string();
// Persist the re-validated cookie (drops now-invalid lines). // Persist the re-validated cookie (drops now-invalid lines).
let jar = jar.add(cart_cookie(serialize_cart(&valid))); let jar = jar.add(cart_cookie(serialize_cart(&valid)));
let response = format::view( let response = format::view(
@@ -186,8 +182,8 @@ async fn cart_response(
"shop/_cart_body.html", "shop/_cart_body.html",
json!({ json!({
"items": lines, "items": lines,
"total": format_price(total), "total": cur.format(total),
"currency": currency, "currency_symbol": cur.symbol,
"lang": current_lang(&jar), "lang": current_lang(&jar),
}), }),
)?; )?;
@@ -200,6 +196,7 @@ async fn cart_response(
pub(crate) async fn resolve_cart( pub(crate) async fn resolve_cart(
ctx: &AppContext, ctx: &AppContext,
jar: &CookieJar, jar: &CookieJar,
cur: &Currency,
) -> Result<(Vec<serde_json::Value>, Vec<(i32, i32)>, i64)> { ) -> Result<(Vec<serde_json::Value>, Vec<(i32, i32)>, i64)> {
// Resolve the cart entries to in-stock products first, then price them all // 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). // for the current viewer in one batch (the price depends on who's logged in).
@@ -232,13 +229,12 @@ pub(crate) async fn resolve_cart(
"name": product.name, "name": product.name,
"variant_label": variant.label, "variant_label": variant.label,
"slug": product.slug, "slug": product.slug,
"price": format_price(unit_price), "price": cur.format(unit_price),
"regular_price": format_price(priced.regular_cents), "regular_price": cur.format(priced.regular_cents),
"on_sale": priced.is_reduced(), "on_sale": priced.is_reduced(),
"currency": product.currency,
"quantity": qty, "quantity": qty,
"stock": variant.stock, "stock": variant.stock,
"line_total": format_price(line_total), "line_total": cur.format(line_total),
})); }));
} }
@@ -251,12 +247,8 @@ async fn show(
ViewEngine(v): ViewEngine<TeraView>, ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
let (lines, valid, total) = resolve_cart(&ctx, &jar).await?; let cur = currency::resolve(&ctx, &jar).await;
let currency = lines let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?;
.first()
.and_then(|line| line["currency"].as_str())
.unwrap_or("EUR")
.to_string();
// Drop any now-invalid lines from the cookie so the badge stays accurate. // Drop any now-invalid lines from the cookie so the badge stays accurate.
let rebuilt = serialize_cart(&valid); let rebuilt = serialize_cart(&valid);
@@ -266,8 +258,8 @@ async fn show(
"shop/cart.html", "shop/cart.html",
json!({ json!({
"items": lines, "items": lines,
"total": format_price(total), "total": cur.format(total),
"currency": currency, "currency_symbol": cur.symbol,
"logged_in_admin": c.logged_in_admin, "logged_in_admin": c.logged_in_admin,
"logged_in_customer": c.logged_in_customer, "logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name, "customer_name": c.customer_name,
@@ -287,20 +279,16 @@ async fn preview(
ViewEngine(v): ViewEngine<TeraView>, ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
let (lines, valid, total) = resolve_cart(&ctx, &jar).await?; let cur = currency::resolve(&ctx, &jar).await;
let currency = lines let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?;
.first()
.and_then(|line| line["currency"].as_str())
.unwrap_or("EUR")
.to_string();
let rebuilt = serialize_cart(&valid); let rebuilt = serialize_cart(&valid);
let response = format::view( let response = format::view(
&v, &v,
"shop/_cart_preview.html", "shop/_cart_preview.html",
json!({ json!({
"items": lines, "items": lines,
"total": format_price(total), "total": cur.format(total),
"currency": currency, "currency_symbol": cur.symbol,
"lang": current_lang(&jar), "lang": current_lang(&jar),
}), }),
)?; )?;

View File

@@ -18,7 +18,7 @@ use crate::{
users::{self, normalize_account_type}, users::{self, normalize_account_type},
}, },
controllers::i18n::current_lang, controllers::i18n::current_lang,
shared::{guard, money::format_price, settings}, shared::{currency::Currency, guard, money::format_price, settings},
views::checkout as view, views::checkout as view,
}; };
@@ -77,15 +77,12 @@ async fn checkout_page(
ViewEngine(v): ViewEngine<TeraView>, ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> 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() { if lines.is_empty() {
return format::redirect("/cart"); 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) let methods: Vec<serde_json::Value> = enabled_shipping_methods(&ctx)
.await? .await?
@@ -127,7 +124,6 @@ async fn checkout_page(
"items": lines, "items": lines,
"subtotal": format_price(subtotal), "subtotal": format_price(subtotal),
"subtotal_cents": subtotal, "subtotal_cents": subtotal,
"currency": currency,
"shipping_methods": methods, "shipping_methods": methods,
"packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""), "packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""),
"logged_in_admin": is_admin, "logged_in_admin": is_admin,
@@ -165,7 +161,7 @@ async fn place_order(
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
Form(form): Form<CheckoutForm>, Form(form): Form<CheckoutForm>,
) -> Result<Response> { ) -> 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() { if valid.is_empty() {
return format::redirect("/cart"); 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 loco_rs::prelude::*;
use serde_json::json; 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] #[debug_handler]
async fn index( async fn index(
@@ -13,7 +15,8 @@ async fn index(
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
let user = guard::current_user(&ctx, &jar).await; 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()); let c = guard::chrome_from(&ctx, user.as_ref());
format::view( format::view(
@@ -25,6 +28,7 @@ async fn index(
"logged_in_customer": c.logged_in_customer, "logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name, "customer_name": c.customer_name,
"customer_account_type": c.customer_account_type, "customer_account_type": c.customer_account_type,
"currency_symbol": cur.symbol,
"lang": current_lang(&jar), "lang": current_lang(&jar),
}), }),
) )

View File

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

View File

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

View File

@@ -13,8 +13,9 @@ use serde_json::json;
use crate::{ use crate::{
controllers::i18n::current_lang, controllers::i18n::current_lang,
shared::{ shared::{
currency::{self, Currency},
guard, guard,
money::{format_price, parse_price_to_cents}, money::parse_price_to_cents,
pricing, pricing,
}, },
models::{categories, product_images, product_variants, products, users}, models::{categories, product_images, product_variants, products, users},
@@ -90,6 +91,7 @@ async fn run_search(
ctx: &AppContext, ctx: &AppContext,
user: Option<&users::Model>, user: Option<&users::Model>,
params: &SearchParams, params: &SearchParams,
cur: &Currency,
) -> Result<serde_json::Value> { ) -> Result<serde_json::Value> {
let q = params.q.clone().unwrap_or_default(); let q = params.q.clone().unwrap_or_default();
let q_trim = q.trim().to_string(); let q_trim = q.trim().to_string();
@@ -136,9 +138,19 @@ async fn run_search(
let price_floor = items.iter().map(|i| i.priced.price_cents).min().unwrap_or(0); 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); let price_ceil = items.iter().map(|i| i.priced.price_cents).max().unwrap_or(0);
// 3. Non-category filters: price band + in-stock. // 3. Non-category filters: price band + in-stock. The typed bounds are in
let min_c = params.min_price.as_deref().and_then(|s| parse_price_to_cents(s).ok()); // the buyer's display currency; convert them back to EUR cents to compare
let max_c = params.max_price.as_deref().and_then(|s| parse_price_to_cents(s).ok()); // 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); let in_stock_only = is_on(&params.in_stock);
items.retain(|i| { items.retain(|i| {
min_c.is_none_or(|m| i.priced.price_cents >= m) min_c.is_none_or(|m| i.priced.price_cents >= m)
@@ -203,6 +215,7 @@ async fn run_search(
item.count, item.count,
image, image,
cat_name, cat_name,
cur,
)); ));
} }
@@ -219,8 +232,9 @@ async fn run_search(
"in_stock": in_stock_only, "in_stock": in_stock_only,
"min_price": params.min_price.clone().unwrap_or_default(), "min_price": params.min_price.clone().unwrap_or_default(),
"max_price": params.max_price.clone().unwrap_or_default(), "max_price": params.max_price.clone().unwrap_or_default(),
"price_floor": format_price(price_floor), "price_floor": cur.format(price_floor),
"price_ceil": format_price(price_ceil), "price_ceil": cur.format(price_ceil),
"currency_symbol": cur.symbol,
"total": total, "total": total,
"page": page, "page": page,
"pages": pages, "pages": pages,
@@ -240,6 +254,7 @@ async fn product_rows(
ctx: &AppContext, ctx: &AppContext,
user: Option<&users::Model>, user: Option<&users::Model>,
list: Vec<products::Model>, list: Vec<products::Model>,
cur: &Currency,
) -> Result<Vec<serde_json::Value>> { ) -> Result<Vec<serde_json::Value>> {
let ids: Vec<i32> = list.iter().map(|p| p.id).collect(); let ids: Vec<i32> = list.iter().map(|p| p.id).collect();
let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &ids).await?; let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &ids).await?;
@@ -261,7 +276,7 @@ async fn product_rows(
let mut rows = Vec::with_capacity(entries.len()); let mut rows = Vec::with_capacity(entries.len());
for ((product, rep, count), priced) in entries.iter().zip(priced.iter()) { for ((product, rep, count), priced) in entries.iter().zip(priced.iter()) {
let image = product_images::first_for(ctx, product.id).await?; 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) Ok(rows)
} }
@@ -272,6 +287,7 @@ pub(crate) async fn featured_products(
ctx: &AppContext, ctx: &AppContext,
user: Option<&users::Model>, user: Option<&users::Model>,
limit: u64, limit: u64,
cur: &Currency,
) -> Result<Vec<serde_json::Value>> { ) -> Result<Vec<serde_json::Value>> {
let list = products::Entity::find() let list = products::Entity::find()
.filter(products::Column::Published.eq(true)) .filter(products::Column::Published.eq(true))
@@ -279,7 +295,7 @@ pub(crate) async fn featured_products(
.limit(limit) .limit(limit)
.all(&ctx.db) .all(&ctx.db)
.await?; .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 /// The site-wide category sidebar, loaded lazily via htmx by the base layout so
@@ -320,7 +336,8 @@ async fn index(
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
let user = guard::current_user(&ctx, &jar).await; let user = guard::current_user(&ctx, &jar).await;
let mut context = run_search(&ctx, user.as_ref(), &SearchParams::default()).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()); let c = guard::chrome_from(&ctx, user.as_ref());
add_chrome(&mut context, &c, &current_lang(&jar)); add_chrome(&mut context, &c, &current_lang(&jar));
format::view(&v, "shop/index.html", context) format::view(&v, "shop/index.html", context)
@@ -341,7 +358,8 @@ async fn search(
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
let user = guard::current_user(&ctx, &jar).await; let user = guard::current_user(&ctx, &jar).await;
let mut context = run_search(&ctx, user.as_ref(), &params).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); let lang = current_lang(&jar);
if headers.contains_key("HX-Request") { if headers.contains_key("HX-Request") {
@@ -385,12 +403,13 @@ async fn show(
}; };
let user = guard::current_user(&ctx, &jar).await; 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 variants = product_variants::Entity::for_product(&ctx.db, product.id).await?;
let variant_prices = pricing::price_variants(&ctx, &variants, user.as_ref()).await?; let variant_prices = pricing::price_variants(&ctx, &variants, user.as_ref()).await?;
let options: Vec<serde_json::Value> = variants let options: Vec<serde_json::Value> = variants
.iter() .iter()
.zip(variant_prices.iter()) .zip(variant_prices.iter())
.map(|(variant, priced)| view::variant_option(variant, priced)) .map(|(variant, priced)| view::variant_option(variant, priced, &cur))
.collect(); .collect();
// The card header uses the representative (first) variant for its headline // The card header uses the representative (first) variant for its headline
// price; the picker below lets the customer switch. // price; the picker below lets the customer switch.
@@ -404,6 +423,7 @@ async fn show(
variants.len(), variants.len(),
None, None,
category.as_ref().map(|c| c.name.clone()), category.as_ref().map(|c| c.name.clone()),
&cur,
), ),
// A product with no variants isn't purchasable; show it without a price. // A product with no variants isn't purchasable; show it without a price.
_ => serde_json::json!({ _ => serde_json::json!({
@@ -411,7 +431,6 @@ async fn show(
"name": product.name, "name": product.name,
"slug": product.slug, "slug": product.slug,
"description": product.description, "description": product.description,
"currency": product.currency,
"variant_count": 0, "variant_count": 0,
"has_options": false, "has_options": false,
}), }),
@@ -429,6 +448,7 @@ async fn show(
"logged_in_customer": c.logged_in_customer, "logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name, "customer_name": c.customer_name,
"customer_account_type": c.customer_account_type, "customer_account_type": c.customer_account_type,
"currency_symbol": cur.symbol,
"lang": current_lang(&jar), "lang": current_lang(&jar),
}), }),
) )
@@ -464,7 +484,8 @@ async fn category(
}; };
let user = guard::current_user(&ctx, &jar).await; let user = guard::current_user(&ctx, &jar).await;
let mut context = run_search(&ctx, user.as_ref(), &params).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() { if let Some(map) = context.as_object_mut() {
map.insert("category".into(), serde_json::to_value(&category)?); map.insert("category".into(), serde_json::to_value(&category)?);
map.insert("breadcrumbs".into(), serde_json::to_value(&breadcrumbs)?); map.insert("breadcrumbs".into(), serde_json::to_value(&breadcrumbs)?);

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 admin_seeder;
pub mod currency_seeder;
pub mod oauth2; pub mod oauth2;
pub mod oauth2_session; pub mod oauth2_session;
pub mod shipping_seeder; pub mod shipping_seeder;

View File

@@ -54,6 +54,12 @@ impl Initializer for ViewEngineInitializer {
crate::shared::csrf::current_token().unwrap_or_default(), 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(()) Ok(())
})?; })?;

View File

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

View File

@@ -77,7 +77,7 @@ pub async fn create_shipment(ctx: &AppContext, req: ShipmentRequest<'_>) -> Resu
xml_escape(address_id), xml_escape(address_id),
value, value,
cod, cod,
xml_escape(req.currency), "EUR",
weight_kg, weight_kg,
xml_escape(sender_label), 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 audience_discount_profiles;
pub mod audit_logs; pub mod audit_logs;
pub mod categories; pub mod categories;
pub mod currencies;
pub mod customer_profiles; pub mod customer_profiles;
pub mod discount_profile_products; pub mod discount_profile_products;
pub mod discount_profiles; pub mod discount_profiles;

View File

@@ -16,7 +16,6 @@ pub struct Model {
pub customer_name: Option<String>, pub customer_name: Option<String>,
pub status: String, pub status: String,
pub total_cents: i64, pub total_cents: i64,
pub currency: String,
pub address: Option<String>, pub address: Option<String>,
pub city: Option<String>, pub city: Option<String>,
pub zip: 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::audience_discount_profiles::Entity as AudienceDiscountProfiles;
pub use super::audit_logs::Entity as AuditLogs; pub use super::audit_logs::Entity as AuditLogs;
pub use super::categories::Entity as Categories; 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::customer_profiles::Entity as CustomerProfiles;
pub use super::discount_profile_products::Entity as DiscountProfileProducts; pub use super::discount_profile_products::Entity as DiscountProfileProducts;
pub use super::discount_profiles::Entity as DiscountProfiles; pub use super::discount_profiles::Entity as DiscountProfiles;

View File

@@ -15,7 +15,8 @@ pub struct Model {
pub slug: String, pub slug: String,
#[sea_orm(column_type = "Text", nullable)] #[sea_orm(column_type = "Text", nullable)]
pub description: Option<String>, pub description: Option<String>,
pub currency: String, #[sea_orm(column_type = "Text", nullable)]
pub short_description: Option<String>,
pub view_count: i32, pub view_count: i32,
pub published: bool, pub published: bool,
pub published_at: Option<DateTimeWithTimeZone>, 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 audience_discount_profiles;
pub mod audit_logs; pub mod audit_logs;
pub mod categories; pub mod categories;
pub mod currencies;
pub mod discount_profile_products; pub mod discount_profile_products;
pub mod discount_profiles; pub mod discount_profiles;
pub mod customer_profiles; pub mod customer_profiles;

View File

@@ -53,7 +53,6 @@ pub async fn place(
let txn = ctx.db.begin().await?; let txn = ctx.db.begin().await?;
let mut subtotal: i64 = 0; let mut subtotal: i64 = 0;
let mut currency = "EUR".to_string();
let mut snapshots = Vec::new(); let mut snapshots = Vec::new();
for (variant_id, qty) in items { for (variant_id, qty) in items {
let variant = product_variants::Entity::find_by_id(*variant_id) let variant = product_variants::Entity::find_by_id(*variant_id)
@@ -75,7 +74,6 @@ pub async fn place(
))); )));
} }
} }
currency = product.currency.clone();
// Snapshot the price the buyer actually pays — public sale or, for a // Snapshot the price the buyer actually pays — public sale or, for a
// business account, their negotiated/lowest price (same resolver the // business account, their negotiated/lowest price (same resolver the
// cart and storefront use). // cart and storefront use).
@@ -98,7 +96,6 @@ pub async fn place(
customer_name: Set(details.customer_name), customer_name: Set(details.customer_name),
status: Set("pending".to_string()), status: Set("pending".to_string()),
total_cents: Set(subtotal + details.method.price_cents), total_cents: Set(subtotal + details.method.price_cents),
currency: Set(currency),
user_id: Set(details.user_id), user_id: Set(details.user_id),
account_type: Set(details.account_type), account_type: Set(details.account_type),
company_name: Set(details.company_name), company_name: Set(details.company_name),

View File

@@ -57,7 +57,7 @@ impl Entity {
let sql = format!( let sql = format!(
r#" r#"
SELECT p.created_at, p.updated_at, p.id, p.name, p.slug, p.description, SELECT p.created_at, p.updated_at, p.id, p.name, p.slug, p.description,
p.currency, p.view_count, p.published, p.published_at, p.category_id p.short_description, p.view_count, p.published, p.published_at, p.category_id
FROM products p FROM products p
WHERE {published_clause} ( WHERE {published_clause} (
p.search_vector @@ websearch_to_tsquery('sk_unaccent', $1) p.search_vector @@ websearch_to_tsquery('sk_unaccent', $1)

View File

@@ -161,7 +161,6 @@ pub async fn seed_catalog(ctx: &AppContext) -> Result<()> {
name: Set(item.name.to_string()), name: Set(item.name.to_string()),
slug: Set(product_slug), slug: Set(product_slug),
description: Set(Some(item.description.to_string())), description: Set(Some(item.description.to_string())),
currency: Set("EUR".to_string()),
published: Set(true), published: Set(true),
published_at: Set(Some(now.into())), published_at: Set(Some(now.into())),
category_id: Set(category.map(|c| c.id)), category_id: Set(category.map(|c| c.id)),

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. //! Cross-cutting helpers used across feature slices.
pub mod csrf; pub mod csrf;
pub mod currency;
pub mod guard; pub mod guard;
pub mod money; pub mod money;
pub mod pricing; 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), "subtotal": format_price(order.total_cents - order.shipping_cents),
"shipping": format_price(order.shipping_cents), "shipping": format_price(order.shipping_cents),
"total": format_price(order.total_cents), "total": format_price(order.total_cents),
"currency": order.currency,
"address": order.address, "address": order.address,
"city": order.city, "city": order.city,
"zip": order.zip, "zip": order.zip,
@@ -68,7 +67,6 @@ pub fn summary(order: &orders::Model) -> Value {
"email": order.email, "email": order.email,
"status": order.status, "status": order.status,
"total": format_price(order.total_cents), "total": format_price(order.total_cents),
"currency": order.currency,
"created_at": order.created_at.to_rfc3339(), "created_at": order.created_at.to_rfc3339(),
}) })
} }

View File

@@ -3,7 +3,7 @@
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::models::_entities::{categories, product_images, product_variants, products}; use crate::models::_entities::{categories, product_images, product_variants, products};
use crate::shared::money::format_price; use crate::shared::currency::Currency;
use crate::shared::pricing::PricedProduct; use crate::shared::pricing::PricedProduct;
/// Card/list shape for a product: model fields plus the viewer's resolved price /// 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, variant_count: usize,
image: Option<String>, image: Option<String>,
category_name: Option<String>, category_name: Option<String>,
cur: &Currency,
) -> Value { ) -> Value {
json!({ json!({
"id": product.id, "id": product.id,
@@ -27,11 +28,11 @@ pub fn product_card(
"name": product.name, "name": product.name,
"slug": product.slug, "slug": product.slug,
"description": product.description, "description": product.description,
"price": format_price(priced.price_cents), "short_description": product.short_description,
"price": cur.format(priced.price_cents),
"on_sale": priced.is_reduced(), "on_sale": priced.is_reduced(),
"is_business": priced.is_business, "is_business": priced.is_business,
"regular_price": format_price(priced.regular_cents), "regular_price": cur.format(priced.regular_cents),
"currency": product.currency,
"sku": representative.sku, "sku": representative.sku,
"stock": representative.stock, "stock": representative.stock,
"tracked": representative.tracked(), "tracked": representative.tracked(),
@@ -45,7 +46,11 @@ pub fn product_card(
} }
/// One priced variant row for the product detail page's option picker. /// 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!({ json!({
"id": variant.id, "id": variant.id,
"label": variant.label, "label": variant.label,
@@ -53,9 +58,9 @@ pub fn variant_option(variant: &product_variants::Model, priced: &PricedProduct)
"stock": variant.stock, "stock": variant.stock,
"tracked": variant.tracked(), "tracked": variant.tracked(),
"in_stock": variant.in_stock(), "in_stock": variant.in_stock(),
"price": format_price(priced.price_cents), "price": cur.format(priced.price_cents),
"on_sale": priced.is_reduced(), "on_sale": priced.is_reduced(),
"regular_price": format_price(priced.regular_cents), "regular_price": cur.format(priced.regular_cents),
"is_business": priced.is_business, "is_business": priced.is_business,
}) })
} }
@@ -69,7 +74,7 @@ pub fn product_form(product: &products::Model, images: &[product_images::Model])
"name": product.name, "name": product.name,
"slug": product.slug, "slug": product.slug,
"description": product.description, "description": product.description,
"currency": product.currency, "short_description": product.short_description,
"published": product.published, "published": product.published,
"category_id": product.category_id, "category_id": product.category_id,
"images": images "images": images