21 Commits

Author SHA1 Message Date
Priec
2023b24d92 search notify where we are searching
Some checks are pending
CI / Check Style (push) Waiting to run
CI / Run Clippy (push) Waiting to run
CI / Run Tests (push) Waiting to run
2026-06-25 20:46:46 +02:00
Priec
aea4782e68 avatar 2026-06-25 19:24:50 +02:00
Priec
0c0cae2355 search changing newest to relevance on search now
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-25 17:09:02 +02:00
Priec
194e9e2de3 search needs button now 2026-06-25 16:36:50 +02:00
Priec
848042c304 page is better in shop now 2026-06-25 15:38:18 +02:00
Priec
ee8ec5c85b right sidebar is scrolled over now 2026-06-25 15:31:29 +02:00
Priec
a53bd720bd left sidebar is scrollable 2026-06-25 15:30:43 +02:00
Priec
2ed069ea63 breadcrumbs position 2026-06-25 15:03:13 +02:00
Priec
c0f4d0c93c navbar search removed where it shouldnt be 2026-06-25 14:56:19 +02:00
Priec
d68ed5ce7c search looks better now 2026-06-25 14:53:51 +02:00
Priec
72babdf74f search in the shop bar is not duplicated anymore 2026-06-25 13:59:51 +02:00
Priec
8dd9a53ad8 home search fixed 2026-06-25 13:09:32 +02:00
Priec
aae8083de1 catppuccin latte is on the light mode 2026-06-25 12:19:08 +02:00
Priec
3159c5b30b dark mode is now gruvbox 2026-06-25 12:16:25 +02:00
Priec
f51875d5f4 new ui4 2026-06-25 12:13:30 +02:00
Priec
d3d1c0d157 new ui3 2026-06-24 23:28:40 +02:00
Priec
a34fd1725b new ui2 2026-06-24 23:04:10 +02:00
Priec
f665eee96e new ui 2026-06-24 22:45:33 +02:00
Priec
ac31cdfbf3 eur czk can be disabled from now on
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-23 21:54:09 +02:00
Priec
c409e85995 CZK implemented 2026-06-23 12:54:11 +02:00
Priec
6b7422806f whole eshop is now in euro 2026-06-23 12:31:52 +02:00
75 changed files with 1869 additions and 358 deletions

View File

@@ -37,29 +37,33 @@
* dark:bg-surface-dark, border-outline, etc. * dark:bg-surface-dark, border-outline, etc.
* ============================================================ */ * ============================================================ */
@theme { @theme {
/* light mode */ /* light mode — Catppuccin Latte (https://catppuccin.com/palette)
--color-surface: var(--color-white); * Base #eff1f5, Mantle #e6e9ef, Surface1 #bcc0cc, Subtext1 #5c5f77,
--color-surface-alt: var(--color-slate-100); * Subtext0 #6c6f85, Text #4c4f69, Blue #1e66f5. */
--color-on-surface: var(--color-slate-700); --color-surface: #eff1f5; /* Base */
--color-on-surface-strong: var(--color-slate-900); --color-surface-alt: #e6e9ef; /* Mantle */
--color-primary: var(--color-indigo-600); --color-on-surface: #5c5f77; /* Subtext1 */
--color-on-primary: var(--color-white); --color-on-surface-strong: #4c4f69; /* Text */
--color-secondary: var(--color-slate-600); --color-primary: #1e66f5; /* Blue */
--color-on-secondary: var(--color-white); --color-on-primary: #eff1f5; /* Base */
--color-outline: var(--color-slate-300); --color-secondary: #6c6f85; /* Subtext0 */
--color-outline-strong: var(--color-slate-800); --color-on-secondary: #eff1f5; /* Base */
--color-outline: #bcc0cc; /* Surface1 */
--color-outline-strong: #4c4f69; /* Text */
/* dark mode */ /* dark mode — Gruvbox dark palette (https://github.com/morhetz/gruvbox)
--color-surface-dark: var(--color-slate-900); * bg0 #282828, bg1 #3c3836, bg2 #504945, fg0 #fbf1c7, fg1 #ebdbb2,
--color-surface-dark-alt: var(--color-slate-800); * fg2 #d5c4a1, fg3 #bdae93, bright blue #83a598, bg0_h #1d2021. */
--color-on-surface-dark: var(--color-slate-300); --color-surface-dark: #282828; /* bg0 */
--color-on-surface-dark-strong: var(--color-white); --color-surface-dark-alt: #3c3836; /* bg1 */
--color-primary-dark: var(--color-indigo-400); --color-on-surface-dark: #ebdbb2; /* fg1 */
--color-on-primary-dark: var(--color-slate-950); --color-on-surface-dark-strong: #fbf1c7; /* fg0 */
--color-secondary-dark: var(--color-slate-300); --color-primary-dark: #83a598; /* bright blue */
--color-on-secondary-dark: var(--color-slate-950); --color-on-primary-dark: #1d2021; /* bg0_h */
--color-outline-dark: var(--color-slate-700); --color-secondary-dark: #d5c4a1; /* fg2 */
--color-outline-dark-strong: var(--color-slate-300); --color-on-secondary-dark: #1d2021; /* bg0_h */
--color-outline-dark: #504945; /* bg2 */
--color-outline-dark-strong: #bdae93; /* fg3 */
/* shared status colors (same in both modes) */ /* shared status colors (same in both modes) */
--color-info: var(--color-sky-500); --color-info: var(--color-sky-500);

View File

@@ -20,6 +20,7 @@ admin-audio-desc = upload songs, then group them into albums.
logout = Log out logout = Log out
settings = Settings settings = Settings
settings-language = Language settings-language = Language
settings-currency = Currency
settings-theme = Theme settings-theme = Theme
language-en = English language-en = English
language-sk = Slovak language-sk = Slovak
@@ -314,6 +315,7 @@ order-search-placeholder = Search orders…
search-empty = Nothing matched your search: search-empty = Nothing matched your search:
results-count = { $count } products results-count = { $count } products
sort-label = Sort sort-label = Sort
per-page-label = Per page
sort-relevance = Relevance sort-relevance = Relevance
sort-newest = Newest sort-newest = Newest
sort-price_asc = Price: low to high sort-price_asc = Price: low to high
@@ -385,6 +387,11 @@ profile-last-name = Surname
profile-edit = Edit profile profile-edit = Edit profile
profile-cancel = Cancel profile-cancel = Cancel
profile-not-set = Not set profile-not-set = Not set
profile-avatar = Profile picture
profile-avatar-hint = PNG, JPG, WEBP or GIF, up to 10 MB.
profile-avatar-choose = Choose a picture
profile-avatar-upload = Upload
profile-avatar-remove = Remove picture
nav-account = My account nav-account = My account
account-orders = My orders account-orders = My orders
account-change-password = Change password account-change-password = Change password
@@ -475,6 +482,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
@@ -491,3 +506,29 @@ order-manual-fulfillment = Manual fulfilment — no carrier API for this option.
order-send-hint = When the goods are ready, send this order to the carrier. order-send-hint = When the goods are ready, send this order to the carrier.
order-send-to-carrier = Send to order-send-to-carrier = Send to
order-send-confirm = Send this order to the carrier now? order-send-confirm = Send this order to the carrier now?
# --- storefront chrome: top bar, header, footer ---
brand-subtitle = medical supplies
top-contact = Contact
top-sitemap = Sitemap
search-button = Search
search-scope-in = Searching in:
search-scope-all = Search the whole shop
welcome = Welcome
cart-units = items
hotline = +421 903 410 476
footer-tagline = Medical supplies for clinics, hospitals and home care. Delivery within 24 hours.
footer-info = Information
footer-account = Account
footer-contact = Contact
footer-terms = Terms and conditions
footer-about = About our company
footer-stores = Our stores
footer-shipping = Shipping and payment
footer-orders = My orders
footer-email = info@kompress.sk
footer-hours = MonFri 8:0016:00
footer-rights = © 2026 Kompress · Medical supplies
page-coming-soon = This page is coming soon. In the meantime, feel free to contact us by phone or e-mail.
page-contact-intro = We're happy to help you choose. Get in touch:
page-sitemap-intro = An overview of the shop's main sections.

View File

@@ -20,6 +20,7 @@ admin-audio-desc = nahrať skladby a potom ich zoskupiť do albumov.
logout = Odhlásiť sa logout = Odhlásiť sa
settings = Nastavenia settings = Nastavenia
settings-language = Jazyk settings-language = Jazyk
settings-currency = Mena
settings-theme = Téma settings-theme = Téma
language-en = Angličtina language-en = Angličtina
language-sk = Slovenčina language-sk = Slovenčina
@@ -314,6 +315,7 @@ order-search-placeholder = Hľadať objednávky…
search-empty = Pre váš výraz sme nič nenašli: search-empty = Pre váš výraz sme nič nenašli:
results-count = { $count } produktov results-count = { $count } produktov
sort-label = Zoradiť sort-label = Zoradiť
per-page-label = Na stránku
sort-relevance = Relevancia sort-relevance = Relevancia
sort-newest = Najnovšie sort-newest = Najnovšie
sort-price_asc = Cena: od najnižšej sort-price_asc = Cena: od najnižšej
@@ -385,6 +387,11 @@ profile-last-name = Priezvisko
profile-edit = Upraviť profil profile-edit = Upraviť profil
profile-cancel = Zrušiť profile-cancel = Zrušiť
profile-not-set = Neuvedené profile-not-set = Neuvedené
profile-avatar = Profilová fotka
profile-avatar-hint = PNG, JPG, WEBP alebo GIF, max. 10 MB.
profile-avatar-choose = Vybrať fotku
profile-avatar-upload = Nahrať
profile-avatar-remove = Odstrániť fotku
nav-account = Môj účet nav-account = Môj účet
account-orders = Moje objednávky account-orders = Moje objednávky
account-change-password = Zmeniť heslo account-change-password = Zmeniť heslo
@@ -475,6 +482,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
@@ -491,3 +506,29 @@ order-manual-fulfillment = Manuálne spracovanie — táto možnosť nemá API d
order-send-hint = Keď je tovar pripravený, odošlite objednávku dopravcovi. order-send-hint = Keď je tovar pripravený, odošlite objednávku dopravcovi.
order-send-to-carrier = Odoslať dopravcovi order-send-to-carrier = Odoslať dopravcovi
order-send-confirm = Odoslať túto objednávku dopravcovi teraz? order-send-confirm = Odoslať túto objednávku dopravcovi teraz?
# --- storefront chrome: top bar, header, footer ---
brand-subtitle = zdravotnícke potreby
top-contact = Kontakt
top-sitemap = Mapa stránky
search-button = Hľadať
search-scope-in = Hľadáte v kategórii:
search-scope-all = Hľadať v celom obchode
welcome = Vitajte
cart-units = ks
hotline = +421 903 410 476
footer-tagline = Zdravotnícke potreby pre ambulancie, nemocnice a domácu starostlivosť. Dodanie do 24 hodín.
footer-info = Informácie
footer-account = Účet
footer-contact = Kontakt
footer-terms = Obchodné podmienky
footer-about = O našej spoločnosti
footer-stores = Naše obchody
footer-shipping = Doprava a platba
footer-orders = Moje objednávky
footer-email = info@kompress.sk
footer-hours = PoPia 8:0016:00
footer-rights = © 2026 Kompress · Zdravotnícke potreby
page-coming-soon = Túto stránku práve pripravujeme. Medzitým nás môžete kontaktovať telefonicky alebo e-mailom.
page-contact-intro = Radi vám poradíme s výberom. Ozvite sa nám:
page-sitemap-intro = Prehľad hlavných sekcií obchodu.

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

@@ -28,6 +28,45 @@
{{ ui::alert_danger(message=t(key="profile-company-required", lang=lang | default(value='sk')), extra="mt-4") }} {{ ui::alert_danger(message=t(key="profile-company-required", lang=lang | default(value='sk')), extra="mt-4") }}
{% endif %} {% endif %}
{# initials fallback when no avatar is set, e.g. "Filip Priec" -> "FP" #}
{% set _name = name | default(value='') | trim %}
{% set _parts = _name | split(pat=' ') %}
{% set _initials = _parts.0 | truncate(length=1, end='') | upper %}
{% if _parts | length > 1 %}{% set _second = _parts | last | truncate(length=1, end='') | upper %}{% set _initials = _initials ~ _second %}{% endif %}
<!-- avatar: upload / replace / remove. Own multipart form, independent of the
profile edit toggle below, so it works in both view and edit modes. -->
<fieldset class="mt-6 space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt"
x-data="{ name: '' }">
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="profile-avatar", lang=lang | default(value='sk')) }}</legend>
<div class="flex items-center gap-5">
<span class="flex size-20 shrink-0 items-center justify-center overflow-hidden rounded-full border border-primary bg-primary text-2xl font-bold tracking-wider text-on-primary/90 dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark/90">
{%- if avatar_id %}<img src="/images/{{ avatar_id }}" alt="{{ _name }}" class="size-full object-cover">{% elif _initials %}{{ _initials }}{% endif -%}
</span>
<div class="min-w-0 space-y-3">
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="profile-avatar-hint", lang=lang | default(value='sk')) }}</p>
<div class="flex flex-wrap items-center gap-3">
<form method="post" action="/account/profile/avatar" enctype="multipart/form-data" hx-boost="false" class="flex flex-wrap items-center gap-3">
{{ ui::csrf_field() }}
<label class="inline-flex cursor-pointer items-center gap-2 rounded-radius border border-outline bg-surface-alt px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-primary/5 hover:text-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark dark:hover:text-primary-dark">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4 shrink-0" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" /></svg>
<span class="truncate max-w-[12rem]" x-text="name || '{{ t(key='profile-avatar-choose', lang=lang | default(value='sk')) }}'">{{ t(key="profile-avatar-choose", lang=lang | default(value='sk')) }}</span>
<input type="file" name="image" accept="image/png,image/jpeg,image/webp,image/gif" class="sr-only"
@change="name = $event.target.files.length ? $event.target.files[0].name : ''">
</label>
{{ ui::button(label=t(key="profile-avatar-upload", lang=lang | default(value='sk')), type="submit", size="px-4 py-2 text-sm", attrs='x-show="name" x-cloak') }}
</form>
{% if avatar_id %}
<form method="post" action="/account/profile/avatar/remove" hx-boost="false">
{{ ui::csrf_field() }}
{{ ui::button(label=t(key="profile-avatar-remove", lang=lang | default(value='sk')), type="submit", variant="outline-secondary", size="px-4 py-2 text-sm") }}
</form>
{% endif %}
</div>
</div>
</div>
</fieldset>
<!-- read-only view (default) --> <!-- read-only view (default) -->
<div x-show="!editing" class="mt-6 space-y-6"> <div x-show="!editing" class="mt-6 space-y-6">
<fieldset class="space-y-2 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt"> <fieldset class="space-y-2 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">

View File

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

View File

