Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ac31cdfbf3 | ||
|
|
c409e85995 | ||
|
|
6b7422806f | ||
|
|
8085052b2b | ||
|
|
1cf330e4e8 | ||
|
|
031f86adb0 |
@@ -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; }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
174
assets/static/js/rich-editor.js
Normal file
174
assets/static/js/rich-editor.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
// Quill-based rich text editor, ported from the universal_web blog editor and
|
||||||
|
// adapted to this shop: each editor lives in a `[data-rich-field]` wrapper so a
|
||||||
|
// single form can host several (e.g. short + long description); image uploads go
|
||||||
|
// to this app's /images/upload and carry the CSRF token the middleware expects.
|
||||||
|
(function () {
|
||||||
|
function setImageSize(image, size) {
|
||||||
|
image.classList.remove('rich-image-small', 'rich-image-medium', 'rich-image-full');
|
||||||
|
image.style.removeProperty('width');
|
||||||
|
image.style.removeProperty('height');
|
||||||
|
image.classList.add('rich-image-' + size);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setImageWidth(image, width) {
|
||||||
|
var px = parseInt(width, 10);
|
||||||
|
if (!Number.isFinite(px) || px < 40) return;
|
||||||
|
image.classList.remove('rich-image-small', 'rich-image-medium', 'rich-image-full');
|
||||||
|
image.style.width = Math.min(px, 1200) + 'px';
|
||||||
|
image.style.height = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEditorImages(root) {
|
||||||
|
root.querySelectorAll('img').forEach(function (image) {
|
||||||
|
if (
|
||||||
|
!image.classList.contains('rich-image-small')
|
||||||
|
&& !image.classList.contains('rich-image-medium')
|
||||||
|
&& !image.classList.contains('rich-image-full')
|
||||||
|
) {
|
||||||
|
image.classList.add('rich-image-full');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The CSRF middleware accepts the token as an X-CSRF-Token header; read it from
|
||||||
|
// the form's hidden _csrf field (rendered by ui::csrf_field()).
|
||||||
|
function csrfToken(field) {
|
||||||
|
var form = field.closest('form');
|
||||||
|
var input = form && form.querySelector('input[name="_csrf"]');
|
||||||
|
return input ? input.value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function initField(field) {
|
||||||
|
var editorEl = field.querySelector('[data-rich-editor]');
|
||||||
|
var contentInput = field.querySelector('[data-rich-content]');
|
||||||
|
var status = field.querySelector('[data-rich-status]');
|
||||||
|
var imageControls = field.querySelector('[data-image-size-controls]');
|
||||||
|
var imageWidthInput = field.querySelector('[data-image-width]');
|
||||||
|
if (!editorEl || !contentInput || !window.Quill) return;
|
||||||
|
|
||||||
|
var selectedImage = null;
|
||||||
|
var toolbar = [
|
||||||
|
[{ header: [2, 3, false] }],
|
||||||
|
['bold', 'italic'],
|
||||||
|
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||||
|
['link', 'image'],
|
||||||
|
['clean']
|
||||||
|
];
|
||||||
|
var editor = new Quill(editorEl, {
|
||||||
|
modules: { toolbar: toolbar },
|
||||||
|
placeholder: editorEl.dataset.placeholder || '',
|
||||||
|
theme: 'snow'
|
||||||
|
});
|
||||||
|
|
||||||
|
var initialContent = contentInput.value.trim();
|
||||||
|
if (initialContent) {
|
||||||
|
if (initialContent.indexOf('<') >= 0) editor.clipboard.dangerouslyPasteHTML(initialContent);
|
||||||
|
else editor.setText(initialContent);
|
||||||
|
normalizeEditorImages(editor.root);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncContent() {
|
||||||
|
normalizeEditorImages(editor.root);
|
||||||
|
// Quill leaves an empty editor as "<p><br></p>"; store empty instead so the
|
||||||
|
// server sees a blank (nullable) value rather than stray markup.
|
||||||
|
var html = editor.root.innerHTML;
|
||||||
|
contentInput.value = editor.getText().trim() === '' && !editor.root.querySelector('img')
|
||||||
|
? ''
|
||||||
|
: html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(message) {
|
||||||
|
if (status) status.textContent = message || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseImageFile() {
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = 'image/jpeg,image/png,image/webp,image/gif';
|
||||||
|
input.addEventListener('change', function () {
|
||||||
|
var file = input.files && input.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
uploadImage(file);
|
||||||
|
});
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadImage(file) {
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
setStatus(status ? status.dataset.uploading : '');
|
||||||
|
try {
|
||||||
|
var response = await fetch('/images/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'X-CSRF-Token': csrfToken(field) }
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('upload failed');
|
||||||
|
var result = await response.json();
|
||||||
|
var range = editor.getSelection(true);
|
||||||
|
editor.insertEmbed(range.index, 'image', result.url, 'user');
|
||||||
|
editor.setSelection(range.index + 1, 0, 'silent');
|
||||||
|
window.setTimeout(function () {
|
||||||
|
var images = editor.root.querySelectorAll('img');
|
||||||
|
var image = images[images.length - 1];
|
||||||
|
if (image) {
|
||||||
|
setImageSize(image, 'full');
|
||||||
|
selectedImage = image;
|
||||||
|
if (imageControls) imageControls.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
syncContent();
|
||||||
|
}, 0);
|
||||||
|
setStatus(status ? status.dataset.uploaded : '');
|
||||||
|
} catch (_error) {
|
||||||
|
setStatus(status ? status.dataset.error : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.getModule('toolbar').addHandler('image', chooseImageFile);
|
||||||
|
|
||||||
|
editor.root.addEventListener('click', function (event) {
|
||||||
|
if (event.target && event.target.tagName === 'IMG') {
|
||||||
|
selectedImage = event.target;
|
||||||
|
if (imageWidthInput) imageWidthInput.value = parseInt(selectedImage.style.width, 10) || '';
|
||||||
|
if (imageControls) imageControls.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (imageControls) {
|
||||||
|
imageControls.addEventListener('click', function (event) {
|
||||||
|
var button = event.target.closest('[data-image-size]');
|
||||||
|
if (button && selectedImage) {
|
||||||
|
setImageSize(selectedImage, button.dataset.imageSize);
|
||||||
|
if (imageWidthInput) imageWidthInput.value = '';
|
||||||
|
syncContent();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageWidthInput) {
|
||||||
|
imageWidthInput.addEventListener('change', function () {
|
||||||
|
if (!selectedImage) return;
|
||||||
|
setImageWidth(selectedImage, imageWidthInput.value);
|
||||||
|
syncContent();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.on('text-change', syncContent);
|
||||||
|
var form = field.closest('form');
|
||||||
|
if (form) form.addEventListener('submit', syncContent);
|
||||||
|
syncContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAll(root) {
|
||||||
|
(root || document).querySelectorAll('[data-rich-field]').forEach(function (field) {
|
||||||
|
if (field.dataset.richReady) return;
|
||||||
|
field.dataset.richReady = '1';
|
||||||
|
initField(field);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () { initAll(document); });
|
||||||
|
// Re-init after htmx swaps a fragment containing an editor into the page.
|
||||||
|
document.addEventListener('htmx:afterSwap', function (event) { initAll(event.target); });
|
||||||
|
})();
|
||||||
31
assets/static/vendor/quill/LICENSE
vendored
Normal file
31
assets/static/vendor/quill/LICENSE
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
Copyright (c) 2017-2024, Slab
|
||||||
|
Copyright (c) 2014, Jason Chen
|
||||||
|
Copyright (c) 2013, salesforce.com
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions
|
||||||
|
are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in the
|
||||||
|
documentation and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
3. Neither the name of the copyright holder nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
|
||||||
|
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
|
||||||
|
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||||
|
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||||
|
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||||
|
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
3
assets/static/vendor/quill/quill.js
vendored
Normal file
3
assets/static/vendor/quill/quill.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
assets/static/vendor/quill/quill.js.LICENSE.txt
vendored
Normal file
7
assets/static/vendor/quill/quill.js.LICENSE.txt
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/*!
|
||||||
|
* Quill Editor v2.0.3
|
||||||
|
* https://quilljs.com
|
||||||
|
* Copyright (c) 2017-2024, Slab
|
||||||
|
* Copyright (c) 2014, Jason Chen
|
||||||
|
* Copyright (c) 2013, salesforce.com
|
||||||
|
*/
|
||||||
10
assets/static/vendor/quill/quill.snow.css
vendored
Normal file
10
assets/static/vendor/quill/quill.snow.css
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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 %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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 || '',
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
44
assets/views/admin/currencies/index.html
Normal file
44
assets/views/admin/currencies/index.html
Normal 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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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" %}
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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 }}/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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"') }}
|
||||||
|
|||||||
@@ -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 }}"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// A short blurb shown on product cards (grid/list), distinct from the full
|
||||||
|
// `description` rendered on the product detail page.
|
||||||
|
add_column(m, "products", "short_description", ColType::TextNull).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
remove_column(m, "products", "short_description").await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
//! Product descriptions are now authored as rich text (Quill) and stored as
|
||||||
|
//! HTML. The product search vector (see m20260622_000005) tokenizes the raw
|
||||||
|
//! description, so without this the markup itself (`p`, `strong`, `li`, `href`,
|
||||||
|
//! `class`, `ql`, …) would land in the index and pollute matches.
|
||||||
|
//!
|
||||||
|
//! Redefine `kompress_build_product_search` so the description is run through a
|
||||||
|
//! tag-stripping `regexp_replace` before `to_tsvector`, then backfill every
|
||||||
|
//! product's stored vector. Everything else about the function is unchanged.
|
||||||
|
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let db = m.get_connection();
|
||||||
|
db.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
CREATE OR REPLACE FUNCTION kompress_build_product_search(
|
||||||
|
p_name text, p_description text, p_id integer
|
||||||
|
) RETURNS tsvector
|
||||||
|
LANGUAGE sql STABLE AS $func$
|
||||||
|
SELECT
|
||||||
|
setweight(to_tsvector('sk_unaccent', COALESCE(p_name, '')), 'A')
|
||||||
|
|| setweight(to_tsvector('sk_unaccent', COALESCE((
|
||||||
|
SELECT string_agg(t.name, ' ')
|
||||||
|
FROM product_product_tags ppt
|
||||||
|
JOIN product_tags t ON t.id = ppt.product_tag_id
|
||||||
|
WHERE ppt.product_id = p_id
|
||||||
|
), '')), 'B')
|
||||||
|
|| setweight(to_tsvector('sk_unaccent', COALESCE((
|
||||||
|
SELECT string_agg(v.label, ' ')
|
||||||
|
FROM product_variants v
|
||||||
|
WHERE v.product_id = p_id
|
||||||
|
), '')), 'B')
|
||||||
|
|| setweight(to_tsvector('sk_unaccent',
|
||||||
|
regexp_replace(COALESCE(p_description, ''), '<[^>]+>', ' ', 'g')
|
||||||
|
), 'C')
|
||||||
|
|| setweight(to_tsvector('sk_unaccent', COALESCE((
|
||||||
|
SELECT string_agg(v.sku, ' ')
|
||||||
|
FROM product_variants v
|
||||||
|
WHERE v.product_id = p_id AND v.sku IS NOT NULL
|
||||||
|
), '')), 'C');
|
||||||
|
$func$;
|
||||||
|
|
||||||
|
-- Backfill: recompute every product's vector with the new definition.
|
||||||
|
UPDATE products
|
||||||
|
SET search_vector = kompress_build_product_search(name, description, id);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// Restore the prior definition (raw, un-stripped description).
|
||||||
|
let db = m.get_connection();
|
||||||
|
db.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
CREATE OR REPLACE FUNCTION kompress_build_product_search(
|
||||||
|
p_name text, p_description text, p_id integer
|
||||||
|
) RETURNS tsvector
|
||||||
|
LANGUAGE sql STABLE AS $func$
|
||||||
|
SELECT
|
||||||
|
setweight(to_tsvector('sk_unaccent', COALESCE(p_name, '')), 'A')
|
||||||
|
|| setweight(to_tsvector('sk_unaccent', COALESCE((
|
||||||
|
SELECT string_agg(t.name, ' ')
|
||||||
|
FROM product_product_tags ppt
|
||||||
|
JOIN product_tags t ON t.id = ppt.product_tag_id
|
||||||
|
WHERE ppt.product_id = p_id
|
||||||
|
), '')), 'B')
|
||||||
|
|| setweight(to_tsvector('sk_unaccent', COALESCE((
|
||||||
|
SELECT string_agg(v.label, ' ')
|
||||||
|
FROM product_variants v
|
||||||
|
WHERE v.product_id = p_id
|
||||||
|
), '')), 'B')
|
||||||
|
|| setweight(to_tsvector('sk_unaccent', COALESCE(p_description, '')), 'C')
|
||||||
|
|| setweight(to_tsvector('sk_unaccent', COALESCE((
|
||||||
|
SELECT string_agg(v.sku, ' ')
|
||||||
|
FROM product_variants v
|
||||||
|
WHERE v.product_id = p_id AND v.sku IS NOT NULL
|
||||||
|
), '')), 'C');
|
||||||
|
$func$;
|
||||||
|
|
||||||
|
UPDATE products
|
||||||
|
SET search_vector = kompress_build_product_search(name, description, id);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
20
migration/src/m20260623_000003_drop_currency.rs
Normal file
20
migration/src/m20260623_000003_drop_currency.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
31
migration/src/m20260623_000004_currencies.rs
Normal file
31
migration/src/m20260623_000004_currencies.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
|||||||
94
src/controllers/admin_currencies.rs
Normal file
94
src/controllers/admin_currencies.rs
Normal 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))
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
}),
|
}),
|
||||||
)?;
|
)?;
|
||||||
|
|||||||
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
39
src/controllers/currency.rs
Normal file
39
src/controllers/currency.rs
Normal 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))
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(¶ms.in_stock);
|
let in_stock_only = is_on(¶ms.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, ¤t_lang(&jar));
|
add_chrome(&mut context, &c, ¤t_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(), ¶ms).await?;
|
let cur = currency::resolve(&ctx, &jar).await;
|
||||||
|
let mut context = run_search(&ctx, user.as_ref(), ¶ms, &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(), ¶ms).await?;
|
let cur = currency::resolve(&ctx, &jar).await;
|
||||||
|
let mut context = run_search(&ctx, user.as_ref(), ¶ms, &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)?);
|
||||||
|
|||||||
52
src/initializers/currency_seeder.rs
Normal file
52
src/initializers/currency_seeder.rs
Normal 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", "Kč", 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
);
|
);
|
||||||
|
|||||||
22
src/models/_entities/currencies.rs
Normal file
22
src/models/_entities/currencies.rs
Normal 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 {}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
40
src/models/currencies.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
206
src/shared/currency.rs
Normal 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}")
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user