46 Commits
v0.2.9 ... main

Author SHA1 Message Date
Priec
e5c84e631f color in sidebar
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-28 00:40:11 +02:00
Priec
0f3189ca26 best sellers colors 2026-06-27 23:11:15 +02:00
Priec
f4c66936c0 removed contact widget 2026-06-27 23:09:41 +02:00
Priec
4a5e0404c7 footer 2026-06-27 22:57:26 +02:00
Priec
80f3e7d48e proper mobil search 2026-06-27 22:39:16 +02:00
Priec
97c4c23af1 search bar is at the navbar now 2026-06-27 22:31:21 +02:00
Priec
269bb15e6f product page 2026-06-27 22:27:33 +02:00
Priec
da2c487dc4 product page refresh fixed 2026-06-27 22:19:55 +02:00
Priec
c549e2bc03 basket logic working 2026-06-27 22:11:13 +02:00
Priec
9bdf91e717 api for packeta required to enable it 2026-06-27 18:03:02 +02:00
Priec
d1f9838890 admin panel have more control over payment now 2026-06-27 14:27:37 +02:00
Priec
e8d8aafd97 dodacia adresa 2026-06-27 14:01:30 +02:00
Priec
5001e46866 button text
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-26 23:03:38 +02:00
Priec
5dcc8028b2 COLOR CHANGE - buttons have logo color but faded 2026-06-26 22:28:45 +02:00
Priec
3df88b4cee color scheme matching the company 2026-06-26 20:39:14 +02:00
Priec
ba02930454 width of the cards is the same now 2026-06-26 20:30:34 +02:00
Priec
1fc8796389 better mobile3 - product cards 2026-06-26 15:48:04 +02:00
Priec
e5ec2a2de6 better mobile2 2026-06-26 12:59:14 +02:00
Priec
70908cba8b mobile view logo 2026-06-26 12:40:10 +02:00
Priec
6b3739d629 mobile view 2026-06-26 12:32:20 +02:00
Priec
f3b920d4b2 storing vertical vs horizontal product card 2026-06-25 23:22:53 +02:00
Priec
caec8b4fb3 better product card defaults 2026-06-25 23:20:49 +02:00
Priec
d6d4f19010 less height for the product card 2026-06-25 23:16:52 +02:00
Priec
77066d660c removed old page 2026-06-25 23:12:23 +02:00
Priec
3aa5f63264 contanct page 2026-06-25 23:07:40 +02:00
Priec
f04691a733 where and who we are 2026-06-25 22:35:35 +02:00
Priec
6dd1164c65 search now fixed and also the elements of the old site are back 2026-06-25 22:27:19 +02:00
Priec
5f7ddce6a7 menus in the same height 2026-06-25 21:42:21 +02:00
Priec
2023b24d92 search notify where we are searching
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 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
72 changed files with 2244 additions and 390 deletions

View File

@@ -37,29 +37,43 @@
* dark:bg-surface-dark, border-outline, etc.
* ============================================================ */
@theme {
/* light mode */
--color-surface: var(--color-white);
--color-surface-alt: var(--color-slate-100);
--color-on-surface: var(--color-slate-700);
--color-on-surface-strong: var(--color-slate-900);
--color-primary: var(--color-indigo-600);
--color-on-primary: var(--color-white);
--color-secondary: var(--color-slate-600);
--color-on-secondary: var(--color-white);
--color-outline: var(--color-slate-300);
--color-outline-strong: var(--color-slate-800);
/* light mode — Catppuccin Latte (https://catppuccin.com/palette)
* Base #eff1f5, Mantle #e6e9ef, Surface1 #bcc0cc, Subtext1 #5c5f77,
* Subtext0 #6c6f85, Text #4c4f69. Primary is the KOMPRESS logo blue
* (sampled from logo.jpg) rather than the Latte blue. */
--color-surface: #eff1f5; /* Base */
--color-surface-alt: #e6e9ef; /* Mantle */
--color-on-surface: #5c5f77; /* Subtext1 */
--color-on-surface-strong: #4c4f69; /* Text */
--color-primary: #1600ff; /* KOMPRESS logo blue */
--color-on-primary: #eff1f5; /* Base */
--color-secondary: #6c6f85; /* Subtext0 */
--color-on-secondary: #eff1f5; /* Base */
--color-outline: #bcc0cc; /* Surface1 */
--color-outline-strong: #4c4f69; /* Text */
/* CTA: solid fill for large/filled buttons + the contact block. The vivid
* logo blue (--color-primary) is reserved for tiny accents (links, hover
* tints, badges); the CTA color is the logo blue itself, just with alpha so
* big buttons read as a translucent tint rather than the full vivid fill. */
--color-cta: rgba(22, 0, 255, 0.85);
--color-on-cta: #eff1f5;
/* dark mode */
--color-surface-dark: var(--color-slate-900);
--color-surface-dark-alt: var(--color-slate-800);
--color-on-surface-dark: var(--color-slate-300);
--color-on-surface-dark-strong: var(--color-white);
--color-primary-dark: var(--color-indigo-400);
--color-on-primary-dark: var(--color-slate-950);
--color-secondary-dark: var(--color-slate-300);
--color-on-secondary-dark: var(--color-slate-950);
--color-outline-dark: var(--color-slate-700);
--color-outline-dark-strong: var(--color-slate-300);
/* dark mode — Gruvbox dark palette (https://github.com/morhetz/gruvbox)
* bg0 #282828, bg1 #3c3836, bg2 #504945, fg0 #fbf1c7, fg1 #ebdbb2,
* fg2 #d5c4a1, fg3 #bdae93, bright blue #83a598, bg0_h #1d2021. */
--color-surface-dark: #282828; /* bg0 */
--color-surface-dark-alt: #3c3836; /* bg1 */
--color-on-surface-dark: #ebdbb2; /* fg1 */
--color-on-surface-dark-strong: #fbf1c7; /* fg0 */
--color-primary-dark: #83a598; /* bright blue */
--color-on-primary-dark: #1d2021; /* bg0_h */
--color-secondary-dark: #d5c4a1; /* fg2 */
--color-on-secondary-dark: #1d2021; /* bg0_h */
--color-outline-dark: #504945; /* bg2 */
--color-outline-dark-strong: #bdae93; /* fg3 */
/* CTA in dark mode tracks the existing primary so dark buttons are unchanged. */
--color-cta-dark: #83a598; /* = primary-dark */
--color-on-cta-dark: #1d2021; /* = on-primary-dark */
/* shared status colors (same in both modes) */
--color-info: var(--color-sky-500);
@@ -177,6 +191,8 @@
[data-theme="dark"] .rich-content a { color: var(--color-primary-dark); }
.rich-content :first-child { margin-top: 0; }
.rich-content :last-child { margin-bottom: 0; }
.rich-summary :where(p) { display: inline; margin: 0; }
.rich-summary .product-more-link { margin-left: 0.25rem; }
/* Compact rich blurb for product cards: neutralize block spacing so the
* line-clamp truncation stays tidy regardless of the authored markup. */

View File

@@ -1,6 +1,6 @@
brand = Kompress eshop
brand = WWW.KOMPRESS.SK, s.r.o.
hello-world = Hello world!
meta-description = Kompress eshop
meta-description = Manufacturer and distributor of medical aids and supplies
nav-home = Home
nav-about = About
nav-blog = Blog
@@ -152,6 +152,7 @@ artist = Artist
release-date = Release date
cover-image = Cover image
description = Description
product-more = more
songs-in-album = Songs in this album
admin-new-album-desc = Fill in the details, then tick the songs to include.
cover-help = Optional - png, jpg, webp or gif; shown on the album page.

View File

@@ -1,6 +1,6 @@
brand = Kompress eshop
brand = WWW.KOMPRESS.SK, s.r.o.
hello-world = Hello world!
meta-description = Kompress eshop
meta-description = Manufacturer and distributor of medical aids and supplies
nav-home = Home
nav-about = About
nav-blog = Blog
@@ -172,6 +172,7 @@ artist = Artist
release-date = Release date
cover-image = Cover image
description = Description
product-more = more
short-description = Short description
short-description-hint = Shown on product cards. Keep it short.
songs-in-album = Songs in this album
@@ -218,7 +219,7 @@ option-label = Option label
optional = optional
stock-untracked-hint = Leave blank = available without stock tracking
available = Available
choose-option = Choose an option
choose-option = Options
from-price = from { $price }
admin-discounts = Discounts
admin-discounts-desc = Set discounted product prices. A discount shows up as a sale in the shop.
@@ -300,7 +301,7 @@ position-hint = Sort order in the menu (lowest first). Leave blank to add it las
parent-category = Parent category
no-parent = — None (top level) —
quantity = Quantity
add-to-cart = Add to cart
add-to-cart = To cart
cart-added = Added to cart
in-stock = In stock
out-of-stock = Out of stock
@@ -315,6 +316,7 @@ order-search-placeholder = Search orders…
search-empty = Nothing matched your search:
results-count = { $count } products
sort-label = Sort
per-page-label = Per page
sort-relevance = Relevance
sort-newest = Newest
sort-price_asc = Price: low to high
@@ -349,7 +351,9 @@ cart-update = Update
cart-continue = Continue shopping
checkout-title = Checkout
checkout-contact = Contact details
checkout-shipping = Shipping address
checkout-shipping = Delivery address
checkout-residence-address = Residence address
checkout-delivery-same = Delivery address is the same as residence address
checkout-email = Email
checkout-name = Full name
checkout-phone = Phone
@@ -364,7 +368,8 @@ country-de = Germany
country-pl = Poland
country-hu = Hungary
checkout-note = Order note
checkout-save-profile = Save this address to my profile
checkout-save-profile = Save residence address to my profile
payment-none = No payment method is currently available.
account-type = Account type
account-personal = Individual
account-company = Company
@@ -386,6 +391,11 @@ profile-last-name = Surname
profile-edit = Edit profile
profile-cancel = Cancel
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
account-orders = My orders
account-change-password = Change password
@@ -475,6 +485,12 @@ bank-variable-symbol = Variable symbol
bank-amount = Amount
admin-shipping = Shipping
admin-shipping-desc = set the price and availability of each delivery option.
shipping-packeta-missing-settings = Packeta can be enabled after PACKETA_API_KEY, PACKETA_API_PASSWORD and PACKETA_SENDER_LABEL are configured.
admin-payments = Payments
admin-payments-desc = enable or disable payment methods and edit bank-transfer details.
payment-methods = Payment methods
payment-enabled = Active
payment-bank-settings = Bank transfer details
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.
@@ -500,3 +516,43 @@ 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-to-carrier = Send to
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 = Where it's made
home-stores-photo = Our production facility
home-stores-discover = Step inside our workshop
page-stores-intro = This is our own facility where our medical aids and supplies are produced.
page-stores-facility = Production facility
page-stores-address-label = Facility address
page-stores-address = Nádražná 328/62, 015 01 Rajec nad Rajčankou
page-stores-photo-caption = Our production facility in Rajec nad Rajčankou
page-stores-map = Where to find us
page-stores-map-open = Open in Google Maps
home-bestsellers = Best sellers
home-bestsellers-all = All best sellers
home-contact-title = Contact us
home-contact-text = Our hotline is available 24/7. We're happy to help you choose.
home-contact-cta = Contact the hotline
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

@@ -1,6 +1,6 @@
brand = Kompress eshop
brand = WWW.KOMPRESS.SK, s.r.o.
hello-world = Ahoj svet!
meta-description = Kompress eshop
meta-description = Výrobca a distribútor zdravotníckych pomôcok a potrieb
nav-home = Domov
nav-about = O mne
nav-blog = Blog
@@ -172,6 +172,7 @@ artist = Interpret
release-date = Dátum vydania
cover-image = Obrázok obalu
description = Popis
product-more = viac
short-description = Krátky popis
short-description-hint = Zobrazuje sa na kartách produktov. Najlepšie krátke.
songs-in-album = Skladby v albume
@@ -218,7 +219,7 @@ option-label = Označenie možnosti
optional = voliteľné
stock-untracked-hint = Nechajte prázdne = dostupné bez sledovania zásob
available = Dostupné
choose-option = Vyberte možnosť
choose-option = Options
from-price = od { $price }
admin-discounts = Zľavy
admin-discounts-desc = Nastavte zľavnené ceny produktov. Zľava sa v obchode zobrazí ako akcia.
@@ -300,7 +301,7 @@ position-hint = Poradie v menu (najnižšie ako prvé). Nechajte prázdne a prid
parent-category = Nadradená kategória
no-parent = — Žiadna (najvyššia úroveň) —
quantity = Množstvo
add-to-cart = Pridať do košíka
add-to-cart = Do košíka
cart-added = Pridané do košíka
in-stock = Na sklade
out-of-stock = Vypredané
@@ -315,6 +316,7 @@ order-search-placeholder = Hľadať objednávky…
search-empty = Pre váš výraz sme nič nenašli:
results-count = { $count } produktov
sort-label = Zoradiť
per-page-label = Na stránku
sort-relevance = Relevancia
sort-newest = Najnovšie
sort-price_asc = Cena: od najnižšej
@@ -350,6 +352,8 @@ cart-continue = Pokračovať v nákupe
checkout-title = Pokladňa
checkout-contact = Kontaktné údaje
checkout-shipping = Dodacia adresa
checkout-residence-address = Adresa bydliska
checkout-delivery-same = Dodacia adresa je rovnaká ako adresa bydliska
checkout-email = E-mail
checkout-name = Meno a priezvisko
checkout-phone = Telefón
@@ -364,7 +368,8 @@ country-de = Nemecko
country-pl = Poľsko
country-hu = Maďarsko
checkout-note = Poznámka k objednávke
checkout-save-profile = Uložiť túto adresu do môjho profilu
checkout-save-profile = Uložiť adresu bydliska do môjho profilu
payment-none = Momentálne nie je dostupný žiadny spôsob platby.
account-type = Typ účtu
account-personal = Súkromná osoba
account-company = Firma
@@ -386,6 +391,11 @@ profile-last-name = Priezvisko
profile-edit = Upraviť profil
profile-cancel = Zrušiť
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
account-orders = Moje objednávky
account-change-password = Zmeniť heslo
@@ -475,6 +485,12 @@ bank-variable-symbol = Variabilný symbol
bank-amount = Suma
admin-shipping = Doprava
admin-shipping-desc = nastaviť cenu a dostupnosť jednotlivých možností dopravy.
shipping-packeta-missing-settings = Packeta sa dá zapnúť až po nastavení PACKETA_API_KEY, PACKETA_API_PASSWORD a PACKETA_SENDER_LABEL.
admin-payments = Platby
admin-payments-desc = zapnite alebo vypnite spôsoby platby a upravte údaje pre prevod na účet.
payment-methods = Spôsoby platby
payment-enabled = Aktívne
payment-bank-settings = Údaje pre prevod na účet
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.
@@ -500,3 +516,43 @@ 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-to-carrier = Odoslať dopravcovi
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 = Kde to vzniká
home-stores-photo = Naša výrobná prevádzka
home-stores-discover = Nahliadnite do výroby
page-stores-intro = Toto je naša vlastná prevádzka, kde vyrábame naše zdravotnícke pomôcky a potreby.
page-stores-facility = Výrobná prevádzka
page-stores-address-label = Adresa prevádzky
page-stores-address = Nádražná 328/62, 015 01 Rajec nad Rajčankou
page-stores-photo-caption = Naša výrobná prevádzka v Rajci nad Rajčankou
page-stores-map = Kde nás nájdete
page-stores-map-open = Otvoriť v Google Mapách
home-bestsellers = Najpredávanejšie
home-bestsellers-all = Všetko najpredávanejšie
home-contact-title = Kontaktujte nás
home-contact-text = Naša horúca linka je dostupná 24/7. Radi vám poradíme s výberom.
home-contact-cta = Kontaktujte hotline
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

BIN
assets/static/img/logo.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

BIN
assets/static/img/store.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -55,11 +55,22 @@
{% endif %}
<div class="rounded-radius border border-outline bg-surface p-6 text-sm dark:border-outline-dark dark:bg-surface-dark-alt">
<div class="space-y-4">
{% if order.residence_address %}
<div>
<h2 class="mb-2 font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-residence-address", lang=lang | default(value='sk')) }}</h2>
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.residence_address }}</p>
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.residence_zip }} {{ order.residence_city }}{% if order.residence_country %}, {{ order.residence_country }}{% endif %}</p>
</div>
{% endif %}
<div>
<h2 class="mb-2 font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</h2>
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.customer_name }}</p>
{% if order.address %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.address }}</p>{% endif %}
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.zip }} {{ order.city }}{% if order.country %}, {{ order.country }}{% endif %}</p>
</div>
</div>
</div>
{% if order.payment_method == "bank_transfer" and order.status == "pending" %}
<div class="space-y-2 rounded-radius border border-primary/40 bg-primary/5 p-6 text-sm dark:border-primary-dark/40">