@@ -38,7 +38,7 @@
x-text="row.label || ('#' + row.id)"></span> x-text="row.label || ('#' + row.id)"></span>
<span class="text-sm tabular-nums text-on-surface/70 dark:text-on-surface-dark/70"> <span class="text-sm tabular-nums text-on-surface/70 dark:text-on-surface-dark/70">
{{ t(key="price", lang=lang | default(value='sk')) }}: {{ t(key="price", lang=lang | default(value='sk')) }}:
<span x-text="row.regular_price"></span> <span x-text="row.currency"></span> <span x-text="row.regular_price"></span>
</span> </span>
</div> </div>
@@ -80,9 +80,9 @@
class="flex flex-wrap items-center justify-between gap-3 rounded-radius border border-outline bg-surface-alt px-4 py-2.5 text-sm dark:border-outline-dark dark:bg-surface-dark/40"> class="flex flex-wrap items-center justify-between gap-3 rounded-radius border border-outline bg-surface-alt px-4 py-2.5 text-sm dark:border-outline-dark dark:bg-surface-dark/40">
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="discount-preview-after", lang=lang | default(value='sk')) }}</span> <span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="discount-preview-after", lang=lang | default(value='sk')) }}</span>
<span class="flex items-center gap-2"> <span class="flex items-center gap-2">
<span class="tabular-nums text-on-surface/50 line-through dark:text-on-surface-dark/50" x-text="money(row.regular_cents) + ' ' + row.currency"></span> <span class="tabular-nums text-on-surface/50 line-through dark:text-on-surface-dark/50" x-text="money(row.regular_cents) + ' '"></span>
<span class="text-base font-semibold tabular-nums" :class="valid(row) ? 'text-danger' : 'text-on-surface/40 dark:text-on-surface-dark/40'" <span class="text-base font-semibold tabular-nums" :class="valid(row) ? 'text-danger' : 'text-on-surface/40 dark:text-on-surface-dark/40'"
x-text="money(afterCents(row)) + ' ' + row.currency"></span> x-text="money(afterCents(row)) + ' '"></span>
<span x-show="valid(row)" class="text-xs text-on-surface/60 dark:text-on-surface-dark/60" x-text="'(' + percentOff(row) + '%)'"></span> <span x-show="valid(row)" class="text-xs text-on-surface/60 dark:text-on-surface-dark/60" x-text="'(' + percentOff(row) + '%)'"></span>
</span> </span>
</div> </div>
@@ -106,7 +106,6 @@
label: r.label || '', label: r.label || '',
regular_cents: r.regular_cents, regular_cents: r.regular_cents,
regular_price: r.regular_price, regular_price: r.regular_price,
currency: r.currency,
mode: r.mode || 'fixed', mode: r.mode || 'fixed',
fixed: r.fixed || '', fixed: r.fixed || '',
percent: r.percent || '', percent: r.percent || '',

View File

@@ -21,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_short = product.short_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_short = "" %}{% 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" %}
@@ -33,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 #}
@@ -76,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>

View File

@@ -122,7 +122,7 @@
</div> </div>
</div> </div>
</td> </td>
<td class="px-4 py-3 tabular-nums">{% if product.has_options %}{{ t(key="from-price", price=product.regular_price, lang=lang | default(value='sk')) }}{% else %}{{ product.regular_price }}{% endif %} {{ product.currency }}</td> <td class="px-4 py-3 tabular-nums">{% if product.has_options %}{{ t(key="from-price", price=product.regular_price, lang=lang | default(value='sk')) }}{% else %}{{ product.regular_price }}{% endif %} </td>
<td class="px-4 py-3 tabular-nums">{{ product.variant_count }}</td> <td class="px-4 py-3 tabular-nums">{{ product.variant_count }}</td>
<td class="px-4 py-3 tabular-nums"> <td class="px-4 py-3 tabular-nums">
<span id="eff-{{ product.id }}">{{ ui::eff_price(p=product) }}</span> <span id="eff-{{ product.id }}">{{ ui::eff_price(p=product) }}</span>

View File

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

View File

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

View File

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

View File

@@ -44,7 +44,7 @@
<td class="px-4 py-3"> <td class="px-4 py-3">
{{ ui::badge(label=t(key="order-status-" ~ order.status, lang=lang | default(value='sk')), variant="neutral") }} {{ ui::badge(label=t(key="order-status-" ~ order.status, lang=lang | default(value='sk')), variant="neutral") }}
</td> </td>
<td class="px-4 py-3 text-right tabular-nums">{{ order.total }} {{ order.currency }}</td> <td class="px-4 py-3 text-right tabular-nums">{{ order.total }} </td>
<td class="px-4 py-3 text-right"> <td class="px-4 py-3 text-right">
{{ ui::button(variant="outline-secondary", label=t(key="view", lang=lang | default(value='sk')), href="/admin/orders/" ~ order.id, size="px-3 py-1.5 text-xs") }} {{ ui::button(variant="outline-secondary", label=t(key="view", lang=lang | default(value='sk')), href="/admin/orders/" ~ order.id, size="px-3 py-1.5 text-xs") }}
</td> </td>

View File

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

View File

@@ -73,42 +73,90 @@
x-data="{ cats: false, lg: window.matchMedia('(min-width: 1024px)').matches }" x-data="{ cats: false, lg: window.matchMedia('(min-width: 1024px)').matches }"
x-init="window.matchMedia('(min-width: 1024px)').addEventListener('change', e => lg = e.matches)" x-init="window.matchMedia('(min-width: 1024px)').addEventListener('change', e => lg = e.matches)"
class="min-h-screen bg-surface text-on-surface antialiased dark:bg-surface-dark dark:text-on-surface-dark"> class="min-h-screen bg-surface text-on-surface antialiased dark:bg-surface-dark dark:text-on-surface-dark">
<!-- top utility bar (Kompress design): primary nav on the left, contact /
sitemap links on the right. Non-sticky — it scrolls away above the
sticky header. -->
<div class="hidden border-b border-outline bg-surface text-xs sm:block dark:border-outline-dark dark:bg-surface-dark">
<div class="mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-2 text-on-surface/70 dark:text-on-surface-dark/70">
<div class="flex items-center gap-5">
<a href="/" data-nav="/" class="transition hover:text-primary aria-[current=page]:font-semibold aria-[current=page]:text-primary dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-home", lang=lang | default(value='sk')) }}</a>
<a href="/shop" data-nav="/shop" class="transition hover:text-primary aria-[current=page]:font-semibold aria-[current=page]:text-primary dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</a>
</div>
<div class="flex items-center gap-4">
<a href="/kontakt" class="transition hover:text-primary dark:hover:text-primary-dark">{{ t(key="top-contact", lang=lang | default(value='sk')) }}</a>
<span class="h-3 w-px bg-outline dark:bg-outline-dark"></span>
<a href="/mapa-stranky" class="transition hover:text-primary dark:hover:text-primary-dark">{{ t(key="top-sitemap", lang=lang | default(value='sk')) }}</a>
</div>
</div>
</div>
<header <header
class="sticky top-0 z-30 border-b border-outline bg-surface/95 backdrop-blur dark:border-outline-dark dark:bg-surface-dark/95"> class="sticky top-0 z-30 border-b border-outline bg-surface/95 backdrop-blur dark:border-outline-dark dark:bg-surface-dark/95">
<nav x-data="{ mobile: false }" class="mx-auto flex max-w-7xl items-center gap-4 px-4 py-3"> <nav x-data="{ mobile: false }" class="mx-auto flex max-w-7xl items-center gap-3 px-4 py-3 sm:gap-4">
<!-- category sidebar toggle (mobile only) --> <!-- category sidebar toggle (mobile only) -->
{% set hamburger_icon = ui::icon(name="hamburger", size="size-6") %} {% set hamburger_icon = ui::icon(name="hamburger", size="size-6") %}
{{ ui::icon_button(aria_label=t(key='categories', lang=lang | default(value='sk')), attrs='@click="cats = !cats" :aria-expanded="cats"', extra="lg:hidden", icon=hamburger_icon) }} {{ ui::icon_button(aria_label=t(key='categories', lang=lang | default(value='sk')), attrs='@click="cats = !cats" :aria-expanded="cats"', extra="lg:hidden", icon=hamburger_icon) }}
<a href="/"
class="text-lg font-bold tracking-tight text-on-surface-strong dark:text-on-surface-dark-strong"> <!-- logo lockup: blue cross tile + wordmark + medical-supplies subtitle -->
{{ t(key="brand", lang=lang | default(value='sk')) }} <a href="/" class="flex shrink-0 items-center gap-2.5">
<span class="inline-flex size-9 items-center justify-center rounded-radius bg-primary text-on-primary dark:bg-primary-dark dark:text-on-primary-dark">
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="10" y="3" width="4" height="18" rx="1.5"></rect><rect x="3" y="10" width="18" height="4" rx="1.5"></rect></svg>
</span>
<span class="flex flex-col leading-none">
<span class="text-lg font-extrabold tracking-tight text-primary dark:text-primary-dark">{{ t(key="brand", lang=lang | default(value='sk')) }}</span>
<span class="hidden text-[10px] font-semibold uppercase tracking-wider text-on-surface/50 sm:block dark:text-on-surface-dark/50">{{ t(key="brand-subtitle", lang=lang | default(value='sk')) }}</span>
</span>
</a> </a>
<!-- desktop links — Penguin navbar link treatment via ui::nav_link --> <!-- in-header search → existing GET /search (q param). Only on the home
<ul class="ml-2 hidden items-center gap-6 md:flex"> page; elsewhere the shop's own toolbar carries the search box. Hidden
<li>{{ ui::nav_link(label=t(key="nav-home", lang=lang | default(value='sk')), href="/", data_nav="/") }}</li> on small screens (a compact copy lives in the mobile menu below). -->
<li>{{ ui::nav_link(label=t(key="nav-shop", lang=lang | default(value='sk')), href="/shop", data_nav="/shop") }}</li> {% if on_home | default(value=false) %}
<form action="/search" method="get" role="search" class="hidden min-w-0 flex-1 md:flex md:max-w-xl">
<div class="flex min-w-0 flex-1 overflow-hidden rounded-radius border border-outline transition focus-within:border-primary dark:border-outline-dark dark:focus-within:border-primary-dark">
<span class="pointer-events-none flex items-center bg-surface-alt pl-3.5 text-on-surface/40 dark:bg-surface-dark-alt dark:text-on-surface-dark/40">{{ ui::icon(name="search", size="size-[18px]") }}</span>
<input type="search" name="q" autocomplete="off"
placeholder="{{ t(key='search-placeholder', lang=lang | default(value='sk')) }}"
aria-label="{{ t(key='search-placeholder', lang=lang | default(value='sk')) }}"
class="min-w-0 flex-1 border-0 bg-surface-alt px-2.5 py-2.5 text-sm text-on-surface placeholder:text-on-surface/50 focus:outline-none dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50" />
<button type="submit" class="shrink-0 bg-primary px-5 text-sm font-bold text-on-primary transition hover:opacity-90 dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="search-button", lang=lang | default(value='sk')) }}</button>
</div>
</form>
{% endif %}
<!-- right side: kurz + account + cart + settings + mobile toggle -->
<div class="ml-auto flex items-center gap-2 sm: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 %}
<!-- account area: admin quick links / customer profile dropdown /
guest two-line "Vitajte · Prihláste sa" button (Kompress design) -->
{% if logged_in_admin %} {% if logged_in_admin %}
<li>{{ ui::nav_link(label=t(key="admin-title", lang=lang | default(value='sk')), href="/admin/dashboard", data_nav="/admin", variant="warning", attrs='hx-boost="false"') }}</li> <div class="hidden items-center gap-3 sm:flex">
<li> {{ ui::nav_link(label=t(key="admin-title", lang=lang | default(value='sk')), href="/admin/dashboard", data_nav="/admin", variant="warning", attrs='hx-boost="false"') }}
<form method="post" action="/logout" hx-boost="false"> <form method="post" action="/logout" hx-boost="false">
{{ ui::csrf_field() }} {{ ui::csrf_field() }}
<button type="submit" class="text-sm font-medium text-danger underline-offset-2 transition hover:opacity-75 focus:outline-hidden focus-visible:underline">{{ t(key="logout", lang=lang | default(value='sk')) }}</button> <button type="submit" class="text-sm font-medium text-danger underline-offset-2 transition hover:opacity-75 focus:outline-hidden focus-visible:underline">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
</form> </form>
</li> </div>
{% elif logged_in_customer %} {% elif logged_in_customer %}
{# customer account links live in the profile dropdown next to the cart #}
{% else %}
<li>{{ ui::nav_link(label=t(key="nav-login", lang=lang | default(value='sk')), href="/login", data_nav="/login") }}</li>
<li>{{ ui::nav_link(label=t(key="nav-register", lang=lang | default(value='sk')), href="/register", data_nav="/register") }}</li>
{% endif %}
</ul>
<!-- right side: cart + settings + mobile toggle -->
<div class="ml-auto flex items-center gap-3">
<!-- customer profile dropdown (avatar + name + account type) -->
{% if logged_in_customer %}
{% include "partials/profile_menu.html" %} {% include "partials/profile_menu.html" %}
{% else %}
<a href="/login" data-nav="/login" class="hidden items-center gap-2.5 rounded-radius px-2.5 py-1.5 text-on-surface transition hover:bg-surface-alt sm:inline-flex dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" class="size-5 shrink-0" aria-hidden="true"><circle cx="12" cy="8" r="4"></circle><path d="M5 20a7 7 0 0 1 14 0"></path></svg>
<span class="flex flex-col items-start leading-tight">
<span class="text-[11px] text-on-surface/50 dark:text-on-surface-dark/50">{{ t(key="welcome", lang=lang | default(value='sk')) }}</span>
<span class="text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="nav-login", lang=lang | default(value='sk')) }}</span>
</span>
</a>
{% endif %} {% endif %}
<!-- cart: hover opens an Alza-style mini-cart preview (Penguin <!-- cart: hover opens an Alza-style mini-cart preview (Penguin
dropdown-with-hover), lazy-loaded from /partials/cart on each hover dropdown-with-hover), lazy-loaded from /partials/cart on each hover
@@ -127,10 +175,16 @@
hx-get="/partials/cart" hx-trigger="mouseenter delay:150ms" hx-target="#cart-preview-body" hx-swap="innerHTML" hx-get="/partials/cart" hx-trigger="mouseenter delay:150ms" hx-target="#cart-preview-body" hx-swap="innerHTML"
aria-label="{{ t(key='cart-title', lang=lang | default(value='sk')) }}" aria-label="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
title="{{ t(key='cart-title', lang=lang | default(value='sk')) }}" title="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
class="relative inline-flex size-9 shrink-0 items-center justify-center rounded-radius bg-transparent text-secondary transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-secondary active:opacity-100 active:outline-offset-0 dark:text-secondary-dark dark:focus-visible:outline-secondary-dark"> class="flex shrink-0 items-center gap-2.5 rounded-radius border border-outline bg-surface-alt px-2.5 py-1.5 text-on-surface transition hover:border-outline-strong focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:border-outline-dark-strong dark:focus-visible:outline-primary-dark">
{{ ui::icon(name="cart") }} <span class="relative inline-flex text-primary dark:text-primary-dark">
<span x-show="count > 0" x-cloak x-text="count" {{ ui::icon(name="cart", size="size-6") }}
class="absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-4 text-on-primary dark:bg-primary-dark dark:text-on-primary-dark"></span> <span x-show="count > 0" x-cloak x-text="count"
class="absolute -right-2 -top-2 inline-flex min-w-[18px] items-center justify-center rounded-full bg-danger px-1 text-[10px] font-bold leading-[18px] text-on-danger ring-2 ring-surface-alt dark:ring-surface-dark-alt"></span>
</span>
<span class="hidden flex-col items-start leading-tight sm:flex">
<span class="text-[11px] text-on-surface/50 dark:text-on-surface-dark/50">{{ t(key="cart-title", lang=lang | default(value='sk')) }}</span>
<span class="text-sm font-bold text-on-surface-strong dark:text-on-surface-dark-strong"><span x-text="count">0</span> {{ t(key="cart-units", lang=lang | default(value='sk')) }}</span>
</span>
</a> </a>
<!-- hover preview panel (no id on the panel → not htmx-settled on boosted nav) --> <!-- hover preview panel (no id on the panel → not htmx-settled on boosted nav) -->
<div x-cloak x-show="isOpen" x-transition <div x-cloak x-show="isOpen" x-transition
@@ -159,6 +213,17 @@
underline focus), active state via data-nav + markActiveNav() --> underline focus), active state via data-nav + markActiveNav() -->
<ul x-show="mobile" x-cloak @click.outside="mobile = false" x-transition <ul x-show="mobile" x-cloak @click.outside="mobile = false" x-transition
class="absolute inset-x-0 top-full mx-4 mt-2 flex flex-col gap-1 rounded-radius border border-outline bg-surface p-2 shadow-lg md:hidden dark:border-outline-dark dark:bg-surface-dark-alt"> class="absolute inset-x-0 top-full mx-4 mt-2 flex flex-col gap-1 rounded-radius border border-outline bg-surface p-2 shadow-lg md:hidden dark:border-outline-dark dark:bg-surface-dark-alt">
{% if on_home | default(value=false) %}
<li class="mb-1">
<form action="/search" method="get" role="search" class="flex overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
<input type="search" name="q" autocomplete="off"
placeholder="{{ t(key='search-placeholder', lang=lang | default(value='sk')) }}"
aria-label="{{ t(key='search-placeholder', lang=lang | default(value='sk')) }}"
class="min-w-0 flex-1 border-0 bg-surface-alt px-3 py-2 text-sm text-on-surface placeholder:text-on-surface/50 focus:outline-none dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50" />
<button type="submit" class="shrink-0 bg-primary px-4 text-sm font-bold text-on-primary dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="search-button", lang=lang | default(value='sk')) }}</button>
</form>
</li>
{% endif %}
<li><a href="/" data-nav="/" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-home", lang=lang | default(value='sk')) }}</a></li> <li><a href="/" data-nav="/" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-home", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/shop" data-nav="/shop" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</a></li> <li><a href="/shop" data-nav="/shop" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</a></li>
{% if logged_in_admin %} {% if logged_in_admin %}
@@ -189,12 +254,15 @@
<div x-cloak x-show="cats" x-transition.opacity @click="cats = false" aria-hidden="true" <div x-cloak x-show="cats" x-transition.opacity @click="cats = false" aria-hidden="true"
class="fixed inset-0 z-30 bg-black/50 lg:hidden"></div> class="fixed inset-0 z-30 bg-black/50 lg:hidden"></div>
<div class="mx-auto flex w-full max-w-7xl gap-8 px-4 py-8"> <div class="mx-auto w-full max-w-7xl px-4 py-8">
<!-- page breadcrumbs: full width, above the sidebar + content row -->
{% block breadcrumbs %}{% endblock breadcrumbs %}
<div class="flex w-full gap-8">
{% if account_nav %} {% if account_nav %}
<!-- account-area sidebar: replaces the storefront categories while the <!-- account-area sidebar: replaces the storefront categories while the
customer is inside /account/*. --> customer is inside /account/*. -->
<aside x-cloak x-show="cats || lg" aria-label="{{ t(key='nav-account', lang=lang | default(value='sk')) }}" <aside x-cloak x-show="cats || lg" aria-label="{{ t(key='nav-account', lang=lang | default(value='sk')) }}"
class="fixed inset-y-0 left-0 z-40 w-64 overflow-y-auto border-r border-outline bg-surface-alt p-4 lg:static lg:z-auto lg:w-64 lg:shrink-0 lg:self-start lg:overflow-visible lg:rounded-radius lg:border lg:p-3 dark:border-outline-dark dark:bg-surface-dark-alt"> class="fixed inset-y-0 left-0 z-40 w-64 overflow-y-auto border-r border-outline bg-surface-alt p-4 lg:sticky lg:top-24 lg:z-auto lg:w-64 lg:shrink-0 lg:self-start lg:overflow-visible lg:rounded-radius lg:border lg:p-3 dark:border-outline-dark dark:bg-surface-dark-alt">
<h2 class="px-3 pb-2 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="nav-account", lang=lang | default(value='sk')) }}</h2> <h2 class="px-3 pb-2 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="nav-account", lang=lang | default(value='sk')) }}</h2>
<ul class="space-y-1"> <ul class="space-y-1">
<li><a href="/account/orders" data-nav="/account/orders" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="account-orders", lang=lang | default(value='sk')) }}</a></li> <li><a href="/account/orders" data-nav="/account/orders" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="account-orders", lang=lang | default(value='sk')) }}</a></li>
@@ -214,15 +282,59 @@
<aside id="category-sidebar" hx-preserve="true" <aside id="category-sidebar" hx-preserve="true"
x-cloak x-show="cats || lg" aria-label="{{ t(key='categories', lang=lang | default(value='sk')) }}" x-cloak x-show="cats || lg" aria-label="{{ t(key='categories', lang=lang | default(value='sk')) }}"
hx-get="/partials/categories" hx-trigger="load" hx-get="/partials/categories" hx-trigger="load"
class="fixed inset-y-0 left-0 z-40 w-64 overflow-y-auto border-r border-outline bg-surface-alt p-4 lg:static lg:z-auto lg:w-64 lg:shrink-0 lg:self-start lg:overflow-visible lg:rounded-radius lg:border lg:p-3 dark:border-outline-dark dark:bg-surface-dark-alt"> class="fixed inset-y-0 left-0 z-40 w-64 overflow-y-auto border-r border-outline bg-surface-alt p-4 lg:sticky lg:top-24 lg:z-auto lg:w-64 lg:shrink-0 lg:self-start lg:overflow-visible lg:rounded-radius lg:border lg:p-3 dark:border-outline-dark dark:bg-surface-dark-alt">
</aside> </aside>
{% endif %} {% endif %}
<main class="min-w-0 flex-1"> <main class="min-w-0 flex-1">
{% block content %}{% endblock content %} {% block content %}{% endblock content %}
</main> </main>
</div>
</div> </div>
<!-- site footer (Kompress design): brand blurb + Informácie / Účet / Kontakt
link columns + copyright bar. Static links; reuses the nav i18n keys. -->
<footer class="border-t border-outline bg-surface dark:border-outline-dark dark:bg-surface-dark">
<div class="mx-auto grid max-w-7xl grid-cols-2 gap-8 px-4 py-10 md:grid-cols-4 md:px-8">
<div class="col-span-2 md:col-span-1">
<div class="flex items-center gap-2.5">
<span class="inline-flex size-8 items-center justify-center rounded-radius bg-primary text-on-primary dark:bg-primary-dark dark:text-on-primary-dark">
<svg width="17" height="17" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="10" y="3" width="4" height="18" rx="1.5"></rect><rect x="3" y="10" width="18" height="4" rx="1.5"></rect></svg>
</span>
<span class="text-lg font-extrabold tracking-tight text-primary dark:text-primary-dark">{{ t(key="brand", lang=lang | default(value='sk')) }}</span>
</div>
<p class="mt-3 max-w-xs text-sm leading-relaxed text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="footer-tagline", lang=lang | default(value='sk')) }}</p>
</div>
<div class="flex flex-col gap-2.5">
<div class="text-xs font-bold uppercase tracking-wider text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="footer-info", lang=lang | default(value='sk')) }}</div>
<a href="/obchodne-podmienky" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="footer-terms", lang=lang | default(value='sk')) }}</a>
<a href="/o-nas" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="footer-about", lang=lang | default(value='sk')) }}</a>
<a href="/predajne" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="footer-stores", lang=lang | default(value='sk')) }}</a>
<a href="/doprava-a-platba" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="footer-shipping", lang=lang | default(value='sk')) }}</a>
</div>
<div class="flex flex-col gap-2.5">
<div class="text-xs font-bold uppercase tracking-wider text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="footer-account", lang=lang | default(value='sk')) }}</div>
{% if logged_in_customer %}
<a href="/account/orders" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="footer-orders", lang=lang | default(value='sk')) }}</a>
<a href="/account/profile" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="nav-profile", lang=lang | default(value='sk')) }}</a>
{% else %}
<a href="/login" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="nav-login", lang=lang | default(value='sk')) }}</a>
<a href="/register" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="nav-register", lang=lang | default(value='sk')) }}</a>
{% endif %}
<a href="/cart" hx-boost="false" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="cart-title", lang=lang | default(value='sk')) }}</a>
</div>
<div class="flex flex-col gap-2.5">
<div class="text-xs font-bold uppercase tracking-wider text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="footer-contact", lang=lang | default(value='sk')) }}</div>
<a href="tel:+421903410476" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="hotline", lang=lang | default(value='sk')) }}</a>
<a href="mailto:info@kompress.sk" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="footer-email", lang=lang | default(value='sk')) }}</a>
<span class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="footer-hours", lang=lang | default(value='sk')) }}</span>
</div>
</div>
<div class="border-t border-outline dark:border-outline-dark">
<div class="mx-auto max-w-7xl px-4 py-4 text-xs text-on-surface/50 md:px-8 dark:text-on-surface-dark/50">{{ t(key="footer-rights", lang=lang | default(value='sk')) }}</div>
</div>
</footer>
<!-- toast notifications: fire from anywhere with toast('message'). <!-- toast notifications: fire from anywhere with toast('message').
Adapted from the vendored Penguin UI component Adapted from the vendored Penguin UI component
(penguinui-components/toast-notification/stacking-toast-notification.html): (penguinui-components/toast-notification/stacking-toast-notification.html):

View File

@@ -3,28 +3,104 @@
{% block title %}{{ t(key="brand", lang=lang | default(value='sk')) }}{% endblock title %} {% block title %}{{ t(key="brand", lang=lang | default(value='sk')) }}{% endblock title %}
{% block content %} {% block breadcrumbs %}
<div class="space-y-12"> <nav aria-label="breadcrumb" class="mb-5 text-sm">
<!-- hero --> <ol class="flex flex-wrap items-center gap-1.5 text-on-surface/60 dark:text-on-surface-dark/60">
<section class="rounded-radius border border-outline bg-surface-alt px-6 py-12 text-center dark:border-outline-dark dark:bg-surface-dark-alt"> {{ ui::crumb_current(label=t(key="nav-home", lang=lang | default(value='sk'))) }}
<h1 class="text-4xl font-bold tracking-tight text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shop-title", lang=lang | default(value='sk')) }}</h1> </ol>
<p class="mx-auto mt-3 max-w-xl text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-subtitle", lang=lang | default(value='sk')) }}</p> </nav>
<a href="/shop" class="mt-6 inline-flex items-center justify-center rounded-radius bg-primary px-6 py-2.5 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</a> {% endblock breadcrumbs %}
</section>
<!-- featured products --> {% block content %}
{% if products | length > 0 %} {# Home layout adapted from the Kompress design mockup: the left "Kategórie"
<section class="space-y-5"> column is already supplied by base.html's #category-sidebar, so the main
<div class="flex items-end justify-between"> area is split into a featured product grid + a right rail (bestsellers /
<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> our stores / contact). All colors use the design tokens so light + dark
<a href="/shop" class="text-sm font-medium text-primary dark:text-primary-dark">{{ t(key="cart-continue", lang=lang | default(value='sk')) }} →</a> both work; the brand accent is the medical blue set in app.css. #}
</div> <div class="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_19rem]">
<div x-data="{ view: 'grid' }" class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
{% for product in products %} <!-- center column -->
{% include "shop/_card.html" %} <div class="flex min-w-0 flex-col gap-6">
{% endfor %} <!-- hero / heading -->
</div> <section>
</section> <h1 class="text-3xl font-extrabold tracking-tight text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shop-title", lang=lang | default(value='sk')) }}</h1>
{% endif %} <p class="mt-2 max-w-2xl text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-subtitle", lang=lang | default(value='sk')) }}</p>
</section>
<!-- featured products -->
{% if products | length > 0 %}
<section class="space-y-4">
<div class="flex items-end justify-between">
<h2 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</h2>
<a href="/shop" class="text-sm font-semibold text-primary dark:text-primary-dark">{{ t(key="cart-continue", lang=lang | default(value='sk')) }} →</a>
</div>
<div x-data="{ view: 'grid' }" class="grid grid-cols-2 gap-4 sm:grid-cols-3">
{% for product in products %}
{% include "shop/_card.html" %}
{% endfor %}
</div>
</section>
{% else %}
<section class="rounded-radius border border-outline bg-surface-alt px-6 py-16 text-center dark:border-outline-dark dark:bg-surface-dark-alt">
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-empty", lang=lang | default(value='sk')) }}</p>
<a href="/shop" class="mt-4 inline-flex items-center justify-center rounded-radius bg-primary px-6 py-2.5 text-sm font-semibold text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</a>
</section>
{% endif %}
</div>
<!-- right rail -->
<aside class="flex flex-col gap-5">
<!-- bestsellers (reuses the featured products) -->
{% if products | length > 0 %}
<section class="overflow-hidden rounded-radius border border-outline bg-surface-alt dark:border-outline-dark dark:bg-surface-dark-alt">
<h2 class="border-b border-outline px-4 py-3.5 text-xs font-bold uppercase tracking-wider text-on-surface-strong dark:border-outline-dark dark:text-on-surface-dark-strong">Najpredávanejšie</h2>
<ol class="p-2">
{% for product in products | slice(end=5) %}
<li>
<a href="/shop/{{ product.slug }}" class="flex items-center gap-3 rounded-radius px-2 py-2 transition hover:bg-primary/5">
<span class="inline-flex size-6 shrink-0 items-center justify-center rounded-md bg-primary/10 text-xs font-extrabold text-primary dark:bg-primary-dark/15 dark:text-primary-dark">{{ loop.index }}</span>
<span class="flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-md border border-outline bg-surface dark:border-outline-dark dark:bg-surface-dark">
{% if product.image %}
<img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover">
{% else %}
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" class="text-on-surface/30 dark:text-on-surface-dark/30"><rect x="3" y="4" width="18" height="16" rx="2"></rect><circle cx="8.5" cy="9" r="1.6"></circle><path d="M21 16l-5-5L5 20"></path></svg>
{% endif %}
</span>
<span class="flex min-w-0 flex-col gap-0.5">
<span class="line-clamp-2 text-[13px] font-semibold leading-tight text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</span>
<span class="text-sm font-extrabold text-primary dark:text-primary-dark">{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ currency_symbol }}</span>
</span>
</a>
</li>
{% endfor %}
</ol>
<a href="/shop" class="block border-t border-outline px-4 py-3 text-center text-[13px] font-semibold text-primary transition hover:bg-primary/5 dark:border-outline-dark dark:text-primary-dark">Všetko najpredávanejšie </a>
</section>
{% endif %}
<!-- our stores (static) -->
<section class="overflow-hidden rounded-radius border border-outline bg-surface-alt dark:border-outline-dark dark:bg-surface-dark-alt">
<h2 class="border-b border-outline px-4 py-3.5 text-xs font-bold uppercase tracking-wider text-on-surface-strong dark:border-outline-dark dark:text-on-surface-dark-strong">Naše obchody</h2>
<div class="p-3.5">
<div class="flex h-28 items-center justify-center rounded-radius border border-outline bg-surface text-sm text-on-surface/50 dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark/40">Foto predajne</div>
<a href="/predajne" class="mt-3 inline-block text-sm font-bold text-primary transition hover:underline dark:text-primary-dark">Objaviť naše obchody </a>
</div>
</section>
<!-- contact CTA (static, brand blue) -->
<section class="overflow-hidden rounded-radius bg-primary text-on-primary dark:bg-primary-dark dark:text-on-primary-dark">
<div class="p-5">
<div class="text-xs font-bold uppercase tracking-wider opacity-80">Kontaktujte nás</div>
<p class="mt-2.5 text-sm leading-relaxed opacity-90">Naša horúca linka je dostupná 24/7. Radi vám poradíme s výberom.</p>
<a href="tel:+421903410476" class="mt-3.5 flex items-center gap-2.5 text-xl font-extrabold tracking-tight">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 4h4l2 5-3 2a12 12 0 0 0 5 5l2-3 5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2Z"></path></svg>
+421 903 410 476
</a>
<a href="tel:+421903410476" class="mt-3.5 block w-full rounded-radius bg-surface px-4 py-3 text-center text-sm font-bold text-primary transition hover:opacity-90 dark:bg-surface-dark dark:text-primary-dark">Kontaktujte hotline</a>
</div>
</section>
</aside>
</div> </div>
{% endblock content %} {% endblock content %}

View File

@@ -132,10 +132,10 @@
{% macro eff_price(p, preview=false) -%} {% macro eff_price(p, preview=false) -%}
{%- if preview -%}{% set strong = "text-info" %}{%- else -%}{% set strong = "text-primary dark:text-primary-dark" %}{%- endif -%} {%- if preview -%}{% set strong = "text-info" %}{%- else -%}{% set strong = "text-primary dark:text-primary-dark" %}{%- endif -%}
{% if p.effective_reduced %} {% if p.effective_reduced %}
<span class="font-medium {{ strong }}">{{ p.effective_price }} {{ p.currency }}</span> <span class="font-medium {{ strong }}">{{ p.effective_price }} </span>
<span class="ml-1 text-xs text-on-surface/60 dark:text-on-surface-dark/60">({{ p.effective_percent_off }}%)</span> <span class="ml-1 text-xs text-on-surface/60 dark:text-on-surface-dark/60">({{ p.effective_percent_off }}%)</span>
{% else %} {% else %}
{{ p.effective_price }} {{ p.currency }} {{ p.effective_price }}
{% endif %} {% endif %}
{%- endmacro eff_price %} {%- endmacro eff_price %}
@@ -267,3 +267,42 @@ border-t border-outline dark:border-outline-dark
{%- endif -%} {%- endif -%}
<a href="{{ href }}"{% if data_nav %} data-nav="{{ data_nav }}"{% endif %} class="text-sm font-medium underline-offset-2 transition focus:outline-hidden focus-visible:underline {{ c }}" {{ attrs | safe }}>{{ label }}</a> <a href="{{ href }}"{% if data_nav %} data-nav="{{ data_nav }}"{% endif %} class="text-sm font-medium underline-offset-2 transition focus:outline-hidden focus-visible:underline {{ c }}" {{ attrs | safe }}>{{ label }}</a>
{%- endmacro nav_link %} {%- endmacro nav_link %}
{# Breadcrumbs (Kompress design: chevron separators). Build a trail by emitting
one ui::crumb(label, href) per ancestor and a final ui::crumb_current(label)
for the active page, all inside <nav><ol></ol></nav>:
<nav aria-label="breadcrumb" class="text-sm">
<ol class="flex flex-wrap items-center gap-1.5 text-on-surface/60 dark:text-on-surface-dark/60">
{{ ui::crumb(label="Domov", href="/") }}
{{ ui::crumb(label="Obchod", href="/shop") }}
{{ ui::crumb_current(label=category.name) }}
</ol>
</nav>
Adapted from penguinui/breadcrumbs/breadcrumb-with-chevron.html. #}
{% macro crumb(label, href) -%}
<li class="flex items-center gap-1.5">
<a href="{{ href }}" class="transition hover:text-primary dark:hover:text-primary-dark">{{ label }}</a>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-3.5 shrink-0 text-on-surface/30 dark:text-on-surface-dark/30" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /></svg>
</li>
{%- endmacro crumb %}
{% macro crumb_current(label) -%}
<li class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong" aria-current="page">{{ label }}</li>
{%- endmacro crumb_current %}
{# Title for the static info pages (controllers/pages.rs → pages/info.html),
resolved from the `page` slug. Lives in a macro because a child template's
top-level {% set %} isn't visible inside its {% block %}s under `extends`;
the macro can be called from both the title and content blocks. #}
{% macro page_title(page, lang) -%}
{%- if page == "contact" -%}{{ t(key="top-contact", lang=lang) }}
{%- elif page == "sitemap" -%}{{ t(key="top-sitemap", lang=lang) }}
{%- elif page == "terms" -%}{{ t(key="footer-terms", lang=lang) }}
{%- elif page == "about" -%}{{ t(key="footer-about", lang=lang) }}
{%- elif page == "stores" -%}{{ t(key="footer-stores", lang=lang) }}
{%- elif page == "shipping" -%}{{ t(key="footer-shipping", lang=lang) }}
{%- else -%}{{ t(key="brand", lang=lang) }}
{%- endif -%}
{%- endmacro page_title %}

View File

@@ -0,0 +1,70 @@
{% extends "base.html" %}
{% import "macros/ui.html" as ui %}
{# Static info pages (contact / sitemap / terms / about / stores / shipping).
One template switches title + body on the `page` slug passed by
controllers/pages.rs. Titles reuse the existing top-/footer- i18n keys. #}
{% block title %}{{ ui::page_title(page=page, lang=lang | default(value='sk')) }}{% endblock title %}
{% block breadcrumbs %}
{% set L = lang | default(value='sk') %}
<nav aria-label="breadcrumb" class="mb-5 text-sm">
<ol class="flex flex-wrap items-center gap-1.5 text-on-surface/60 dark:text-on-surface-dark/60">
{{ ui::crumb(label=t(key="nav-home", lang=L), href="/") }}
{{ ui::crumb_current(label=ui::page_title(page=page, lang=L)) }}
</ol>
</nav>
{% endblock breadcrumbs %}
{% block content %}
{% set L = lang | default(value='sk') %}
{% set title = ui::page_title(page=page, lang=L) %}
<div class="mx-auto max-w-3xl space-y-6">
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ title }}</h1>
{% if page == "contact" %}
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="page-contact-intro", lang=L) }}</p>
<div class="grid gap-3 sm:grid-cols-3">
<div class="rounded-radius border border-outline bg-surface-alt p-4 dark:border-outline-dark dark:bg-surface-dark-alt">
<div class="text-xs font-semibold uppercase tracking-wide text-on-surface/50 dark:text-on-surface-dark/50">{{ t(key="top-contact", lang=L) }}</div>
<a href="tel:+421903410476" class="mt-1 block text-lg font-bold text-primary dark:text-primary-dark">{{ t(key="hotline", lang=L) }}</a>
</div>
<div class="rounded-radius border border-outline bg-surface-alt p-4 dark:border-outline-dark dark:bg-surface-dark-alt">
<div class="text-xs font-semibold uppercase tracking-wide text-on-surface/50 dark:text-on-surface-dark/50">E-mail</div>
<a href="mailto:info@kompress.sk" class="mt-1 block font-semibold text-primary dark:text-primary-dark">{{ t(key="footer-email", lang=L) }}</a>
</div>
<div class="rounded-radius border border-outline bg-surface-alt p-4 dark:border-outline-dark dark:bg-surface-dark-alt">
<div class="text-xs font-semibold uppercase tracking-wide text-on-surface/50 dark:text-on-surface-dark/50">{{ t(key="footer-hours", lang=L) }}</div>
<div class="mt-1 font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="footer-hours", lang=L) }}</div>
</div>
</div>
{% elif page == "sitemap" %}
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="page-sitemap-intro", lang=L) }}</p>
<ul class="grid gap-2 sm:grid-cols-2">
<li><a href="/" class="text-primary transition hover:underline dark:text-primary-dark">{{ t(key="nav-home", lang=L) }}</a></li>
<li><a href="/shop" class="text-primary transition hover:underline dark:text-primary-dark">{{ t(key="nav-shop", lang=L) }}</a></li>
<li><a href="/cart" hx-boost="false" class="text-primary transition hover:underline dark:text-primary-dark">{{ t(key="cart-title", lang=L) }}</a></li>
<li><a href="/kontakt" class="text-primary transition hover:underline dark:text-primary-dark">{{ t(key="top-contact", lang=L) }}</a></li>
<li><a href="/o-nas" class="text-primary transition hover:underline dark:text-primary-dark">{{ t(key="footer-about", lang=L) }}</a></li>
<li><a href="/predajne" class="text-primary transition hover:underline dark:text-primary-dark">{{ t(key="footer-stores", lang=L) }}</a></li>
<li><a href="/doprava-a-platba" class="text-primary transition hover:underline dark:text-primary-dark">{{ t(key="footer-shipping", lang=L) }}</a></li>
<li><a href="/obchodne-podmienky" class="text-primary transition hover:underline dark:text-primary-dark">{{ t(key="footer-terms", lang=L) }}</a></li>
{% if logged_in_customer %}
<li><a href="/account/orders" class="text-primary transition hover:underline dark:text-primary-dark">{{ t(key="footer-orders", lang=L) }}</a></li>
{% else %}
<li><a href="/login" class="text-primary transition hover:underline dark:text-primary-dark">{{ t(key="nav-login", lang=L) }}</a></li>
{% endif %}
</ul>
{% else %}
<div class="rounded-radius border border-outline bg-surface-alt p-6 text-on-surface/70 dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark/70">
<p>{{ t(key="page-coming-soon", lang=L) }}</p>
<div class="mt-4 flex flex-wrap gap-4 text-sm">
<a href="tel:+421903410476" class="font-semibold text-primary dark:text-primary-dark">{{ t(key="hotline", lang=L) }}</a>
<a href="mailto:info@kompress.sk" class="font-semibold text-primary dark:text-primary-dark">{{ t(key="footer-email", lang=L) }}</a>
</div>
</div>
{% endif %}
</div>
{% endblock content %}

View File

@@ -30,7 +30,7 @@
x-bind:aria-expanded="isOpen || openedWithKeyboard" aria-haspopup="true" x-bind:aria-expanded="isOpen || openedWithKeyboard" aria-haspopup="true"
aria-label="{{ t(key='nav-account', lang=lang | default(value='sk')) }}" aria-label="{{ t(key='nav-account', lang=lang | default(value='sk')) }}"
class="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full border border-primary bg-primary text-sm font-bold tracking-wider text-on-primary/90 transition hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark/90 dark:focus-visible:outline-primary-dark"> class="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full border border-primary bg-primary text-sm font-bold tracking-wider text-on-primary/90 transition hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark/90 dark:focus-visible:outline-primary-dark">
{%- if _initials %}{{ _initials }}{% else %}{{ _person_icon | safe }}{% endif -%} {%- if customer_avatar %}<img src="/images/{{ customer_avatar }}" alt="{{ _name }}" class="size-full object-cover">{% elif _initials %}{{ _initials }}{% else %}{{ _person_icon | safe }}{% endif -%}
</button> </button>
<!-- Dropdown Menu (positioned like the settings cog: right-0 mt-2) --> <!-- Dropdown Menu (positioned like the settings cog: right-0 mt-2) -->
<div x-cloak x-show="isOpen || openedWithKeyboard" x-transition x-trap="openedWithKeyboard" <div x-cloak x-show="isOpen || openedWithKeyboard" x-transition x-trap="openedWithKeyboard"
@@ -40,7 +40,7 @@
<!-- header: avatar + name + account type --> <!-- header: avatar + name + account type -->
<div class="flex items-center gap-3 px-4 py-2.5"> <div class="flex items-center gap-3 px-4 py-2.5">
<span class="flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-full border border-primary bg-primary text-base font-bold tracking-wider text-on-primary/90 dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark/90"> <span class="flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-full border border-primary bg-primary text-base font-bold tracking-wider text-on-primary/90 dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark/90">
{%- if _initials %}{{ _initials }}{% else %}{{ _person_icon | safe }}{% endif -%} {%- if customer_avatar %}<img src="/images/{{ customer_avatar }}" alt="{{ _name }}" class="size-full object-cover">{% elif _initials %}{{ _initials }}{% else %}{{ _person_icon | safe }}{% endif -%}
</span> </span>
<div class="flex min-w-0 flex-col"> <div class="flex min-w-0 flex-col">
<span class="truncate text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ _name }}</span> <span class="truncate text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ _name }}</span>

View File

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

View File

@@ -16,8 +16,11 @@
<a href="/shop/{{ product.slug }}" class="flex min-w-0 flex-1" <a href="/shop/{{ product.slug }}" class="flex min-w-0 flex-1"
:class="view === 'list' ? 'flex-row' : 'flex-col'"> :class="view === 'list' ? 'flex-row' : 'flex-col'">
<!-- Image --> <!-- Image -->
<div class="overflow-hidden bg-surface-alt dark:bg-surface-dark" <div class="relative overflow-hidden bg-surface-alt dark:bg-surface-dark"
:class="view === 'list' ? 'size-28 shrink-0 sm:size-40' : 'h-44 md:h-64'"> :class="view === 'list' ? 'size-28 shrink-0 sm:size-40' : 'h-44 md:h-64'">
{% if product.on_sale and product.percent_off > 0 %}
<span class="absolute left-2 top-2 z-10 rounded-full bg-danger px-2 py-0.5 text-[11px] font-bold text-on-danger shadow-sm">{{ product.percent_off }} %</span>
{% endif %}
{% if product.image %} {% if product.image %}
<img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover transition duration-700 ease-out group-hover:scale-105"> <img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover transition duration-700 ease-out group-hover:scale-105">
{% endif %} {% endif %}
@@ -38,12 +41,24 @@
{% endif %} {% 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 %}
<!-- stock pill (Kompress design): green "in stock" / red "sold out" -->
<div class="mt-0.5">
{% if product.in_stock %}
<span class="inline-flex items-center gap-1.5 rounded-full bg-success/10 px-2 py-0.5 text-xs font-semibold text-success">
<span class="size-1.5 rounded-full bg-success" aria-hidden="true"></span>{{ t(key="in-stock", lang=lang | default(value='sk')) }}
</span>
{% else %}
<span class="inline-flex items-center gap-1.5 rounded-full bg-danger/10 px-2 py-0.5 text-xs font-semibold text-danger">
<span class="size-1.5 rounded-full bg-danger" aria-hidden="true"></span>{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}
</span>
{% endif %}
</div>
</div> </div>
</a> </a>
<div class="flex flex-col gap-2" <div class="flex flex-col gap-2"

View File

@@ -23,10 +23,10 @@
</td> </td>
<td class="px-4 py-3 tabular-nums"> <td class="px-4 py-3 tabular-nums">
{% if item.on_sale %} {% if item.on_sale %}
<span class="font-medium text-danger">{{ item.price }} {{ item.currency }}</span> <span class="font-medium text-danger">{{ item.price }} {{ currency_symbol }}</span>
<span class="ml-1 text-xs text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ item.regular_price }}</span> <span class="ml-1 text-xs text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ item.regular_price }}</span>
{% else %} {% else %}
{{ item.price }} {{ item.currency }} {{ item.price }} {{ currency_symbol }}
{% endif %} {% endif %}
</td> </td>
<td class="px-4 py-3"> <td class="px-4 py-3">
@@ -48,7 +48,7 @@
class="w-20 rounded-radius border border-outline bg-surface-alt px-2 py-1 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark"> class="w-20 rounded-radius border border-outline bg-surface-alt px-2 py-1 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark">
</form> </form>
</td> </td>
<td class="px-4 py-3 text-right font-medium tabular-nums">{{ item.line_total }} {{ item.currency }}</td> <td class="px-4 py-3 text-right font-medium tabular-nums">{{ item.line_total }} {{ currency_symbol }}</td>
<td class="px-4 py-3 text-right"> <td class="px-4 py-3 text-right">
<form method="post" action="/cart/remove" <form method="post" action="/cart/remove"
hx-post="/cart/remove" hx-target="#cart-body" hx-swap="innerHTML"> hx-post="/cart/remove" hx-target="#cart-body" hx-swap="innerHTML">
@@ -63,7 +63,7 @@
<tfoot class="{{ ui::tfoot_cls() }}"> <tfoot class="{{ ui::tfoot_cls() }}">
<tr> <tr>
<td colspan="3" class="px-4 py-3 text-right font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="cart-total", lang=lang | default(value='sk')) }}</td> <td colspan="3" class="px-4 py-3 text-right font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="cart-total", lang=lang | default(value='sk')) }}</td>
<td class="px-4 py-3 text-right text-lg font-bold tabular-nums text-primary dark:text-primary-dark">{{ total }} {{ currency }}</td> <td class="px-4 py-3 text-right text-lg font-bold tabular-nums text-primary dark:text-primary-dark">{{ total }} {{ currency_symbol }}</td>
<td></td> <td></td>
</tr> </tr>
</tfoot> </tfoot>

View File

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

View File

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

View File

@@ -2,10 +2,22 @@
server-side on first load. Holds the result summary, the product grid and server-side on first load. Holds the result summary, the product grid and
pagination. #} pagination. #}
{% set L = lang | default(value='sk') %} {% set L = lang | default(value='sk') %}
{# On htmx responses the toolbar's Sort dropdown isn't in this swapped region;
re-render it out-of-band so a search-triggered "newest → relevance" switch is
reflected in the visible selection. #}
{% if is_fragment | default(value=false) %}{% set oob = true %}{% include "shop/_sort_select.html" %}{% endif %}
<div class="space-y-4"> <div class="space-y-4">
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70" aria-live="polite"> <div class="flex flex-wrap items-center justify-between gap-2">
{{ t(key="results-count", lang=L, count=total) }}{% if query and query != "" %} · “{{ query }}”{% endif %} <p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70" aria-live="polite">
</p> {{ t(key="results-count", lang=L, count=total) }}{% if query and query != "" %} · “{{ query }}”{% endif %}
</p>
{% if query_base and query_base != "" %}
<a href="/shop" hx-get="/search" hx-target="#shop-results" hx-push-url="true"
class="text-sm font-medium text-on-surface/70 underline-offset-2 transition hover:text-primary hover:underline dark:text-on-surface-dark/70 dark:hover:text-primary-dark">
{{ t(key="filter-clear", lang=L) }}
</a>
{% endif %}
</div>
{% if products | length > 0 %} {% if products | length > 0 %}
{% include "shop/_product_grid.html" %} {% include "shop/_product_grid.html" %}

View File

@@ -1,97 +1,121 @@
{# Shared storefront search + filter toolbar and results region, used by the shop {# Shared storefront search box + results region, used by the shop index and
index and every category page. One form drives the whole listing: htmx re-runs every category page. One form drives the listing: htmx re-runs /search and
/search and swaps only #shop-results; the toolbar keeps its own DOM state. swaps only #shop-results; the toolbar keeps its own DOM state. Triggers: live
Triggers: live (debounced) typing in the search box, immediate on any (debounced) typing in the search box, immediate on a sort change, and submit
select/checkbox change, and submit (Enter / Apply) for the price band. Degrades (Enter). Degrades to a plain GET form without JS.
to a plain GET form without JS. Category is chosen from the sidebar (carried here as a hidden field so it
Expects: query, category_groups, selected_category, selected_category_id, survives a search / re-sort). The grid/list view toggle lives next to sort;
uncategorized_count, sort, min_price, max_price, price_floor, price_ceil, its `view` state is held in Alpine on this wrapper so both the toggle and the
in_stock, plus the result vars consumed by _results.html. #} swapped-in product grid (and `_card.html`) share it.
Expects: query, selected_category, sort, plus the result vars consumed by
_results.html. #}
{% set L = lang | default(value='sk') %} {% set L = lang | default(value='sk') %}
<div class="space-y-6"> <div x-data="{ view: localStorage.getItem('shopView') === 'list' ? 'list' : 'grid' }"
x-init="$watch('view', v => localStorage.setItem('shopView', v))"
class="space-y-6">
<form action="/search" method="get" role="search" <form action="/search" method="get" role="search"
hx-get="/search" hx-target="#shop-results" hx-swap="innerHTML" hx-get="/search" hx-target="#shop-results" hx-swap="innerHTML"
hx-push-url="true" hx-indicator="#search-spinner" hx-push-url="true" hx-indicator="#search-spinner"
hx-trigger="submit, change, keyup changed delay:350ms from:input[name='q']" {# The text query runs only on submit (Enter / the Search button); the
sort / per-page / in-stock controls still apply immediately on change. #}
hx-trigger="submit, change from:select, change from:input[type='checkbox']"
class="space-y-3"> class="space-y-3">
{# Category comes from the sidebar; keep it on the query so searching /
re-sorting stays within the active category. #}
<input type="hidden" name="category" value="{{ selected_category | default(value='all') }}" />
<!-- search box --> <!-- search box -->
<div class="relative max-w-xl"> <div class="flex max-w-xl gap-2">
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-on-surface/50 dark:text-on-surface-dark/50"> <div class="relative flex-1">
{{ ui::icon(name="search", size="size-5") }} <span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-on-surface/50 dark:text-on-surface-dark/50">
</span> {{ ui::icon(name="search", size="size-5") }}
<input type="search" name="q" value="{{ query | default(value='') }}" autocomplete="off" </span>
placeholder="{{ t(key='search-placeholder', lang=L) }}" <input type="search" name="q" value="{{ query | default(value='') }}" autocomplete="off"
aria-label="{{ t(key='search-placeholder', lang=L) }}" placeholder="{{ t(key='search-placeholder', lang=L) }}"
class="w-full rounded-radius border border-outline bg-surface py-2 pl-10 pr-10 text-sm text-on-surface placeholder:text-on-surface/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50 dark:focus-visible:outline-primary-dark" /> aria-label="{{ t(key='search-placeholder', lang=L) }}"
<span id="search-spinner" class="htmx-indicator pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-on-surface/50 dark:text-on-surface-dark/50"> class="w-full rounded-radius border border-outline bg-surface py-2 pl-10 pr-10 text-sm text-on-surface placeholder:text-on-surface/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50 dark:focus-visible:outline-primary-dark" />
<svg class="size-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true"> <span id="search-spinner" class="htmx-indicator pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-on-surface/50 dark:text-on-surface-dark/50">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle> <svg class="size-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.4 0 0 5.4 0 12h4Z"></path> <circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
</svg> <path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.4 0 0 5.4 0 12h4Z"></path>
</span> </svg>
</span>
</div>
<button type="submit" class="shrink-0 rounded-radius bg-primary px-5 text-sm font-bold text-on-primary transition hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:bg-primary-dark dark:text-on-primary-dark dark:focus-visible:outline-primary-dark">
{{ t(key="search-button", lang=L) }}
</button>
</div> </div>
<!-- filter toolbar --> {# Scope indicator: when a category is active, make clear the search is
<div class="flex flex-wrap items-end gap-3 rounded-radius border border-outline bg-surface-alt p-3 dark:border-outline-dark dark:bg-surface-dark-alt"> limited to it (not the whole shop), with a one-click escape to search
<!-- category --> everything. Category only changes via full navigation (the sidebar), so
<label class="flex flex-col gap-1 text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70"> this stays accurate across the toolbar's results-only htmx swaps. #}
{{ t(key="filter-category", lang=L) }} {% if selected_category and selected_category != "all" %}
<select name="category" {# set_global so the value survives the nested if (a plain `set` inside a
class="rounded-radius border border-outline bg-surface px-2 py-1.5 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark"> block is scoped to that block in Tera and wouldn't be visible below). #}
<option value="all"{% if selected_category == "all" %} selected{% endif %}>{{ t(key="filter-all-categories", lang=L) }}</option> {% set_global _scope = selected_category_name | default(value="") %}
{% for g in category_groups %} {% if selected_category == "none" %}{% set_global _scope = t(key="uncategorized", lang=L) %}{% endif %}
<option value="{{ g.id }}"{% if selected_category_id == g.id %} selected{% endif %}>{{ g.name }} ({{ g.count }})</option> {% if _scope %}
{% for ch in g.children %} <div class="flex max-w-xl flex-wrap items-center gap-2 text-xs">
<option value="{{ ch.id }}"{% if selected_category_id == ch.id %} selected{% endif %}>&nbsp;&nbsp;— {{ ch.name }} ({{ ch.count }})</option> <span class="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1 font-medium text-primary dark:bg-primary-dark/15 dark:text-primary-dark">
{% endfor %} {{ ui::icon(name="search", size="size-3.5", extra="shrink-0") }}
{% endfor %} {{ t(key="search-scope-in", lang=L) }} <span class="font-semibold">{{ _scope }}</span>
{% if uncategorized_count > 0 %} </span>
<option value="none"{% if selected_category == "none" %} selected{% endif %}>{{ t(key="filter-uncategorized", lang=L) }} ({{ uncategorized_count }})</option> <a href="/search{% if query %}?q={{ query | urlencode }}{% endif %}"
{% endif %} class="font-medium text-on-surface/60 underline-offset-2 hover:text-primary hover:underline dark:text-on-surface-dark/60 dark:hover:text-primary-dark">
</select> {{ t(key="search-scope-all", lang=L) }}
</label> </a>
</div>
{% endif %}
{% endif %}
<!-- sort --> <!-- sort + product card style switch -->
<label class="flex flex-col gap-1 text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70"> <div class="flex flex-wrap items-center justify-end gap-3">
<label class="flex items-center gap-2 text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70">
{{ t(key="sort-label", lang=L) }} {{ t(key="sort-label", lang=L) }}
<select name="sort" {% include "shop/_sort_select.html" %}
</label>
<!-- per-page count -->
<label class="flex items-center gap-2 text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70">
{{ t(key="per-page-label", lang=L) }}
<select name="per_page"
class="rounded-radius border border-outline bg-surface px-2 py-1.5 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark"> class="rounded-radius border border-outline bg-surface px-2 py-1.5 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
{% for opt in ["newest", "relevance", "price_asc", "price_desc", "name_asc", "name_desc"] %} {% for opt in per_page_options %}
<option value="{{ opt }}"{% if sort == opt %} selected{% endif %}>{{ t(key="sort-" ~ opt, lang=L) }}</option> <option value="{{ opt }}"{% if per_page == opt %} selected{% endif %}>{{ opt }}</option>
{% endfor %} {% endfor %}
</select> </select>
</label> </label>
<!-- price band --> <!-- in stock only -->
<label class="flex flex-col gap-1 text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70"> <label class="flex items-center gap-2 text-sm text-on-surface dark:text-on-surface-dark">
{{ t(key="filter-price", lang=L) }}
<span class="flex items-center gap-1">
<input type="number" name="min_price" min="0" step="0.01" inputmode="decimal"
value="{{ min_price | default(value='') }}" placeholder="{{ price_floor }}"
aria-label="{{ t(key='filter-price-from', lang=L) }}"
class="w-24 rounded-radius border border-outline bg-surface px-2 py-1.5 text-sm text-on-surface dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark" />
<span class="text-on-surface/50 dark:text-on-surface-dark/50"></span>
<input type="number" name="max_price" min="0" step="0.01" inputmode="decimal"
value="{{ max_price | default(value='') }}" placeholder="{{ price_ceil }}"
aria-label="{{ t(key='filter-price-to', lang=L) }}"
class="w-24 rounded-radius border border-outline bg-surface px-2 py-1.5 text-sm text-on-surface dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark" />
</span>
</label>
<!-- in stock -->
<label class="flex items-center gap-2 pb-1.5 text-sm text-on-surface dark:text-on-surface-dark">
<input type="checkbox" name="in_stock" value="1"{% if in_stock %} checked{% endif %} <input type="checkbox" name="in_stock" value="1"{% if in_stock %} checked{% endif %}
class="size-4 rounded border-outline text-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:text-primary-dark" /> class="size-4 rounded border-outline text-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:text-primary-dark" />
{{ t(key="filter-in-stock", lang=L) }} {{ t(key="filter-in-stock", lang=L) }}
</label> </label>
<div class="ml-auto flex items-end gap-2"> <!-- grid / list view toggle -->
{{ ui::button(label=t(key="filter-apply", lang=L), type="submit", variant="secondary") }} <div class="inline-flex gap-0.5 rounded-radius border border-outline p-0.5 dark:border-outline-dark" role="group"
<a href="/shop" hx-get="/search" hx-target="#shop-results" hx-push-url="true" aria-label="{{ t(key='view-grid', lang=L) }} / {{ t(key='view-list', lang=L) }}">
class="self-end pb-1.5 text-sm font-medium text-on-surface/70 underline-offset-2 transition hover:text-primary hover:underline dark:text-on-surface-dark/70 dark:hover:text-primary-dark"> <button type="button" @click="view = 'grid'" :aria-pressed="view === 'grid'"
{{ t(key="filter-clear", lang=L) }} class="inline-flex size-8 items-center justify-center rounded-radius transition"
</a> :class="view === 'grid' ? 'bg-primary text-on-primary dark:bg-primary-dark dark:text-on-primary-dark' : 'text-on-surface hover:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark'"
aria-label="{{ t(key='view-grid', lang=L) }}"
title="{{ t(key='view-grid', lang=L) }}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-4">
<path d="M3 3h6v6H3V3Zm8 0h6v6h-6V3ZM3 11h6v6H3v-6Zm8 0h6v6h-6v-6Z" />
</svg>
</button>
<button type="button" @click="view = 'list'" :aria-pressed="view === 'list'"
class="inline-flex size-8 items-center justify-center rounded-radius transition"
:class="view === 'list' ? 'bg-primary text-on-primary dark:bg-primary-dark dark:text-on-primary-dark' : 'text-on-surface hover:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark'"
aria-label="{{ t(key='view-list', lang=L) }}"
title="{{ t(key='view-list', lang=L) }}">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-4">
<path d="M3 4h14v2.5H3V4Zm0 4.75h14v2.5H3v-2.5ZM3 13.5h14V16H3v-2.5Z" />
</svg>
</button>
</div> </div>
</div> </div>
</form> </form>

View File

@@ -60,3 +60,19 @@
{% if category_groups | length == 0 %} {% if category_groups | length == 0 %}
<p class="px-2 py-2 text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="shop-empty", lang=lang | default(value='sk')) }}</p> <p class="px-2 py-2 text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="shop-empty", lang=lang | default(value='sk')) }}</p>
{% endif %} {% endif %}
{# "Informácie" card (Kompress design): static info links below the category
tree, separated by a divider. Targets are placeholders (#) until real pages
exist; labels reuse the footer-* i18n keys. #}
<div class="mt-4 border-t border-outline pt-3 dark:border-outline-dark">
<p class="px-2 pb-2 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
{{ t(key="footer-info", lang=lang | default(value='sk')) }}
</p>
{% set L = lang | default(value='sk') %}
<div class="flex flex-col gap-0.5">
<a href="/obchodne-podmienky" class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">{{ t(key="footer-terms", lang=L) }}</a>
<a href="/o-nas" class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">{{ t(key="footer-about", lang=L) }}</a>
<a href="/predajne" class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">{{ t(key="footer-stores", lang=L) }}</a>
<a href="/doprava-a-platba" class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">{{ t(key="footer-shipping", lang=L) }}</a>
</div>
</div>

View File

@@ -0,0 +1,12 @@
{# Sort dropdown, shared by the toolbar (in the search form) and the results
fragment. A search promotes the default "newest" to "relevance" server-side,
but the toolbar select lives outside the swapped #shop-results region — so on
htmx responses _results.html re-renders this with `oob = true` (hx-swap-oob)
to keep the visible selection in sync with the actual ordering. #}
{% set L = lang | default(value='sk') %}
<select id="sort-select" name="sort"{% if oob | default(value=false) %} hx-swap-oob="true"{% endif %}
class="rounded-radius border border-outline bg-surface px-2 py-1.5 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
{% for opt in ["newest", "relevance", "price_asc", "price_desc", "name_asc", "name_desc"] %}
<option value="{{ opt }}"{% if sort == opt %} selected{% endif %}>{{ t(key="sort-" ~ opt, lang=L) }}</option>
{% endfor %}
</select>

View File

@@ -3,19 +3,24 @@
{% block title %}{{ category.name }}{% endblock title %} {% block title %}{{ category.name }}{% endblock title %}
{% block breadcrumbs %}
{% set L = lang | default(value='sk') %}
<nav aria-label="breadcrumb" class="mb-5 text-sm">
<ol class="flex flex-wrap items-center gap-1.5 text-on-surface/60 dark:text-on-surface-dark/60">
{{ ui::crumb(label=t(key="nav-home", lang=L), href="/") }}
{{ ui::crumb(label=t(key="nav-shop", lang=L), href="/shop") }}
{% for crumb in breadcrumbs %}
{{ ui::crumb(label=crumb.name, href="/category/" ~ crumb.slug) }}
{% endfor %}
{{ ui::crumb_current(label=category.name) }}
</ol>
</nav>
{% endblock breadcrumbs %}
{% block content %} {% block content %}
{% set L = lang | default(value='sk') %} {% set L = lang | default(value='sk') %}
<div class="space-y-6"> <div class="space-y-6">
<header class="space-y-2"> <header class="space-y-2">
<nav class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">
<a href="/shop" class="hover:text-primary dark:hover:text-primary-dark">{{ t(key="nav-shop", lang=L) }}</a>
{% for crumb in breadcrumbs %}
<span class="px-1">/</span>
<a href="/category/{{ crumb.slug }}" class="hover:text-primary dark:hover:text-primary-dark">{{ crumb.name }}</a>
{% endfor %}
<span class="px-1">/</span>
<span>{{ category.name }}</span>
</nav>
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ category.name }}</h1> <h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ category.name }}</h1>
{% if category.description %}<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ category.description }}</p>{% endif %} {% if category.description %}<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ category.description }}</p>{% endif %}

View File

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

View File

@@ -3,6 +3,16 @@
{% block title %}{{ t(key="nav-shop", lang=lang | default(value='sk')) }}{% endblock title %} {% block title %}{{ t(key="nav-shop", lang=lang | default(value='sk')) }}{% endblock title %}
{% block breadcrumbs %}
{% set L = lang | default(value='sk') %}
<nav aria-label="breadcrumb" class="mb-5 text-sm">
<ol class="flex flex-wrap items-center gap-1.5 text-on-surface/60 dark:text-on-surface-dark/60">
{{ ui::crumb(label=t(key="nav-home", lang=L), href="/") }}
{{ ui::crumb_current(label=t(key="nav-shop", lang=L)) }}
</ol>
</nav>
{% endblock breadcrumbs %}
{% block content %} {% block content %}
{% set L = lang | default(value='sk') %} {% set L = lang | default(value='sk') %}
<div class="space-y-6"> <div class="space-y-6">

View File

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

View File

@@ -67,7 +67,7 @@
<label for="variant-select" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="choose-option", lang=lang | default(value='sk')) }}</label> <label for="variant-select" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="choose-option", lang=lang | default(value='sk')) }}</label>
<select id="variant-select" x-model.number="sel" class="{{ fld }}"> <select id="variant-select" x-model.number="sel" class="{{ fld }}">
<template x-for="(v, i) in variants" :key="v.id"> <template x-for="(v, i) in variants" :key="v.id">
<option :value="i" x-text="(v.label || '—') + ' · ' + v.price + ' {{ product.currency }}' + (v.in_stock ? '' : ' ({{ t(key='out-of-stock', lang=lang | default(value='sk')) }})')"></option> <option :value="i" x-text="(v.label || '—') + ' · ' + v.price + ' {{ currency_symbol }}' + (v.in_stock ? '' : ' ({{ t(key='out-of-stock', lang=lang | default(value='sk')) }})')"></option>
</template> </template>
</select> </select>
</div> </div>
@@ -75,10 +75,10 @@
<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>

184
design-parity.md Normal file
View File

@@ -0,0 +1,184 @@
# Design parity — Kompress mockup vs current storefront
Tracks the gap between the imported Claude Design mockup (`Kompress-eshop.html`,
project `eshop` on claude.ai/design) and the live storefront. The home page body
(featured grid + right rail) and the medicalblue accent are **done**; this file
lists everything else from the mockup that is still missing or only halfwired, so
it can be picked up incrementally.
Constraints for all items: **htmx + Alpine + PenguinUI + Tailwind tokens only.**
Express colors with the existing design tokens (`bg-primary`, `bg-surface-alt`,
`border-outline`, `dark:*` …) — never inline hex — so light **and** dark both work.
PenguinUI is copypaste: lift markup out of `penguinui-components/<name>/…`
(readonly, never `{% include %}`'d) and adapt at the usesite.
Legend: 🟢 backend already exists, only UI missing · 🟡 partial · 🔴 netnew.
---
## A. Global chrome (`assets/views/base.html`)
These affect every page, so they live in `base.html`, not the home template.
> **✅ Implemented (pass 2)** — A1A6 are now built in `base.html`, with i18n keys
> added to `assets/i18n/{sk,en}/main.ftl` (`top-contact`, `search-button`,
> `welcome`, `brand-subtitle`, `footer-*`, `hotline`, …). Verified: `/`, `/shop`,
> `/login`, `/register`, `/cart`, `/search?q=` all 200, no render errors.
> The `Kontakt` / `Mapa stránky` top-bar links and the footer info links point to
> `#` until real pages exist.
### A1. Top utility bar — ✅ done
Mockup: thin white bar above the header, rightaligned links *Kontakt* · *Mapa
stránky*.
- New `<div>` strip at the very top of `base.html`, `border-b border-outline
bg-surface text-xs text-on-surface/70`.
- Links to a future `/kontakt` and `/sitemap` (or existing pages).
- Low effort, no backend.
### A2. Inheader search bar — ✅ done
Mockup: fullwidth search input with a magnifier icon + blue **Hľadať** submit
button, centered in the header.
- **Backend already exists**: `GET /search` (`src/controllers/shop.rs:353`,
`run_search` at `:90`, fragment `assets/views/shop/_search.html`). Just needs a
header form: `<form action="/search" method="get">` with `name="q"`, optionally
`hx-get="/search" hx-trigger="keyup changed delay:300ms"` for live results.
- Copy markup from `penguinui-components/navbar/with-search.html` and/or
`penguinui-components/text-input/search-input.html`.
- Medium effort — mostly wiring the existing endpoint into the header layout.
### A3. Logo lockup with subtitle — ✅ done
Mockup: rounded blue tile with a "+" cross glyph + **KOMPRESS** + uppercase
subtitle *zdravotnícke potreby*.
- Current logo is a plain text wordmark in `base.html`. Add the icon tile
(`bg-primary text-on-primary`, `rounded-radius`) and the muted subtitle line.
- Cross SVG is in the mockup (two rounded `<rect>`s). Low effort.
### A4. Account button (twoline) — ✅ done
Mockup: person icon + stacked *Vitajte* / *Prihláste sa* (or the customer name when
logged in).
- Loggedin/out state already available via `logged_in_customer` /
`customer_name` and the existing `partials/profile_menu.html`.
- Restyle the login link / profile menu trigger into the twoline button. Low effort.
### A5. Cart button with text label — ✅ done
Mockup: cart icon + red count badge + stacked *Košík* / *N produktov* label.
- The icon + cookiedriven badge already exist in `base.html` (`cartCount()` JS).
Add the text label beside it; pluralize "produkt/produkty/produktov" (mirror the
mockup's `plural()` helper, or add an i18n key).
- Low effort.
### A6. Footer — ✅ done
Mockup: 4column footer (brand blurb · Informácie · Účet · Kontakt) + copyright bar.
- **No footer exists anywhere** (`grep "<footer"` → none). Add once to `base.html`
after `<main>`.
- Static links; reuse the same i18n keys as the nav. Low effort, high visual payoff.
---
## B. Listing layout (`shop/category.html`, `shop/_product_grid.html`)
The mockup's body is really a **category/listing** layout. The home page borrows its
look; the real listing pages should adopt the rest.
> **✅ Implemented (pass 3)** — reusable `ui::crumb` / `ui::crumb_current` chevron
> breadcrumb macros (`assets/views/macros/ui.html`), wired into `home/index.html`
> (Domov), `shop/index.html` (Domov Obchod) and restyled on `shop/category.html`
> (Domov Obchod category). Added the sidebar **Informácie** card to
> `shop/_sidebar.html`. While verifying, confirmed **B3 sort**, **B4 view toggle**
> and **C1 collapsible tree** were already built — only styling polish remains.
> Verified: `/`, `/shop`, `/partials/categories`, `/search?q=` all 200, no errors.
### B1. Breadcrumbs — ✅ done
Mockup: *Domov Zdravotnícke {kategória}* with chevron separators.
- **Already implemented** on category pages: `breadcrumbs` loop in
`assets/views/shop/category.html:12`. Just needs the mockup's chevron styling and
to be surfaced on more pages (e.g. product detail, optionally a "Domov" crumb on
home).
- Copy from `penguinui-components/breadcrumbs/breadcrumb-with-chevron.html`.
- Low effort (restyle existing).
### B2. Category banner card — 🔴
Mockup: white card with a 148px image placeholder + category description paragraph +
**Viac ** link, shown above the product list.
- Percategory copy/image. Needs a `description` (and optional banner image) field on
the category model, or static copy keyed by slug for now.
- Render as the app card idiom (`border border-outline bg-surface-alt dark:…`).
- Medium effort (model field) or low (static placeholder first).
### B3. Sort control ("Zoradiť podľa") — ✅ already in toolbar
Mockup: styled `<select>` — Predvolené / Cena ↑ / Cena ↓ / Názov AZ /
Najpredávanejšie.
- **Backend already exists**: `sort` query param (`src/controllers/shop.rs:41,81,
179181`). Needs the styled select wired to it (`hx-get` on `change`, preserving
the query + filters via the existing `build_query` helper at `shop.rs:63`).
- Copy from `penguinui-components/select/default-select.html`.
- Low effort.
### B4. View grid/list toggle — 🟢 (done elsewhere)
- **Already implemented** in `assets/views/shop/_product_grid.html` (Alpine `view`
state, persisted to `localStorage`). The mockup's toggle is the same idea; just
align the icon/segmentedbutton styling with the mockup. Cosmetic only.
### B5. "Porovnať" (compare) — 🔴
Mockup: a *Porovnať* button in the listing toolbar.
- **No compare feature exists.** Netnew: selecttocompare state (Alpine + a cookie
or server list) and a compare view. Largest item here — defer unless wanted.
### B6. Listview product card — ✅ done
Mockup list rows are richer (left thumbnail, description, stock pill, price block,
add/options/outofstock button on the right).
- `shop/_card.html` already has a `view === 'list'` horizontal layout. Close already;
optionally add the stock pill (*Skladom* / *Vypredané*) and discount badge to match.
- Low effort.
---
## C. Left sidebar (`assets/views/partials/categories.html` via `/partials/categories`)
Mockup left column = two white cards: a collapsible **Kategórie** tree and an
**Informácie** links card. base.html already renders `#category-sidebar` from
`/partials/categories`, so this is a restyle of that partial.
### C1. Collapsible category tree — ✅ already implemented
Mockup: toplevel groups with +/ chevrons; expanded group shows leaf links, active
leaf highlighted (`bg-primary/10 text-primary font-bold`).
- Style the existing categories partial as a white card with Alpinetoggled
sublists (`x-data="{ open: … }"`). Active state via `aria-current` + the existing
`markActiveNav()` pattern.
- Medium effort.
### C2. "Informácie" links card — ✅ done
Mockup: second sidebar card with Obchodné podmienky / O našej spoločnosti / Naše
obchody / Doprava a platba.
- Static links card; same markup as the contact/stores cards already added to the
home right rail. Low effort.
---
## D. Right rail — mostly done
Implemented on the home page: **Najpredávanejšie** (from featured products), **Naše
obchody** placeholder, **Kontaktujte nás** blue card.
Remaining 🟡:
- **D1. Bestsellers from real sales data** — currently reuses the featured query;
wire to actual order/sales counts when available.
- **D2. "Naše obchody" real photo + page** — replace the placeholder block and link
to a real stores page.
- **D3. Contact hotline** — phone number is hardcoded; move to config/i18n.
---
## Suggested order (quick wins first)
1. Footer (A6) + top utility bar (A1) — pure markup, big visual lift.
2. Header search (A2) + cart label (A5) + logo lockup (A3) — wire existing data.
3. Sort select (B3) + breadcrumb restyle (B1) — backends already exist.
4. Sidebar restyle (C1, C2).
5. Category banner (B2), richer list card (B6).
6. Compare (B5) — only if the feature is actually wanted.
## Reference
- Mockup source extracted to (scratch, regenerate via DesignSync if needed):
`DesignSync get_file` on project `015b8bf5-fd27-4a7e-82d9-d1864a59578c`,
file `Kompress-eshop.html` → `__bundler/template` script holds the real markup.
- PenguinUI workflow: see `penguinui-workflow` memory + `hardcoded-inventory.md`.

View File

@@ -47,6 +47,9 @@ 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_000001_add_short_description_to_products;
mod m20260623_000002_strip_html_from_product_search; mod m20260623_000002_strip_html_from_product_search;
mod m20260623_000003_drop_currency;
mod m20260623_000004_currencies;
mod m20260625_000001_add_avatar_to_users;
pub struct Migrator; pub struct Migrator;
#[async_trait::async_trait] #[async_trait::async_trait]
@@ -98,6 +101,9 @@ impl MigratorTrait for Migrator {
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_000001_add_short_description_to_products::Migration),
Box::new(m20260623_000002_strip_html_from_product_search::Migration), Box::new(m20260623_000002_strip_html_from_product_search::Migration),
Box::new(m20260623_000003_drop_currency::Migration),
Box::new(m20260623_000004_currencies::Migration),
Box::new(m20260625_000001_add_avatar_to_users::Migration),
// inject-above (do not remove this comment) // inject-above (do not remove this comment)
] ]
} }

View File

@@ -0,0 +1,20 @@
use loco_rs::schema::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
// The store is EUR-only. Currency is no longer stored per product/order;
// the euro symbol is rendered everywhere in the UI.
remove_column(m, "products", "currency").await?;
remove_column(m, "orders", "currency").await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
add_column(m, "products", "currency", ColType::StringWithDefault("EUR".to_string())).await?;
add_column(m, "orders", "currency", ColType::StringWithDefault("EUR".to_string())).await
}
}

View File

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

View File

@@ -0,0 +1,20 @@
use loco_rs::schema::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
// Optional profile avatar. `avatar_id` holds the stored image's filename (the
// same `<uuid>.<ext>` scheme as product/category images), served through the
// shared `/images/{filename}` route. NULL = no avatar, fall back to initials.
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
add_column(m, "users", "avatar_id", ColType::StringNull).await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
remove_column(m, "users", "avatar_id").await
}
}

View File

@@ -17,10 +17,10 @@ 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, pages, shop,
}, },
initializers, initializers,
models::_entities::users, models::_entities::users,
@@ -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,8 @@ 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())
.add_route(pages::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 +113,7 @@ impl Hooks for App {
.add_route(admin_orders::routes()) .add_route(admin_orders::routes())
.add_route(admin_customers::routes()) .add_route(admin_customers::routes())
.add_route(admin_shipping::routes()) .add_route(admin_shipping::routes())
.add_route(admin_currencies::routes())
} }
async fn after_context(ctx: AppContext) -> Result<AppContext> { async fn after_context(ctx: AppContext) -> Result<AppContext> {

View File

@@ -7,6 +7,7 @@
//! on the user — it is shown here read-only and can never be changed. The //! on the user — it is shown here read-only and can never be changed. The
//! profile only edits the type-specific details (company identity + address). //! profile only edits the type-specific details (company identity + address).
use axum::extract::{DefaultBodyLimit, Multipart};
use axum_extra::extract::cookie::CookieJar; use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*; use loco_rs::prelude::*;
use sea_orm::QueryOrder; use sea_orm::QueryOrder;
@@ -14,7 +15,11 @@ use serde::Deserialize;
use serde_json::json; use serde_json::json;
use crate::{ use crate::{
controllers::i18n::current_lang, controllers::{
admin_form::{read_multipart_form, store_image},
i18n::current_lang,
media::IMAGE_MAX_BYTES,
},
models::{ models::{
customer_profiles::{self, ProfileFields}, customer_profiles::{self, ProfileFields},
order_items, orders, users, order_items, orders, users,
@@ -128,6 +133,8 @@ fn profile_view(
"account_nav": true, "account_nav": true,
"customer_name": user.name, "customer_name": user.name,
"customer_account_type": user.account_type, "customer_account_type": user.account_type,
"customer_avatar": user.avatar_id,
"avatar_id": user.avatar_id,
"saved": saved, "saved": saved,
"error": error, "error": error,
"name": user.name, "name": user.name,
@@ -202,6 +209,64 @@ async fn save_profile(
profile_view(&v, &jar, &user, &fields, true, false) profile_view(&v, &jar, &user, &fields, true, false)
} }
/// Persist `avatar_id` (a stored image filename, or `None` to clear) on the
/// signed-in customer and re-render the profile page with the success banner.
async fn set_avatar(
v: &TeraView,
jar: &CookieJar,
ctx: &AppContext,
user: users::Model,
avatar_id: Option<String>,
) -> Result<Response> {
let mut active = user.clone().into_active_model();
active.avatar_id = ActiveValue::set(avatar_id.clone());
let user = active.update(&ctx.db).await?;
let profile = customer_profiles::Model::find_for_user(&ctx.db, user.id).await?;
profile_view(v, jar, &user, &fields_of(profile.as_ref()), true, false)
}
/// Upload (or replace) the signed-in customer's avatar picture. The single
/// `image` file part is validated and stored through the shared image storage,
/// then its generated filename is saved as the user's `avatar_id`.
#[debug_handler]
async fn upload_avatar(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
multipart: Multipart,
) -> Result<Response> {
let Some(user) = guard::current_user(&ctx, &jar).await else {
return format::redirect("/login");
};
if guard::is_admin(&ctx, &user) {
return format::redirect("/admin/dashboard");
}
let form = read_multipart_form(multipart).await?;
let Some(image) = form.single_image() else {
// No file chosen — nothing to do, just re-show the profile.
let profile = customer_profiles::Model::find_for_user(&ctx.db, user.id).await?;
return profile_view(&v, &jar, &user, &fields_of(profile.as_ref()), false, false);
};
let filename = store_image(&ctx, image).await?;
set_avatar(&v, &jar, &ctx, user, Some(filename)).await
}
/// Remove the signed-in customer's avatar, reverting to the initials fallback.
#[debug_handler]
async fn remove_avatar(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let Some(user) = guard::current_user(&ctx, &jar).await else {
return format::redirect("/login");
};
if guard::is_admin(&ctx, &user) {
return format::redirect("/admin/dashboard");
}
set_avatar(&v, &jar, &ctx, user, None).await
}
/// Lists the signed-in customer's orders, split into still-active and past. /// Lists the signed-in customer's orders, split into still-active and past.
#[debug_handler] #[debug_handler]
async fn orders_page( async fn orders_page(
@@ -236,6 +301,7 @@ async fn orders_page(
"account_nav": true, "account_nav": true,
"customer_name": user.name, "customer_name": user.name,
"customer_account_type": user.account_type, "customer_account_type": user.account_type,
"customer_avatar": user.avatar_id,
"active_orders": shape(active), "active_orders": shape(active),
"past_orders": shape(past), "past_orders": shape(past),
"lang": current_lang(&jar), "lang": current_lang(&jar),
@@ -278,6 +344,7 @@ async fn order_detail_page(
"account_nav": true, "account_nav": true,
"customer_name": user.name, "customer_name": user.name,
"customer_account_type": user.account_type, "customer_account_type": user.account_type,
"customer_avatar": user.avatar_id,
"order": order_view::detail( "order": order_view::detail(
&order, &order,
settings::get(&ctx, "bank_iban").unwrap_or(""), settings::get(&ctx, "bank_iban").unwrap_or(""),
@@ -312,6 +379,7 @@ fn password_view(
"account_nav": true, "account_nav": true,
"customer_name": user.name, "customer_name": user.name,
"customer_account_type": user.account_type, "customer_account_type": user.account_type,
"customer_avatar": user.avatar_id,
"changed": changed, "changed": changed,
"error": error, "error": error,
"lang": current_lang(jar), "lang": current_lang(jar),
@@ -406,6 +474,7 @@ fn security_view(
"account_nav": true, "account_nav": true,
"customer_name": user.name, "customer_name": user.name,
"customer_account_type": user.account_type, "customer_account_type": user.account_type,
"customer_avatar": user.avatar_id,
"totp_enabled": user.totp_enabled(), "totp_enabled": user.totp_enabled(),
"enrolling": enrolling, "enrolling": enrolling,
"qr": qr, "qr": qr,
@@ -538,6 +607,11 @@ pub fn routes() -> Routes {
Routes::new() Routes::new()
.add("/account/profile", get(profile_page)) .add("/account/profile", get(profile_page))
.add("/account/profile", post(save_profile)) .add("/account/profile", post(save_profile))
.add(
"/account/profile/avatar",
post(upload_avatar).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024)),
)
.add("/account/profile/avatar/remove", post(remove_avatar))
.add("/account/orders", get(orders_page)) .add("/account/orders", get(orders_page))
.add("/account/orders/{order_number}", get(order_detail_page)) .add("/account/orders/{order_number}", get(order_detail_page))
.add("/account/password", get(change_password_page)) .add("/account/password", get(change_password_page))

View File

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

View File

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

View File

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

View File

@@ -53,7 +53,6 @@ struct ProductFields {
slug: String, slug: String,
description: Option<String>, description: Option<String>,
short_description: Option<String>, short_description: Option<String>,
currency: String,
category_id: Option<i32>, category_id: Option<i32>,
published: bool, published: bool,
} }
@@ -66,7 +65,6 @@ 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 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());
@@ -94,7 +92,6 @@ async fn parse_product_fields(
slug, slug,
description, description,
short_description, short_description,
currency,
category_id, category_id,
published, published,
}) })
@@ -366,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,
@@ -442,7 +438,6 @@ async fn create(
slug: Set(fields.slug), slug: Set(fields.slug),
description: Set(fields.description), description: Set(fields.description),
short_description: Set(fields.short_description), short_description: Set(fields.short_description),
currency: Set(fields.currency),
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())),
@@ -557,7 +552,6 @@ async fn update(
product.slug = Set(fields.slug); product.slug = Set(fields.slug);
product.description = Set(fields.description); product.description = Set(fields.description);
product.short_description = Set(fields.short_description); product.short_description = Set(fields.short_description);
product.currency = Set(fields.currency);
product.category_id = Set(fields.category_id); product.category_id = Set(fields.category_id);
product.published = Set(fields.published); product.published = Set(fields.published);
if fields.published && !was_published { if fields.published && !was_published {
@@ -704,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),
@@ -816,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,
@@ -871,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,
@@ -880,7 +872,6 @@ async fn discount_view(
"product": { "product": {
"id": product.id, "id": product.id,
"name": product.name, "name": product.name,
"currency": product.currency,
}, },
"rows": rows_json, "rows": rows_json,
"audience": audience, "audience": audience,

View File

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

View File

@@ -18,7 +18,7 @@ use crate::{
users::{self, normalize_account_type}, users::{self, normalize_account_type},
}, },
controllers::i18n::current_lang, controllers::i18n::current_lang,
shared::{guard, money::format_price, settings}, shared::{currency::Currency, guard, money::format_price, settings},
views::checkout as view, views::checkout as view,
}; };
@@ -77,15 +77,12 @@ async fn checkout_page(
ViewEngine(v): ViewEngine<TeraView>, ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar).await?; // Checkout and everything past it (orders, confirmation) stay in the EUR
// base — the settlement currency — even when the buyer browsed in another.
let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
if lines.is_empty() { if lines.is_empty() {
return format::redirect("/cart"); return format::redirect("/cart");
} }
let currency = lines
.first()
.and_then(|line| line["currency"].as_str())
.unwrap_or("EUR")
.to_string();
let methods: Vec<serde_json::Value> = enabled_shipping_methods(&ctx) let methods: Vec<serde_json::Value> = enabled_shipping_methods(&ctx)
.await? .await?
@@ -127,7 +124,6 @@ async fn checkout_page(
"items": lines, "items": lines,
"subtotal": format_price(subtotal), "subtotal": format_price(subtotal),
"subtotal_cents": subtotal, "subtotal_cents": subtotal,
"currency": currency,
"shipping_methods": methods, "shipping_methods": methods,
"packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""), "packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""),
"logged_in_admin": is_admin, "logged_in_admin": is_admin,
@@ -136,6 +132,7 @@ async fn checkout_page(
// logged_in_customer is true); None for admins/guests. // logged_in_customer is true); None for admins/guests.
"customer_name": user.as_ref().filter(|_| is_customer).map(|u| u.name.clone()), "customer_name": user.as_ref().filter(|_| is_customer).map(|u| u.name.clone()),
"customer_account_type": user.as_ref().filter(|_| is_customer).map(|u| u.account_type.clone()), "customer_account_type": user.as_ref().filter(|_| is_customer).map(|u| u.account_type.clone()),
"customer_avatar": user.as_ref().filter(|_| is_customer).and_then(|u| u.avatar_id.clone()),
"profile_filled": profile_filled, "profile_filled": profile_filled,
// A logged-in customer's account type is fixed; only guests pick it // A logged-in customer's account type is fixed; only guests pick it
// and may opt to create an account from the order. // and may opt to create an account from the order.
@@ -165,7 +162,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");
} }
@@ -379,6 +376,7 @@ async fn order_confirmation(
"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,
"customer_avatar": c.customer_avatar,
"account_created": account_created, "account_created": account_created,
"lang": current_lang(&jar), "lang": current_lang(&jar),
}), }),

View File

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

View File

@@ -4,7 +4,9 @@ use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*; use loco_rs::prelude::*;
use serde_json::json; use serde_json::json;
use crate::{controllers::i18n::current_lang, shared::guard, controllers::shop}; use crate::{
controllers::i18n::current_lang, controllers::shop, shared::currency, shared::guard,
};
#[debug_handler] #[debug_handler]
async fn index( async fn index(
@@ -13,7 +15,8 @@ async fn index(
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
let user = guard::current_user(&ctx, &jar).await; let user = guard::current_user(&ctx, &jar).await;
let products = shop::featured_products(&ctx, user.as_ref(), 8).await?; let cur = currency::resolve(&ctx, &jar).await;
let products = shop::featured_products(&ctx, user.as_ref(), 8, &cur).await?;
let c = guard::chrome_from(&ctx, user.as_ref()); let c = guard::chrome_from(&ctx, user.as_ref());
format::view( format::view(
@@ -25,7 +28,11 @@ 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,
"customer_avatar": c.customer_avatar,
"currency_symbol": cur.symbol,
"lang": current_lang(&jar), "lang": current_lang(&jar),
// The header search bar only appears on the landing page.
"on_home": true,
}), }),
) )
} }

View File

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

View File

@@ -3,6 +3,7 @@ pub mod auth;
pub mod auth_pages; pub mod auth_pages;
pub mod oauth2; pub mod oauth2;
pub mod admin_categories; pub mod admin_categories;
pub mod admin_currencies;
pub mod admin_customers; pub mod admin_customers;
pub mod admin_dashboard; pub mod admin_dashboard;
pub mod admin_discount_profiles; pub mod admin_discount_profiles;
@@ -12,7 +13,9 @@ 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;
pub mod pages;
pub mod shop; pub mod shop;

97
src/controllers/pages.rs Normal file
View File

@@ -0,0 +1,97 @@
//! Static informational pages (contact, sitemap, terms, about, stores,
//! shipping). These back the top-bar / footer / sidebar links so none of them
//! is a dead `#`. Content is static; the same chrome context as the home page
//! is supplied so `base.html` (header, cart badge, currencies) renders.
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use serde_json::json;
use crate::{controllers::i18n::current_lang, shared::currency, shared::guard};
/// Render one static page through `pages/info.html`, which switches its title +
/// body on the `page` slug. Mirrors `home::index`'s chrome wiring.
async fn render(v: &TeraView, jar: &CookieJar, ctx: &AppContext, page: &str) -> Result<Response> {
let user = guard::current_user(ctx, jar).await;
let cur = currency::resolve(ctx, jar).await;
let c = guard::chrome_from(ctx, user.as_ref());
format::view(
v,
"pages/info.html",
json!({
"page": page,
"logged_in_admin": c.logged_in_admin,
"logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
"customer_account_type": c.customer_account_type,
"customer_avatar": c.customer_avatar,
"currency_symbol": cur.symbol,
"lang": current_lang(jar),
}),
)
}
#[debug_handler]
async fn contact(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
render(&v, &jar, &ctx, "contact").await
}
#[debug_handler]
async fn sitemap(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
render(&v, &jar, &ctx, "sitemap").await
}
#[debug_handler]
async fn terms(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
render(&v, &jar, &ctx, "terms").await
}
#[debug_handler]
async fn about(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
render(&v, &jar, &ctx, "about").await
}
#[debug_handler]
async fn stores(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
render(&v, &jar, &ctx, "stores").await
}
#[debug_handler]
async fn shipping(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
render(&v, &jar, &ctx, "shipping").await
}
pub fn routes() -> Routes {
Routes::new()
.add("/kontakt", get(contact))
.add("/mapa-stranky", get(sitemap))
.add("/obchodne-podmienky", get(terms))
.add("/o-nas", get(about))
.add("/predajne", get(stores))
.add("/doprava-a-platba", get(shipping))
}

View File

@@ -13,16 +13,30 @@ 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},
views::shop as view, views::shop as view,
}; };
/// Results per page in the storefront listing/search. /// Default results per page in the storefront listing/search.
const PER_PAGE: usize = 24; const PER_PAGE: usize = 24;
/// Allowed per-page choices offered in the toolbar; any other value falls back
/// to [`PER_PAGE`].
const PER_PAGE_OPTIONS: [usize; 3] = [24, 48, 96];
/// Resolve the requested per-page count to one of [`PER_PAGE_OPTIONS`],
/// defaulting to [`PER_PAGE`].
fn resolve_per_page(params: &SearchParams) -> usize {
params
.per_page
.map(|p| p as usize)
.filter(|p| PER_PAGE_OPTIONS.contains(p))
.unwrap_or(PER_PAGE)
}
/// Hard cap on candidates a single text search considers before faceting; well /// Hard cap on candidates a single text search considers before faceting; well
/// above any realistic page of results for this catalog. /// above any realistic page of results for this catalog.
const SEARCH_CAP: u64 = 1000; const SEARCH_CAP: u64 = 1000;
@@ -39,6 +53,7 @@ struct SearchParams {
in_stock: Option<String>, in_stock: Option<String>,
sort: Option<String>, sort: Option<String>,
page: Option<u32>, page: Option<u32>,
per_page: Option<u32>,
} }
/// A candidate product with everything the listing needs to filter, sort and /// A candidate product with everything the listing needs to filter, sort and
@@ -80,6 +95,9 @@ fn query_base(params: &SearchParams) -> String {
if let Some(s) = params.sort.as_deref().filter(|s| !s.is_empty()) { if let Some(s) = params.sort.as_deref().filter(|s| !s.is_empty()) {
ser.append_pair("sort", s); ser.append_pair("sort", s);
} }
if let Some(p) = params.per_page.filter(|p| *p as usize != PER_PAGE) {
ser.append_pair("per_page", &p.to_string());
}
ser.finish() ser.finish()
} }
@@ -90,6 +108,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 +155,19 @@ async fn run_search(
let price_floor = items.iter().map(|i| i.priced.price_cents).min().unwrap_or(0); let price_floor = items.iter().map(|i| i.priced.price_cents).min().unwrap_or(0);
let price_ceil = items.iter().map(|i| i.priced.price_cents).max().unwrap_or(0); let price_ceil = items.iter().map(|i| i.priced.price_cents).max().unwrap_or(0);
// 3. Non-category filters: price band + in-stock. // 3. Non-category filters: price band + in-stock. The typed bounds are in
let min_c = params.min_price.as_deref().and_then(|s| parse_price_to_cents(s).ok()); // the buyer's display currency; convert them back to EUR cents to compare
let max_c = params.max_price.as_deref().and_then(|s| parse_price_to_cents(s).ok()); // against the (EUR) resolved prices.
let min_c = params
.min_price
.as_deref()
.and_then(|s| parse_price_to_cents(s).ok())
.map(|c| cur.to_eur_cents(c));
let max_c = params
.max_price
.as_deref()
.and_then(|s| parse_price_to_cents(s).ok())
.map(|c| cur.to_eur_cents(c));
let in_stock_only = is_on(&params.in_stock); let in_stock_only = is_on(&params.in_stock);
items.retain(|i| { items.retain(|i| {
min_c.is_none_or(|m| i.priced.price_cents >= m) min_c.is_none_or(|m| i.priced.price_cents >= m)
@@ -165,12 +194,17 @@ async fn run_search(
items.retain(|i| view::category_filter_keep(&filter, i.product.category_id)); items.retain(|i| view::category_filter_keep(&filter, i.product.category_id));
// 6. Sort. Newest-first is the default; relevance (the ranked search order) // 6. Sort. Newest-first is the default; relevance (the ranked search order)
// is available explicitly via the sort control. // is available explicitly via the sort control. When a search runs, the
let sort = params // default "newest" becomes "relevance" (a query implies relevance matters
// most); any explicitly chosen non-newest sort is left untouched.
let mut sort = params
.sort .sort
.clone() .clone()
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.unwrap_or_else(|| "newest".to_string()); .unwrap_or_else(|| "newest".to_string());
if !q_trim.is_empty() && sort == "newest" {
sort = "relevance".to_string();
}
match sort.as_str() { match sort.as_str() {
"price_asc" => items.sort_by(|a, b| a.priced.price_cents.cmp(&b.priced.price_cents)), "price_asc" => items.sort_by(|a, b| a.priced.price_cents.cmp(&b.priced.price_cents)),
"price_desc" => items.sort_by(|a, b| b.priced.price_cents.cmp(&a.priced.price_cents)), "price_desc" => items.sort_by(|a, b| b.priced.price_cents.cmp(&a.priced.price_cents)),
@@ -186,14 +220,15 @@ async fn run_search(
} }
// 7. Paginate. // 7. Paginate.
let per_page = resolve_per_page(params);
let total = items.len(); let total = items.len();
let pages = total.div_ceil(PER_PAGE).max(1); let pages = total.div_ceil(per_page).max(1);
let page = params.page.unwrap_or(1).clamp(1, pages as u32); let page = params.page.unwrap_or(1).clamp(1, pages as u32);
let start = (page as usize - 1) * PER_PAGE; let start = (page as usize - 1) * per_page;
// 8. Render only the current page's cards (images fetched per row). // 8. Render only the current page's cards (images fetched per row).
let mut rows = Vec::new(); let mut rows = Vec::new();
for item in items.iter().skip(start).take(PER_PAGE) { for item in items.iter().skip(start).take(per_page) {
let image = product_images::first_for(ctx, item.product.id).await?; let image = product_images::first_for(ctx, item.product.id).await?;
let cat_name = item.product.category_id.and_then(|id| category_name.get(&id).cloned()); let cat_name = item.product.category_id.and_then(|id| category_name.get(&id).cloned());
rows.push(view::product_card( rows.push(view::product_card(
@@ -203,6 +238,7 @@ async fn run_search(
item.count, item.count,
image, image,
cat_name, cat_name,
cur,
)); ));
} }
@@ -214,13 +250,23 @@ async fn run_search(
// Numeric form so the <select> can mark the active option (Tera can't // Numeric form so the <select> can mark the active option (Tera can't
// compare a string param against a numeric category id). // compare a string param against a numeric category id).
"selected_category_id": selected_category.parse::<i32>().unwrap_or(-1), "selected_category_id": selected_category.parse::<i32>().unwrap_or(-1),
// Display name of the active category, so the search bar can show that
// the query is scoped to it. `None` for "all"/"none" (the template maps
// "none" to the localized "uncategorized" label itself).
"selected_category_name": selected_category
.parse::<i32>()
.ok()
.and_then(|id| category_name.get(&id).cloned()),
"uncategorized_count": uncategorized_count, "uncategorized_count": uncategorized_count,
"sort": sort, "sort": sort,
"per_page": per_page,
"per_page_options": PER_PAGE_OPTIONS,
"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 +286,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 +308,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 +319,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 +327,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
@@ -309,6 +357,7 @@ fn add_chrome(ctx_value: &mut serde_json::Value, c: &guard::Chrome, lang: &str)
map.insert("logged_in_customer".into(), json!(c.logged_in_customer)); map.insert("logged_in_customer".into(), json!(c.logged_in_customer));
map.insert("customer_name".into(), json!(c.customer_name)); map.insert("customer_name".into(), json!(c.customer_name));
map.insert("customer_account_type".into(), json!(c.customer_account_type)); map.insert("customer_account_type".into(), json!(c.customer_account_type));
map.insert("customer_avatar".into(), json!(c.customer_avatar));
map.insert("lang".into(), json!(lang)); map.insert("lang".into(), json!(lang));
} }
} }
@@ -320,7 +369,8 @@ async fn index(
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
let user = guard::current_user(&ctx, &jar).await; let user = guard::current_user(&ctx, &jar).await;
let mut context = run_search(&ctx, user.as_ref(), &SearchParams::default()).await?; let cur = currency::resolve(&ctx, &jar).await;
let mut context = run_search(&ctx, user.as_ref(), &SearchParams::default(), &cur).await?;
let c = guard::chrome_from(&ctx, user.as_ref()); let c = guard::chrome_from(&ctx, user.as_ref());
add_chrome(&mut context, &c, &current_lang(&jar)); add_chrome(&mut context, &c, &current_lang(&jar));
format::view(&v, "shop/index.html", context) format::view(&v, "shop/index.html", context)
@@ -330,8 +380,10 @@ async fn index(
/// ([`products::Entity::search`]) with category, price-band, in-stock and sort /// ([`products::Entity::search`]) with category, price-band, in-stock and sort
/// filters, ranked and paginated by [`run_search`]. A blank query falls back to /// filters, ranked and paginated by [`run_search`]. A blank query falls back to
/// the full published listing, so the same endpoint powers both "browse" and /// the full published listing, so the same endpoint powers both "browse" and
/// "search". htmx requests get just the results fragment (for live updates); /// "search". Targeted htmx requests from the listing toolbar/pagination get just
/// direct navigation (or no-JS) renders the whole page. /// the results fragment (for live updates); direct navigation, no-JS, and boosted
/// navigations (e.g. submitting the header search box, which hx-boost turns into
/// an AJAX nav) render the whole eshop page.
#[debug_handler] #[debug_handler]
async fn search( async fn search(
jar: CookieJar, jar: CookieJar,
@@ -341,12 +393,20 @@ async fn search(
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
let user = guard::current_user(&ctx, &jar).await; let user = guard::current_user(&ctx, &jar).await;
let mut context = run_search(&ctx, user.as_ref(), &params).await?; let cur = currency::resolve(&ctx, &jar).await;
let mut context = run_search(&ctx, user.as_ref(), &params, &cur).await?;
let lang = current_lang(&jar); let lang = current_lang(&jar);
if headers.contains_key("HX-Request") { // A boosted request (the header search form, links) replaces the whole body,
// so it needs the full page — only the toolbar's own targeted hx-get requests
// (HX-Request without HX-Boosted) want the bare results fragment.
let fragment = headers.contains_key("HX-Request") && !headers.contains_key("HX-Boosted");
if fragment {
if let Some(map) = context.as_object_mut() { if let Some(map) = context.as_object_mut() {
map.insert("lang".into(), json!(lang)); map.insert("lang".into(), json!(lang));
// Lets _results.html out-of-band swap the toolbar's Sort dropdown
// (which lives outside the swapped region) to match the ordering.
map.insert("is_fragment".into(), json!(true));
} }
return format::view(&v, "shop/_results.html", context); return format::view(&v, "shop/_results.html", context);
} }
@@ -385,12 +445,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 +465,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 +473,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 +490,8 @@ 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,
"customer_avatar": c.customer_avatar,
"currency_symbol": cur.symbol,
"lang": current_lang(&jar), "lang": current_lang(&jar),
}), }),
) )
@@ -464,7 +527,8 @@ async fn category(
}; };
let user = guard::current_user(&ctx, &jar).await; let user = guard::current_user(&ctx, &jar).await;
let mut context = run_search(&ctx, user.as_ref(), &params).await?; let cur = currency::resolve(&ctx, &jar).await;
let mut context = run_search(&ctx, user.as_ref(), &params, &cur).await?;
if let Some(map) = context.as_object_mut() { if let Some(map) = context.as_object_mut() {
map.insert("category".into(), serde_json::to_value(&category)?); map.insert("category".into(), serde_json::to_value(&category)?);
map.insert("breadcrumbs".into(), serde_json::to_value(&breadcrumbs)?); map.insert("breadcrumbs".into(), serde_json::to_value(&breadcrumbs)?);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,7 +17,6 @@ pub struct Model {
pub description: Option<String>, pub description: Option<String>,
#[sea_orm(column_type = "Text", nullable)] #[sea_orm(column_type = "Text", nullable)]
pub short_description: Option<String>, pub short_description: Option<String>,
pub currency: 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>,

View File

@@ -31,6 +31,7 @@ pub struct Model {
pub totp_enabled_at: Option<DateTimeWithTimeZone>, pub totp_enabled_at: Option<DateTimeWithTimeZone>,
#[sea_orm(column_type = "Text", nullable)] #[sea_orm(column_type = "Text", nullable)]
pub totp_backup_codes: Option<String>, pub totp_backup_codes: Option<String>,
pub avatar_id: Option<String>,
} }
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

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

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -56,6 +56,9 @@ pub struct Chrome {
pub logged_in_customer: bool, pub logged_in_customer: bool,
pub customer_name: Option<String>, pub customer_name: Option<String>,
pub customer_account_type: Option<String>, pub customer_account_type: Option<String>,
/// Stored avatar image filename (served via `/images/{filename}`), set only
/// for a logged-in customer who uploaded one. `None` -> initials fallback.
pub customer_avatar: Option<String>,
} }
pub async fn chrome(ctx: &AppContext, jar: &CookieJar) -> Chrome { pub async fn chrome(ctx: &AppContext, jar: &CookieJar) -> Chrome {
@@ -74,6 +77,7 @@ pub fn chrome_from(ctx: &AppContext, user: Option<&users::Model>) -> Chrome {
logged_in_customer: true, logged_in_customer: true,
customer_name: Some(user.name.clone()), customer_name: Some(user.name.clone()),
customer_account_type: Some(user.account_type.clone()), customer_account_type: Some(user.account_type.clone()),
customer_avatar: user.avatar_id.clone(),
..Default::default() ..Default::default()
}, },
None => Chrome::default(), None => Chrome::default(),

View File

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

View File

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

View File

@@ -3,7 +3,7 @@
use serde_json::{json, Value}; use serde_json::{json, Value};
use crate::models::_entities::{categories, product_images, product_variants, products}; use crate::models::_entities::{categories, product_images, product_variants, products};
use crate::shared::money::format_price; use crate::shared::currency::Currency;
use crate::shared::pricing::PricedProduct; use crate::shared::pricing::PricedProduct;
/// Card/list shape for a product: model fields plus the viewer's resolved price /// Card/list shape for a product: model fields plus the viewer's resolved price
@@ -20,7 +20,16 @@ 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 {
// Whole-percent discount for the card's sale badge (e.g. "15 %"). Only
// meaningful when the resolved price is actually reduced below the regular.
let percent_off = if priced.is_reduced() && priced.regular_cents > priced.price_cents {
(((priced.regular_cents - priced.price_cents) as f64 / priced.regular_cents as f64) * 100.0)
.round() as i64
} else {
0
};
json!({ json!({
"id": product.id, "id": product.id,
"variant_id": representative.id, "variant_id": representative.id,
@@ -28,11 +37,11 @@ pub fn product_card(
"slug": product.slug, "slug": product.slug,
"description": product.description, "description": product.description,
"short_description": product.short_description, "short_description": product.short_description,
"price": format_price(priced.price_cents), "price": cur.format(priced.price_cents),
"on_sale": priced.is_reduced(), "on_sale": priced.is_reduced(),
"percent_off": percent_off,
"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(),
@@ -46,7 +55,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,
@@ -54,9 +67,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,
}) })
} }
@@ -71,7 +84,6 @@ pub fn product_form(product: &products::Model, images: &[product_images::Model])
"slug": product.slug, "slug": product.slug,
"description": product.description, "description": product.description,
"short_description": product.short_description, "short_description": product.short_description,
"currency": product.currency,
"published": product.published, "published": product.published,
"category_id": product.category_id, "category_id": product.category_id,
"images": images "images": images