View File

@@ -28,6 +28,45 @@
{{ ui::alert_danger(message=t(key="profile-company-required", lang=lang | default(value='sk')), extra="mt-4") }}
{% 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) -->
<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">
@@ -68,7 +107,7 @@
</fieldset>
<fieldset class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</legend>
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-residence-address", lang=lang | default(value='sk')) }}</legend>
{{ self::field(label=t(key="checkout-address", lang=lang | default(value='sk')), value=address) }}
<div class="grid gap-4 sm:grid-cols-3">
{{ self::field(label=t(key="checkout-city", lang=lang | default(value='sk')), value=city) }}
@@ -172,9 +211,9 @@
</div>
</fieldset>
<!-- default shipping address -->
<!-- residence address -->
<fieldset class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</legend>
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-residence-address", lang=lang | default(value='sk')) }}</legend>
<div class="space-y-1.5">
<label for="address" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-address", lang=lang | default(value='sk')) }}</label>
{{ ui::input(name="address", id="address", value=address | default(value=''), autocomplete="street-address") }}

View File

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

View File

@@ -69,6 +69,10 @@
{% if order.vat_id %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ t(key="company-icdph", lang=lang | default(value='sk')) }}: {{ order.vat_id }}</p>{% endif %}
</div>
{% endif %}
<div>
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-residence-address", lang=lang | default(value='sk')) }}</p>
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{% if order.residence_address %}{{ order.residence_address }}<br>{{ order.residence_zip }} {{ order.residence_city }}<br>{{ order.residence_country }}{% else %}{{ t(key="profile-not-set", lang=lang | default(value='sk')) }}{% endif %}</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</p>
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.address }}<br>{{ order.zip }} {{ order.city }}<br>{{ order.country }}</p>

View File

@@ -0,0 +1,47 @@
{% extends "admin/base.html" %}
{% import "macros/ui.html" as ui %}
{% block title %}{{ t(key="admin-payments", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}{{ t(key="admin-payments", 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-payments", lang=lang | default(value='sk')) }}</h1>
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-payments-desc", lang=lang | default(value='sk')) }}</p>
</header>
<section class="mt-6 space-y-4">
<h2 class="text-lg font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-methods", lang=lang | default(value='sk')) }}</h2>
{% for method in methods %}
<form method="post" action="/admin/payments/methods/{{ method.id }}"
class="flex flex-wrap items-center 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">{{ t(key=method.label_key, lang=lang | default(value='sk')) }}</p>
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ method.code }}</p>
</div>
<div class="pb-1">{{ ui::checkbox(name="enabled", label=t(key="payment-enabled", lang=lang | default(value='sk')), checked=method.enabled) }}</div>
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", extra="ml-auto") }}
</form>
{% endfor %}
</section>
<section class="mt-8 space-y-4">
<h2 class="text-lg font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-bank-settings", lang=lang | default(value='sk')) }}</h2>
<form method="post" action="/admin/payments/bank"
class="space-y-4 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
{{ ui::csrf_field() }}
<div class="space-y-1.5">
<label for="bank_account_name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="bank-account-name", lang=lang | default(value='sk')) }}</label>
{{ ui::input(name="bank_account_name", id="bank_account_name", value=bank_account_name) }}
</div>
<div class="space-y-1.5">
<label for="bank_iban" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">IBAN</label>
{{ ui::input(name="bank_iban", id="bank_iban", value=bank_iban) }}
</div>
<div class="flex justify-end">
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit") }}
</div>
</form>
</section>
{% endblock content %}

View File

@@ -18,12 +18,21 @@
<div class="min-w-40">
<p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ method.name }}</p>
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ method.carrier | upper }}{% if method.requires_pickup_point %} · {{ t(key="checkout-pickup-point", lang=lang | default(value='sk')) }}{% endif %}</p>
{% if method.packeta_not_ready %}
<p class="mt-1 text-xs text-warning">{{ t(key=method.lock_reason, lang=lang | default(value='sk')) }}</p>
{% endif %}
</div>
<div class="space-y-1.5">
<label for="price-{{ method.id }}" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
{{ ui::input(name="price", id="price-" ~ method.id, value=method.price, width="w-28", attrs='inputmode="decimal"') }}
</div>
<div class="pb-2">{{ ui::checkbox(name="enabled", label=t(key="shipping-enabled", lang=lang | default(value='sk')), checked=method.enabled) }}</div>
<div class="pb-2">
{% if method.locked %}
{{ ui::checkbox(name="enabled", label=t(key="shipping-enabled", lang=lang | default(value='sk')), checked=method.enabled, attrs='disabled') }}
{% else %}
{{ ui::checkbox(name="enabled", label=t(key="shipping-enabled", lang=lang | default(value='sk')), checked=method.enabled) }}
{% endif %}
</div>
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", extra="ml-auto") }}
</form>
{% endfor %}

View File

@@ -73,39 +73,54 @@
x-data="{ cats: false, lg: window.matchMedia('(min-width: 1024px)').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">
<!-- 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
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 class="mx-auto flex max-w-7xl items-center gap-3 px-4 py-3 sm:gap-4">
<!-- category sidebar toggle (mobile only) -->
{% 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) }}
<a href="/"
class="text-lg font-bold tracking-tight text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="brand", lang=lang | default(value='sk')) }}
<!-- real KOMPRESS logo from www.e-shop.kompress.sk (hidden on mobile;
the category drawer carries navigation there) -->
<a href="/" class="hidden shrink-0 items-center sm:flex">
<img src="/static/img/logo.jpg" alt="{{ t(key='brand', lang=lang | default(value='sk')) }}" width="260" height="52" class="h-8 w-auto dark:rounded-radius dark:bg-white dark:px-1.5 dark:py-0.5" />
</a>
<!-- desktop links — Penguin navbar link treatment via ui::nav_link -->
<ul class="ml-2 hidden items-center gap-6 md:flex">
<li>{{ ui::nav_link(label=t(key="nav-home", lang=lang | default(value='sk')), href="/", data_nav="/") }}</li>
<li>{{ ui::nav_link(label=t(key="nav-shop", lang=lang | default(value='sk')), href="/shop", data_nav="/shop") }}</li>
{% 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>
<li>
<form method="post" action="/logout" hx-boost="false">
{{ 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>
</form>
</li>
{% 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>
<!-- in-header search → existing GET /search (q param). Hidden on small
screens; the shop page keeps its compact mobile search row there. -->
<form action="/search" method="get" role="search" class="hidden min-w-0 flex-1 md:flex md:max-w-sm lg:max-w-md">
{% if selected_category and selected_category != "all" %}
<input type="hidden" name="category" value="{{ selected_category }}" />
{% endif %}
</ul>
<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" value="{{ query | default(value='') }}" 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-cta px-5 text-sm font-bold text-on-cta transition hover:opacity-90 dark:bg-cta-dark dark:text-on-cta-dark">{{ t(key="search-button", lang=lang | default(value='sk')) }}</button>
</div>
</form>
<!-- right side: kurz + cart + settings + mobile toggle -->
<div class="ml-auto flex items-center gap-3">
<!-- 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() %}
@@ -117,9 +132,26 @@
{% endfor %}
</div>
{% endif %}
<!-- customer profile dropdown (avatar + name + account type) -->
{% if logged_in_customer %}
<!-- account area: admin quick links / customer profile dropdown /
guest two-line "Vitajte · Prihláste sa" button (Kompress design) -->
{% if logged_in_admin %}
<div class="flex items-center gap-3">
{{ 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">
{{ 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>
</form>
</div>
{% elif logged_in_customer %}
{% include "partials/profile_menu.html" %}
{% else %}
<a href="/login" data-nav="/login" class="inline-flex items-center gap-2.5 rounded-radius px-2.5 py-1.5 text-on-surface transition hover:bg-surface-alt 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="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="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 %}
<!-- cart: hover opens an Alza-style mini-cart preview (Penguin
dropdown-with-hover), lazy-loaded from /partials/cart on each hover
@@ -138,10 +170,16 @@
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')) }}"
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">
{{ ui::icon(name="cart") }}
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">
<span class="relative inline-flex text-primary dark:text-primary-dark">
{{ ui::icon(name="cart", size="size-6") }}
<span x-show="count > 0" x-cloak x-text="count"
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>
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>
<!-- hover preview panel (no id on the panel → not htmx-settled on boosted nav) -->
<div x-cloak x-show="isOpen" x-transition
@@ -156,56 +194,39 @@
<!-- settings (language + theme) dropdown (self-contained Alpine state) -->
{% include "partials/settings_dropdown.html" %}
<!-- mobile hamburger — Penguin animated icon swap (bars ↔ X), kept in
our ghost-square icon-button shell for consistency with cart/gear -->
<button type="button" @click="mobile = !mobile" :aria-expanded="mobile" aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}"
class="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 md:hidden dark:text-secondary-dark dark:focus-visible:outline-secondary-dark">
{{ ui::icon(name="hamburger", size="size-6", attrs='x-show="!mobile"') }}
{{ ui::icon(name="close", size="size-6", attrs='x-cloak x-show="mobile"') }}
</button>
</div>
<!-- mobile menu panel — Penguin sidebar-style menu rows (hover:bg-primary/5,
underline focus), active state via data-nav + markActiveNav() -->
<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">
<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>
{% if logged_in_admin %}
<li><a href="/admin/dashboard" hx-boost="false" data-nav="/admin" class="block rounded-radius px-3 py-2 text-sm font-medium text-warning underline-offset-2 transition hover:bg-primary/5 focus:outline-hidden focus-visible:underline">{{ t(key="admin-title", lang=lang | default(value='sk')) }}</a></li>
<li>
<form method="post" action="/logout" hx-boost="false">
{{ ui::csrf_field() }}
<button type="submit" class="block w-full rounded-radius px-3 py-2 text-left text-sm font-medium text-danger underline-offset-2 transition hover:bg-primary/5 focus:outline-hidden focus-visible:underline">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
</form>
</li>
{% elif logged_in_customer %}
<li><a href="/account/profile" data-nav="/account" 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-profile", lang=lang | default(value='sk')) }}</a></li>
<li>
<form method="post" action="/logout" hx-boost="false">
{{ ui::csrf_field() }}
<button type="submit" class="block w-full rounded-radius px-3 py-2 text-left text-sm font-medium text-danger underline-offset-2 transition hover:bg-primary/5 focus:outline-hidden focus-visible:underline">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
</form>
</li>
{% else %}
<li><a href="/login" data-nav="/login" 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-login", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/register" data-nav="/register" 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-register", lang=lang | default(value='sk')) }}</a></li>
{% endif %}
</ul>
</nav>
{% set mobile_search_category = selected_category | default(value="") %}
{% if on_home | default(value=false) or mobile_search_category %}
<form action="/search" method="get" role="search" class="px-4 pb-3 md:hidden">
{% if mobile_search_category and mobile_search_category != "all" %}
<input type="hidden" name="category" value="{{ mobile_search_category }}" />
{% endif %}
<div class="flex min-w-0 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" value="{{ query | default(value='') }}" 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-cta px-4 text-sm font-bold text-on-cta transition hover:opacity-90 dark:bg-cta-dark dark:text-on-cta-dark">{{ t(key="search-button", lang=lang | default(value='sk')) }}</button>
</div>
</form>
{% endif %}
</header>
<!-- dark overlay behind the category drawer on small screens -->
<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>
<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 %}
<!-- account-area sidebar: replaces the storefront categories while the
customer is inside /account/*. -->
<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>
<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>
@@ -225,7 +246,7 @@
<aside id="category-sidebar" hx-preserve="true"
x-cloak x-show="cats || lg" aria-label="{{ t(key='categories', lang=lang | default(value='sk')) }}"
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>
{% endif %}
@@ -233,6 +254,41 @@
{% block content %}{% endblock content %}
</main>
</div>
</div>
<!-- site footer (Kompress design): 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-1 gap-8 px-4 py-10 sm:w-fit sm:grid-cols-3 sm:gap-x-32 md:gap-x-36 md:px-8 lg:gap-x-40">
<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').
Adapted from the vendored Penguin UI component

View File

@@ -3,28 +3,99 @@
{% block title %}{{ t(key="brand", lang=lang | default(value='sk')) }}{% endblock title %}
{% block breadcrumbs %}
<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_current(label=t(key="nav-home", lang=lang | default(value='sk'))) }}
</ol>
</nav>
{% endblock breadcrumbs %}
{% block content %}
<div class="space-y-12">
<!-- hero -->
<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">
<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>
<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>
<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>
{% set L = lang | default(value='sk') %}
{# Home layout adapted from the Kompress design mockup: the left "Kategórie"
column is already supplied by base.html's #category-sidebar, so the main
area is split into a featured product grid + a right rail (bestsellers /
our stores / contact). All colors use the design tokens so light + dark
both work; the brand accent is the medical blue set in app.css. #}
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_19rem] lg:grid-rows-[auto_1fr] lg:items-start">
<!-- bestsellers (reuses the featured products). DOM-first so it stacks above
the product grid on mobile; placed in the right rail's top cell on lg. -->
{% if products | length > 0 %}
<section class="overflow-hidden rounded-radius border border-outline bg-surface-alt lg:col-start-2 lg:row-start-1 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">{{ t(key="home-bestsellers", lang=L) }}</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-on-surface-strong dark:text-on-surface-dark-strong">{% 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">{{ t(key="home-bestsellers-all", lang=L) }}</a>
</section>
{% endif %}
<!-- center column -->
<div class="flex min-w-0 flex-col gap-6 lg:col-start-1 lg:row-span-2 lg:row-start-1">
<!-- hero / heading -->
<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>
<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-5">
<section class="space-y-4">
<div class="flex items-end justify-between">
<h2 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shop-title", lang=lang | default(value='sk')) }}</h2>
<a href="/shop" class="text-sm font-medium text-primary dark:text-primary-dark">{{ t(key="cart-continue", lang=lang | default(value='sk')) }} →</a>
<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-5 sm:grid-cols-3 lg:grid-cols-4">
<div x-data="{ view: localStorage.getItem('shopView') === 'grid' ? 'grid' : 'list' }"
x-init="$watch('view', v => localStorage.setItem('shopView', v))"
{# Fixed-width cards (14rem), identical to the shop. Cards never stretch;
the column just fits as many as it can (home fewer, shop more), so a
card is the exact same width on both pages regardless of column count. #}
:class="view === 'list' ? 'flex flex-col gap-5' : 'grid grid-cols-2 gap-5 sm:grid-cols-[repeat(auto-fill,14rem)] sm:justify-center'">
{% 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 lg:col-start-2 lg:row-start-2">
<!-- 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">{{ t(key="footer-stores", lang=L) }}</h2>
<div class="p-3.5">
<img src="/static/img/store.jpg" alt="{{ t(key='home-stores-photo', lang=L) }}" width="142" height="115" loading="lazy"
class="h-28 w-full rounded-radius border border-outline object-cover dark:border-outline-dark" />
<a href="/predajne" class="mt-3 inline-block text-sm font-bold text-primary transition hover:underline dark:text-primary-dark">{{ t(key="home-stores-discover", lang=L) }}</a>
</div>
</section>
</aside>
</div>
{% endblock content %}

View File

@@ -36,7 +36,7 @@
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
{%- endmacro %}
{% macro button(label, variant="primary", type="button", href="", attrs="", extra="", icon="", size="px-4 py-2 text-sm") -%}
{% macro button(label, variant="primary", type="button", href="", attrs="", extra="", icon="", size="px-4 py-2 text-sm", nowrap=true) -%}
{%- if variant == "secondary" -%}{% set cls = "border border-secondary bg-secondary text-on-secondary focus-visible:outline-secondary dark:border-secondary-dark dark:bg-secondary-dark dark:text-on-secondary-dark dark:focus-visible:outline-secondary-dark" -%}
{%- elif variant == "danger" -%}{% set cls = "border border-danger bg-danger text-on-danger focus-visible:outline-danger dark:bg-danger dark:border-danger dark:text-on-danger dark:focus-visible:outline-danger" -%}
{%- elif variant == "success" -%}{% set cls = "border border-success bg-success text-on-success focus-visible:outline-success dark:bg-success dark:border-success dark:text-on-success dark:focus-visible:outline-success" -%}
@@ -49,9 +49,9 @@
{%- elif variant == "ghost-primary" -%}{% set cls = "bg-transparent text-primary focus-visible:outline-primary dark:text-primary-dark dark:focus-visible:outline-primary-dark" -%}
{%- elif variant == "ghost-secondary" -%}{% set cls = "bg-transparent text-secondary focus-visible:outline-secondary dark:text-secondary-dark dark:focus-visible:outline-secondary-dark" -%}
{%- elif variant == "ghost-danger" -%}{% set cls = "bg-transparent text-danger focus-visible:outline-danger dark:text-danger dark:focus-visible:outline-danger" -%}
{%- else -%}{% set cls = "border border-primary bg-primary text-on-primary focus-visible:outline-primary dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark dark:focus-visible:outline-primary-dark" -%}
{%- else -%}{% set cls = "border border-cta bg-cta text-on-cta focus-visible:outline-cta dark:border-cta-dark dark:bg-cta-dark dark:text-on-cta-dark dark:focus-visible:outline-cta-dark" -%}
{%- endif -%}
{% if href %}<a href="{{ href }}"{% else %}<button type="{{ type }}"{% endif %} class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-radius {{ size }} text-center font-medium tracking-wide transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75 {{ cls }} {{ extra }}" {{ attrs | safe }}>{{ icon | safe }}{{ label }}</{% if href %}a{% else %}button{% endif %}>
{% if nowrap %}{% set wrap = "whitespace-nowrap" %}{% else %}{% set wrap = "text-balance" %}{% endif %}{% if href %}<a href="{{ href }}"{% else %}<button type="{{ type }}"{% endif %} class="inline-flex items-center justify-center gap-2 {{ wrap }} rounded-radius {{ size }} text-center font-medium tracking-wide transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75 {{ cls }} {{ extra }}" {{ attrs | safe }}>{{ icon | safe }}{{ label }}</{% if href %}a{% else %}button{% endif %}>
{%- endmacro button %}
{# Icon-only button (square). Penguin ghost treatment (bg-transparent,
@@ -267,3 +267,42 @@ border-t border-outline dark:border-outline-dark
{%- 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>
{%- 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,102 @@
{% 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>
{% elif page == "stores" %}
{# Production facility (not a retail store): intro, Google map on top, then a
small facility photo next to the address card. #}
<p class="text-lg text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="page-stores-intro", lang=L) }}</p>
{# Google Map of the production facility. Embedded via the keyless Maps embed
(centered on the geocoded coords); a static PNG would need a Maps Static
API key. The header links out to the full map. #}
<section class="overflow-hidden rounded-radius border border-outline bg-surface-alt dark:border-outline-dark dark:bg-surface-dark-alt">
<div class="flex items-center justify-between gap-3 border-b border-outline px-4 py-3 dark:border-outline-dark">
<h2 class="text-sm font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="page-stores-map", lang=L) }}</h2>
<a href="https://www.google.com/maps/search/?api=1&query=49.092412,18.643697" target="_blank" rel="noopener"
class="text-sm font-semibold text-primary transition hover:underline dark:text-primary-dark">{{ t(key="page-stores-map-open", lang=L) }}</a>
</div>
<iframe title="{{ t(key='page-stores-map', lang=L) }}" loading="lazy" class="block h-72 w-full border-0 sm:h-96"
referrerpolicy="no-referrer-when-downgrade"
src="https://maps.google.com/maps?q=49.092412,18.643697&z=15&hl={{ L }}&output=embed"></iframe>
</section>
{# Small facility photo next to the address. #}
<div class="flex flex-col gap-4 sm:flex-row sm:items-stretch">
<figure class="shrink-0 overflow-hidden rounded-radius border border-outline bg-surface-alt dark:border-outline-dark dark:bg-surface-dark-alt sm:w-48">
<img src="/static/img/store.jpg" alt="{{ t(key='home-stores-photo', lang=L) }}" width="142" height="115" loading="lazy"
class="h-32 w-full object-cover sm:h-full" />
</figure>
<div class="flex-1 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="page-stores-address-label", lang=L) }}</div>
<div class="mt-1 font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="page-stores-facility", lang=L) }}</div>
<address class="mt-1 not-italic text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="page-stores-address", lang=L) }}</address>
</div>
</div>
{% 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"
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">
{%- 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>
<!-- Dropdown Menu (positioned like the settings cog: right-0 mt-2) -->
<div x-cloak x-show="isOpen || openedWithKeyboard" x-transition x-trap="openedWithKeyboard"
@@ -40,7 +40,7 @@
<!-- header: avatar + name + account type -->
<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">
{%- 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>
<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>

View File

@@ -12,8 +12,8 @@
for why — htmx hx-boost settles by id). #}
<div x-data="{ isOpen: false, openedWithKeyboard: false }"
x-on:keydown.esc.window="isOpen = false, openedWithKeyboard = false"
class="relative">
{{ ui::icon_button(aria_label=t(key='settings', lang=lang | default(value='sk')), attrs='x-on:click="isOpen = ! isOpen" x-on:keydown.space.prevent="openedWithKeyboard = true" x-on:keydown.enter.prevent="openedWithKeyboard = true" x-on:keydown.down.prevent="openedWithKeyboard = true" x-bind:aria-expanded="isOpen || openedWithKeyboard" aria-haspopup="true"', icon='<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>') }}
class="relative self-stretch">
{{ ui::icon_button(size="h-full w-9", aria_label=t(key='settings', lang=lang | default(value='sk')), attrs='x-on:click="isOpen = ! isOpen" x-on:keydown.space.prevent="openedWithKeyboard = true" x-on:keydown.enter.prevent="openedWithKeyboard = true" x-on:keydown.down.prevent="openedWithKeyboard = true" x-bind:aria-expanded="isOpen || openedWithKeyboard" aria-haspopup="true"', icon='<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>') }}
<div x-cloak x-show="isOpen || openedWithKeyboard" x-transition x-trap="openedWithKeyboard"
x-on:click.outside="isOpen = false, openedWithKeyboard = false"
x-on:keydown.down.prevent="$focus.wrap().next()" x-on:keydown.up.prevent="$focus.wrap().previous()"

View File

@@ -12,19 +12,22 @@
that state (e.g. home) `view` is undefined, so the grid layout applies. #}
<article
class="group flex overflow-hidden rounded-radius border border-outline bg-surface-alt text-on-surface transition hover:border-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:border-primary-dark"
:class="view === 'list' ? 'flex-row flex-wrap' : 'flex-col'">
:class="view === 'list' ? 'flex-col sm:flex-row' : 'flex-col'">
<a href="/shop/{{ product.slug }}" class="flex min-w-0 flex-1"
:class="view === 'list' ? 'flex-row' : 'flex-col'">
<!-- Image -->
<div class="overflow-hidden bg-surface-alt dark:bg-surface-dark"
:class="view === 'list' ? 'size-28 shrink-0 sm:size-40' : 'h-44 md:h-64'">
<div class="relative overflow-hidden bg-surface-alt dark:bg-surface-dark"
:class="view === 'list' ? 'w-28 shrink-0 self-stretch min-h-36 sm:w-48' : 'aspect-[5/4]'">
{% 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 %}
<img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover transition duration-700 ease-out group-hover:scale-105">
{% endif %}
</div>
<!-- Content -->
<div class="flex min-w-0 flex-1 flex-col gap-1"
:class="view === 'list' ? 'p-4 sm:p-5' : 'p-6 pb-2'">
:class="view === 'list' ? 'justify-center p-4 sm:p-5' : 'px-4 pt-3 pb-1'">
<!-- Header: Title & Price (stacked so neither overflows the narrow card) -->
<h3 class="break-words text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
{# Short blurb for the card; falls back to the full description (clamped)
@@ -44,13 +47,25 @@
{% else %}
<span class="break-words text-xl"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ currency_symbol }}</span>
{% endif %}
<!-- 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>
</a>
<div class="flex flex-col gap-2"
:class="view === 'list' ? 'w-full justify-center p-4 sm:w-56 sm:p-5' : 'p-6 pt-0'">
:class="view === 'list' ? 'w-full justify-center border-t border-outline p-4 sm:w-48 sm:self-stretch sm:border-l sm:border-t-0 sm:p-5 dark:border-outline-dark' : 'px-4 pb-4 pt-0'">
{% if product.has_options %}
{# Multiple variants: customer must pick on the product page. #}
{{ ui::button(label=t(key="choose-option", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, extra="w-full") }}
{{ ui::button(label=t(key="choose-option", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, extra="w-full", nowrap=false) }}
{% elif product.in_stock %}
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{% if product.tracked %}{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}{% else %}{{ t(key="available", lang=lang | default(value='sk')) }}{% endif %}</p>
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none"
@@ -58,7 +73,7 @@
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="variant_id" value="{{ product.variant_id }}">
<input type="hidden" name="quantity" value="1">
{{ ui::button(label=t(key="add-to-cart", lang=lang | default(value='sk')), type="submit", extra="w-full", icon='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" class="size-3.5"><path fill-rule="evenodd" d="M5 4a3 3 0 0 1 6 0v1h.643a1.5 1.5 0 0 1 1.492 1.35l.7 7A1.5 1.5 0 0 1 12.342 15H3.657a1.5 1.5 0 0 1-1.492-1.65l.7-7A1.5 1.5 0 0 1 4.357 5H5V4Zm4.5 0v1h-3V4a1.5 1.5 0 0 1 3 0Zm-3 3.75a.75.75 0 0 0-1.5 0v1a3 3 0 1 0 6 0v-1a.75.75 0 0 0-1.5 0v1a1.5 1.5 0 1 1-3 0v-1Z" clip-rule="evenodd" /></svg>') }}
{{ ui::button(label=t(key="add-to-cart", lang=lang | default(value='sk')), type="submit", extra="w-full", nowrap=false, icon='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" class="size-3.5 shrink-0"><path fill-rule="evenodd" d="M5 4a3 3 0 0 1 6 0v1h.643a1.5 1.5 0 0 1 1.492 1.35l.7 7A1.5 1.5 0 0 1 12.342 15H3.657a1.5 1.5 0 0 1-1.492-1.65l.7-7A1.5 1.5 0 0 1 4.357 5H5V4Zm4.5 0v1h-3V4a1.5 1.5 0 0 1 3 0Zm-3 3.75a.75.75 0 0 0-1.5 0v1a3 3 0 1 0 6 0v-1a.75.75 0 0 0-1.5 0v1a1.5 1.5 0 1 1-3 0v-1Z" clip-rule="evenodd" /></svg>') }}
</form>
{% else %}
<p class="inline-flex justify-center rounded-radius bg-danger/10 px-3 py-2 text-xs font-medium text-danger">{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}</p>

View File

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

View File

@@ -2,10 +2,22 @@
server-side on first load. Holds the result summary, the product grid and
pagination. #}
{% 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="flex flex-wrap items-center justify-between gap-2">
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70" aria-live="polite">
{{ t(key="results-count", lang=L, count=total) }}{% if query and query != "" %} · “{{ query }}”{% endif %}
</p>
{% if 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 %}
{% include "shop/_product_grid.html" %}

View File

@@ -1,22 +1,33 @@
{# Shared storefront search + filter toolbar and results region, used by the shop
index and every category page. One form drives the whole listing: htmx re-runs
/search and swaps only #shop-results; the toolbar keeps its own DOM state.
Triggers: live (debounced) typing in the search box, immediate on any
select/checkbox change, and submit (Enter / Apply) for the price band. Degrades
to a plain GET form without JS.
Expects: query, category_groups, selected_category, selected_category_id,
uncategorized_count, sort, min_price, max_price, price_floor, price_ceil,
in_stock, plus the result vars consumed by _results.html. #}
{# Shared storefront search box + results region, used by the shop index and
every category page. One form drives the listing: htmx re-runs /search and
swaps only #shop-results; the toolbar keeps its own DOM state. Triggers: live
(debounced) typing in the search box, immediate on a sort change, and submit
(Enter). Degrades to a plain GET form without JS.
Category is chosen from the sidebar (carried here as a hidden field so it
survives a search / re-sort). The grid/list view toggle lives next to sort;
its `view` state is held in Alpine on this wrapper so both the toggle and the
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') %}
<div class="space-y-6">
<div x-data="{ view: localStorage.getItem('shopView') === 'grid' ? 'grid' : 'list' }"
x-init="$watch('view', v => localStorage.setItem('shopView', v))"
class="space-y-6">
<form action="/search" method="get" role="search"
hx-get="/search" hx-target="#shop-results" hx-swap="innerHTML"
hx-push-url="true" hx-indicator="#search-spinner"
hx-trigger="submit, change, keyup changed delay:350ms from:input[name='q']"
{# 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">
{# 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 -->
<div class="relative max-w-xl">
<div class="hidden max-w-xl gap-2">
<div class="relative flex-1">
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-on-surface/50 dark:text-on-surface-dark/50">
{{ ui::icon(name="search", size="size-5") }}
</span>
@@ -31,67 +42,86 @@
</svg>
</span>
</div>
<button type="submit" class="shrink-0 rounded-radius bg-cta px-5 text-sm font-bold text-on-cta transition hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cta dark:bg-cta-dark dark:text-on-cta-dark dark:focus-visible:outline-cta-dark">
{{ t(key="search-button", lang=L) }}
</button>
</div>
<!-- filter toolbar -->
<div class="flex flex-wrap items-end gap-3 rounded-radius border border-outline bg-surface-alt p-3 dark:border-outline-dark dark:bg-surface-dark-alt">
<!-- category -->
<label class="flex flex-col gap-1 text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70">
{{ t(key="filter-category", lang=L) }}
<select name="category"
class="rounded-radius border border-outline bg-surface px-2 py-1.5 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
<option value="all"{% if selected_category == "all" %} selected{% endif %}>{{ t(key="filter-all-categories", lang=L) }}</option>
{% for g in category_groups %}
<option value="{{ g.id }}"{% if selected_category_id == g.id %} selected{% endif %}>{{ g.name }} ({{ g.count }})</option>
{% for ch in g.children %}
<option value="{{ ch.id }}"{% if selected_category_id == ch.id %} selected{% endif %}>&nbsp;&nbsp;— {{ ch.name }} ({{ ch.count }})</option>
{% endfor %}
{% endfor %}
{% if uncategorized_count > 0 %}
<option value="none"{% if selected_category == "none" %} selected{% endif %}>{{ t(key="filter-uncategorized", lang=L) }} ({{ uncategorized_count }})</option>
{% endif %}
</select>
</label>
<!-- sort -->
<label class="flex flex-col gap-1 text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70">
{{ t(key="sort-label", lang=L) }}
<select name="sort"
class="rounded-radius border border-outline bg-surface px-2 py-1.5 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
{% for opt in ["newest", "relevance", "price_asc", "price_desc", "name_asc", "name_desc"] %}
<option value="{{ opt }}"{% if sort == opt %} selected{% endif %}>{{ t(key="sort-" ~ opt, lang=L) }}</option>
{% endfor %}
</select>
</label>
<!-- price band -->
<label class="flex flex-col gap-1 text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70">
{{ t(key="filter-price", lang=L) }}{% if currency_symbol %} ({{ currency_symbol }}){% endif %}
<span class="flex items-center gap-1">
<input type="number" name="min_price" min="0" step="0.01" inputmode="decimal"
value="{{ min_price | default(value='') }}" placeholder="{{ price_floor }}"
aria-label="{{ t(key='filter-price-from', lang=L) }}"
class="w-24 rounded-radius border border-outline bg-surface px-2 py-1.5 text-sm text-on-surface dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark" />
<span class="text-on-surface/50 dark:text-on-surface-dark/50"></span>
<input type="number" name="max_price" min="0" step="0.01" inputmode="decimal"
value="{{ max_price | default(value='') }}" placeholder="{{ price_ceil }}"
aria-label="{{ t(key='filter-price-to', lang=L) }}"
class="w-24 rounded-radius border border-outline bg-surface px-2 py-1.5 text-sm text-on-surface dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark" />
{# Scope indicator: when a category is active, make clear the search is
limited to it (not the whole shop), with a one-click escape to search
everything. Category only changes via full navigation (the sidebar), so
this stays accurate across the toolbar's results-only htmx swaps. #}
{% if selected_category and selected_category != "all" %}
{# set_global so the value survives the nested if (a plain `set` inside a
block is scoped to that block in Tera and wouldn't be visible below). #}
{% set_global _scope = selected_category_name | default(value="") %}
{% if selected_category == "none" %}{% set_global _scope = t(key="uncategorized", lang=L) %}{% endif %}
{% if _scope %}
<div class="flex max-w-xl flex-wrap items-center gap-2 text-xs">
<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">
{{ ui::icon(name="search", size="size-3.5", extra="shrink-0") }}
{{ t(key="search-scope-in", lang=L) }} <span class="font-semibold">{{ _scope }}</span>
</span>
{# This link descends from the search form, so it inherits its
hx-target="#shop-results" / hx-swap="innerHTML". Switching scope is a
real navigation (new breadcrumb, sidebar state, full-page response),
so override the inherited target back to the body — otherwise the
boosted full page gets nested inside the results region. #}
<a href="/search{% if query %}?q={{ query | urlencode }}{% endif %}"
hx-target="body" hx-swap="innerHTML"
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">
{{ t(key="search-scope-all", lang=L) }}
</a>
</div>
{% endif %}
{% endif %}
<!-- sort + product card style switch -->
<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) }}
{% include "shop/_sort_select.html" %}
</label>
<!-- in stock -->
<label class="flex items-center gap-2 pb-1.5 text-sm text-on-surface dark:text-on-surface-dark">
<!-- 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">
{% for opt in per_page_options %}
<option value="{{ opt }}"{% if per_page == opt %} selected{% endif %}>{{ opt }}</option>
{% endfor %}
</select>
</label>
<!-- in stock only -->
<label class="flex items-center gap-2 text-sm text-on-surface dark:text-on-surface-dark">
<input type="checkbox" name="in_stock" value="1"{% if in_stock %} checked{% endif %}
class="size-4 rounded border-outline text-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:text-primary-dark" />
{{ t(key="filter-in-stock", lang=L) }}
</label>
<div class="ml-auto flex items-end gap-2">
{{ ui::button(label=t(key="filter-apply", lang=L), type="submit", variant="secondary") }}
<a href="/shop" hx-get="/search" hx-target="#shop-results" hx-push-url="true"
class="self-end pb-1.5 text-sm font-medium text-on-surface/70 underline-offset-2 transition hover:text-primary hover:underline dark:text-on-surface-dark/70 dark:hover:text-primary-dark">
{{ t(key="filter-clear", lang=L) }}
</a>
<!-- grid / list view toggle -->
<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=L) }} / {{ t(key='view-list', lang=L) }}">
<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=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>
</form>

View File

@@ -16,8 +16,13 @@
{{ t(key="categories", lang=lang | default(value='sk')) }}
</p>
<div class="flex flex-col gap-1">
{# mobile-only Home link: the navbar logo (the Home affordance) is hidden on
small screens, so navigation home lives here in the drawer instead. #}
<a href="/" data-nav="/" 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-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm lg:hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark">
{{ t(key="nav-home", lang=lang | default(value='sk')) }}
</a>
<a href="/shop" data-nav="/shop"
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-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark">
{{ t(key="all-products", lang=lang | default(value='sk')) }}
</a>
{% for group in category_groups %}
@@ -26,7 +31,7 @@
x-init="open = ['{{ group.slug }}'{% for child in group.children %}, '{{ child.slug }}'{% endfor %}].some(s => location.pathname === '/category/' + s)">
<div class="flex items-stretch">
<a href="/category/{{ group.slug }}" data-nav="/category/{{ group.slug }}"
class="flex flex-1 items-center gap-2 truncate rounded-l-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 flex-1 items-center gap-2 truncate rounded-l-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-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark">
{{ group.name }}
</a>
<button type="button" x-on:click="open = ! open" x-bind:aria-expanded="open ? 'true' : 'false'"
@@ -42,7 +47,7 @@
{% for child in group.children %}
<li>
<a href="/category/{{ child.slug }}" data-nav="/category/{{ child.slug }}"
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 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 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 aria-[current=page]:bg-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark">
{{ child.name }}
</a>
</li>
@@ -51,7 +56,7 @@
</div>
{% else %}
<a href="/category/{{ group.slug }}" data-nav="/category/{{ group.slug }}"
class="flex items-center gap-2 truncate 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 truncate 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-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark">
{{ group.name }}
</a>
{% endif %}
@@ -60,3 +65,18 @@
{% 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>
{% 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" data-nav="/obchodne-podmienky" class="flex items-center gap-2 truncate 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-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark">{{ t(key="footer-terms", lang=L) }}</a>
<a href="/predajne" data-nav="/predajne" class="flex items-center gap-2 truncate 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-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark">{{ t(key="footer-stores", lang=L) }}</a>
<a href="/doprava-a-platba" data-nav="/doprava-a-platba" class="flex items-center gap-2 truncate 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-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark">{{ 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 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 %}
{% set L = lang | default(value='sk') %}
<div class="space-y-6">
<header class="space-y-2">
<nav class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">
<a href="/shop" class="hover:text-primary dark:hover:text-primary-dark">{{ t(key="nav-shop", lang=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>
{% if category.description %}<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ category.description }}</p>{% endif %}

View File

@@ -12,6 +12,7 @@
x-data="{
paymentMethod: '',
accountType: '{{ prefill_account_type | default(value='personal') }}',
deliverySame: false,
carrier: '',
carrierPrice: 0,
requiresPoint: false,
@@ -128,26 +129,26 @@
</div>
</fieldset>
<!-- shipping address -->
<!-- residence address -->
<fieldset class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</legend>
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-residence-address", lang=lang | default(value='sk')) }}</legend>
<div class="space-y-1.5">
<label for="address" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-address", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
{{ ui::input(name="address", id="address", value=prefill_address | default(value=''), required=true, autocomplete="street-address") }}
<label for="residence_address" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-address", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
{{ ui::input(name="residence_address", id="residence_address", value=prefill_residence_address | default(value=''), required=true, autocomplete="billing street-address") }}
</div>
<div class="grid gap-4 sm:grid-cols-3">
<div class="space-y-1.5">
<label for="city" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-city", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
{{ ui::input(name="city", id="city", value=prefill_city | default(value=''), required=true, autocomplete="address-level2") }}
<label for="residence_city" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-city", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
{{ ui::input(name="residence_city", id="residence_city", value=prefill_residence_city | default(value=''), required=true, autocomplete="billing address-level2") }}
</div>
<div class="space-y-1.5">
<label for="zip" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-zip", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
{{ ui::input(name="zip", id="zip", value=prefill_zip | default(value=''), required=true, autocomplete="postal-code") }}
<label for="residence_zip" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-zip", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
{{ ui::input(name="residence_zip", id="residence_zip", value=prefill_residence_zip | default(value=''), required=true, autocomplete="billing postal-code") }}
</div>
<div class="space-y-1.5">
<label for="country" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-country", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
<label for="residence_country" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-country", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
<div class="relative" @click.outside="countryOpen = false"
x-data="{ countryOpen: false, country: '{{ prefill_country | default(value=t(key='country-sk', lang=lang | default(value='sk'))) }}', opts: [
x-data="{ countryOpen: false, country: '{{ prefill_residence_country | default(value=t(key='country-sk', lang=lang | default(value='sk'))) }}', opts: [
{ v: '{{ t(key='country-sk', lang=lang | default(value='sk')) }}', l: '🇸🇰 {{ t(key='country-sk', lang=lang | default(value='sk')) }}' },
{ v: '{{ t(key='country-cz', lang=lang | default(value='sk')) }}', l: '🇨🇿 {{ t(key='country-cz', lang=lang | default(value='sk')) }}' },
{ v: '{{ t(key='country-at', lang=lang | default(value='sk')) }}', l: '🇦🇹 {{ t(key='country-at', lang=lang | default(value='sk')) }}' },
@@ -155,7 +156,57 @@
{ v: '{{ t(key='country-pl', lang=lang | default(value='sk')) }}', l: '🇵🇱 {{ t(key='country-pl', lang=lang | default(value='sk')) }}' },
{ v: '{{ t(key='country-hu', lang=lang | default(value='sk')) }}', l: '🇭🇺 {{ t(key='country-hu', lang=lang | default(value='sk')) }}' }
], get filtered() { return this.opts.filter(o => !this.country || o.v.toLowerCase().includes(this.country.toLowerCase())) } }">
<input id="country" name="country" type="text" x-model="country" required @focus="countryOpen = true" @input="countryOpen = true"
<input id="residence_country" name="residence_country" type="text" x-model="country" required @focus="countryOpen = true" @input="countryOpen = true"
class="w-full rounded-radius border border-outline bg-surface py-2 pl-3 pr-8 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
<button type="button" tabindex="-1" @click="countryOpen = !countryOpen"
class="absolute inset-y-0 right-0 flex w-8 items-center justify-center text-on-surface/60 dark:text-on-surface-dark/60">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"
class="size-4 transition-transform" :class="countryOpen && 'rotate-180'">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</button>
<ul x-show="countryOpen" x-cloak x-transition
class="absolute z-20 mt-1 max-h-56 w-full overflow-auto rounded-radius border border-outline bg-surface p-1 shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt">
<template x-for="o in filtered" :key="o.v">
<li><button type="button" @click="country = o.v; countryOpen = false" x-text="o.l"
class="block w-full rounded-radius px-3 py-1.5 text-left text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark"></button></li>
</template>
</ul>
</div>
</div>
</div>
</fieldset>
{{ ui::checkbox(name="delivery_same_as_residence", id="delivery_same_as_residence", label=t(key="checkout-delivery-same", lang=lang | default(value='sk')), attrs='x-model="deliverySame"') }}
<!-- delivery address -->
<fieldset x-show="!deliverySame" x-cloak class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</legend>
<div class="space-y-1.5">
<label for="address" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-address", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
{{ ui::input(name="address", id="address", autocomplete="shipping street-address", attrs=':required="!deliverySame"') }}
</div>
<div class="grid gap-4 sm:grid-cols-3">
<div class="space-y-1.5">
<label for="city" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-city", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
{{ ui::input(name="city", id="city", autocomplete="shipping address-level2", attrs=':required="!deliverySame"') }}
</div>
<div class="space-y-1.5">
<label for="zip" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-zip", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
{{ ui::input(name="zip", id="zip", autocomplete="shipping postal-code", attrs=':required="!deliverySame"') }}
</div>
<div class="space-y-1.5">
<label for="country" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-country", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
<div class="relative" @click.outside="countryOpen = false"
x-data="{ countryOpen: false, country: '{{ t(key='country-sk', lang=lang | default(value='sk')) }}', opts: [
{ v: '{{ t(key='country-sk', lang=lang | default(value='sk')) }}', l: '🇸🇰 {{ t(key='country-sk', lang=lang | default(value='sk')) }}' },
{ v: '{{ t(key='country-cz', lang=lang | default(value='sk')) }}', l: '🇨🇿 {{ t(key='country-cz', lang=lang | default(value='sk')) }}' },
{ v: '{{ t(key='country-at', lang=lang | default(value='sk')) }}', l: '🇦🇹 {{ t(key='country-at', lang=lang | default(value='sk')) }}' },
{ v: '{{ t(key='country-de', lang=lang | default(value='sk')) }}', l: '🇩🇪 {{ t(key='country-de', lang=lang | default(value='sk')) }}' },
{ v: '{{ t(key='country-pl', lang=lang | default(value='sk')) }}', l: '🇵🇱 {{ t(key='country-pl', lang=lang | default(value='sk')) }}' },
{ v: '{{ t(key='country-hu', lang=lang | default(value='sk')) }}', l: '🇭🇺 {{ t(key='country-hu', lang=lang | default(value='sk')) }}' }
], get filtered() { return this.opts.filter(o => !this.country || o.v.toLowerCase().includes(this.country.toLowerCase())) } }">
<input id="country" name="country" type="text" x-model="country" :required="!deliverySame" @focus="countryOpen = true" @input="countryOpen = true"
class="w-full rounded-radius border border-outline bg-surface py-2 pl-3 pr-8 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
<button type="button" tabindex="-1" @click="countryOpen = !countryOpen"
class="absolute inset-y-0 right-0 flex w-8 items-center justify-center text-on-surface/60 dark:text-on-surface-dark/60">
@@ -215,14 +266,16 @@
<!-- payment -->
<fieldset class="space-y-3 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-payment", lang=lang | default(value='sk')) }}{{ ui::req() }}</legend>
{% if payment_methods | length > 0 %}
{% for method in payment_methods %}
<label class="flex cursor-pointer items-center gap-3 rounded-radius border border-outline px-4 py-3 transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark">
{{ ui::radio(name="payment_method", value="cod", attrs='required x-model="paymentMethod"') }}
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-cod", lang=lang | default(value='sk')) }}</span>
</label>
<label class="flex cursor-pointer items-center gap-3 rounded-radius border border-outline px-4 py-3 transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark">
{{ ui::radio(name="payment_method", value="bank_transfer", attrs='required x-model="paymentMethod"') }}
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-bank", lang=lang | default(value='sk')) }}</span>
{{ ui::radio(name="payment_method", value=method.code, attrs='required x-model="paymentMethod"') }}
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key=method.label_key, lang=lang | default(value='sk')) }}</span>
</label>
{% endfor %}
{% else %}
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="payment-none", lang=lang | default(value='sk')) }}</p>
{% endif %}
</fieldset>
<div class="space-y-1.5">

View File

@@ -3,6 +3,16 @@
{% 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 %}
{% set L = lang | default(value='sk') %}
<div class="space-y-6">

View File

@@ -45,6 +45,21 @@
</div>
</div>
<div class="grid gap-4 text-sm sm:grid-cols-2">
{% if order.residence_address %}
<div class="rounded-radius border border-outline bg-surface p-4 dark:border-outline-dark dark:bg-surface-dark-alt">
<h2 class="mb-2 font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-residence-address", lang=lang | default(value='sk')) }}</h2>
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.residence_address }}</p>
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.residence_zip }} {{ order.residence_city }}{% if order.residence_country %}, {{ order.residence_country }}{% endif %}</p>
</div>
{% endif %}
<div class="rounded-radius border border-outline bg-surface p-4 dark:border-outline-dark dark:bg-surface-dark-alt">
<h2 class="mb-2 font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</h2>
{% if order.address %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.address }}</p>{% endif %}
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.zip }} {{ order.city }}{% if order.country %}, {{ order.country }}{% endif %}</p>
</div>
</div>
{% if order.payment_method == "bank_transfer" %}
<div class="space-y-2 rounded-radius border border-primary/40 bg-primary/5 p-6 text-sm dark:border-primary-dark/40">
<p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-bank-instructions", lang=lang | default(value='sk')) }}</p>

View File

@@ -4,6 +4,7 @@
{% block title %}{{ product.name }}{% endblock title %}
{% block content %}
<div class="space-y-12">
<div class="grid gap-10 lg:grid-cols-2">
<!-- gallery — prev/next arrows + opacity transitions adapted from
penguinui/carousel/default-carousel.html; kept our product thumbnail strip
@@ -50,14 +51,34 @@
<!-- details -->
{% set fld = "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 btn = "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-radius px-5 py-2 text-sm text-center font-medium tracking-wide transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75 border border-primary bg-primary text-on-primary focus-visible:outline-primary dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark dark:focus-visible:outline-primary-dark" %}
{% set btn = "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-radius px-5 py-2 text-sm text-center font-medium tracking-wide transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75 border border-cta bg-cta text-on-cta focus-visible:outline-cta dark:border-cta-dark dark:bg-cta-dark dark:text-on-cta-dark dark:focus-visible:outline-cta-dark" %}
<script id="variant-data" type="application/json">{{ variants | json_encode() | safe }}</script>
<div class="space-y-6" x-data="productBuy(JSON.parse(document.getElementById('variant-data').textContent))">
<div class="space-y-6"
x-data="{
variants: JSON.parse(document.getElementById('variant-data').textContent) || [],
sel: 0,
get current() { return this.variants[this.sel] || null },
init() {
const firstInStock = this.variants.findIndex(v => v.in_stock);
this.sel = Math.max(0, firstInStock);
},
}">
{% if category %}
<a href="/category/{{ category.slug }}" class="text-sm font-medium text-primary dark:text-primary-dark">{{ category.name }}</a>
{% endif %}
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h1>
{% if product.short_description %}
<div class="rich-content rich-summary text-on-surface/80 dark:text-on-surface-dark/80">
{{ product.short_description | safe }}
{% if product.description %}
<a href="#product-description" class="product-more-link inline font-medium text-primary underline underline-offset-4 hover:opacity-75 dark:text-primary-dark">{{ t(key="product-more", lang=lang | default(value='sk')) }}</a>
{% endif %}
</div>
{% elif product.description %}
<a href="#product-description" class="inline-flex text-sm font-medium text-primary underline underline-offset-4 hover:opacity-75 dark:text-primary-dark">{{ t(key="product-more", lang=lang | default(value='sk')) }}</a>
{% endif %}
<template x-if="current">
<div class="space-y-6">
<!-- option picker (only when there's a real choice); first option is
@@ -82,11 +103,6 @@
</template>
</div>
{% if product.description %}
{# Authored as rich text (Quill) in the admin; render the stored HTML. #}
<div class="rich-content text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description | safe }}</div>
{% endif %}
<template x-if="current.in_stock">
<div class="space-y-2">
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none" class="flex flex-wrap items-end gap-3"
@@ -119,16 +135,13 @@
<p class="inline-flex rounded-radius bg-danger/10 px-3 py-2 text-sm font-medium text-danger">{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}</p>
</template>
</div>
<script>
function productBuy(variants) {
return {
variants: variants || [],
// Default to the first in-stock variant, else the first.
sel: Math.max(0, (variants || []).findIndex(v => v.in_stock)),
get current() { return this.variants[this.sel] || null; },
};
}
</script>
</div>
{% if product.description %}
<section id="product-description" class="scroll-mt-28 border-t border-outline pt-8 dark:border-outline-dark">
<h2 class="mb-4 text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="description", lang=lang | default(value='sk')) }}</h2>
{# Authored as rich text (Quill) in the admin; render the stored HTML. #}
<div class="rich-content text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description | safe }}</div>
</section>
{% endif %}
</div>
{% endblock content %}

View File

@@ -0,0 +1,123 @@
# Real data to port from www.e-shop.kompress.sk
Source: <http://e-shop.kompress.sk/> (live PrestaShop site). This file lists the
real business content that exists on the production site but is **not** yet in
this app, so it can be ported into our catalog / CMS / config.
> Status note: the header branding has already been switched from the
> placeholder "Kompress eshop" to the real logo (`assets/static/img/logo.jpg`,
> the blue **KOMPRESS** wordmark pulled from `/img/logo.jpg` on the live site)
> and the `brand` / `meta-description` i18n keys now carry the real company
> name and tagline.
---
## 1. Company / branding
| Field | Real value |
|---|---|
| Legal name | **WWW.KOMPRESS.SK, s.r.o.** |
| Display name | www.e-shop.kompress.sk |
| Tagline (meta) | *Výrobca a distribútor zdravotníckych pomôcok a potrieb* (Manufacturer and distributor of medical aids and supplies) |
| Logo | blue **KOMPRESS** wordmark — `/img/logo.jpg` (260×52), saved to `assets/static/img/logo.jpg` |
| Keywords | obchod, výroba, distribúcia, striekačky, ihly, krytie, sušenie, jednorázový, materiál, štvorce |
## 2. Contact / legal (for footer + invoicing config)
- **Sídlo (registered seat):** Gunduličova 4, 811 05 Bratislava
*(the homepage footer block also shows "Moyzesova 3, 811 05 Bratislava" — confirm which is current)*
- **Prevádzka (operations / warehouse):** Nádražná 328/62, 015 01 Rajec nad Rajčankou
- **Registrácia:** Obchodný register Okresného súdu v Bratislave, Odd. Sro, Vložka číslo: 102522/B
- **Telefón / hotline:** +421 903 410476
- **E-mail:** kompress@kompress.sk
## 3. "O našej spoločnosti" (About) — CMS page
> WWW.KOMPRESS.SK, s.r.o., Sídlo: Gunduličova 4, 811 05 Bratislava, Prevádzka:
> Nádražná 328/62, 015 01 Rajec nad Rajčankou, Registrácia: Obchodný register
> Okresného súdu v Bratislave Odd. Sro, Vložka číslo: 102522/B. Spoločnosť
> dodáva zdravotnícke potreby a svojich zákazníkov tradične oslovuje vysokou
> kvalitou a nízkou cenou. Samozrejmosťou je doprava tovaru až k rukám
> odberateľa a kvalitný zákaznícky servis.
Other CMS pages present on the live site:
- `O našej spoločnosti``/content/4-kompress`
- `Podmienky používania obchodu` (terms) — `/content/3-podmienky-pouzivania-obchodu`
## 4. Product categories (PrestaShop IDs → name / description)
These are the real catalog categories. Top-level is **Zdravotnícke pomôcky**
(id 7); the rest are its subtree. The text below is the real category
description copy to port.
| ID | Category / description copy |
|---|---|
| 7 | **Zdravotnícke pomôcky** (root) |
| 8 | **Gáza, role, štvorce, prírezy** — Gáza a výrobky z gázy, prírezy, role, zložky, kompresy resp. štvorce, pásy resp. longety, sterilné či nesterilné. Čistá biela bavlna. |
| 9 | **Vata** (buničitá, obväzová, stomatologická) — bielená, v rezoch, v návine, delená na tampóny, skladaná ako harmonika, savá a mäkká. |
| 10 | **Netkané textílie** — Pervin, SMS obaly na sterilizáciu, návleky na obuv. |
| 11 | **Textil, Sanavel** |
| 12 | **Plasty, injekčná technika** — vrecká, tŕne, hadičky, ihly, striekačky, infúzne súpravy, urínové vrecká, odberové vaky, cievky, katétre Nelaton, odsávačky. |
| 13 | **Papier na lôžko** — krepovaný biely/farebný, tissue embosovaný, laminovaný tissue s plastovou spodnou vrstvou. |
| 14 | **Somatické / psycho-somatické prístroje** — pomôcky na elimináciu geopatogénnych zón, elektrosmogu. |
| 15 | **Somavedic** (rad prístrojov) — eliminácia vplyvu geopatogénnych zón, elektrosmogu; dosah 30 m. |
| 16 | **Úprava vody** — voda ako nosič energie. |
| 17 | **Gely a lubrikanty** — gély na sonografiu/ECG, lubrikanty na sondy; balenia 260/500/1000 ml, kanister 5 l. |
| 18 | **Somavedic AURUM** — najúčinnejší z kolekcie. |
| 19 | **Tecasorb** — moderné absorpčné krytie (vlhké aj suché). |
| 20 | (Somavedic — biofyzikálne sledovanie IEGF) |
| 21 | **Obväzy** — fixačné, elastické ovínadlá, sádrové obväzy, gumové škrtidlá MARTIN, ESMARCH. |
| 22 | **Nástroje** — čepelky, peany, nožničky, pinzety. |
| 23 | **Pre invalidov** — vozíky, postele, matrace, návleky, poťahy, stoličky, nádstavce na WC, antidekubitné matrace. |
| 24 | **Proti preležaninám** — antidekubitné matrace, poťahy, podložky, sedáky, motorové pumpy. |
| 25 | **Barle, palice, chodúliky** — chodítka, statické/pohyblivé chodúliky s kolieskami. |
| 26 | **Vozíky pre invalidov - mechanické** — bez pohonu, polohovateľné, skladacie. |
| 27 | **Držadlá, nástavce na WC, operadlá do kúpeľne** — namontovateľné na stenu/umývadlo/WC. |
| 28 | **Ochrana matracov** — umývateľné poťahy, návleky, obliečky. |
| 29 | **Ortopedické pomôcky** — vložky do topánok, silikónové, medziprstové, gelové. |
| 30 | **Polohovacie pomôcky** — podložky s výrezom, sedáky, krúžky, valce. |
| 31 | **Do kúpeľne** — stolička/sedačka/opierka/držadlo do vane a sprchy, protišmykové, nastaviteľné. |
| 32 | **Relaxácia a rehabilitácia** — lopty, balóny, gumové pásy, pedále, masážne loptičky. |
| 33 | **Sebaobsluha a obsluha pacienta** — obúvanie, poháre, pásy na dvíhanie, sklápacie stolíky. |
| 34 | **Toaletné kreslá a toaletné vozíky** — kreslá/vozíky s otvorom v sedadle, kreslo do sprchy. |
| 35 | **Somavedic MEDIC** — certifikát IGEF na rušenie elektrosmogu. |
| 36 | **Rukavice** — vyšetrovacie, chirurgické bezpúdrové/púdrované, latexové, nitrilové, vinylové, neoprénové. |
## 5. Featured products (homepage) with prices
Prices are EUR (display on site uses `X,XX €`). Image paths are on the live
site under `/<id>-home_default/<slug>.jpg`.
| Product | Price (€) | Notes |
|---|---|---|
| Somavedic URAN | 600,00 | id 581 |
| Somavedic MEDIC (elektrosmog eliminátor) | 360,00 | id 213 |
| Somavedic ATLANTIK | 300,00 | id 572 |
| Sedačka do sprchy nastaviteľná, s opierkou chrbta a výrezom | 65,00 | id 497 |
| Opierka pod chrbát polohovateľná | 37,20 | id 378 |
| Krepovaný papier na lôžko (papier na operačné stoly) | 4,80 | id 248 |
| GAMMEX chirurgické rukavice pár | 1,23 | id 582 |
| Gáza v páse s buničitou vatou (Mullro) | 1,14 / 1,23 | id 567 |
| Návleky na topánky NT | 0,12 | id 224 |
| Návlek na obuv (plastový s gumičkou) | 0,04 | id 279 |
| Gázové kompresy, štvorce nesterilné | — | id 100 |
Other Somavedic models referenced on site: **Somavedic AURUM** (id 18 cat).
## 6. Storefront blocks present on live site (UX reference)
- Home slider (homeslider) — "pomôcky pre pacientov", "prístroj Somavedic aurum".
- "Naše obchody" (Our stores / blockstore) block.
- Reinsurance block (5 trust badges) — `blockreinsurance`.
- Price-comparison badges: Pricemania, Heureka.sk, Tovar.sk, NajNakup.sk.
## 7. Suggested port order
1. **Config/contact** — drop real legal name, seat, ops address, IČO/registration,
phone, email into footer + invoicing config (see `account-type-rules` memory).
2. **CMS pages** — seed `O našej spoločnosti` and `Obchodné podmienky` content.
3. **Categories** — seed categories 736 (names + descriptions above) under root
"Zdravotnícke pomôcky"; map to our category model.
4. **Products** — import the featured products with prices/variants; pull images
from `/<id>-home_default/` on the live site.
5. **Currency** — site prices are EUR (matches our EUR base; CZK display optional).

View File

@@ -49,6 +49,10 @@ mod m20260623_000001_add_short_description_to_products;
mod m20260623_000002_strip_html_from_product_search;
mod m20260623_000003_drop_currency;
mod m20260623_000004_currencies;
mod m20260625_000001_add_avatar_to_users;
mod m20260627_000001_order_residence_address;
mod m20260627_000002_payment_settings;
mod m20260627_000003_account_cart_items;
pub struct Migrator;
#[async_trait::async_trait]
@@ -102,6 +106,10 @@ impl MigratorTrait for Migrator {
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),
Box::new(m20260627_000001_order_residence_address::Migration),
Box::new(m20260627_000002_payment_settings::Migration),
Box::new(m20260627_000003_account_cart_items::Migration),
// 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;
// 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

@@ -0,0 +1,22 @@
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> {
add_column(m, "orders", "residence_address", ColType::StringNull).await?;
add_column(m, "orders", "residence_city", ColType::StringNull).await?;
add_column(m, "orders", "residence_zip", ColType::StringNull).await?;
add_column(m, "orders", "residence_country", ColType::StringNull).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
remove_column(m, "orders", "residence_country").await?;
remove_column(m, "orders", "residence_zip").await?;
remove_column(m, "orders", "residence_city").await?;
remove_column(m, "orders", "residence_address").await
}
}

View File

@@ -0,0 +1,41 @@
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> {
create_table(
m,
"payment_methods",
&[
("id", ColType::PkAuto),
("code", ColType::StringUniq),
("name", ColType::String),
("enabled", ColType::BooleanWithDefault(true)),
("position", ColType::IntegerWithDefault(0)),
],
&[],
)
.await?;
create_table(
m,
"shop_settings",
&[
("id", ColType::PkAuto),
("key", ColType::StringUniq),
("value", ColType::TextNull),
],
&[],
)
.await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "shop_settings").await?;
drop_table(m, "payment_methods").await
}
}

View File

@@ -0,0 +1,48 @@
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> {
create_table(
m,
"account_cart_items",
&[
("id", ColType::PkAuto),
("variant_id", ColType::Integer),
("quantity", ColType::Integer),
],
&[("user", "")],
)
.await?;
m.create_foreign_key(
ForeignKey::create()
.name("fk-account_cart_items-variant_id-to-product_variants")
.from(Alias::new("account_cart_items"), Alias::new("variant_id"))
.to(Alias::new("product_variants"), Alias::new("id"))
.on_delete(ForeignKeyAction::Cascade)
.on_update(ForeignKeyAction::NoAction)
.to_owned(),
)
.await?;
m.create_index(
Index::create()
.name("idx_account_cart_items_user_variant_unique")
.table(Alias::new("account_cart_items"))
.col(Alias::new("user_id"))
.col(Alias::new("variant_id"))
.unique()
.to_owned(),
)
.await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "account_cart_items").await
}
}

View File

@@ -18,9 +18,9 @@ use std::{path::Path, sync::Arc};
use crate::{
controllers::{
account, admin_categories, admin_currencies, admin_customers, admin_dashboard,
admin_discount_profiles, admin_form, admin_orders, admin_products, admin_shipping,
admin_discount_profiles, admin_form, admin_orders, admin_payments, admin_products, admin_shipping,
auth, auth_pages, cart, checkout, currency, home, i18n, media, oauth2,
shop,
pages, shop,
},
initializers,
models::_entities::users,
@@ -83,6 +83,7 @@ impl Hooks for App {
Box::new(initializers::view_engine::ViewEngineInitializer),
Box::new(initializers::admin_seeder::AdminSeeder),
Box::new(initializers::shipping_seeder::ShippingSeeder),
Box::new(initializers::payment_seeder::PaymentSeeder),
Box::new(initializers::currency_seeder::CurrencySeeder),
Box::new(initializers::oauth2::OAuth2StoreInitializer),
Box::new(initializers::oauth2_session::OAuth2SessionInitializer),
@@ -97,6 +98,7 @@ impl Hooks for App {
.add_route(cart::routes())
.add_route(checkout::routes())
.add_route(currency::routes())
.add_route(pages::routes())
// cross-cutting
.add_route(auth::routes())
.add_route(auth_pages::routes())
@@ -110,6 +112,7 @@ impl Hooks for App {
.add_route(admin_discount_profiles::routes())
.add_route(admin_categories::routes())
.add_route(admin_orders::routes())
.add_route(admin_payments::routes())
.add_route(admin_customers::routes())
.add_route(admin_shipping::routes())
.add_route(admin_currencies::routes())

View File

@@ -7,6 +7,7 @@
//! 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).
use axum::extract::{DefaultBodyLimit, Multipart};
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::QueryOrder;
@@ -14,7 +15,11 @@ use serde::Deserialize;
use serde_json::json;
use crate::{
controllers::i18n::current_lang,
controllers::{
admin_form::{read_multipart_form, store_image},
i18n::current_lang,
media::IMAGE_MAX_BYTES,
},
models::{
customer_profiles::{self, ProfileFields},
order_items, orders, users,
@@ -128,6 +133,8 @@ fn profile_view(
"account_nav": true,
"customer_name": user.name,
"customer_account_type": user.account_type,
"customer_avatar": user.avatar_id,
"avatar_id": user.avatar_id,
"saved": saved,
"error": error,
"name": user.name,
@@ -202,6 +209,64 @@ async fn save_profile(
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.
#[debug_handler]
async fn orders_page(
@@ -236,6 +301,7 @@ async fn orders_page(
"account_nav": true,
"customer_name": user.name,
"customer_account_type": user.account_type,
"customer_avatar": user.avatar_id,
"active_orders": shape(active),
"past_orders": shape(past),
"lang": current_lang(&jar),
@@ -269,6 +335,7 @@ async fn order_detail_page(
.all(&ctx.db)
.await?;
let (bank_iban, bank_account_name) = settings::bank_details(&ctx).await?;
format::view(
&v,
"account/order_detail.html",
@@ -278,10 +345,11 @@ async fn order_detail_page(
"account_nav": true,
"customer_name": user.name,
"customer_account_type": user.account_type,
"customer_avatar": user.avatar_id,
"order": order_view::detail(
&order,
settings::get(&ctx, "bank_iban").unwrap_or(""),
settings::get(&ctx, "bank_account_name").unwrap_or(""),
&bank_iban,
&bank_account_name,
),
"items": order_view::items(&items),
"lang": current_lang(&jar),
@@ -312,6 +380,7 @@ fn password_view(
"account_nav": true,
"customer_name": user.name,
"customer_account_type": user.account_type,
"customer_avatar": user.avatar_id,
"changed": changed,
"error": error,
"lang": current_lang(jar),
@@ -406,6 +475,7 @@ fn security_view(
"account_nav": true,
"customer_name": user.name,
"customer_account_type": user.account_type,
"customer_avatar": user.avatar_id,
"totp_enabled": user.totp_enabled(),
"enrolling": enrolling,
"qr": qr,
@@ -538,6 +608,11 @@ pub fn routes() -> Routes {
Routes::new()
.add("/account/profile", get(profile_page))
.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/{order_number}", get(order_detail_page))
.add("/account/password", get(change_password_page))

View File

@@ -93,6 +93,7 @@ async fn render_show(
.await?;
let carrier = order_carrier(ctx, &order).await?;
let (bank_iban, bank_account_name) = settings::bank_details(ctx).await?;
// The order can be sent only if it maps to a real carrier and hasn't been
// dispatched yet.
let can_ship = carrier != "none" && order.tracking_number.is_none();
@@ -103,8 +104,8 @@ async fn render_show(
json!({
"order": view::detail(
&order,
settings::get(ctx, "bank_iban").unwrap_or(""),
settings::get(ctx, "bank_account_name").unwrap_or(""),
&bank_iban,
&bank_account_name,
),
"items": view::items(&items),
"statuses": ORDER_STATUSES,

View File

@@ -0,0 +1,112 @@
//! Admin management for checkout payment methods and bank-transfer details.
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::{payment_methods, shop_settings},
shared::guard,
};
#[derive(Debug, Deserialize)]
struct PaymentMethodForm {
enabled: Option<String>,
}
#[derive(Debug, Deserialize)]
struct BankSettingsForm {
bank_account_name: String,
bank_iban: String,
}
fn is_checked(value: &Option<String>) -> bool {
matches!(value.as_deref(), Some("on" | "true" | "1"))
}
fn trimmed(value: &str) -> Option<String> {
let value = value.trim();
(!value.is_empty()).then(|| value.to_string())
}
#[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 methods = payment_methods::Entity::find()
.order_by_asc(payment_methods::Column::Position)
.all(&ctx.db)
.await?;
let rows: Vec<serde_json::Value> = methods
.iter()
.map(|m| {
json!({
"id": m.id,
"code": m.code,
"label_key": m.label_key(),
"enabled": m.enabled,
})
})
.collect();
let bank_account_name = shop_settings::Entity::get(&ctx.db, "bank_account_name")
.await?
.unwrap_or_default();
let bank_iban = shop_settings::Entity::get(&ctx.db, "bank_iban")
.await?
.unwrap_or_default();
format::view(
&v,
"admin/payments/index.html",
json!({
"methods": rows,
"bank_account_name": bank_account_name,
"bank_iban": bank_iban,
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn update_method(
auth: auth::JWT,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
Form(form): Form<PaymentMethodForm>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let method = payment_methods::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
let mut active = method.into_active_model();
active.enabled = Set(is_checked(&form.enabled));
active.update(&ctx.db).await?;
format::redirect("/admin/payments")
}
#[debug_handler]
async fn update_bank(
auth: auth::JWT,
State(ctx): State<AppContext>,
Form(form): Form<BankSettingsForm>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
shop_settings::Entity::set(&ctx.db, "bank_account_name", trimmed(&form.bank_account_name)).await?;
shop_settings::Entity::set(&ctx.db, "bank_iban", trimmed(&form.bank_iban)).await?;
format::redirect("/admin/payments")
}
pub fn routes() -> Routes {
Routes::new()
.add("/admin/payments", get(index))
.add("/admin/payments/methods/{id}", post(update_method))
.add("/admin/payments/bank", post(update_bank))
}

View File

@@ -16,6 +16,7 @@ use crate::{
shared::{
guard,
money::{format_price, parse_price_to_cents},
shipping as shipping_rules,
},
};
@@ -37,13 +38,17 @@ async fn index(
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
shipping_rules::disable_packeta_if_unconfigured(&ctx).await?;
let methods = shipping_methods::Entity::find()
.order_by_asc(shipping_methods::Column::Position)
.all(&ctx.db)
.await?;
let packeta_ready = shipping_rules::packeta_ready(&ctx);
let rows: Vec<serde_json::Value> = methods
.iter()
.map(|m| {
let packeta_not_ready = m.carrier == "packeta" && !packeta_ready;
let locked = packeta_not_ready && !m.enabled;
json!({
"id": m.id,
"code": m.code,
@@ -52,6 +57,9 @@ async fn index(
"carrier": m.carrier,
"requires_pickup_point": m.requires_pickup_point,
"enabled": m.enabled,
"packeta_not_ready": packeta_not_ready,
"locked": locked,
"lock_reason": if packeta_not_ready { Some("shipping-packeta-missing-settings") } else { None::<&str> },
})
})
.collect();
@@ -74,9 +82,15 @@ async fn update(
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
let requested_enabled = is_checked(&form.enabled);
if requested_enabled && method.carrier == "packeta" && !shipping_rules::packeta_ready(&ctx) {
return Err(Error::BadRequest(
"Packeta cannot be enabled until PACKETA_API_KEY, PACKETA_API_PASSWORD and PACKETA_SENDER_LABEL are configured.".to_string(),
));
}
let mut active = method.into_active_model();
active.price_cents = Set(parse_price_to_cents(&form.price)?);
active.enabled = Set(is_checked(&form.enabled));
active.enabled = Set(requested_enabled);
active.update(&ctx.db).await?;
format::redirect("/admin/shipping")
}

View File

@@ -1,10 +1,11 @@
use crate::{
controllers::cart,
models::users::{self, LoginParams, RegisterParams},
views::auth::{CurrentResponse, LoginResponse},
mailers::auth::AuthMailer,
shared::guard::is_admin,
};
use axum_extra::extract::cookie::{Cookie, SameSite};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use loco_rs::prelude::*;
use regex::Regex;
use serde::{Deserialize, Serialize};
@@ -88,6 +89,7 @@ pub struct ResendVerificationParams {
/// welcome email to the user
#[debug_handler]
async fn register(
jar: CookieJar,
State(ctx): State<AppContext>,
Json(params): Json<RegisterParams>,
) -> Result<Response> {
@@ -109,6 +111,7 @@ async fn register(
.into_active_model()
.set_email_verification_sent(&ctx.db)
.await?;
cart::claim_guest_cart(&ctx, &jar, user.id).await?;
AuthMailer::send_welcome(&ctx, &user).await?;
@@ -199,8 +202,9 @@ async fn login(State(ctx): State<AppContext>, Json(params): Json<LoginParams>) -
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?;
let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?;
format::render()
.cookies(&[auth_cookie(&token, jwt_secret.expiration)])?
.cookies(&[auth_cookie(&token, jwt_secret.expiration), cart_cookie])?
.json(LoginResponse::new(&user, &token, is_admin(&ctx, &user)))
}
@@ -212,7 +216,9 @@ async fn current(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Respo
#[debug_handler]
async fn logout() -> Result<Response> {
format::render().cookies(&[clear_auth_cookie()])?.json(())
format::render()
.cookies(&[clear_auth_cookie(), cart::cleared_cart_cookie()])?
.json(())
}
/// Magic link authentication provides a secure and passwordless way to log in to the application.
@@ -274,8 +280,9 @@ async fn magic_link_verify(
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?;
let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?;
format::render()
.cookies(&[auth_cookie(&token, jwt_secret.expiration)])?
.cookies(&[auth_cookie(&token, jwt_secret.expiration), cart_cookie])?
.json(LoginResponse::new(&user, &token, is_admin(&ctx, &user)))
}

View File

@@ -13,6 +13,7 @@ use serde_json::json;
use crate::{
controllers::auth as auth_controller,
controllers::cart,
controllers::i18n::current_lang,
mailers::auth::AuthMailer,
models::users::{self, LoginParams, RegisterParams},
@@ -105,9 +106,13 @@ async fn login(
let token = user
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?;
let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?;
format::render()
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])?
.cookies(&[
auth_controller::auth_cookie(&token, jwt_secret.expiration),
cart_cookie,
])?
.redirect(home_for(&ctx, &user))
}
@@ -185,11 +190,13 @@ async fn login_totp(
let token = user
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?;
let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?;
format::render()
.cookies(&[
auth_controller::auth_cookie(&token, jwt_secret.expiration),
auth_controller::clear_totp_pending_cookie(),
cart_cookie,
])?
.redirect(home_for(&ctx, &user))
}
@@ -270,6 +277,7 @@ async fn register(
.into_active_model()
.set_email_verification_sent(&ctx.db)
.await?;
cart::claim_guest_cart(&ctx, &jar, user.id).await?;
// The account already exists; a failed email send shouldn't 500 the page —
// log it and let the user fall back to resend-verification.
@@ -304,7 +312,9 @@ async fn verify(
};
if user.email_verified_at.is_none() {
let user_id = user.id;
user.into_active_model().verified(&ctx.db).await?;
cart::claim_guest_cart(&ctx, &jar, user_id).await?;
}
verified_view(&v, &jar, true)
@@ -446,7 +456,10 @@ async fn set_password(
#[debug_handler]
async fn logout() -> Result<Response> {
format::render()
.cookies(&[auth_controller::clear_auth_cookie()])?
.cookies(&[
auth_controller::clear_auth_cookie(),
cart::cleared_cart_cookie(),
])?
.redirect("/login")
}

View File

@@ -1,4 +1,11 @@
use crate::{controllers::i18n::current_lang, shared::{currency::{self, Currency}, guard, pricing}, models::{product_variants, products}};
use crate::{
controllers::i18n::current_lang,
models::{account_cart_items, product_variants, products, users},
shared::{
currency::{self, Currency},
guard, pricing,
},
};
use axum::{
http::{HeaderMap, StatusCode},
response::Redirect,
@@ -64,6 +71,75 @@ fn cart_cookie(value: String) -> Cookie<'static> {
.build()
}
pub(crate) fn cleared_cart_cookie() -> Cookie<'static> {
Cookie::build((CART_COOKIE, ""))
.path("/")
.same_site(SameSite::Lax)
.max_age(TimeDuration::seconds(0))
.build()
}
fn normalize_items(items: Vec<(i32, i32)>) -> Vec<(i32, i32)> {
let mut normalized: Vec<(i32, i32)> = Vec::new();
for (id, qty) in items.into_iter().filter(|(_, qty)| *qty > 0) {
if let Some(existing) = normalized.iter_mut().find(|(existing_id, _)| *existing_id == id) {
existing.1 += qty;
} else {
normalized.push((id, qty));
}
}
normalized
}
async fn stored_cart(
ctx: &AppContext,
user: Option<&users::Model>,
jar: &CookieJar,
) -> Result<Vec<(i32, i32)>> {
match user {
Some(user) => Ok(account_cart_items::Model::find_for_user(&ctx.db, user.id).await?),
None => Ok(normalize_items(parse_cart(jar))),
}
}
async fn persist_cart(
ctx: &AppContext,
jar: CookieJar,
user: Option<&users::Model>,
items: &[(i32, i32)],
) -> Result<CookieJar> {
let items = normalize_items(items.to_vec());
if let Some(user) = user {
account_cart_items::Model::replace_for_user(&ctx.db, user.id, &items).await?;
}
Ok(jar.add(cart_cookie(serialize_cart(&items))))
}
pub(crate) async fn claim_guest_cart(
ctx: &AppContext,
jar: &CookieJar,
user_id: i32,
) -> Result<()> {
let items = normalize_items(parse_cart(jar));
if !items.is_empty() {
account_cart_items::Model::replace_for_user(&ctx.db, user_id, &items).await?;
}
Ok(())
}
pub(crate) async fn cart_cookie_for_user(
ctx: &AppContext,
user_id: i32,
) -> Result<Cookie<'static>> {
let items = account_cart_items::Model::find_for_user(&ctx.db, user_id).await?;
Ok(cart_cookie(serialize_cart(&items)))
}
pub(crate) async fn clear_account_cart(ctx: &AppContext, user_id: i32) -> Result<()> {
account_cart_items::Model::replace_for_user(&ctx.db, user_id, &[]).await?;
Ok(())
}
/// Look up a variant whose product is published, returning the variant together
/// with its parent product (for name/slug).
async fn published_variant(
@@ -94,7 +170,8 @@ async fn add(
return Err(Error::NotFound);
};
let mut items = parse_cart(&jar);
let user = guard::current_user(&ctx, &jar).await;
let mut items = stored_cart(&ctx, user.as_ref(), &jar).await?;
let add_qty = form.quantity.unwrap_or(1).max(1);
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == variant.id) {
entry.1 = variant.cap(entry.1 + add_qty);
@@ -103,7 +180,7 @@ async fn add(
}
items.retain(|(_, qty)| *qty > 0);
let jar = jar.add(cart_cookie(serialize_cart(&items)));
let jar = persist_cart(&ctx, jar, user.as_ref(), &items).await?;
// Adding to the cart should never navigate away: htmx requests get an empty
// 204 (the header cart badge updates client-side), and a no-JS submit goes
@@ -135,13 +212,14 @@ async fn update(
None => 0,
};
let mut items = parse_cart(&jar);
let user = guard::current_user(&ctx, &jar).await;
let mut items = stored_cart(&ctx, user.as_ref(), &jar).await?;
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.variant_id) {
entry.1 = clamped;
}
items.retain(|(_, qty)| *qty > 0);
let jar = jar.add(cart_cookie(serialize_cart(&items)));
let jar = persist_cart(&ctx, jar, user.as_ref(), &items).await?;
cart_response(&ctx, &v, jar, &headers).await
}
@@ -153,10 +231,11 @@ async fn remove(
headers: HeaderMap,
Form(form): Form<RemoveForm>,
) -> Result<Response> {
let mut items = parse_cart(&jar);
let user = guard::current_user(&ctx, &jar).await;
let mut items = stored_cart(&ctx, user.as_ref(), &jar).await?;
items.retain(|(id, _)| *id != form.variant_id);
let jar = jar.add(cart_cookie(serialize_cart(&items)));
let jar = persist_cart(&ctx, jar, user.as_ref(), &items).await?;
cart_response(&ctx, &v, jar, &headers).await
}
@@ -176,7 +255,8 @@ async fn cart_response(
let cur = currency::resolve(ctx, &jar).await;
let (lines, valid, total) = resolve_cart(ctx, &jar, &cur).await?;
// Persist the re-validated cookie (drops now-invalid lines).
let jar = jar.add(cart_cookie(serialize_cart(&valid)));
let user = guard::current_user(ctx, &jar).await;
let jar = persist_cart(ctx, jar, user.as_ref(), &valid).await?;
let response = format::view(
v,
"shop/_cart_body.html",
@@ -190,9 +270,9 @@ async fn cart_response(
Ok((jar, response).into_response())
}
/// Resolve the cart cookie into priced line items, dropping anything that is no
/// longer purchasable and clamping quantities to current stock. Returns the
/// (re-validated) lines, the rebuilt cookie value, and the total in cents.
/// Resolve the active cart into priced line items, dropping anything that is no
/// longer purchasable and clamping quantities to current stock. Guests resolve
/// from the cookie; authenticated users resolve from their account cart.
pub(crate) async fn resolve_cart(
ctx: &AppContext,
jar: &CookieJar,
@@ -202,7 +282,7 @@ pub(crate) async fn resolve_cart(
// for the current viewer in one batch (the price depends on who's logged in).
let user = guard::current_user(ctx, jar).await;
let mut items: Vec<(product_variants::Model, products::Model, i32)> = Vec::new();
for (id, qty) in parse_cart(jar) {
for (id, qty) in stored_cart(ctx, user.as_ref(), jar).await? {
let Some((variant, product)) = published_variant(ctx, id).await? else {
continue;
};
@@ -238,6 +318,10 @@ pub(crate) async fn resolve_cart(
}));
}
if let Some(user) = user.as_ref() {
account_cart_items::Model::replace_for_user(&ctx.db, user.id, &valid).await?;
}
Ok((lines, valid, total))
}
@@ -250,8 +334,6 @@ async fn show(
let cur = currency::resolve(&ctx, &jar).await;
let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?;
// Drop any now-invalid lines from the cookie so the badge stays accurate.
let rebuilt = serialize_cart(&valid);
let c = guard::chrome(&ctx, &jar).await;
let response = format::view(
&v,
@@ -264,11 +346,14 @@ async fn show(
"logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
"customer_account_type": c.customer_account_type,
"customer_avatar": c.customer_avatar,
"lang": current_lang(&jar),
}),
)?;
Ok((jar.add(cart_cookie(rebuilt)), response).into_response())
let user = guard::current_user(&ctx, &jar).await;
let jar = persist_cart(&ctx, jar, user.as_ref(), &valid).await?;
Ok((jar, response).into_response())
}
/// Mini-cart preview for the navbar hover dropdown. Lazy-loaded via htmx from
@@ -281,7 +366,6 @@ async fn preview(
) -> Result<Response> {
let cur = currency::resolve(&ctx, &jar).await;
let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?;
let rebuilt = serialize_cart(&valid);
let response = format::view(
&v,
"shop/_cart_preview.html",
@@ -292,7 +376,9 @@ async fn preview(
"lang": current_lang(&jar),
}),
)?;
Ok((jar.add(cart_cookie(rebuilt)), response).into_response())
let user = guard::current_user(&ctx, &jar).await;
let jar = persist_cart(&ctx, jar, user.as_ref(), &valid).await?;
Ok((jar, response).into_response())
}
pub fn routes() -> Routes {

View File

@@ -2,28 +2,24 @@
//! confirmation page.
use axum::extract::Query;
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
use serde::Deserialize;
use serde_json::json;
use time::Duration as TimeDuration;
use crate::{
controllers::cart::{resolve_cart, CART_COOKIE},
controllers::cart::{self, resolve_cart},
mailers::auth::AuthMailer,
models::{
customer_profiles::{self, ProfileFields},
order_items, orders, shipping_methods,
order_items, orders, payment_methods, shipping_methods,
users::{self, normalize_account_type},
},
controllers::i18n::current_lang,
shared::{currency::Currency, guard, money::format_price, settings},
shared::{currency::Currency, guard, money::format_price, settings, shipping as shipping_rules},
views::checkout as view,
};
const PAYMENT_METHODS: [&str; 2] = ["cod", "bank_transfer"];
#[derive(Debug, Deserialize)]
struct CheckoutForm {
email: String,
@@ -35,10 +31,15 @@ struct CheckoutForm {
company_id: Option<String>,
tax_id: Option<String>,
vat_id: Option<String>,
address: String,
city: String,
zip: String,
country: String,
residence_address: String,
residence_city: String,
residence_zip: String,
residence_country: String,
delivery_same_as_residence: Option<String>,
address: Option<String>,
city: Option<String>,
zip: Option<String>,
country: Option<String>,
note: Option<String>,
payment_method: String,
carrier_code: String,
@@ -55,20 +56,21 @@ fn trimmed(value: &str) -> Option<String> {
(!value.is_empty()).then(|| value.to_string())
}
fn cleared_cart_cookie() -> Cookie<'static> {
Cookie::build((CART_COOKIE, ""))
.path("/")
.same_site(SameSite::Lax)
.max_age(TimeDuration::seconds(0))
.build()
}
async fn enabled_shipping_methods(ctx: &AppContext) -> Result<Vec<shipping_methods::Model>> {
shipping_rules::disable_packeta_if_unconfigured(ctx).await?;
let packeta_ready = shipping_rules::packeta_ready(ctx);
Ok(shipping_methods::Entity::find()
.filter(shipping_methods::Column::Enabled.eq(true))
.order_by_asc(shipping_methods::Column::Position)
.all(&ctx.db)
.await?)
.await?
.into_iter()
.filter(|method| method.carrier != "packeta" || packeta_ready)
.collect())
}
async fn enabled_payment_methods(ctx: &AppContext) -> Result<Vec<payment_methods::Model>> {
Ok(payment_methods::Entity::enabled(&ctx.db).await?)
}
#[debug_handler]
@@ -97,6 +99,16 @@ async fn checkout_page(
})
})
.collect();
let payments: Vec<serde_json::Value> = enabled_payment_methods(&ctx)
.await?
.iter()
.map(|m| {
json!({
"code": m.code,
"label_key": m.label_key(),
})
})
.collect();
// Prefill the form for a logged-in customer: contact name/email come from
// the user account, the address/phone from their saved profile (if any).
@@ -110,7 +122,7 @@ async fn checkout_page(
let p = |get: fn(&customer_profiles::Model) -> Option<String>| {
profile.as_ref().and_then(get)
};
// Whether the customer already has a shipping address on file. When they do,
// Whether the customer already has a residence address on file. When they do,
// the "save this address to my profile" opt-in is pointless (the profile was
// filled in advance), so it's hidden and the existing profile is left alone.
let profile_filled = profile
@@ -125,6 +137,7 @@ async fn checkout_page(
"subtotal": format_price(subtotal),
"subtotal_cents": subtotal,
"shipping_methods": methods,
"payment_methods": payments,
"packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""),
"logged_in_admin": is_admin,
"logged_in_customer": is_customer,
@@ -132,6 +145,7 @@ async fn checkout_page(
// logged_in_customer is true); None for admins/guests.
"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_avatar": user.as_ref().filter(|_| is_customer).and_then(|u| u.avatar_id.clone()),
"profile_filled": profile_filled,
// A logged-in customer's account type is fixed; only guests pick it
// and may opt to create an account from the order.
@@ -146,10 +160,10 @@ async fn checkout_page(
"prefill_vat_id": p(|x| x.vat_id.clone()),
"prefill_phone_prefix": p(|x| x.phone_prefix.clone()),
"prefill_phone": p(|x| x.phone.clone()),
"prefill_address": p(|x| x.address.clone()),
"prefill_city": p(|x| x.city.clone()),
"prefill_zip": p(|x| x.zip.clone()),
"prefill_country": p(|x| x.country.clone()),
"prefill_residence_address": p(|x| x.address.clone()),
"prefill_residence_city": p(|x| x.city.clone()),
"prefill_residence_zip": p(|x| x.zip.clone()),
"prefill_residence_country": p(|x| x.country.clone()),
"lang": current_lang(&jar),
}),
)
@@ -176,16 +190,37 @@ async fn place_order(
None => number.clone(),
};
// Contact and shipping-address fields are mandatory (also enforced in the
// Contact and residence-address fields are mandatory (also enforced in the
// browser via `required`).
let require = |value: &str, field: &str| -> Result<String> {
trimmed(value).ok_or_else(|| Error::BadRequest(format!("{field} is required")))
};
let require_opt = |value: Option<&str>, field: &str| -> Result<String> {
value
.and_then(trimmed)
.ok_or_else(|| Error::BadRequest(format!("{field} is required")))
};
let customer_name = require(&form.customer_name, "name")?;
let address = require(&form.address, "address")?;
let city = require(&form.city, "city")?;
let zip = require(&form.zip, "zip")?;
let country = require(&form.country, "country")?;
let residence_address = require(&form.residence_address, "residence address")?;
let residence_city = require(&form.residence_city, "residence city")?;
let residence_zip = require(&form.residence_zip, "residence zip")?;
let residence_country = require(&form.residence_country, "residence country")?;
let same_address = form.delivery_same_as_residence.is_some();
let (address, city, zip, country) = if same_address {
(
residence_address.clone(),
residence_city.clone(),
residence_zip.clone(),
residence_country.clone(),
)
} else {
(
require_opt(form.address.as_deref(), "delivery address")?,
require_opt(form.city.as_deref(), "delivery city")?,
require_opt(form.zip.as_deref(), "delivery zip")?,
require_opt(form.country.as_deref(), "delivery country")?,
)
};
// The account type is fixed for a logged-in customer (taken from their
// account, never the form); a guest picks it on the form. Admins are treated
@@ -212,7 +247,7 @@ async fn place_order(
(None, None, None, None)
};
if !PAYMENT_METHODS.contains(&form.payment_method.as_str()) {
if payment_methods::Entity::find_enabled(&ctx.db, &form.payment_method).await?.is_none() {
return Err(Error::BadRequest("invalid payment method".to_string()));
}
@@ -224,6 +259,9 @@ async fn place_order(
.one(&ctx.db)
.await?
.ok_or_else(|| Error::BadRequest("invalid shipping method".to_string()))?;
if method.carrier == "packeta" && !shipping_rules::packeta_ready(&ctx) {
return Err(Error::BadRequest("invalid shipping method".to_string()));
}
let (pickup_point_id, pickup_point_name) = if method.requires_pickup_point {
let id = form
@@ -245,10 +283,10 @@ async fn place_order(
vat_id: vat_id.clone(),
phone_prefix: trimmed(&form.phone_prefix),
phone: Some(number.clone()),
address: Some(address.clone()),
city: Some(city.clone()),
zip: Some(zip.clone()),
country: Some(country.clone()),
address: Some(residence_address.clone()),
city: Some(residence_city.clone()),
zip: Some(residence_zip.clone()),
country: Some(residence_country.clone()),
};
// Resolve the account that will own this order. A logged-in customer always
@@ -317,6 +355,10 @@ async fn place_order(
company_id,
tax_id,
vat_id,
residence_address: Some(residence_address),
residence_city: Some(residence_city),
residence_zip: Some(residence_zip),
residence_country: Some(residence_country),
address: Some(address),
city: Some(city),
zip: Some(zip),
@@ -336,8 +378,11 @@ async fn place_order(
} else {
format!("/orders/{}", order.order_number)
};
if let Some(user) = logged_in_customer {
cart::clear_account_cart(&ctx, user.id).await?;
}
format::render()
.cookies(&[cleared_cart_cookie()])?
.cookies(&[cart::cleared_cart_cookie()])?
.redirect(&target)
}
@@ -361,20 +406,22 @@ async fn order_confirmation(
let c = guard::chrome(&ctx, &jar).await;
let account_created = params.contains_key("account_created");
let (bank_iban, bank_account_name) = settings::bank_details(&ctx).await?;
format::view(
&v,
"shop/order_confirmed.html",
json!({
"order": view::detail(
&order,
settings::get(&ctx, "bank_iban").unwrap_or(""),
settings::get(&ctx, "bank_account_name").unwrap_or(""),
&bank_iban,
&bank_account_name,
),
"items": view::items(&items),
"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,
"account_created": account_created,
"lang": current_lang(&jar),
}),

View File

@@ -28,8 +28,11 @@ async fn index(
"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),
// The header search bar only appears on the landing page.
"on_home": true,
}),
)
}

View File

@@ -9,6 +9,7 @@ pub mod admin_dashboard;
pub mod admin_discount_profiles;
pub mod admin_form;
pub mod admin_orders;
pub mod admin_payments;
pub mod admin_products;
pub mod admin_shipping;
pub mod cart;
@@ -17,4 +18,5 @@ pub mod currency;
pub mod home;
pub mod i18n;
pub mod media;
pub mod pages;
pub mod shop;

View File

@@ -17,6 +17,7 @@ use loco_rs::prelude::*;
use crate::{
controllers::auth as auth_controller,
controllers::cart,
models::{o_auth2_sessions, users, users::OAuth2UserProfile},
shared::guard,
};
@@ -36,8 +37,9 @@ async fn complete(State(ctx): State<AppContext>, user: GoogleCookieUser) -> Resu
} else {
"/"
};
let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?;
format::render()
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])?
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration), cart_cookie])?
.redirect(dest)
}

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

@@ -22,8 +22,21 @@ use crate::{
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;
/// 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
/// above any realistic page of results for this catalog.
const SEARCH_CAP: u64 = 1000;
@@ -40,6 +53,7 @@ struct SearchParams {
in_stock: Option<String>,
sort: Option<String>,
page: Option<u32>,
per_page: Option<u32>,
}
/// A candidate product with everything the listing needs to filter, sort and
@@ -81,6 +95,9 @@ fn query_base(params: &SearchParams) -> String {
if let Some(s) = params.sort.as_deref().filter(|s| !s.is_empty()) {
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()
}
@@ -177,12 +194,17 @@ async fn run_search(
items.retain(|i| view::category_filter_keep(&filter, i.product.category_id));
// 6. Sort. Newest-first is the default; relevance (the ranked search order)
// is available explicitly via the sort control.
let sort = params
// is available explicitly via the sort control. When a search runs, the
// default "newest" becomes "relevance" (a query implies relevance matters
// most); any explicitly chosen non-newest sort is left untouched.
let mut sort = params
.sort
.clone()
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "newest".to_string());
if !q_trim.is_empty() && sort == "newest" {
sort = "relevance".to_string();
}
match sort.as_str() {
"price_asc" => items.sort_by(|a, b| a.priced.price_cents.cmp(&b.priced.price_cents)),
"price_desc" => items.sort_by(|a, b| b.priced.price_cents.cmp(&a.priced.price_cents)),
@@ -198,14 +220,15 @@ async fn run_search(
}
// 7. Paginate.
let per_page = resolve_per_page(params);
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 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).
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 cat_name = item.product.category_id.and_then(|id| category_name.get(&id).cloned());
rows.push(view::product_card(
@@ -227,8 +250,17 @@ async fn run_search(
// Numeric form so the <select> can mark the active option (Tera can't
// compare a string param against a numeric category id).
"selected_category_id": selected_category.parse::<i32>().unwrap_or(-1),
// 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,
"sort": sort,
"per_page": per_page,
"per_page_options": PER_PAGE_OPTIONS,
"in_stock": in_stock_only,
"min_price": params.min_price.clone().unwrap_or_default(),
"max_price": params.max_price.clone().unwrap_or_default(),
@@ -325,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("customer_name".into(), json!(c.customer_name));
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));
}
}
@@ -347,8 +380,10 @@ async fn index(
/// ([`products::Entity::search`]) with category, price-band, in-stock and sort
/// filters, ranked and paginated by [`run_search`]. A blank query falls back to
/// the full published listing, so the same endpoint powers both "browse" and
/// "search". htmx requests get just the results fragment (for live updates);
/// direct navigation (or no-JS) renders the whole page.
/// "search". Targeted htmx requests from the listing toolbar/pagination get just
/// 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]
async fn search(
jar: CookieJar,
@@ -362,9 +397,16 @@ async fn search(
let mut context = run_search(&ctx, user.as_ref(), &params, &cur).await?;
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() {
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);
}
@@ -431,6 +473,7 @@ async fn show(
"name": product.name,
"slug": product.slug,
"description": product.description,
"short_description": product.short_description,
"variant_count": 0,
"has_options": false,
}),
@@ -448,6 +491,7 @@ async fn show(
"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),
}),

View File

@@ -2,5 +2,6 @@ pub mod admin_seeder;
pub mod currency_seeder;
pub mod oauth2;
pub mod oauth2_session;
pub mod payment_seeder;
pub mod shipping_seeder;
pub mod view_engine;

View File

@@ -0,0 +1,73 @@
//! Ensures built-in payment methods and editable bank-transfer settings exist.
//!
//! Payment method enabled flags and bank account details are admin-managed in the
//! database. We seed missing rows only, so admin changes persist across restarts.
use async_trait::async_trait;
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
use crate::{
models::{payment_methods, shop_settings},
shared::settings,
};
/// `(code, name, enabled, position)`
const METHODS: [(&str, &str, bool, i32); 2] = [
(payment_methods::COD, "Cash on delivery", true, 0),
(payment_methods::BANK_TRANSFER, "Bank transfer", true, 1),
];
pub struct PaymentSeeder;
#[async_trait]
impl Initializer for PaymentSeeder {
fn name(&self) -> String {
"payment-seeder".to_string()
}
async fn before_run(&self, ctx: &AppContext) -> Result<()> {
for (code, name, enabled, position) in METHODS {
let exists = payment_methods::Entity::find()
.filter(payment_methods::Column::Code.eq(code))
.count(&ctx.db)
.await?
> 0;
if exists {
continue;
}
payment_methods::ActiveModel {
code: Set(code.to_string()),
name: Set(name.to_string()),
enabled: Set(enabled),
position: Set(position),
..Default::default()
}
.insert(&ctx.db)
.await?;
tracing::info!(payment = code, "seeded built-in payment method");
}
seed_setting(ctx, "bank_iban").await?;
seed_setting(ctx, "bank_account_name").await
}
}
async fn seed_setting(ctx: &AppContext, key: &str) -> Result<()> {
let exists = shop_settings::Entity::find()
.filter(shop_settings::Column::Key.eq(key))
.count(&ctx.db)
.await?
> 0;
if exists {
return Ok(());
}
shop_settings::ActiveModel {
key: Set(key.to_string()),
value: Set(settings::get(ctx, key).map(str::to_string)),
..Default::default()
}
.insert(&ctx.db)
.await?;
Ok(())
}

View File

@@ -9,7 +9,7 @@ use async_trait::async_trait;
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
use crate::models::shipping_methods;
use crate::{models::shipping_methods, shared::shipping as shipping_rules};
/// `(code, name, carrier, requires_pickup_point, default_price_cents, position)`
const BUILTINS: [(&str, &str, &str, bool, i64, i32); 2] = [
@@ -49,6 +49,6 @@ impl Initializer for ShippingSeeder {
.await?;
tracing::info!(carrier = code, "seeded built-in delivery option");
}
Ok(())
shipping_rules::disable_packeta_if_unconfigured(ctx).await
}
}

View File

@@ -0,0 +1,48 @@
//! `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 = "account_cart_items")]
pub struct Model {
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
#[sea_orm(primary_key)]
pub id: i32,
pub variant_id: i32,
pub quantity: i32,
pub user_id: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::product_variants::Entity",
from = "Column::VariantId",
to = "super::product_variants::Column::Id",
on_update = "NoAction",
on_delete = "Cascade"
)]
ProductVariants,
#[sea_orm(
belongs_to = "super::users::Entity",
from = "Column::UserId",
to = "super::users::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Users,
}
impl Related<super::product_variants::Entity> for Entity {
fn to() -> RelationDef {
Relation::ProductVariants.def()
}
}
impl Related<super::users::Entity> for Entity {
fn to() -> RelationDef {
Relation::Users.def()
}
}

View File

@@ -2,6 +2,7 @@
pub mod prelude;
pub mod account_cart_items;
pub mod account_discount_profiles;
pub mod account_product_prices;
pub mod account_product_resolutions;
@@ -15,10 +16,12 @@ pub mod discount_profiles;
pub mod o_auth2_sessions;
pub mod order_items;
pub mod orders;
pub mod payment_methods;
pub mod product_images;
pub mod product_product_tags;
pub mod product_tags;
pub mod product_variants;
pub mod products;
pub mod shipping_methods;
pub mod shop_settings;
pub mod users;

View File

@@ -38,6 +38,10 @@ pub struct Model {
pub tax_id: Option<String>,
pub vat_id: Option<String>,
pub user_id: Option<i32>,
pub residence_address: Option<String>,
pub residence_city: Option<String>,
pub residence_zip: Option<String>,
pub residence_country: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -0,0 +1,21 @@
//! `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 = "payment_methods")]
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 name: String,
pub enabled: bool,
pub position: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

View File

@@ -1,5 +1,6 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
pub use super::account_cart_items::Entity as AccountCartItems;
pub use super::account_discount_profiles::Entity as AccountDiscountProfiles;
pub use super::account_product_prices::Entity as AccountProductPrices;
pub use super::account_product_resolutions::Entity as AccountProductResolutions;
@@ -13,10 +14,12 @@ pub use super::discount_profiles::Entity as DiscountProfiles;
pub use super::o_auth2_sessions::Entity as OAuth2Sessions;
pub use super::order_items::Entity as OrderItems;
pub use super::orders::Entity as Orders;
pub use super::payment_methods::Entity as PaymentMethods;
pub use super::product_images::Entity as ProductImages;
pub use super::product_product_tags::Entity as ProductProductTags;
pub use super::product_tags::Entity as ProductTags;
pub use super::product_variants::Entity as ProductVariants;
pub use super::products::Entity as Products;
pub use super::shipping_methods::Entity as ShippingMethods;
pub use super::shop_settings::Entity as ShopSettings;
pub use super::users::Entity as Users;

View File

@@ -22,6 +22,8 @@ pub struct Model {
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::account_cart_items::Entity")]
AccountCartItems,
#[sea_orm(has_many = "super::account_product_prices::Entity")]
AccountProductPrices,
#[sea_orm(has_many = "super::account_product_resolutions::Entity")]
@@ -38,6 +40,12 @@ pub enum Relation {
Products,
}
impl Related<super::account_cart_items::Entity> for Entity {
fn to() -> RelationDef {
Relation::AccountCartItems.def()
}
}
impl Related<super::account_product_prices::Entity> for Entity {
fn to() -> RelationDef {
Relation::AccountProductPrices.def()

View File

@@ -0,0 +1,20 @@
//! `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 = "shop_settings")]
pub struct Model {
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub key: String,
#[sea_orm(column_type = "Text", nullable)]
pub value: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

View File

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

View File

@@ -0,0 +1,55 @@
pub use crate::models::_entities::account_cart_items::{ActiveModel, Column, Entity, Model};
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue, QueryFilter, QueryOrder};
pub type AccountCartItems = 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,
{
Ok(self)
}
}
impl Model {
pub async fn find_for_user(
db: &DatabaseConnection,
user_id: i32,
) -> Result<Vec<(i32, i32)>, DbErr> {
Ok(Entity::find()
.filter(Column::UserId.eq(user_id))
.order_by_asc(Column::Id)
.all(db)
.await?
.into_iter()
.filter_map(|item| (item.quantity > 0).then_some((item.variant_id, item.quantity)))
.collect())
}
pub async fn replace_for_user(
db: &DatabaseConnection,
user_id: i32,
items: &[(i32, i32)],
) -> Result<(), DbErr> {
Entity::delete_many()
.filter(Column::UserId.eq(user_id))
.exec(db)
.await?;
for (variant_id, quantity) in items.iter().copied().filter(|(_, qty)| *qty > 0) {
ActiveModel {
user_id: ActiveValue::set(user_id),
variant_id: ActiveValue::set(variant_id),
quantity: ActiveValue::set(quantity),
..Default::default()
}
.insert(db)
.await?;
}
Ok(())
}
}

View File

@@ -6,6 +6,7 @@
pub mod _entities;
pub mod account_cart_items;
pub mod account_discount_profiles;
pub mod account_product_prices;
pub mod account_product_resolutions;
@@ -19,10 +20,12 @@ pub mod customer_profiles;
pub mod o_auth2_sessions;
pub mod order_items;
pub mod orders;
pub mod payment_methods;
pub mod product_images;
pub mod product_product_tags;
pub mod product_tags;
pub mod products;
pub mod shipping_methods;
pub mod shop_settings;
pub mod users;
pub mod product_variants;

View File

@@ -24,6 +24,10 @@ pub struct Checkout {
pub company_id: Option<String>,
pub tax_id: Option<String>,
pub vat_id: Option<String>,
pub residence_address: Option<String>,
pub residence_city: Option<String>,
pub residence_zip: Option<String>,
pub residence_country: Option<String>,
pub address: Option<String>,
pub city: Option<String>,
pub zip: Option<String>,
@@ -102,6 +106,10 @@ pub async fn place(
company_id: Set(details.company_id),
tax_id: Set(details.tax_id),
vat_id: Set(details.vat_id),
residence_address: Set(details.residence_address),
residence_city: Set(details.residence_city),
residence_zip: Set(details.residence_zip),
residence_country: Set(details.residence_country),
address: Set(details.address),
city: Set(details.city),
zip: Set(details.zip),

View File

@@ -0,0 +1,54 @@
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue, ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
pub use crate::models::_entities::payment_methods::{ActiveModel, Column, Entity, Model};
pub type PaymentMethods = Entity;
pub const COD: &str = "cod";
pub const BANK_TRANSFER: &str = "bank_transfer";
#[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 = ActiveValue::set(chrono::Utc::now().into());
Ok(this)
} else {
Ok(self)
}
}
}
impl Entity {
pub async fn enabled<C: ConnectionTrait>(db: &C) -> Result<Vec<Model>, DbErr> {
Entity::find()
.filter(Column::Enabled.eq(true))
.order_by_asc(Column::Position)
.all(db)
.await
}
pub async fn find_enabled<C: ConnectionTrait>(db: &C, code: &str) -> Result<Option<Model>, DbErr> {
Entity::find()
.filter(Column::Code.eq(code))
.filter(Column::Enabled.eq(true))
.one(db)
.await
}
}
impl Model {
pub fn label_key(&self) -> &'static str {
match self.code.as_str() {
COD => "payment-cod",
BANK_TRANSFER => "payment-bank",
_ => "payment-custom",
}
}
}
impl ActiveModel {}

View File

@@ -0,0 +1,47 @@
use sea_orm::entity::prelude::*;
use sea_orm::{ActiveValue, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, TryIntoModel};
pub use crate::models::_entities::shop_settings::{ActiveModel, Column, Entity, Model};
pub type ShopSettings = 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 = ActiveValue::set(chrono::Utc::now().into());
Ok(this)
} else {
Ok(self)
}
}
}
impl Entity {
pub async fn get<C: ConnectionTrait>(db: &C, key: &str) -> Result<Option<String>, DbErr> {
Ok(Entity::find()
.filter(Column::Key.eq(key))
.one(db)
.await?
.and_then(|setting| setting.value))
}
pub async fn set<C: ConnectionTrait>(db: &C, key: &str, value: Option<String>) -> Result<Model, DbErr> {
let mut active = match Entity::find()
.filter(Column::Key.eq(key))
.one(db)
.await?
{
Some(existing) => existing.into_active_model(),
None => ActiveModel {
key: ActiveValue::set(key.to_string()),
..Default::default()
},
};
active.value = ActiveValue::set(value);
active.save(db).await?.try_into_model()
}
}

View File

@@ -56,6 +56,9 @@ pub struct Chrome {
pub logged_in_customer: bool,
pub customer_name: 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 {
@@ -74,6 +77,7 @@ pub fn chrome_from(ctx: &AppContext, user: Option<&users::Model>) -> Chrome {
logged_in_customer: true,
customer_name: Some(user.name.clone()),
customer_account_type: Some(user.account_type.clone()),
customer_avatar: user.avatar_id.clone(),
..Default::default()
},
None => Chrome::default(),

View File

@@ -7,4 +7,5 @@ pub mod money;
pub mod pricing;
pub mod rbac;
pub mod settings;
pub mod shipping;
pub mod slug;

View File

@@ -2,6 +2,8 @@
use loco_rs::prelude::*;
use crate::models::shop_settings;
/// Look up a string-valued `settings.<key>` entry, returning `None` if config
/// has no settings map, the key is missing, or the value is not a string.
pub fn get<'a>(ctx: &'a AppContext, key: &str) -> Option<&'a str> {
@@ -11,3 +13,20 @@ pub fn get<'a>(ctx: &'a AppContext, key: &str) -> Option<&'a str> {
.and_then(|settings| settings.get(key))
.and_then(|value| value.as_str())
}
/// Look up an admin-editable setting in the database, falling back to config when
/// the row is missing. Empty DB values are returned as-is so admins can clear a
/// setting deliberately.
pub async fn get_editable(ctx: &AppContext, key: &str) -> Result<String> {
Ok(match shop_settings::Entity::get(&ctx.db, key).await? {
Some(value) => value,
None => get(ctx, key).unwrap_or("").to_string(),
})
}
pub async fn bank_details(ctx: &AppContext) -> Result<(String, String)> {
Ok((
get_editable(ctx, "bank_iban").await?,
get_editable(ctx, "bank_account_name").await?,
))
}

31
src/shared/shipping.rs Normal file
View File

@@ -0,0 +1,31 @@
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, Set};
use crate::{
models::shipping_methods,
shared::settings,
};
pub fn packeta_ready(ctx: &AppContext) -> bool {
["packeta_api_key", "packeta_api_password", "packeta_sender_label"]
.iter()
.all(|key| settings::get(ctx, key).is_some_and(|value| !value.trim().is_empty()))
}
pub async fn disable_packeta_if_unconfigured(ctx: &AppContext) -> Result<()> {
if packeta_ready(ctx) {
return Ok(());
}
let Some(method) = shipping_methods::Entity::find()
.filter(shipping_methods::Column::Carrier.eq("packeta"))
.filter(shipping_methods::Column::Enabled.eq(true))
.one(&ctx.db)
.await?
else {
return Ok(());
};
let mut active = method.into_active_model();
active.enabled = Set(false);
active.update(&ctx.db).await?;
Ok(())
}

View File

@@ -40,6 +40,10 @@ pub fn detail(order: &orders::Model, bank_iban: &str, bank_account_name: &str) -
"subtotal": format_price(order.total_cents - order.shipping_cents),
"shipping": format_price(order.shipping_cents),
"total": format_price(order.total_cents),
"residence_address": order.residence_address,
"residence_city": order.residence_city,
"residence_zip": order.residence_zip,
"residence_country": order.residence_country,
"address": order.address,
"city": order.city,
"zip": order.zip,

View File

@@ -22,6 +22,14 @@ pub fn product_card(
category_name: Option<String>,
cur: &Currency,
) -> 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!({
"id": product.id,
"variant_id": representative.id,
@@ -31,6 +39,7 @@ pub fn product_card(
"short_description": product.short_description,
"price": cur.format(priced.price_cents),
"on_sale": priced.is_reduced(),
"percent_off": percent_off,
"is_business": priced.is_business,
"regular_price": cur.format(priced.regular_cents),
"sku": representative.sku,