58 Commits
v0.2.5 ... 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
Priec
ac31cdfbf3 eur czk can be disabled from now on
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-23 21:54:09 +02:00
Priec
c409e85995 CZK implemented 2026-06-23 12:54:11 +02:00
Priec
6b7422806f whole eshop is now in euro 2026-06-23 12:31:52 +02:00
Priec
8085052b2b quill editor 2026-06-23 12:05:06 +02:00
Priec
1cf330e4e8 short and long description 2026-06-23 11:13:26 +02:00
Priec
031f86adb0 fixed front page product cards 2026-06-23 10:55:39 +02:00
Priec
96c428eadd discounts now work well
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-22 23:12:26 +02:00
Priec
5e6263e853 orders search query also working now
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-22 21:52:22 +02:00
Priec
5a474f3474 search in admin also 2026-06-22 21:38:03 +02:00
Priec
1e66bfd657 defaults for the search implemented 2026-06-22 21:18:13 +02:00
Priec
f512fbbb94 saerch query in the shop now works well 2026-06-22 21:12:47 +02:00
Priec
1ecfac2ad6 search with parameters 2026-06-22 21:01:02 +02:00
107 changed files with 4458 additions and 706 deletions

View File

@@ -37,29 +37,43 @@
* dark:bg-surface-dark, border-outline, etc. * dark:bg-surface-dark, border-outline, etc.
* ============================================================ */ * ============================================================ */
@theme { @theme {
/* light mode */ /* light mode — Catppuccin Latte (https://catppuccin.com/palette)
--color-surface: var(--color-white); * Base #eff1f5, Mantle #e6e9ef, Surface1 #bcc0cc, Subtext1 #5c5f77,
--color-surface-alt: var(--color-slate-100); * Subtext0 #6c6f85, Text #4c4f69. Primary is the KOMPRESS logo blue
--color-on-surface: var(--color-slate-700); * (sampled from logo.jpg) rather than the Latte blue. */
--color-on-surface-strong: var(--color-slate-900); --color-surface: #eff1f5; /* Base */
--color-primary: var(--color-indigo-600); --color-surface-alt: #e6e9ef; /* Mantle */
--color-on-primary: var(--color-white); --color-on-surface: #5c5f77; /* Subtext1 */
--color-secondary: var(--color-slate-600); --color-on-surface-strong: #4c4f69; /* Text */
--color-on-secondary: var(--color-white); --color-primary: #1600ff; /* KOMPRESS logo blue */
--color-outline: var(--color-slate-300); --color-on-primary: #eff1f5; /* Base */
--color-outline-strong: var(--color-slate-800); --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 */ /* dark mode — Gruvbox dark palette (https://github.com/morhetz/gruvbox)
--color-surface-dark: var(--color-slate-900); * bg0 #282828, bg1 #3c3836, bg2 #504945, fg0 #fbf1c7, fg1 #ebdbb2,
--color-surface-dark-alt: var(--color-slate-800); * fg2 #d5c4a1, fg3 #bdae93, bright blue #83a598, bg0_h #1d2021. */
--color-on-surface-dark: var(--color-slate-300); --color-surface-dark: #282828; /* bg0 */
--color-on-surface-dark-strong: var(--color-white); --color-surface-dark-alt: #3c3836; /* bg1 */
--color-primary-dark: var(--color-indigo-400); --color-on-surface-dark: #ebdbb2; /* fg1 */
--color-on-primary-dark: var(--color-slate-950); --color-on-surface-dark-strong: #fbf1c7; /* fg0 */
--color-secondary-dark: var(--color-slate-300); --color-primary-dark: #83a598; /* bright blue */
--color-on-secondary-dark: var(--color-slate-950); --color-on-primary-dark: #1d2021; /* bg0_h */
--color-outline-dark: var(--color-slate-700); --color-secondary-dark: #d5c4a1; /* fg2 */
--color-outline-dark-strong: var(--color-slate-300); --color-on-secondary-dark: #1d2021; /* bg0_h */
--color-outline-dark: #504945; /* bg2 */
--color-outline-dark-strong: #bdae93; /* fg3 */
/* 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) */ /* shared status colors (same in both modes) */
--color-info: var(--color-sky-500); --color-info: var(--color-sky-500);
@@ -77,3 +91,110 @@
/* Hide Alpine x-cloak elements until Alpine initializes. */ /* Hide Alpine x-cloak elements until Alpine initializes. */
[x-cloak] { display: none !important; } [x-cloak] { display: none !important; }
/* === Rich text editor (Quill "snow") =======================
* Vendored Quill (assets/static/vendor/quill) drives the admin
* product short/long description fields. Stock snow already suits
* the light theme; the admin panel is locked to data-theme="dark",
* so the rules below repaint the toolbar/editor for dark. Editor
* height is per-instance via the --rich-min-height custom prop set
* by the ui::rich_editor macro.
* ============================================================ */
.rich-editor .ql-editor {
min-height: var(--rich-min-height, 12rem);
font-size: 0.95rem;
line-height: 1.6;
}
.ql-editor.ql-blank::before { font-style: normal; }
[data-theme="dark"] .rich-editor { background: var(--color-surface-dark-alt); }
[data-theme="dark"] .ql-toolbar.ql-snow,
[data-theme="dark"] .ql-container.ql-snow { border-color: var(--color-outline-dark); }
[data-theme="dark"] .ql-toolbar.ql-snow { background: var(--color-surface-dark); }
[data-theme="dark"] .ql-container.ql-snow { color: var(--color-on-surface-dark); }
[data-theme="dark"] .ql-snow .ql-stroke,
[data-theme="dark"] .ql-snow .ql-stroke-miter { stroke: var(--color-on-surface-dark); }
[data-theme="dark"] .ql-snow .ql-fill,
[data-theme="dark"] .ql-snow .ql-stroke.ql-fill { fill: var(--color-on-surface-dark); }
[data-theme="dark"] .ql-snow .ql-picker { color: var(--color-on-surface-dark); }
[data-theme="dark"] .ql-snow .ql-picker-options {
background: var(--color-surface-dark);
border-color: var(--color-outline-dark);
}
/* active / hover toolbar state -> primary accent */
[data-theme="dark"] .ql-snow.ql-toolbar button:hover,
[data-theme="dark"] .ql-snow.ql-toolbar button.ql-active,
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-label:hover,
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-label.ql-active,
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-item:hover,
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-item.ql-selected { color: var(--color-primary-dark); }
[data-theme="dark"] .ql-snow.ql-toolbar button:hover .ql-stroke,
[data-theme="dark"] .ql-snow.ql-toolbar button.ql-active .ql-stroke,
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke,
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke { stroke: var(--color-primary-dark); }
[data-theme="dark"] .ql-snow.ql-toolbar button:hover .ql-fill,
[data-theme="dark"] .ql-snow.ql-toolbar button.ql-active .ql-fill { fill: var(--color-primary-dark); }
[data-theme="dark"] .ql-snow .ql-tooltip {
background-color: var(--color-surface-dark);
border-color: var(--color-outline-dark);
color: var(--color-on-surface-dark);
box-shadow: 0 2px 8px rgb(0 0 0 / 0.45);
}
[data-theme="dark"] .ql-snow .ql-tooltip input[type=text] {
background: var(--color-surface-dark-alt);
border-color: var(--color-outline-dark);
color: var(--color-on-surface-dark);
}
[data-theme="dark"] .ql-snow .ql-editor a,
[data-theme="dark"] .ql-snow .ql-tooltip a { color: var(--color-primary-dark); }
/* Image size controls under the editor. */
.rich-image-size-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
.rich-image-size-controls.hidden { display: none; }
.rich-image-size-controls button {
border: 1px solid var(--color-outline);
border-radius: var(--radius-radius);
padding: 0.3rem 0.65rem;
line-height: 1;
font-size: 0.8rem;
}
.rich-image-size-controls button:hover { border-color: var(--color-primary); color: var(--color-primary); }
[data-theme="dark"] .rich-image-size-controls button { border-color: var(--color-outline-dark); }
[data-theme="dark"] .rich-image-size-controls button:hover {
border-color: var(--color-primary-dark);
color: var(--color-primary-dark);
}
/* Image sizing classes shared by the editor and rendered output. */
.rich-editor img, .rich-content img {
display: block;
max-width: 100%;
height: auto;
margin: 1rem auto;
border-radius: var(--radius-radius);
}
.rich-editor img { cursor: pointer; }
.rich-image-small { width: min(100%, 18rem); }
.rich-image-medium { width: min(100%, 34rem); }
.rich-image-full { width: 100%; }
/* === Rendered rich content (storefront product description) =
* Inherits text color from context so it works in both themes;
* only structural spacing + link/heading treatment is set here. */
.rich-content { line-height: 1.7; }
.rich-content h2 { margin: 1.25rem 0 0.6rem; font-size: 1.3rem; font-weight: 700; }
.rich-content h3 { margin: 1rem 0 0.5rem; font-size: 1.1rem; font-weight: 700; }
.rich-content p, .rich-content ul, .rich-content ol { margin: 0.6rem 0; }
.rich-content ul { list-style: disc; padding-left: 1.4rem; }
.rich-content ol { list-style: decimal; padding-left: 1.4rem; }
.rich-content a { color: var(--color-primary); text-decoration: underline; }
[data-theme="dark"] .rich-content a { color: var(--color-primary-dark); }
.rich-content :first-child { margin-top: 0; }
.rich-content :last-child { margin-bottom: 0; }
.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. */
.rich-blurb :where(p, ul, ol, h2, h3) { margin: 0; }
.rich-blurb :where(ul, ol) { padding-left: 1.1rem; }

View File

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

View File

@@ -1,6 +1,6 @@
brand = Kompress eshop brand = WWW.KOMPRESS.SK, s.r.o.
hello-world = Hello world! hello-world = Hello world!
meta-description = Kompress eshop meta-description = Manufacturer and distributor of medical aids and supplies
nav-home = Home nav-home = Home
nav-about = About nav-about = About
nav-blog = Blog nav-blog = Blog
@@ -20,6 +20,7 @@ admin-audio-desc = upload songs, then group them into albums.
logout = Log out logout = Log out
settings = Settings settings = Settings
settings-language = Language settings-language = Language
settings-currency = Currency
settings-theme = Theme settings-theme = Theme
language-en = English language-en = English
language-sk = Slovak language-sk = Slovak
@@ -171,6 +172,9 @@ artist = Artist
release-date = Release date release-date = Release date
cover-image = Cover image cover-image = Cover image
description = Description 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 songs-in-album = Songs in this album
admin-new-album-desc = Fill in the details, then tick the songs to include. admin-new-album-desc = Fill in the details, then tick the songs to include.
cover-help = Optional - png, jpg, webp or gif; shown on the album page. cover-help = Optional - png, jpg, webp or gif; shown on the album page.
@@ -215,7 +219,7 @@ option-label = Option label
optional = optional optional = optional
stock-untracked-hint = Leave blank = available without stock tracking stock-untracked-hint = Leave blank = available without stock tracking
available = Available available = Available
choose-option = Choose an option choose-option = Options
from-price = from { $price } from-price = from { $price }
admin-discounts = Discounts admin-discounts = Discounts
admin-discounts-desc = Set discounted product prices. A discount shows up as a sale in the shop. admin-discounts-desc = Set discounted product prices. A discount shows up as a sale in the shop.
@@ -297,7 +301,7 @@ position-hint = Sort order in the menu (lowest first). Leave blank to add it las
parent-category = Parent category parent-category = Parent category
no-parent = — None (top level) — no-parent = — None (top level) —
quantity = Quantity quantity = Quantity
add-to-cart = Add to cart add-to-cart = To cart
cart-added = Added to cart cart-added = Added to cart
in-stock = In stock in-stock = In stock
out-of-stock = Out of stock out-of-stock = Out of stock
@@ -308,7 +312,30 @@ shop-title = Shop
shop-subtitle = browse our products. shop-subtitle = browse our products.
shop-empty = There are no products here yet. shop-empty = There are no products here yet.
search-placeholder = Search products… search-placeholder = Search products…
order-search-placeholder = Search orders…
search-empty = Nothing matched your search: 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
sort-price_desc = Price: high to low
sort-name_asc = Name: AZ
sort-name_desc = Name: ZA
filter-category = Category
filter-all-categories = All categories
filter-uncategorized = Uncategorized
filter-price = Price
filter-price-from = Price from
filter-price-to = Price to
filter-in-stock = In stock only
filter-apply = Apply
filter-clear = Clear
pagination = Pagination
page-of = Page { $page } of { $pages }
prev = Previous
next = Next
view-grid = Grid view view-grid = Grid view
view-list = List view view-list = List view
categories = Categories categories = Categories
@@ -324,7 +351,9 @@ cart-update = Update
cart-continue = Continue shopping cart-continue = Continue shopping
checkout-title = Checkout checkout-title = Checkout
checkout-contact = Contact details 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-email = Email
checkout-name = Full name checkout-name = Full name
checkout-phone = Phone checkout-phone = Phone
@@ -339,7 +368,8 @@ country-de = Germany
country-pl = Poland country-pl = Poland
country-hu = Hungary country-hu = Hungary
checkout-note = Order note 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-type = Account type
account-personal = Individual account-personal = Individual
account-company = Company account-company = Company
@@ -361,6 +391,11 @@ profile-last-name = Surname
profile-edit = Edit profile profile-edit = Edit profile
profile-cancel = Cancel profile-cancel = Cancel
profile-not-set = Not set profile-not-set = Not set
profile-avatar = Profile picture
profile-avatar-hint = PNG, JPG, WEBP or GIF, up to 10 MB.
profile-avatar-choose = Choose a picture
profile-avatar-upload = Upload
profile-avatar-remove = Remove picture
nav-account = My account nav-account = My account
account-orders = My orders account-orders = My orders
account-change-password = Change password account-change-password = Change password
@@ -450,7 +485,21 @@ bank-variable-symbol = Variable symbol
bank-amount = Amount bank-amount = Amount
admin-shipping = Shipping admin-shipping = Shipping
admin-shipping-desc = set the price and availability of each delivery option. admin-shipping-desc = set the price and availability of each delivery option.
shipping-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 shipping-enabled = Active
admin-currency = Exchange rate
admin-currency-desc = set the exchange rate for the currencies customers can switch between. You always enter prices in EUR.
currency-rate = Rate
exchange-rate = Exchange rate
exchange-rate-hint = { $code } prices are the { $base } price recalculated at this rate.
currency-enabled = Available to customers
currency-base = Base currency
currency-base-hint = the currency you enter prices in and settle payment in. Cannot be changed.
shipping-new = Add delivery option shipping-new = Add delivery option
shipping-add = Add shipping-add = Add
shipping-requires-pickup = Requires pickup point shipping-requires-pickup = Requires pickup point
@@ -467,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-hint = When the goods are ready, send this order to the carrier.
order-send-to-carrier = Send to order-send-to-carrier = Send to
order-send-confirm = Send this order to the carrier now? order-send-confirm = Send this order to the carrier now?
# --- storefront chrome: top bar, header, footer ---
brand-subtitle = medical supplies
top-contact = Contact
top-sitemap = Sitemap
search-button = Search
search-scope-in = Searching in:
search-scope-all = Search the whole shop
welcome = Welcome
cart-units = items
hotline = +421 903 410 476
footer-tagline = Medical supplies for clinics, hospitals and home care. Delivery within 24 hours.
footer-info = Information
footer-account = Account
footer-contact = Contact
footer-terms = Terms and conditions
footer-about = About our company
footer-stores = 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! 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-home = Domov
nav-about = O mne nav-about = O mne
nav-blog = Blog nav-blog = Blog
@@ -20,6 +20,7 @@ admin-audio-desc = nahrať skladby a potom ich zoskupiť do albumov.
logout = Odhlásiť sa logout = Odhlásiť sa
settings = Nastavenia settings = Nastavenia
settings-language = Jazyk settings-language = Jazyk
settings-currency = Mena
settings-theme = Téma settings-theme = Téma
language-en = Angličtina language-en = Angličtina
language-sk = Slovenčina language-sk = Slovenčina
@@ -171,6 +172,9 @@ artist = Interpret
release-date = Dátum vydania release-date = Dátum vydania
cover-image = Obrázok obalu cover-image = Obrázok obalu
description = Popis description = Popis
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 songs-in-album = Skladby v albume
admin-new-album-desc = Vyplň údaje a potom označ skladby, ktoré chceš zahrnúť. admin-new-album-desc = Vyplň údaje a potom označ skladby, ktoré chceš zahrnúť.
cover-help = Voliteľné - png, jpg, webp alebo gif; zobrazí sa na stránke albumu. cover-help = Voliteľné - png, jpg, webp alebo gif; zobrazí sa na stránke albumu.
@@ -215,7 +219,7 @@ option-label = Označenie možnosti
optional = voliteľné optional = voliteľné
stock-untracked-hint = Nechajte prázdne = dostupné bez sledovania zásob stock-untracked-hint = Nechajte prázdne = dostupné bez sledovania zásob
available = Dostupné available = Dostupné
choose-option = Vyberte možnosť choose-option = Options
from-price = od { $price } from-price = od { $price }
admin-discounts = Zľavy admin-discounts = Zľavy
admin-discounts-desc = Nastavte zľavnené ceny produktov. Zľava sa v obchode zobrazí ako akcia. admin-discounts-desc = Nastavte zľavnené ceny produktov. Zľava sa v obchode zobrazí ako akcia.
@@ -297,7 +301,7 @@ position-hint = Poradie v menu (najnižšie ako prvé). Nechajte prázdne a prid
parent-category = Nadradená kategória parent-category = Nadradená kategória
no-parent = — Žiadna (najvyššia úroveň) — no-parent = — Žiadna (najvyššia úroveň) —
quantity = Množstvo quantity = Množstvo
add-to-cart = Pridať do košíka add-to-cart = Do košíka
cart-added = Pridané do košíka cart-added = Pridané do košíka
in-stock = Na sklade in-stock = Na sklade
out-of-stock = Vypredané out-of-stock = Vypredané
@@ -308,7 +312,30 @@ shop-title = Obchod
shop-subtitle = prezrite si našu ponuku produktov. shop-subtitle = prezrite si našu ponuku produktov.
shop-empty = Zatiaľ tu nie sú žiadne produkty. shop-empty = Zatiaľ tu nie sú žiadne produkty.
search-placeholder = Hľadať produkty… search-placeholder = Hľadať produkty…
order-search-placeholder = Hľadať objednávky…
search-empty = Pre váš výraz sme nič nenašli: search-empty = Pre váš výraz sme nič nenašli:
results-count = { $count } produktov
sort-label = Zoradiť
per-page-label = Na stránku
sort-relevance = Relevancia
sort-newest = Najnovšie
sort-price_asc = Cena: od najnižšej
sort-price_desc = Cena: od najvyššej
sort-name_asc = Názov: AZ
sort-name_desc = Názov: ZA
filter-category = Kategória
filter-all-categories = Všetky kategórie
filter-uncategorized = Bez kategórie
filter-price = Cena
filter-price-from = Cena od
filter-price-to = Cena do
filter-in-stock = Len skladom
filter-apply = Použiť
filter-clear = Zrušiť
pagination = Stránkovanie
page-of = Strana { $page } z { $pages }
prev = Predchádzajúce
next = Ďalšie
view-grid = Zobrazenie v mriežke view-grid = Zobrazenie v mriežke
view-list = Zobrazenie v zozname view-list = Zobrazenie v zozname
categories = Kategórie categories = Kategórie
@@ -325,6 +352,8 @@ cart-continue = Pokračovať v nákupe
checkout-title = Pokladňa checkout-title = Pokladňa
checkout-contact = Kontaktné údaje checkout-contact = Kontaktné údaje
checkout-shipping = Dodacia adresa checkout-shipping = Dodacia adresa
checkout-residence-address = Adresa bydliska
checkout-delivery-same = Dodacia adresa je rovnaká ako adresa bydliska
checkout-email = E-mail checkout-email = E-mail
checkout-name = Meno a priezvisko checkout-name = Meno a priezvisko
checkout-phone = Telefón checkout-phone = Telefón
@@ -339,7 +368,8 @@ country-de = Nemecko
country-pl = Poľsko country-pl = Poľsko
country-hu = Maďarsko country-hu = Maďarsko
checkout-note = Poznámka k objednávke 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-type = Typ účtu
account-personal = Súkromná osoba account-personal = Súkromná osoba
account-company = Firma account-company = Firma
@@ -361,6 +391,11 @@ profile-last-name = Priezvisko
profile-edit = Upraviť profil profile-edit = Upraviť profil
profile-cancel = Zrušiť profile-cancel = Zrušiť
profile-not-set = Neuvedené profile-not-set = Neuvedené
profile-avatar = Profilová fotka
profile-avatar-hint = PNG, JPG, WEBP alebo GIF, max. 10 MB.
profile-avatar-choose = Vybrať fotku
profile-avatar-upload = Nahrať
profile-avatar-remove = Odstrániť fotku
nav-account = Môj účet nav-account = Môj účet
account-orders = Moje objednávky account-orders = Moje objednávky
account-change-password = Zmeniť heslo account-change-password = Zmeniť heslo
@@ -450,7 +485,21 @@ bank-variable-symbol = Variabilný symbol
bank-amount = Suma bank-amount = Suma
admin-shipping = Doprava admin-shipping = Doprava
admin-shipping-desc = nastaviť cenu a dostupnosť jednotlivých možností dopravy. admin-shipping-desc = nastaviť cenu a dostupnosť jednotlivých možností dopravy.
shipping-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 shipping-enabled = Aktívne
admin-currency = Kurz
admin-currency-desc = nastaviť výmenný kurz pre meny, medzi ktorými môžu zákazníci prepínať. Ceny zadávate vždy v EUR.
currency-rate = Kurz
exchange-rate = Výmenný kurz
exchange-rate-hint = ceny v { $code } sa prepočítajú z ceny v { $base } týmto kurzom.
currency-enabled = Dostupná pre zákazníkov
currency-base = Základná mena
currency-base-hint = mena, v ktorej zadávate ceny a prebieha platba. Nedá sa zmeniť.
shipping-new = Pridať možnosť dopravy shipping-new = Pridať možnosť dopravy
shipping-add = Pridať shipping-add = Pridať
shipping-requires-pickup = Vyžaduje výdajné miesto shipping-requires-pickup = Vyžaduje výdajné miesto
@@ -467,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-hint = Keď je tovar pripravený, odošlite objednávku dopravcovi.
order-send-to-carrier = Odoslať dopravcovi order-send-to-carrier = Odoslať dopravcovi
order-send-confirm = Odoslať túto objednávku dopravcovi teraz? order-send-confirm = Odoslať túto objednávku dopravcovi teraz?
# --- storefront chrome: top bar, header, footer ---
brand-subtitle = zdravotnícke potreby
top-contact = Kontakt
top-sitemap = Mapa stránky
search-button = Hľadať
search-scope-in = Hľadáte v kategórii:
search-scope-all = Hľadať v celom obchode
welcome = Vitajte
cart-units = ks
hotline = +421 903 410 476
footer-tagline = Zdravotnícke potreby pre ambulancie, nemocnice a domácu starostlivosť. Dodanie do 24 hodín.
footer-info = Informácie
footer-account = Účet
footer-contact = Kontakt
footer-terms = Obchodné podmienky
footer-about = O našej spoločnosti
footer-stores = 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

@@ -0,0 +1,174 @@
// Quill-based rich text editor, ported from the universal_web blog editor and
// adapted to this shop: each editor lives in a `[data-rich-field]` wrapper so a
// single form can host several (e.g. short + long description); image uploads go
// to this app's /images/upload and carry the CSRF token the middleware expects.
(function () {
function setImageSize(image, size) {
image.classList.remove('rich-image-small', 'rich-image-medium', 'rich-image-full');
image.style.removeProperty('width');
image.style.removeProperty('height');
image.classList.add('rich-image-' + size);
}
function setImageWidth(image, width) {
var px = parseInt(width, 10);
if (!Number.isFinite(px) || px < 40) return;
image.classList.remove('rich-image-small', 'rich-image-medium', 'rich-image-full');
image.style.width = Math.min(px, 1200) + 'px';
image.style.height = 'auto';
}
function normalizeEditorImages(root) {
root.querySelectorAll('img').forEach(function (image) {
if (
!image.classList.contains('rich-image-small')
&& !image.classList.contains('rich-image-medium')
&& !image.classList.contains('rich-image-full')
) {
image.classList.add('rich-image-full');
}
});
}
// The CSRF middleware accepts the token as an X-CSRF-Token header; read it from
// the form's hidden _csrf field (rendered by ui::csrf_field()).
function csrfToken(field) {
var form = field.closest('form');
var input = form && form.querySelector('input[name="_csrf"]');
return input ? input.value : '';
}
function initField(field) {
var editorEl = field.querySelector('[data-rich-editor]');
var contentInput = field.querySelector('[data-rich-content]');
var status = field.querySelector('[data-rich-status]');
var imageControls = field.querySelector('[data-image-size-controls]');
var imageWidthInput = field.querySelector('[data-image-width]');
if (!editorEl || !contentInput || !window.Quill) return;
var selectedImage = null;
var toolbar = [
[{ header: [2, 3, false] }],
['bold', 'italic'],
[{ list: 'ordered' }, { list: 'bullet' }],
['link', 'image'],
['clean']
];
var editor = new Quill(editorEl, {
modules: { toolbar: toolbar },
placeholder: editorEl.dataset.placeholder || '',
theme: 'snow'
});
var initialContent = contentInput.value.trim();
if (initialContent) {
if (initialContent.indexOf('<') >= 0) editor.clipboard.dangerouslyPasteHTML(initialContent);
else editor.setText(initialContent);
normalizeEditorImages(editor.root);
}
function syncContent() {
normalizeEditorImages(editor.root);
// Quill leaves an empty editor as "<p><br></p>"; store empty instead so the
// server sees a blank (nullable) value rather than stray markup.
var html = editor.root.innerHTML;
contentInput.value = editor.getText().trim() === '' && !editor.root.querySelector('img')
? ''
: html;
}
function setStatus(message) {
if (status) status.textContent = message || '';
}
function chooseImageFile() {
var input = document.createElement('input');
input.type = 'file';
input.accept = 'image/jpeg,image/png,image/webp,image/gif';
input.addEventListener('change', function () {
var file = input.files && input.files[0];
if (!file) return;
uploadImage(file);
});
input.click();
}
async function uploadImage(file) {
var formData = new FormData();
formData.append('file', file);
setStatus(status ? status.dataset.uploading : '');
try {
var response = await fetch('/images/upload', {
method: 'POST',
body: formData,
credentials: 'same-origin',
headers: { 'X-CSRF-Token': csrfToken(field) }
});
if (!response.ok) throw new Error('upload failed');
var result = await response.json();
var range = editor.getSelection(true);
editor.insertEmbed(range.index, 'image', result.url, 'user');
editor.setSelection(range.index + 1, 0, 'silent');
window.setTimeout(function () {
var images = editor.root.querySelectorAll('img');
var image = images[images.length - 1];
if (image) {
setImageSize(image, 'full');
selectedImage = image;
if (imageControls) imageControls.classList.remove('hidden');
}
syncContent();
}, 0);
setStatus(status ? status.dataset.uploaded : '');
} catch (_error) {
setStatus(status ? status.dataset.error : '');
}
}
editor.getModule('toolbar').addHandler('image', chooseImageFile);
editor.root.addEventListener('click', function (event) {
if (event.target && event.target.tagName === 'IMG') {
selectedImage = event.target;
if (imageWidthInput) imageWidthInput.value = parseInt(selectedImage.style.width, 10) || '';
if (imageControls) imageControls.classList.remove('hidden');
}
});
if (imageControls) {
imageControls.addEventListener('click', function (event) {
var button = event.target.closest('[data-image-size]');
if (button && selectedImage) {
setImageSize(selectedImage, button.dataset.imageSize);
if (imageWidthInput) imageWidthInput.value = '';
syncContent();
}
});
}
if (imageWidthInput) {
imageWidthInput.addEventListener('change', function () {
if (!selectedImage) return;
setImageWidth(selectedImage, imageWidthInput.value);
syncContent();
});
}
editor.on('text-change', syncContent);
var form = field.closest('form');
if (form) form.addEventListener('submit', syncContent);
syncContent();
}
function initAll(root) {
(root || document).querySelectorAll('[data-rich-field]').forEach(function (field) {
if (field.dataset.richReady) return;
field.dataset.richReady = '1';
initField(field);
});
}
document.addEventListener('DOMContentLoaded', function () { initAll(document); });
// Re-init after htmx swaps a fragment containing an editor into the page.
document.addEventListener('htmx:afterSwap', function (event) { initAll(event.target); });
})();

31
assets/static/vendor/quill/LICENSE vendored Normal file
View File

@@ -0,0 +1,31 @@
Copyright (c) 2017-2024, Slab
Copyright (c) 2014, Jason Chen
Copyright (c) 2013, salesforce.com
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions
are met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
3. Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

3
assets/static/vendor/quill/quill.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,7 @@
/*!
* Quill Editor v2.0.3
* https://quilljs.com
* Copyright (c) 2017-2024, Slab
* Copyright (c) 2014, Jason Chen
* Copyright (c) 2013, salesforce.com
*/

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,82 +15,80 @@
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products?audience=" ~ audience, size="px-3 py-2 text-sm") }} {{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products?audience=" ~ audience, size="px-3 py-2 text-sm") }}
</div> </div>
{# One discount row per option (variant). Each row picks a fixed sale price or a #}
{# percentage off its own regular price; a blank input clears that option's #}
{# discount. Both the fixed and percent inputs always submit (the server reads the #}
{# active mode); rows are pre-filled from `rows` (DB values, or submitted values #}
{# when repainting after a validation error) and indexed as v[<variant id>][...]. #}
<script id="discount-data" type="application/json">{{ rows | json_encode() | safe }}</script>
<form method="post" action="/admin/catalog/products/{{ product.id }}/discount?audience={{ audience }}" <form method="post" action="/admin/catalog/products/{{ product.id }}/discount?audience={{ audience }}"
x-data="{ x-data="discountEditor(JSON.parse(document.getElementById('discount-data').textContent))"
mode: '{{ mode }}', class="mt-6 max-w-2xl space-y-5">
fixed: '{{ fixed }}',
percent: '{{ percent }}',
regular: {{ product.regular_cents }},
num(v) { let n = parseFloat(String(v).replace(',', '.')); return isFinite(n) ? n : null; },
get afterCents() {
if (this.mode === 'percent') {
let p = this.num(this.percent); if (p === null) return null;
return this.regular - Math.round(this.regular * p / 100);
}
let f = this.num(this.fixed); if (f === null) return null;
return Math.round(f * 100);
},
money(c) { return (c / 100).toFixed(2); },
get valid() { let a = this.afterCents; return a !== null && a > 0 && a < this.regular; },
get percentOff() { let a = this.afterCents; return (a === null || this.regular <= 0) ? null : Math.round((this.regular - a) / this.regular * 100); }
}"
class="mt-6 max-w-md space-y-5 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
{{ ui::csrf_field() }} {{ ui::csrf_field() }}
{% if error %} {% if error %}
{{ ui::alert_danger(message=t(key=error, lang=lang | default(value='sk'))) }} {{ ui::alert_danger(message=t(key=error, lang=lang | default(value='sk'))) }}
{% endif %} {% endif %}
<div class="flex items-center justify-between gap-3 rounded-radius bg-surface-alt px-4 py-3 dark:bg-surface-dark/40"> <template x-for="row in rows" :key="row.id">
<span class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="price", lang=lang | default(value='sk')) }}</span> <div class="space-y-4 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
<span class="font-semibold tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.regular_price }} {{ product.currency }}</span> <div class="flex items-center justify-between gap-3">
<span class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong"
x-text="row.label || ('#' + row.id)"></span>
<span class="text-sm tabular-nums text-on-surface/70 dark:text-on-surface-dark/70">
{{ t(key="price", lang=lang | default(value='sk')) }}:
<span x-text="row.regular_price"></span>
</span>
</div> </div>
<input type="hidden" :name="`v[${row.id}][mode]`" :value="row.mode">
<div class="grid gap-4 sm:grid-cols-2">
<!-- mode toggle --> <!-- mode toggle -->
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
<label class="flex cursor-pointer items-center justify-center gap-2 rounded-radius border px-3 py-2 text-sm transition" <label class="flex cursor-pointer items-center justify-center gap-2 rounded-radius border px-3 py-2 text-sm transition"
:class="mode === 'fixed' ? 'border-primary bg-primary/10 text-on-surface-strong dark:border-primary-dark dark:bg-primary-dark/10 dark:text-on-surface-dark-strong' : 'border-outline text-on-surface dark:border-outline-dark dark:text-on-surface-dark'"> :class="row.mode === 'fixed' ? 'border-primary bg-primary/10 text-on-surface-strong dark:border-primary-dark dark:bg-primary-dark/10 dark:text-on-surface-dark-strong' : 'border-outline text-on-surface dark:border-outline-dark dark:text-on-surface-dark'">
<input type="radio" name="mode" value="fixed" x-model="mode" class="sr-only"> <input type="radio" :name="`mode-ui-${row.id}`" value="fixed" x-model="row.mode" class="sr-only">
{{ t(key="discount-mode-fixed", lang=lang | default(value='sk')) }} {{ t(key="discount-mode-fixed", lang=lang | default(value='sk')) }}
</label> </label>
<label class="flex cursor-pointer items-center justify-center gap-2 rounded-radius border px-3 py-2 text-sm transition" <label class="flex cursor-pointer items-center justify-center gap-2 rounded-radius border px-3 py-2 text-sm transition"
:class="mode === 'percent' ? 'border-primary bg-primary/10 text-on-surface-strong dark:border-primary-dark dark:bg-primary-dark/10 dark:text-on-surface-dark-strong' : 'border-outline text-on-surface dark:border-outline-dark dark:text-on-surface-dark'"> :class="row.mode === 'percent' ? 'border-primary bg-primary/10 text-on-surface-strong dark:border-primary-dark dark:bg-primary-dark/10 dark:text-on-surface-dark-strong' : 'border-outline text-on-surface dark:border-outline-dark dark:text-on-surface-dark'">
<input type="radio" name="mode" value="percent" x-model="mode" class="sr-only"> <input type="radio" :name="`mode-ui-${row.id}`" value="percent" x-model="row.mode" class="sr-only">
{{ t(key="discount-mode-percent", lang=lang | default(value='sk')) }} {{ t(key="discount-mode-percent", lang=lang | default(value='sk')) }}
</label> </label>
</div> </div>
<!-- fixed price input --> <!-- value input: both fields stay in the DOM and submit; the server reads
<div class="space-y-1.5" x-show="mode === 'fixed'"> whichever matches the row's mode -->
<label for="sale_price" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="sale-price", lang=lang | default(value='sk')) }}</label> <div class="space-y-1.5">
{{ ui::input(name="sale_price", id="sale_price", value=fixed, placeholder="0.00", attrs='inputmode="decimal" x-model="fixed"') }} <div x-show="row.mode === 'fixed'">
<label class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="sale-price", lang=lang | default(value='sk')) }}</label>
<input :name="`v[${row.id}][fixed]`" x-model="row.fixed" inputmode="decimal" placeholder="0.00"
class="w-full rounded-radius border border-outline bg-surface-alt px-4 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark">
</div>
<div x-show="row.mode === 'percent'">
<label class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="discount-percent", lang=lang | default(value='sk')) }}</label>
<input :name="`v[${row.id}][percent]`" x-model="row.percent" inputmode="decimal" min="0" max="100" placeholder="0"
class="w-full rounded-radius border border-outline bg-surface-alt px-4 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark">
</div>
</div> </div>
<!-- percentage input -->
<div class="space-y-1.5" x-show="mode === 'percent'">
<label for="percent" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="discount-percent", lang=lang | default(value='sk')) }}</label>
{{ ui::input(name="percent", id="percent", value=percent, placeholder="0", attrs='inputmode="decimal" min="0" max="100" x-model="percent"') }}
</div> </div>
<!-- live preview --> <!-- live preview -->
<div x-show="afterCents !== null" x-cloak <div x-show="afterCents(row) !== null" x-cloak
class="space-y-2 rounded-radius border border-outline bg-surface-alt px-4 py-3 dark:border-outline-dark dark:bg-surface-dark/40"> class="flex flex-wrap items-center justify-between gap-3 rounded-radius border border-outline bg-surface-alt px-4 py-2.5 text-sm dark:border-outline-dark dark:bg-surface-dark/40">
<div class="flex items-center justify-between gap-3 text-sm"> <span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="discount-preview-after", lang=lang | default(value='sk')) }}</span>
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="discount-preview-before", lang=lang | default(value='sk')) }}</span> <span class="flex items-center gap-2">
<span class="tabular-nums text-on-surface/60 line-through dark:text-on-surface-dark/60"><span x-text="money(regular)"></span> {{ product.currency }}</span> <span class="tabular-nums text-on-surface/50 line-through dark:text-on-surface-dark/50" x-text="money(row.regular_cents) + ' €'"></span>
</div> <span class="text-base font-semibold tabular-nums" :class="valid(row) ? 'text-danger' : 'text-on-surface/40 dark:text-on-surface-dark/40'"
<div class="flex items-center justify-between gap-3"> x-text="money(afterCents(row)) + ' €'"></span>
<span class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="discount-preview-after", lang=lang | default(value='sk')) }}</span> <span x-show="valid(row)" class="text-xs text-on-surface/60 dark:text-on-surface-dark/60" x-text="'(' + percentOff(row) + '%)'"></span>
<span class="text-lg font-semibold tabular-nums" :class="valid ? 'text-danger' : 'text-on-surface/40 dark:text-on-surface-dark/40'">
<span x-text="money(afterCents)"></span> {{ product.currency }}
</span> </span>
</div> </div>
<div x-show="valid" class="flex items-center justify-between gap-3 text-xs text-on-surface/60 dark:text-on-surface-dark/60"> <p x-show="afterCents(row) !== null && !valid(row)" class="text-xs text-danger">{{ t(key="discount-below-regular", lang=lang | default(value='sk')) }}</p>
<span>{{ t(key="discount-preview-save", lang=lang | default(value='sk')) }}</span>
<span class="tabular-nums"><span x-text="money(regular - afterCents)"></span> {{ product.currency }} (<span x-text="percentOff"></span>%)</span>
</div>
<p x-show="!valid" class="text-xs text-danger">{{ t(key="discount-below-regular", lang=lang | default(value='sk')) }}</p>
</div> </div>
</template>
<div class="flex flex-wrap gap-3 pt-2"> <div class="flex flex-wrap gap-3 pt-2">
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", attrs=`onclick="return confirm('` ~ t(key="discount-apply-confirm", lang=lang | default(value='sk')) ~ `')"`) }} {{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", attrs=`onclick="return confirm('` ~ t(key="discount-apply-confirm", lang=lang | default(value='sk')) ~ `')"`) }}
@@ -99,4 +97,35 @@
{% endif %} {% endif %}
</div> </div>
</form> </form>
<script>
function discountEditor(initial) {
return {
rows: (initial || []).map(r => ({
id: r.id,
label: r.label || '',
regular_cents: r.regular_cents,
regular_price: r.regular_price,
mode: r.mode || 'fixed',
fixed: r.fixed || '',
percent: r.percent || '',
})),
num(v) { let n = parseFloat(String(v).replace(',', '.')); return isFinite(n) ? n : null; },
money(c) { return (c / 100).toFixed(2); },
afterCents(row) {
if (row.mode === 'percent') {
let p = this.num(row.percent); if (p === null) return null;
return row.regular_cents - Math.round(row.regular_cents * p / 100);
}
let f = this.num(row.fixed); if (f === null) return null;
return Math.round(f * 100);
},
valid(row) { let a = this.afterCents(row); return a !== null && a > 0 && a < row.regular_cents; },
percentOff(row) {
let a = this.afterCents(row);
return (a === null || row.regular_cents <= 0) ? null : Math.round((row.regular_cents - a) / row.regular_cents * 100);
},
};
}
</script>
{% endblock content %} {% endblock content %}

View File

@@ -3,6 +3,9 @@
{% block title %}{% if product %}{{ t(key="edit-product", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-product", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %} {% block title %}{% if product %}{{ t(key="edit-product", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-product", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
{% block crumb %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock crumb %} {% block crumb %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% block head %}
<link href="/static/vendor/quill/quill.snow.css" rel="stylesheet" type="text/css">
{% endblock head %}
{% block content %} {% block content %}
<div class="flex items-center justify-between gap-3"> <div class="flex items-center justify-between gap-3">
@@ -18,9 +21,9 @@
{{ ui::csrf_field() }} {{ ui::csrf_field() }}
{% if product %} {% if product %}
{% set v_name = product.name %}{% set v_currency = product.currency %}{% set v_desc = product.description | default(value="") %}{% set v_pub = product.published %} {% set v_name = product.name %}{% set v_desc = product.description | default(value="") %}{% set v_short = product.short_description | default(value="") %}{% set v_pub = product.published %}
{% else %} {% else %}
{% set v_name = "" %}{% set v_currency = "EUR" %}{% set v_desc = "" %}{% set v_pub = false %} {% set v_name = "" %}{% set v_desc = "" %}{% set v_short = "" %}{% set v_pub = false %}
{% endif %} {% endif %}
{% set inp = "w-full rounded-radius border border-outline bg-surface-alt px-3 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark" %} {% set inp = "w-full rounded-radius border border-outline bg-surface-alt px-3 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark" %}
{% set sublabel = "text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70" %} {% set sublabel = "text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70" %}
@@ -30,11 +33,6 @@
{{ ui::input(name="name", id="name", required=true, value=v_name) }} {{ ui::input(name="name", id="name", required=true, value=v_name) }}
</div> </div>
<div class="space-y-1.5 sm:max-w-[10rem]">
<label for="currency" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="currency", lang=lang | default(value='sk')) }}</label>
{{ ui::input(name="currency", id="currency", value=v_currency, attrs='maxlength="3"', extra="uppercase") }}
</div>
{# --- Variants / options editor ------------------------------------------- #} {# --- Variants / options editor ------------------------------------------- #}
{# Each product is sold as one or more variants (a free-text label such as #} {# Each product is sold as one or more variants (a free-text label such as #}
{# "10cm x 13cm" or "5ml" plus its own price). Price is required. Stock is #} {# "10cm x 13cm" or "5ml" plus its own price). Price is required. Stock is #}
@@ -60,7 +58,7 @@
{# items-end bottom-aligns every input regardless of how many lines each {# items-end bottom-aligns every input regardless of how many lines each
label takes, so the row stays aligned even with the "(optional)" notes. #} label takes, so the row stays aligned even with the "(optional)" notes. #}
<div class="grid flex-1 grid-cols-2 gap-3 sm:grid-cols-12 sm:items-end"> <div class="grid flex-1 grid-cols-2 gap-3 sm:grid-cols-12 sm:items-end">
<div class="space-y-1 col-span-2 sm:col-span-4"> <div class="space-y-1 col-span-2 sm:col-span-6">
<label class="{{ sublabel }} block truncate">{{ t(key="option-label", lang=lang | default(value='sk')) }}{{ opt }}</label> <label class="{{ sublabel }} block truncate">{{ t(key="option-label", lang=lang | default(value='sk')) }}{{ opt }}</label>
<input :name="`variants[${i}][label]`" x-model="row.label" class="{{ inp }}" placeholder="napr. 10cm x 13cm"> <input :name="`variants[${i}][label]`" x-model="row.label" class="{{ inp }}" placeholder="napr. 10cm x 13cm">
</div> </div>
@@ -73,13 +71,9 @@
<input type="number" min="0" :name="`variants[${i}][stock]`" x-model="row.stock" class="{{ inp }}" placeholder="∞" title="{{ t(key='stock-untracked-hint', lang=lang | default(value='sk')) }}"> <input type="number" min="0" :name="`variants[${i}][stock]`" x-model="row.stock" class="{{ inp }}" placeholder="∞" title="{{ t(key='stock-untracked-hint', lang=lang | default(value='sk')) }}">
</div> </div>
<div class="space-y-1 sm:col-span-2"> <div class="space-y-1 sm:col-span-2">
<label class="{{ sublabel }} block truncate">{{ t(key="price", lang=lang | default(value='sk')) }}</label> <label class="{{ sublabel }} block truncate">{{ t(key="price", lang=lang | default(value='sk')) }} (€)</label>
<input :name="`variants[${i}][price]`" x-model="row.price" inputmode="decimal" required class="{{ inp }}" placeholder="0.00"> <input :name="`variants[${i}][price]`" x-model="row.price" inputmode="decimal" required class="{{ inp }}" placeholder="0.00">
</div> </div>
<div class="space-y-1 sm:col-span-2">
<label class="{{ sublabel }} block truncate">{{ t(key="business-price", lang=lang | default(value='sk')) }}{{ opt }}</label>
<input :name="`variants[${i}][business_sale]`" x-model="row.business_sale" inputmode="decimal" class="{{ inp }}" placeholder="—">
</div>
</div> </div>
<button type="button" @click="remove(i)" <button type="button" @click="remove(i)"
@@ -90,7 +84,7 @@
<script> <script>
function variantEditor(initial) { function variantEditor(initial) {
const blank = () => ({ id: '', label: '', sku: '', stock: '', price: '', business_sale: '' }); const blank = () => ({ id: '', label: '', sku: '', stock: '', price: '' });
return { return {
rows: (initial || []).map(r => ({ rows: (initial || []).map(r => ({
id: r.id || '', id: r.id || '',
@@ -98,7 +92,6 @@
sku: r.sku || '', sku: r.sku || '',
stock: (r.stock === null || r.stock === undefined) ? '' : r.stock, stock: (r.stock === null || r.stock === undefined) ? '' : r.stock,
price: r.price || '', price: r.price || '',
business_sale: r.business_sale || '',
})), })),
init() { if (this.rows.length === 0) this.add(); }, init() { if (this.rows.length === 0) this.add(); },
add() { this.rows.push(blank()); }, add() { this.rows.push(blank()); },
@@ -122,8 +115,14 @@
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="description" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="description", lang=lang | default(value='sk')) }}</label> <span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="short-description", lang=lang | default(value='sk')) }}</span>
{{ ui::textarea(name="description", id="description", rows="5", value=v_desc) }} <p class="{{ sublabel }}">{{ t(key="short-description-hint", lang=lang | default(value='sk')) }}</p>
{{ ui::rich_editor(name="short_description", lang=lang | default(value='sk'), value=v_short, min_height="6rem") }}
</div>
<div class="space-y-1.5">
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="description", lang=lang | default(value='sk')) }}</span>
{{ ui::rich_editor(name="description", lang=lang | default(value='sk'), value=v_desc, min_height="16rem") }}
</div> </div>
{# --- Images gallery ------------------------------------------------------- #} {# --- Images gallery ------------------------------------------------------- #}
@@ -215,4 +214,6 @@
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products") }} {{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products") }}
</div> </div>
</form> </form>
<script src="/static/vendor/quill/quill.js"></script>
<script src="/static/js/rich-editor.js"></script>
{% endblock content %} {% endblock content %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"> <div class="min-w-40">
<p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ method.name }}</p> <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> <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>
<div class="space-y-1.5"> <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> <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"') }} {{ ui::input(name="price", id="price-" ~ method.id, value=method.price, width="w-28", attrs='inputmode="decimal"') }}
</div> </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") }} {{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", extra="ml-auto") }}
</form> </form>
{% endfor %} {% endfor %}

View File

@@ -73,42 +73,85 @@
x-data="{ cats: false, lg: window.matchMedia('(min-width: 1024px)').matches }" x-data="{ cats: false, lg: window.matchMedia('(min-width: 1024px)').matches }"
x-init="window.matchMedia('(min-width: 1024px)').addEventListener('change', e => lg = e.matches)" x-init="window.matchMedia('(min-width: 1024px)').addEventListener('change', e => lg = e.matches)"
class="min-h-screen bg-surface text-on-surface antialiased dark:bg-surface-dark dark:text-on-surface-dark"> class="min-h-screen bg-surface text-on-surface antialiased dark:bg-surface-dark dark:text-on-surface-dark">
<!-- top utility bar (Kompress design): primary nav on the left, contact /
sitemap links on the right. Non-sticky — it scrolls away above the
sticky header. -->
<div class="hidden border-b border-outline bg-surface text-xs sm:block dark:border-outline-dark dark:bg-surface-dark">
<div class="mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-2 text-on-surface/70 dark:text-on-surface-dark/70">
<div class="flex items-center gap-5">
<a href="/" data-nav="/" class="transition hover:text-primary aria-[current=page]:font-semibold aria-[current=page]:text-primary dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-home", lang=lang | default(value='sk')) }}</a>
<a href="/shop" data-nav="/shop" class="transition hover:text-primary aria-[current=page]:font-semibold aria-[current=page]:text-primary dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</a>
</div>
<div class="flex items-center gap-4">
<a href="/kontakt" class="transition hover:text-primary dark:hover:text-primary-dark">{{ t(key="top-contact", lang=lang | default(value='sk')) }}</a>
<span class="h-3 w-px bg-outline dark:bg-outline-dark"></span>
<a href="/mapa-stranky" class="transition hover:text-primary dark:hover:text-primary-dark">{{ t(key="top-sitemap", lang=lang | default(value='sk')) }}</a>
</div>
</div>
</div>
<header <header
class="sticky top-0 z-30 border-b border-outline bg-surface/95 backdrop-blur dark:border-outline-dark dark:bg-surface-dark/95"> class="sticky top-0 z-30 border-b border-outline bg-surface/95 backdrop-blur dark:border-outline-dark dark:bg-surface-dark/95">
<nav x-data="{ mobile: false }" class="mx-auto flex max-w-7xl items-center gap-4 px-4 py-3"> <nav class="mx-auto flex max-w-7xl items-center gap-3 px-4 py-3 sm:gap-4">
<!-- category sidebar toggle (mobile only) --> <!-- category sidebar toggle (mobile only) -->
{% set hamburger_icon = ui::icon(name="hamburger", size="size-6") %} {% set hamburger_icon = ui::icon(name="hamburger", size="size-6") %}
{{ ui::icon_button(aria_label=t(key='categories', lang=lang | default(value='sk')), attrs='@click="cats = !cats" :aria-expanded="cats"', extra="lg:hidden", icon=hamburger_icon) }} {{ ui::icon_button(aria_label=t(key='categories', lang=lang | default(value='sk')), attrs='@click="cats = !cats" :aria-expanded="cats"', extra="lg:hidden", icon=hamburger_icon) }}
<a href="/"
class="text-lg font-bold tracking-tight text-on-surface-strong dark:text-on-surface-dark-strong"> <!-- real KOMPRESS logo from www.e-shop.kompress.sk (hidden on mobile;
{{ t(key="brand", lang=lang | default(value='sk')) }} 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> </a>
<!-- desktop links — Penguin navbar link treatment via ui::nav_link --> <!-- in-header search → existing GET /search (q param). Hidden on small
<ul class="ml-2 hidden items-center gap-6 md:flex"> screens; the shop page keeps its compact mobile search row there. -->
<li>{{ ui::nav_link(label=t(key="nav-home", lang=lang | default(value='sk')), href="/", data_nav="/") }}</li> <form action="/search" method="get" role="search" class="hidden min-w-0 flex-1 md:flex md:max-w-sm lg:max-w-md">
<li>{{ ui::nav_link(label=t(key="nav-shop", lang=lang | default(value='sk')), href="/shop", data_nav="/shop") }}</li> {% if selected_category and selected_category != "all" %}
<input type="hidden" name="category" value="{{ selected_category }}" />
{% endif %}
<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 + account + cart + settings + mobile toggle -->
<div class="ml-auto flex items-center gap-2 sm:gap-3">
<!-- exchange-rate ("kurz") display: the admin-set EUR→alt rate(s).
Hidden when the store is EUR-only (no enabled alternatives). -->
{% set nav_cc = currencies() %}
{% if nav_cc.alts | length > 0 %}
<div class="hidden items-center gap-2 text-xs text-on-surface/70 dark:text-on-surface-dark/70 sm:flex">
<span class="font-semibold uppercase tracking-wide">{{ t(key="currency-rate", lang=lang | default(value='sk')) }}</span>
{% for a in nav_cc.alts %}
<span class="tabular-nums">1 {{ nav_cc.base.symbol }} = {{ a.rate }} {{ a.symbol }}</span>
{% endfor %}
</div>
{% endif %}
<!-- account area: admin quick links / customer profile dropdown /
guest two-line "Vitajte · Prihláste sa" button (Kompress design) -->
{% if logged_in_admin %} {% if logged_in_admin %}
<li>{{ ui::nav_link(label=t(key="admin-title", lang=lang | default(value='sk')), href="/admin/dashboard", data_nav="/admin", variant="warning", attrs='hx-boost="false"') }}</li> <div class="flex items-center gap-3">
<li> {{ ui::nav_link(label=t(key="admin-title", lang=lang | default(value='sk')), href="/admin/dashboard", data_nav="/admin", variant="warning", attrs='hx-boost="false"') }}
<form method="post" action="/logout" hx-boost="false"> <form method="post" action="/logout" hx-boost="false">
{{ ui::csrf_field() }} {{ ui::csrf_field() }}
<button type="submit" class="text-sm font-medium text-danger underline-offset-2 transition hover:opacity-75 focus:outline-hidden focus-visible:underline">{{ t(key="logout", lang=lang | default(value='sk')) }}</button> <button type="submit" class="text-sm font-medium text-danger underline-offset-2 transition hover:opacity-75 focus:outline-hidden focus-visible:underline">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
</form> </form>
</li> </div>
{% elif logged_in_customer %} {% elif logged_in_customer %}
{# customer account links live in the profile dropdown next to the cart #}
{% else %}
<li>{{ ui::nav_link(label=t(key="nav-login", lang=lang | default(value='sk')), href="/login", data_nav="/login") }}</li>
<li>{{ ui::nav_link(label=t(key="nav-register", lang=lang | default(value='sk')), href="/register", data_nav="/register") }}</li>
{% endif %}
</ul>
<!-- right side: cart + settings + mobile toggle -->
<div class="ml-auto flex items-center gap-3">
<!-- customer profile dropdown (avatar + name + account type) -->
{% if logged_in_customer %}
{% include "partials/profile_menu.html" %} {% include "partials/profile_menu.html" %}
{% else %}
<a href="/login" data-nav="/login" class="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 %} {% endif %}
<!-- cart: hover opens an Alza-style mini-cart preview (Penguin <!-- cart: hover opens an Alza-style mini-cart preview (Penguin
dropdown-with-hover), lazy-loaded from /partials/cart on each hover dropdown-with-hover), lazy-loaded from /partials/cart on each hover
@@ -127,10 +170,16 @@
hx-get="/partials/cart" hx-trigger="mouseenter delay:150ms" hx-target="#cart-preview-body" hx-swap="innerHTML" hx-get="/partials/cart" hx-trigger="mouseenter delay:150ms" hx-target="#cart-preview-body" hx-swap="innerHTML"
aria-label="{{ t(key='cart-title', lang=lang | default(value='sk')) }}" aria-label="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
title="{{ t(key='cart-title', lang=lang | default(value='sk')) }}" title="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
class="relative inline-flex size-9 shrink-0 items-center justify-center rounded-radius bg-transparent text-secondary transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-secondary active:opacity-100 active:outline-offset-0 dark:text-secondary-dark dark:focus-visible:outline-secondary-dark"> class="flex shrink-0 items-center gap-2.5 rounded-radius border border-outline bg-surface-alt px-2.5 py-1.5 text-on-surface transition hover:border-outline-strong focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:border-outline-dark-strong dark:focus-visible:outline-primary-dark">
{{ ui::icon(name="cart") }} <span class="relative inline-flex text-primary dark:text-primary-dark">
{{ ui::icon(name="cart", size="size-6") }}
<span x-show="count > 0" x-cloak x-text="count" <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> </a>
<!-- hover preview panel (no id on the panel → not htmx-settled on boosted nav) --> <!-- hover preview panel (no id on the panel → not htmx-settled on boosted nav) -->
<div x-cloak x-show="isOpen" x-transition <div x-cloak x-show="isOpen" x-transition
@@ -145,56 +194,39 @@
<!-- settings (language + theme) dropdown (self-contained Alpine state) --> <!-- settings (language + theme) dropdown (self-contained Alpine state) -->
{% include "partials/settings_dropdown.html" %} {% 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> </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> </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> </header>
<!-- dark overlay behind the category drawer on small screens --> <!-- dark overlay behind the category drawer on small screens -->
<div x-cloak x-show="cats" x-transition.opacity @click="cats = false" aria-hidden="true" <div x-cloak x-show="cats" x-transition.opacity @click="cats = false" aria-hidden="true"
class="fixed inset-0 z-30 bg-black/50 lg:hidden"></div> class="fixed inset-0 z-30 bg-black/50 lg:hidden"></div>
<div class="mx-auto flex w-full max-w-7xl gap-8 px-4 py-8"> <div class="mx-auto w-full max-w-7xl px-4 py-8">
<!-- page breadcrumbs: full width, above the sidebar + content row -->
{% block breadcrumbs %}{% endblock breadcrumbs %}
<div class="flex w-full gap-8">
{% if account_nav %} {% if account_nav %}
<!-- account-area sidebar: replaces the storefront categories while the <!-- account-area sidebar: replaces the storefront categories while the
customer is inside /account/*. --> customer is inside /account/*. -->
<aside x-cloak x-show="cats || lg" aria-label="{{ t(key='nav-account', lang=lang | default(value='sk')) }}" <aside x-cloak x-show="cats || lg" aria-label="{{ t(key='nav-account', lang=lang | default(value='sk')) }}"
class="fixed inset-y-0 left-0 z-40 w-64 overflow-y-auto border-r border-outline bg-surface-alt p-4 lg:static lg:z-auto lg:w-64 lg:shrink-0 lg:self-start lg:overflow-visible lg:rounded-radius lg:border lg:p-3 dark:border-outline-dark dark:bg-surface-dark-alt"> class="fixed inset-y-0 left-0 z-40 w-64 overflow-y-auto border-r border-outline bg-surface-alt p-4 lg:sticky lg:top-24 lg:z-auto lg:w-64 lg:shrink-0 lg:self-start lg:overflow-visible lg:rounded-radius lg:border lg:p-3 dark:border-outline-dark dark:bg-surface-dark-alt">
<h2 class="px-3 pb-2 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="nav-account", lang=lang | default(value='sk')) }}</h2> <h2 class="px-3 pb-2 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="nav-account", lang=lang | default(value='sk')) }}</h2>
<ul class="space-y-1"> <ul class="space-y-1">
<li><a href="/account/orders" data-nav="/account/orders" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="account-orders", lang=lang | default(value='sk')) }}</a></li> <li><a href="/account/orders" data-nav="/account/orders" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="account-orders", lang=lang | default(value='sk')) }}</a></li>
@@ -214,7 +246,7 @@
<aside id="category-sidebar" hx-preserve="true" <aside id="category-sidebar" hx-preserve="true"
x-cloak x-show="cats || lg" aria-label="{{ t(key='categories', lang=lang | default(value='sk')) }}" x-cloak x-show="cats || lg" aria-label="{{ t(key='categories', lang=lang | default(value='sk')) }}"
hx-get="/partials/categories" hx-trigger="load" hx-get="/partials/categories" hx-trigger="load"
class="fixed inset-y-0 left-0 z-40 w-64 overflow-y-auto border-r border-outline bg-surface-alt p-4 lg:static lg:z-auto lg:w-64 lg:shrink-0 lg:self-start lg:overflow-visible lg:rounded-radius lg:border lg:p-3 dark:border-outline-dark dark:bg-surface-dark-alt"> class="fixed inset-y-0 left-0 z-40 w-64 overflow-y-auto border-r border-outline bg-surface-alt p-4 lg:sticky lg:top-24 lg:z-auto lg:w-64 lg:shrink-0 lg:self-start lg:overflow-visible lg:rounded-radius lg:border lg:p-3 dark:border-outline-dark dark:bg-surface-dark-alt">
</aside> </aside>
{% endif %} {% endif %}
@@ -222,6 +254,41 @@
{% block content %}{% endblock content %} {% block content %}{% endblock content %}
</main> </main>
</div> </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'). <!-- toast notifications: fire from anywhere with toast('message').
Adapted from the vendored Penguin UI component 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 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 %} {% block content %}
<div class="space-y-12"> {% set L = lang | default(value='sk') %}
<!-- hero --> {# Home layout adapted from the Kompress design mockup: the left "Kategórie"
<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"> column is already supplied by base.html's #category-sidebar, so the main
<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> area is split into a featured product grid + a right rail (bestsellers /
<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> our stores / contact). All colors use the design tokens so light + dark
<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> 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> </section>
<!-- featured products --> <!-- featured products -->
{% if products | length > 0 %} {% if products | length > 0 %}
<section class="space-y-5"> <section class="space-y-4">
<div class="flex items-end justify-between"> <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> <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-medium text-primary dark:text-primary-dark">{{ t(key="cart-continue", lang=lang | default(value='sk')) }} →</a> <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>
<div 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 %} {% for product in products %}
{% include "shop/_card.html" %} {% include "shop/_card.html" %}
{% endfor %} {% endfor %}
</div> </div>
</section> </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 %} {% endif %}
</div> </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 %} {% endblock content %}

View File

@@ -36,7 +36,7 @@
<input type="hidden" name="_csrf" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf" value="{{ csrf_token() }}">
{%- endmacro %} {%- 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" -%} {%- 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 == "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" -%} {%- 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-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-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" -%} {%- 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 -%} {%- 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 %} {%- endmacro button %}
{# Icon-only button (square). Penguin ghost treatment (bg-transparent, {# Icon-only button (square). Penguin ghost treatment (bg-transparent,
@@ -132,10 +132,10 @@
{% macro eff_price(p, preview=false) -%} {% macro eff_price(p, preview=false) -%}
{%- if preview -%}{% set strong = "text-info" %}{%- else -%}{% set strong = "text-primary dark:text-primary-dark" %}{%- endif -%} {%- if preview -%}{% set strong = "text-info" %}{%- else -%}{% set strong = "text-primary dark:text-primary-dark" %}{%- endif -%}
{% if p.effective_reduced %} {% if p.effective_reduced %}
<span class="font-medium {{ strong }}">{{ p.effective_price }} {{ p.currency }}</span> <span class="font-medium {{ strong }}">{{ p.effective_price }} </span>
<span class="ml-1 text-xs text-on-surface/60 dark:text-on-surface-dark/60">({{ p.effective_percent_off }}%)</span> <span class="ml-1 text-xs text-on-surface/60 dark:text-on-surface-dark/60">({{ p.effective_percent_off }}%)</span>
{% else %} {% else %}
{{ p.effective_price }} {{ p.currency }} {{ p.effective_price }}
{% endif %} {% endif %}
{%- endmacro eff_price %} {%- endmacro eff_price %}
@@ -153,6 +153,34 @@
<textarea {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" rows="{{ rows }}"{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if required %} required{% endif %} class="w-full rounded-radius border border-outline bg-surface-alt px-2.5 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}>{{ value }}</textarea> <textarea {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" rows="{{ rows }}"{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if required %} required{% endif %} class="w-full rounded-radius border border-outline bg-surface-alt px-2.5 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}>{{ value }}</textarea>
{%- endmacro textarea %} {%- endmacro textarea %}
{# Quill rich-text editor (see /static/js/rich-editor.js + /static/vendor/quill).
The real value rides in a hidden <textarea> the editor keeps in sync, so it
submits like any other field. `value` pre-fills (HTML or plain text). Several
editors may share one form — each is scoped to its own [data-rich-field].
Requires the page to load quill.js + quill.snow.css + rich-editor.js (the
product form does so) and a _csrf field in the form for image uploads. #}
{% macro rich_editor(name, lang, value="", placeholder="", min_height="12rem") -%}
<div data-rich-field>
<textarea name="{{ name }}" data-rich-content class="hidden">{{ value }}</textarea>
<div data-rich-editor class="rich-editor" style="--rich-min-height: {{ min_height }};"{% if placeholder %} data-placeholder="{{ placeholder }}"{% endif %}></div>
<div data-image-size-controls class="rich-image-size-controls mt-2 hidden">
<span class="text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="image-size", lang=lang) }}</span>
<button type="button" data-image-size="small">{{ t(key="image-size-small", lang=lang) }}</button>
<button type="button" data-image-size="medium">{{ t(key="image-size-medium", lang=lang) }}</button>
<button type="button" data-image-size="full">{{ t(key="image-size-full", lang=lang) }}</button>
<label class="inline-flex items-center gap-1.5">
<span class="text-xs text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="image-width-px", lang=lang) }}</span>
<input type="number" min="40" max="1200" step="10" data-image-width
class="w-20 rounded-radius border border-outline bg-surface-alt px-2 py-1 text-sm text-on-surface dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark">
</label>
</div>
<p class="mt-1 text-xs text-on-surface/60 dark:text-on-surface-dark/60" data-rich-status
data-uploading="{{ t(key='image-uploading', lang=lang) }}"
data-uploaded="{{ t(key='image-uploaded', lang=lang) }}"
data-error="{{ t(key='image-upload-error', lang=lang) }}"></p>
</div>
{%- endmacro rich_editor %}
{# File input. #} {# File input. #}
{% macro file_input(name, id="", accept="", attrs="", extra="") -%} {% macro file_input(name, id="", accept="", attrs="", extra="") -%}
<input {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" type="file"{% if accept %} accept="{{ accept }}"{% endif %} class="w-full overflow-clip rounded-radius border border-outline bg-surface-alt/50 text-sm text-on-surface file:mr-4 file:border-none file:bg-surface-alt file:px-4 file:py-2 file:font-medium file:text-on-surface-strong focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:file:bg-surface-dark-alt dark:file:text-on-surface-dark-strong dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}/> <input {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" type="file"{% if accept %} accept="{{ accept }}"{% endif %} class="w-full overflow-clip rounded-radius border border-outline bg-surface-alt/50 text-sm text-on-surface file:mr-4 file:border-none file:bg-surface-alt file:px-4 file:py-2 file:font-medium file:text-on-surface-strong focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:file:bg-surface-dark-alt dark:file:text-on-surface-dark-strong dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}/>
@@ -239,3 +267,42 @@ border-t border-outline dark:border-outline-dark
{%- endif -%} {%- endif -%}
<a href="{{ href }}"{% if data_nav %} data-nav="{{ data_nav }}"{% endif %} class="text-sm font-medium underline-offset-2 transition focus:outline-hidden focus-visible:underline {{ c }}" {{ attrs | safe }}>{{ label }}</a> <a href="{{ href }}"{% if data_nav %} data-nav="{{ data_nav }}"{% endif %} class="text-sm font-medium underline-offset-2 transition focus:outline-hidden focus-visible:underline {{ c }}" {{ attrs | safe }}>{{ label }}</a>
{%- endmacro nav_link %} {%- endmacro nav_link %}
{# Breadcrumbs (Kompress design: chevron separators). Build a trail by emitting
one ui::crumb(label, href) per ancestor and a final ui::crumb_current(label)
for the active page, all inside <nav><ol></ol></nav>:
<nav aria-label="breadcrumb" class="text-sm">
<ol class="flex flex-wrap items-center gap-1.5 text-on-surface/60 dark:text-on-surface-dark/60">
{{ ui::crumb(label="Domov", href="/") }}
{{ ui::crumb(label="Obchod", href="/shop") }}
{{ ui::crumb_current(label=category.name) }}
</ol>
</nav>
Adapted from penguinui/breadcrumbs/breadcrumb-with-chevron.html. #}
{% macro crumb(label, href) -%}
<li class="flex items-center gap-1.5">
<a href="{{ href }}" class="transition hover:text-primary dark:hover:text-primary-dark">{{ label }}</a>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-3.5 shrink-0 text-on-surface/30 dark:text-on-surface-dark/30" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /></svg>
</li>
{%- endmacro crumb %}
{% macro crumb_current(label) -%}
<li class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong" aria-current="page">{{ label }}</li>
{%- endmacro crumb_current %}
{# Title for the static info pages (controllers/pages.rs → pages/info.html),
resolved from the `page` slug. Lives in a macro because a child template's
top-level {% set %} isn't visible inside its {% block %}s under `extends`;
the macro can be called from both the title and content blocks. #}
{% macro page_title(page, lang) -%}
{%- if page == "contact" -%}{{ t(key="top-contact", lang=lang) }}
{%- elif page == "sitemap" -%}{{ t(key="top-sitemap", lang=lang) }}
{%- elif page == "terms" -%}{{ t(key="footer-terms", lang=lang) }}
{%- elif page == "about" -%}{{ t(key="footer-about", lang=lang) }}
{%- elif page == "stores" -%}{{ t(key="footer-stores", lang=lang) }}
{%- elif page == "shipping" -%}{{ t(key="footer-shipping", lang=lang) }}
{%- else -%}{{ t(key="brand", lang=lang) }}
{%- endif -%}
{%- endmacro page_title %}

View File

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

View File

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

View File

@@ -12,36 +12,60 @@
that state (e.g. home) `view` is undefined, so the grid layout applies. #} that state (e.g. home) `view` is undefined, so the grid layout applies. #}
<article <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="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" <a href="/shop/{{ product.slug }}" class="flex min-w-0 flex-1"
:class="view === 'list' ? 'flex-row' : 'flex-col'"> :class="view === 'list' ? 'flex-row' : 'flex-col'">
<!-- Image --> <!-- Image -->
<div class="overflow-hidden bg-surface-alt dark:bg-surface-dark" <div class="relative overflow-hidden bg-surface-alt dark:bg-surface-dark"
:class="view === 'list' ? 'size-28 shrink-0 sm:size-40' : 'h-44 md:h-64'"> :class="view === 'list' ? '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 %} {% if product.image %}
<img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover transition duration-700 ease-out group-hover:scale-105"> <img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover transition duration-700 ease-out group-hover:scale-105">
{% endif %} {% endif %}
</div> </div>
<!-- Content --> <!-- Content -->
<div class="flex min-w-0 flex-1 flex-col gap-1" <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) --> <!-- Header: Title & Price (stacked so neither overflows the narrow card) -->
<h3 class="break-words text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3> <h3 class="break-words text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
{# Short blurb for the card; falls back to the full description (clamped)
for products without a dedicated short one. Both are authored as rich
text (Quill), so render the stored HTML — `.rich-blurb` strips block
spacing so the line-clamp stays tidy. Overflow is truncated with an
ellipsis: 2 lines in the grid, 3 in the roomier list row. #}
{% if product.short_description or product.description %}
<div class="rich-blurb line-clamp-2 break-words text-sm text-on-surface/70 dark:text-on-surface-dark/70"
:class="view === 'list' && 'line-clamp-3'">{% if product.short_description %}{{ product.short_description | safe }}{% else %}{{ product.description | safe }}{% endif %}</div>
{% endif %}
{% if product.on_sale %} {% if product.on_sale %}
<div class="flex flex-wrap items-baseline gap-x-2 leading-tight"> <div class="flex flex-wrap items-baseline gap-x-2 leading-tight">
<span class="text-xl font-semibold text-danger"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ product.currency }}</span> <span class="text-xl font-semibold text-danger"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ currency_symbol }}</span>
<span class="text-sm text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }} {{ product.currency }}</span> <span class="text-sm text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }} {{ currency_symbol }}</span>
</div> </div>
{% else %} {% else %}
<span class="break-words text-xl"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ product.currency }}</span> <span class="break-words text-xl"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ currency_symbol }}</span>
{% endif %} {% endif %}
<!-- stock pill (Kompress design): green "in stock" / red "sold out" -->
<div class="mt-0.5">
{% if product.in_stock %}
<span class="inline-flex items-center gap-1.5 rounded-full bg-success/10 px-2 py-0.5 text-xs font-semibold text-success">
<span class="size-1.5 rounded-full bg-success" aria-hidden="true"></span>{{ t(key="in-stock", lang=lang | default(value='sk')) }}
</span>
{% else %}
<span class="inline-flex items-center gap-1.5 rounded-full bg-danger/10 px-2 py-0.5 text-xs font-semibold text-danger">
<span class="size-1.5 rounded-full bg-danger" aria-hidden="true"></span>{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}
</span>
{% endif %}
</div>
</div> </div>
</a> </a>
<div class="flex flex-col gap-2" <div class="flex flex-col gap-2"
: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 %} {% if product.has_options %}
{# Multiple variants: customer must pick on the product page. #} {# 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 %} {% 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> <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" <form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none"
@@ -49,7 +73,7 @@
<input type="hidden" name="_csrf" value="{{ csrf_token() }}"> <input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="variant_id" value="{{ product.variant_id }}"> <input type="hidden" name="variant_id" value="{{ product.variant_id }}">
<input type="hidden" name="quantity" value="1"> <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> </form>
{% else %} {% 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> <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

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

View File

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

View File

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

View File

@@ -1,13 +1,58 @@
{# Search / listing results, swapped in by htmx on each query and rendered {# Results region: swapped in by htmx on each query/filter change and rendered
server-side on first load. Mirrors the empty-state handling of index.html. #} 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 %} {% if products | length > 0 %}
{% include "shop/_product_grid.html" %} {% include "shop/_product_grid.html" %}
{% if pages > 1 %}
<nav class="flex items-center justify-center gap-2 pt-2" aria-label="{{ t(key='pagination', lang=L) }}">
{% if has_prev %}
<button type="button"
hx-get="/search?{% if query_base %}{{ query_base }}&{% endif %}page={{ prev_page }}"
hx-target="#shop-results" hx-swap="innerHTML" hx-push-url="true"
class="rounded-radius border border-outline px-3 py-1.5 text-sm font-medium text-on-surface transition hover:bg-primary/5 hover:text-primary dark:border-outline-dark dark:text-on-surface-dark dark:hover:text-primary-dark">
{{ t(key="prev", lang=L) }}
</button>
{% endif %}
<span class="px-2 text-sm text-on-surface/70 dark:text-on-surface-dark/70">
{{ t(key="page-of", lang=L, page=page, pages=pages) }}
</span>
{% if has_next %}
<button type="button"
hx-get="/search?{% if query_base %}{{ query_base }}&{% endif %}page={{ next_page }}"
hx-target="#shop-results" hx-swap="innerHTML" hx-push-url="true"
class="rounded-radius border border-outline px-3 py-1.5 text-sm font-medium text-on-surface transition hover:bg-primary/5 hover:text-primary dark:border-outline-dark dark:text-on-surface-dark dark:hover:text-primary-dark">
{{ t(key="next", lang=L) }}
</button>
{% endif %}
</nav>
{% endif %}
{% elif query and query != "" %} {% elif query and query != "" %}
<div class="rounded-radius border border-outline px-6 py-16 text-center text-on-surface/70 dark:border-outline-dark dark:text-on-surface-dark/70"> <div class="rounded-radius border border-outline px-6 py-16 text-center text-on-surface/70 dark:border-outline-dark dark:text-on-surface-dark/70">
{{ t(key="search-empty", lang=lang | default(value='sk')) }} <span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ query }}</span> {{ t(key="search-empty", lang=L) }} <span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ query }}</span>
</div> </div>
{% else %} {% else %}
<div class="rounded-radius border border-outline px-6 py-16 text-center text-on-surface/70 dark:border-outline-dark dark:text-on-surface-dark/70"> <div class="rounded-radius border border-outline px-6 py-16 text-center text-on-surface/70 dark:border-outline-dark dark:text-on-surface-dark/70">
{{ t(key="shop-empty", lang=lang | default(value='sk')) }} {{ t(key="shop-empty", lang=L) }}
</div> </div>
{% endif %} {% endif %}
</div>

View File

@@ -0,0 +1,132 @@
{# 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 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"
{# 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="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>
<input type="search" name="q" value="{{ query | default(value='') }}" autocomplete="off"
placeholder="{{ t(key='search-placeholder', lang=L) }}"
aria-label="{{ t(key='search-placeholder', lang=L) }}"
class="w-full rounded-radius border border-outline bg-surface py-2 pl-10 pr-10 text-sm text-on-surface placeholder:text-on-surface/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50 dark:focus-visible:outline-primary-dark" />
<span id="search-spinner" class="htmx-indicator pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-on-surface/50 dark:text-on-surface-dark/50">
<svg class="size-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.4 0 0 5.4 0 12h4Z"></path>
</svg>
</span>
</div>
<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>
{# 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>
<!-- 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>
<!-- 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>
<div id="shop-results">
{% include "shop/_results.html" %}
</div>
</div>

View File

@@ -16,8 +16,13 @@
{{ t(key="categories", lang=lang | default(value='sk')) }} {{ t(key="categories", lang=lang | default(value='sk')) }}
</p> </p>
<div class="flex flex-col gap-1"> <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" <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')) }} {{ t(key="all-products", lang=lang | default(value='sk')) }}
</a> </a>
{% for group in category_groups %} {% 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)"> x-init="open = ['{{ group.slug }}'{% for child in group.children %}, '{{ child.slug }}'{% endfor %}].some(s => location.pathname === '/category/' + s)">
<div class="flex items-stretch"> <div class="flex items-stretch">
<a href="/category/{{ group.slug }}" data-nav="/category/{{ group.slug }}" <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 }} {{ group.name }}
</a> </a>
<button type="button" x-on:click="open = ! open" x-bind:aria-expanded="open ? 'true' : 'false'" <button type="button" x-on:click="open = ! open" x-bind:aria-expanded="open ? 'true' : 'false'"
@@ -42,7 +47,7 @@
{% for child in group.children %} {% for child in group.children %}
<li> <li>
<a href="/category/{{ child.slug }}" data-nav="/category/{{ child.slug }}" <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 }} {{ child.name }}
</a> </a>
</li> </li>
@@ -51,7 +56,7 @@
</div> </div>
{% else %} {% else %}
<a href="/category/{{ group.slug }}" data-nav="/category/{{ group.slug }}" <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 }} {{ group.name }}
</a> </a>
{% endif %} {% endif %}
@@ -60,3 +65,18 @@
{% if category_groups | length == 0 %} {% if category_groups | length == 0 %}
<p class="px-2 py-2 text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="shop-empty", lang=lang | default(value='sk')) }}</p> <p class="px-2 py-2 text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="shop-empty", lang=lang | default(value='sk')) }}</p>
{% endif %} {% endif %}
{# "Informácie" card (Kompress design): static info links below the category
tree, separated by a divider. Targets are placeholders (#) until real pages
exist; labels reuse the footer-* i18n keys. #}
<div class="mt-4 border-t border-outline pt-3 dark:border-outline-dark">
<p class="px-2 pb-2 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
{{ t(key="footer-info", lang=lang | default(value='sk')) }}
</p>
{% set L = lang | default(value='sk') %}
<div class="flex flex-col gap-0.5">
<a href="/obchodne-podmienky" 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,18 +3,24 @@
{% block title %}{{ category.name }}{% endblock title %} {% block title %}{{ category.name }}{% endblock title %}
{% block content %} {% block breadcrumbs %}
<div class="space-y-8"> {% set L = lang | default(value='sk') %}
<header class="space-y-2"> <nav aria-label="breadcrumb" class="mb-5 text-sm">
<nav class="text-sm text-on-surface/60 dark:text-on-surface-dark/60"> <ol class="flex flex-wrap items-center gap-1.5 text-on-surface/60 dark:text-on-surface-dark/60">
<a href="/shop" class="hover:text-primary dark:hover:text-primary-dark">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</a> {{ 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 %} {% for crumb in breadcrumbs %}
<span class="px-1">/</span> {{ ui::crumb(label=crumb.name, href="/category/" ~ crumb.slug) }}
<a href="/category/{{ crumb.slug }}" class="hover:text-primary dark:hover:text-primary-dark">{{ crumb.name }}</a>
{% endfor %} {% endfor %}
<span class="px-1">/</span> {{ ui::crumb_current(label=category.name) }}
<span>{{ category.name }}</span> </ol>
</nav> </nav>
{% endblock breadcrumbs %}
{% block content %}
{% set L = lang | default(value='sk') %}
<div class="space-y-6">
<header class="space-y-2">
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ category.name }}</h1> <h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ category.name }}</h1>
{% if category.description %}<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ category.description }}</p>{% endif %} {% if category.description %}<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ category.description }}</p>{% endif %}
@@ -28,12 +34,7 @@
{% endif %} {% endif %}
</header> </header>
{% if products | length > 0 %} {# Same search + filters as the shop, with this category preselected. #}
{% include "shop/_product_grid.html" %} {% include "shop/_search.html" %}
{% else %}
<div class="rounded-radius border border-outline px-6 py-16 text-center text-on-surface/70 dark:border-outline-dark dark:text-on-surface-dark/70">
{{ t(key="shop-empty", lang=lang | default(value='sk')) }}
</div>
{% endif %}
</div> </div>
{% endblock content %} {% endblock content %}

View File

@@ -12,6 +12,7 @@
x-data="{ x-data="{
paymentMethod: '', paymentMethod: '',
accountType: '{{ prefill_account_type | default(value='personal') }}', accountType: '{{ prefill_account_type | default(value='personal') }}',
deliverySame: false,
carrier: '', carrier: '',
carrierPrice: 0, carrierPrice: 0,
requiresPoint: false, requiresPoint: false,
@@ -128,26 +129,26 @@
</div> </div>
</fieldset> </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"> <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"> <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> <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="address", id="address", value=prefill_address | default(value=''), required=true, autocomplete="street-address") }} {{ ui::input(name="residence_address", id="residence_address", value=prefill_residence_address | default(value=''), required=true, autocomplete="billing street-address") }}
</div> </div>
<div class="grid gap-4 sm:grid-cols-3"> <div class="grid gap-4 sm:grid-cols-3">
<div class="space-y-1.5"> <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> <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="city", id="city", value=prefill_city | default(value=''), required=true, autocomplete="address-level2") }} {{ ui::input(name="residence_city", id="residence_city", value=prefill_residence_city | default(value=''), required=true, autocomplete="billing address-level2") }}
</div> </div>
<div class="space-y-1.5"> <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> <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="zip", id="zip", value=prefill_zip | default(value=''), required=true, autocomplete="postal-code") }} {{ ui::input(name="residence_zip", id="residence_zip", value=prefill_residence_zip | default(value=''), required=true, autocomplete="billing postal-code") }}
</div> </div>
<div class="space-y-1.5"> <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" <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-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-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-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-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')) }}' } { 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())) } }"> ], 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"> 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" <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"> class="absolute inset-y-0 right-0 flex w-8 items-center justify-center text-on-surface/60 dark:text-on-surface-dark/60">
@@ -188,7 +239,7 @@
class="before:content[''] relative h-4 w-4 appearance-none rounded-full border border-outline bg-surface before:invisible before:absolute before:left-1/2 before:top-1/2 before:h-1.5 before:w-1.5 before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:bg-on-primary checked:border-primary checked:bg-primary checked:before:visible focus:outline-2 focus:outline-offset-2 focus:outline-outline-strong checked:focus:outline-primary disabled:cursor-not-allowed dark:border-outline-dark dark:bg-surface-dark dark:before:bg-on-primary-dark dark:checked:border-primary-dark dark:checked:bg-primary-dark dark:focus:outline-outline-dark-strong dark:checked:focus:outline-primary-dark"> class="before:content[''] relative h-4 w-4 appearance-none rounded-full border border-outline bg-surface before:invisible before:absolute before:left-1/2 before:top-1/2 before:h-1.5 before:w-1.5 before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:bg-on-primary checked:border-primary checked:bg-primary checked:before:visible focus:outline-2 focus:outline-offset-2 focus:outline-outline-strong checked:focus:outline-primary disabled:cursor-not-allowed dark:border-outline-dark dark:bg-surface-dark dark:before:bg-on-primary-dark dark:checked:border-primary-dark dark:checked:bg-primary-dark dark:focus:outline-outline-dark-strong dark:checked:focus:outline-primary-dark">
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ m.name }}</span> <span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ m.name }}</span>
</span> </span>
<span class="tabular-nums text-on-surface/80 dark:text-on-surface-dark/80">{{ m.price }} {{ currency }}</span> <span class="tabular-nums text-on-surface/80 dark:text-on-surface-dark/80">{{ m.price }} </span>
</label> </label>
{% endfor %} {% endfor %}
@@ -215,14 +266,16 @@
<!-- payment --> <!-- payment -->
<fieldset class="space-y-3 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt"> <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> <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"> <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"') }} {{ 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="payment-cod", lang=lang | default(value='sk')) }}</span> <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>
<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>
</label> </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> </fieldset>
<div class="space-y-1.5"> <div class="space-y-1.5">
@@ -252,23 +305,23 @@
{% for item in items %} {% for item in items %}
<li class="flex justify-between gap-2"> <li class="flex justify-between gap-2">
<span class="text-on-surface/80 dark:text-on-surface-dark/80">{{ item.name }} × {{ item.quantity }}</span> <span class="text-on-surface/80 dark:text-on-surface-dark/80">{{ item.name }} × {{ item.quantity }}</span>
<span class="tabular-nums">{{ item.line_total }} {{ item.currency }}</span> <span class="tabular-nums">{{ item.line_total }} </span>
</li> </li>
{% endfor %} {% endfor %}
</ul> </ul>
<div class="space-y-1 border-t border-outline pt-3 text-sm dark:border-outline-dark"> <div class="space-y-1 border-t border-outline pt-3 text-sm dark:border-outline-dark">
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }}</span> <span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }}</span>
<span class="tabular-nums">{{ subtotal }} {{ currency }}</span> <span class="tabular-nums">{{ subtotal }} </span>
</div> </div>
<div class="flex justify-between"> <div class="flex justify-between">
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-shipping-cost", lang=lang | default(value='sk')) }}</span> <span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-shipping-cost", lang=lang | default(value='sk')) }}</span>
<span class="tabular-nums" x-text="fmt(carrierPrice) + ' {{ currency }}'"></span> <span class="tabular-nums" x-text="fmt(carrierPrice) + ' '"></span>
</div> </div>
</div> </div>
<div class="flex justify-between border-t border-outline pt-3 text-base font-bold dark:border-outline-dark"> <div class="flex justify-between border-t border-outline pt-3 text-base font-bold dark:border-outline-dark">
<span>{{ t(key="cart-total", lang=lang | default(value='sk')) }}</span> <span>{{ t(key="cart-total", lang=lang | default(value='sk')) }}</span>
<span class="tabular-nums text-primary dark:text-primary-dark" x-text="fmt(subtotal + carrierPrice) + ' {{ currency }}'"></span> <span class="tabular-nums text-primary dark:text-primary-dark" x-text="fmt(subtotal + carrierPrice) + ' '"></span>
</div> </div>
{{ ui::button(label=t(key="checkout-place-order", lang=lang | default(value='sk')), type="submit", attrs=':disabled="!canSubmit"', extra="w-full", size="px-6 py-2.5 text-sm") }} {{ ui::button(label=t(key="checkout-place-order", lang=lang | default(value='sk')), type="submit", attrs=':disabled="!canSubmit"', extra="w-full", size="px-6 py-2.5 text-sm") }}
</aside> </aside>

View File

@@ -3,43 +3,24 @@
{% block title %}{{ t(key="nav-shop", lang=lang | default(value='sk')) }}{% endblock title %} {% block title %}{{ t(key="nav-shop", lang=lang | default(value='sk')) }}{% endblock title %}
{% block content %} {% block breadcrumbs %}
<div class="space-y-8"> {% set L = lang | default(value='sk') %}
<header class="space-y-4"> <nav aria-label="breadcrumb" class="mb-5 text-sm">
<div class="space-y-2"> <ol class="flex flex-wrap items-center gap-1.5 text-on-surface/60 dark:text-on-surface-dark/60">
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shop-title", lang=lang | default(value='sk')) }}</h1> {{ ui::crumb(label=t(key="nav-home", lang=L), href="/") }}
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-subtitle", lang=lang | default(value='sk')) }}</p> {{ ui::crumb_current(label=t(key="nav-shop", lang=L)) }}
</div> </ol>
</nav>
{% endblock breadcrumbs %}
{# Live search: htmx GETs /search as the customer types (debounced) and {% block content %}
swaps only the results below. hx-push-url keeps the URL shareable; the {% set L = lang | default(value='sk') %}
spinner shows while a request is in flight. Degrades to a normal GET form <div class="space-y-6">
submit when JS/htmx is unavailable. #} <header class="space-y-1">
<form action="/search" method="get" role="search" <h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shop-title", lang=L) }}</h1>
class="relative max-w-md" <p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-subtitle", lang=L) }}</p>
hx-get="/search" hx-target="#shop-results" hx-swap="innerHTML"
hx-trigger="input changed delay:300ms from:input[name='q'], submit"
hx-push-url="true" hx-indicator="#search-spinner">
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-on-surface/50 dark:text-on-surface-dark/50">
{{ ui::icon(name="search", size="size-5") }}
</span>
<input type="search" name="q" value="{{ query | default(value='') }}"
autocomplete="off"
placeholder="{{ t(key='search-placeholder', lang=lang | default(value='sk')) }}"
aria-label="{{ t(key='search-placeholder', lang=lang | default(value='sk')) }}"
class="w-full rounded-radius border border-outline bg-surface py-2 pl-10 pr-10 text-sm text-on-surface placeholder:text-on-surface/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50 dark:focus-visible:outline-primary-dark" />
<span id="search-spinner"
class="htmx-indicator pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-on-surface/50 dark:text-on-surface-dark/50">
<svg class="size-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.4 0 0 5.4 0 12h4Z"></path>
</svg>
</span>
</form>
</header> </header>
<div id="shop-results"> {% include "shop/_search.html" %}
{% include "shop/_results.html" %}
</div>
</div> </div>
{% endblock content %} {% endblock content %}

View File

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

View File

@@ -4,6 +4,7 @@
{% block title %}{{ product.name }}{% endblock title %} {% block title %}{{ product.name }}{% endblock title %}
{% block content %} {% block content %}
<div class="space-y-12">
<div class="grid gap-10 lg:grid-cols-2"> <div class="grid gap-10 lg:grid-cols-2">
<!-- gallery — prev/next arrows + opacity transitions adapted from <!-- gallery — prev/next arrows + opacity transitions adapted from
penguinui/carousel/default-carousel.html; kept our product thumbnail strip penguinui/carousel/default-carousel.html; kept our product thumbnail strip
@@ -50,14 +51,34 @@
<!-- details --> <!-- 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 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> <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 %} {% if category %}
<a href="/category/{{ category.slug }}" class="text-sm font-medium text-primary dark:text-primary-dark">{{ category.name }}</a> <a href="/category/{{ category.slug }}" class="text-sm font-medium text-primary dark:text-primary-dark">{{ category.name }}</a>
{% endif %} {% endif %}
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h1> <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"> <template x-if="current">
<div class="space-y-6"> <div class="space-y-6">
<!-- option picker (only when there's a real choice); first option is <!-- option picker (only when there's a real choice); first option is
@@ -67,7 +88,7 @@
<label for="variant-select" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="choose-option", lang=lang | default(value='sk')) }}</label> <label for="variant-select" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="choose-option", lang=lang | default(value='sk')) }}</label>
<select id="variant-select" x-model.number="sel" class="{{ fld }}"> <select id="variant-select" x-model.number="sel" class="{{ fld }}">
<template x-for="(v, i) in variants" :key="v.id"> <template x-for="(v, i) in variants" :key="v.id">
<option :value="i" x-text="(v.label || '—') + ' · ' + v.price + ' {{ product.currency }}' + (v.in_stock ? '' : ' ({{ t(key='out-of-stock', lang=lang | default(value='sk')) }})')"></option> <option :value="i" x-text="(v.label || '—') + ' · ' + v.price + ' {{ currency_symbol }}' + (v.in_stock ? '' : ' ({{ t(key='out-of-stock', lang=lang | default(value='sk')) }})')"></option>
</template> </template>
</select> </select>
</div> </div>
@@ -75,17 +96,13 @@
<div class="flex items-baseline gap-3"> <div class="flex items-baseline gap-3">
<p class="text-2xl font-semibold" :class="current.on_sale ? 'text-danger' : 'text-primary dark:text-primary-dark'"> <p class="text-2xl font-semibold" :class="current.on_sale ? 'text-danger' : 'text-primary dark:text-primary-dark'">
<span x-text="current.price"></span> {{ product.currency }} <span x-text="current.price"></span> {{ currency_symbol }}
</p> </p>
<template x-if="current.on_sale"> <template x-if="current.on_sale">
<p class="text-lg text-on-surface/50 line-through dark:text-on-surface-dark/50"><span x-text="current.regular_price"></span> {{ product.currency }}</p> <p class="text-lg text-on-surface/50 line-through dark:text-on-surface-dark/50"><span x-text="current.regular_price"></span> {{ currency_symbol }}</p>
</template> </template>
</div> </div>
{% if product.description %}
<div class="whitespace-pre-line leading-relaxed text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description }}</div>
{% endif %}
<template x-if="current.in_stock"> <template x-if="current.in_stock">
<div class="space-y-2"> <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" <form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none" class="flex flex-wrap items-end gap-3"
@@ -118,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> <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> </template>
</div> </div>
</div>
<script> {% if product.description %}
function productBuy(variants) { <section id="product-description" class="scroll-mt-28 border-t border-outline pt-8 dark:border-outline-dark">
return { <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>
variants: variants || [], {# Authored as rich text (Quill) in the admin; render the stored HTML. #}
// Default to the first in-stock variant, else the first. <div class="rich-content text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description | safe }}</div>
sel: Math.max(0, (variants || []).findIndex(v => v.in_stock)), </section>
get current() { return this.variants[this.sel] || null; }, {% endif %}
};
}
</script>
</div> </div>
{% endblock content %} {% 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

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

View File

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

View File

@@ -0,0 +1,18 @@
use loco_rs::schema::*;
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
// A short blurb shown on product cards (grid/list), distinct from the full
// `description` rendered on the product detail page.
add_column(m, "products", "short_description", ColType::TextNull).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
remove_column(m, "products", "short_description").await
}
}

View File

@@ -0,0 +1,94 @@
//! Product descriptions are now authored as rich text (Quill) and stored as
//! HTML. The product search vector (see m20260622_000005) tokenizes the raw
//! description, so without this the markup itself (`p`, `strong`, `li`, `href`,
//! `class`, `ql`, …) would land in the index and pollute matches.
//!
//! Redefine `kompress_build_product_search` so the description is run through a
//! tag-stripping `regexp_replace` before `to_tsvector`, then backfill every
//! product's stored vector. Everything else about the function is unchanged.
use sea_orm_migration::prelude::*;
#[derive(DeriveMigrationName)]
pub struct Migration;
#[async_trait::async_trait]
impl MigrationTrait for Migration {
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
let db = m.get_connection();
db.execute_unprepared(
r#"
CREATE OR REPLACE FUNCTION kompress_build_product_search(
p_name text, p_description text, p_id integer
) RETURNS tsvector
LANGUAGE sql STABLE AS $func$
SELECT
setweight(to_tsvector('sk_unaccent', COALESCE(p_name, '')), 'A')
|| setweight(to_tsvector('sk_unaccent', COALESCE((
SELECT string_agg(t.name, ' ')
FROM product_product_tags ppt
JOIN product_tags t ON t.id = ppt.product_tag_id
WHERE ppt.product_id = p_id
), '')), 'B')
|| setweight(to_tsvector('sk_unaccent', COALESCE((
SELECT string_agg(v.label, ' ')
FROM product_variants v
WHERE v.product_id = p_id
), '')), 'B')
|| setweight(to_tsvector('sk_unaccent',
regexp_replace(COALESCE(p_description, ''), '<[^>]+>', ' ', 'g')
), 'C')
|| setweight(to_tsvector('sk_unaccent', COALESCE((
SELECT string_agg(v.sku, ' ')
FROM product_variants v
WHERE v.product_id = p_id AND v.sku IS NOT NULL
), '')), 'C');
$func$;
-- Backfill: recompute every product's vector with the new definition.
UPDATE products
SET search_vector = kompress_build_product_search(name, description, id);
"#,
)
.await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
// Restore the prior definition (raw, un-stripped description).
let db = m.get_connection();
db.execute_unprepared(
r#"
CREATE OR REPLACE FUNCTION kompress_build_product_search(
p_name text, p_description text, p_id integer
) RETURNS tsvector
LANGUAGE sql STABLE AS $func$
SELECT
setweight(to_tsvector('sk_unaccent', COALESCE(p_name, '')), 'A')
|| setweight(to_tsvector('sk_unaccent', COALESCE((
SELECT string_agg(t.name, ' ')
FROM product_product_tags ppt
JOIN product_tags t ON t.id = ppt.product_tag_id
WHERE ppt.product_id = p_id
), '')), 'B')
|| setweight(to_tsvector('sk_unaccent', COALESCE((
SELECT string_agg(v.label, ' ')
FROM product_variants v
WHERE v.product_id = p_id
), '')), 'B')
|| setweight(to_tsvector('sk_unaccent', COALESCE(p_description, '')), 'C')
|| setweight(to_tsvector('sk_unaccent', COALESCE((
SELECT string_agg(v.sku, ' ')
FROM product_variants v
WHERE v.product_id = p_id AND v.sku IS NOT NULL
), '')), 'C');
$func$;
UPDATE products
SET search_vector = kompress_build_product_search(name, description, id);
"#,
)
.await?;
Ok(())
}
}

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,8 @@
//! Admin order list, detail, status updates, and manual carrier dispatch. //! Admin order list, detail, status updates, and manual carrier dispatch.
use std::collections::HashMap;
use axum::extract::Query;
use axum_extra::extract::cookie::CookieJar; use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*; use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set}; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
@@ -30,18 +33,31 @@ async fn index(
auth: auth::JWT, auth: auth::JWT,
jar: CookieJar, jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>, ViewEngine(v): ViewEngine<TeraView>,
Query(params): Query<HashMap<String, String>>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
guard::current_admin(auth, &ctx).await?; guard::current_admin(auth, &ctx).await?;
let list = orders::Entity::find() // Optional search over order number / customer / email / etc., otherwise the
// full list newest first.
let query = params.get("q").map(String::as_str).unwrap_or("").trim().to_string();
let list = if query.is_empty() {
orders::Entity::find()
.order_by_desc(orders::Column::CreatedAt) .order_by_desc(orders::Column::CreatedAt)
.all(&ctx.db) .all(&ctx.db)
.await?; .await?
} else {
orders::Entity::search(&ctx.db, &query, 500).await?
};
let rows: Vec<serde_json::Value> = list.iter().map(view::summary).collect(); let rows: Vec<serde_json::Value> = list.iter().map(view::summary).collect();
format::view( format::view(
&v, &v,
"admin/orders/index.html", "admin/orders/index.html",
json!({ "orders": rows, "lang": current_lang(&jar) }), json!({
"orders": rows,
"query": query,
"total": list.len(),
"lang": current_lang(&jar),
}),
) )
} }
@@ -77,6 +93,7 @@ async fn render_show(
.await?; .await?;
let carrier = order_carrier(ctx, &order).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 // The order can be sent only if it maps to a real carrier and hasn't been
// dispatched yet. // dispatched yet.
let can_ship = carrier != "none" && order.tracking_number.is_none(); let can_ship = carrier != "none" && order.tracking_number.is_none();
@@ -87,8 +104,8 @@ async fn render_show(
json!({ json!({
"order": view::detail( "order": view::detail(
&order, &order,
settings::get(ctx, "bank_iban").unwrap_or(""), &bank_iban,
settings::get(ctx, "bank_account_name").unwrap_or(""), &bank_account_name,
), ),
"items": view::items(&items), "items": view::items(&items),
"statuses": ORDER_STATUSES, "statuses": ORDER_STATUSES,
@@ -186,7 +203,6 @@ async fn ship(
country: order.country.as_deref(), country: order.country.as_deref(),
pickup_point_id: order.pickup_point_id.as_deref(), pickup_point_id: order.pickup_point_id.as_deref(),
cod_cents, cod_cents,
currency: &order.currency,
value_cents: goods_value, value_cents: goods_value,
weight_grams: DEFAULT_PARCEL_WEIGHT_GRAMS, weight_grams: DEFAULT_PARCEL_WEIGHT_GRAMS,
}; };

View File

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

@@ -26,7 +26,7 @@ use crate::{
}, },
shared::{ shared::{
guard, guard,
money::{format_bp, format_price, parse_price_to_cents}, money::{format_bp, format_price, parse_percent, parse_price_to_cents},
pricing, pricing,
slug::{slugify, unique_slug}, slug::{slugify, unique_slug},
}, },
@@ -52,7 +52,7 @@ struct ProductFields {
name: String, name: String,
slug: String, slug: String,
description: Option<String>, description: Option<String>,
currency: String, short_description: Option<String>,
category_id: Option<i32>, category_id: Option<i32>,
published: bool, published: bool,
} }
@@ -65,8 +65,8 @@ async fn parse_product_fields(
let name = form let name = form
.text("name") .text("name")
.ok_or_else(|| Error::BadRequest("product name is required".to_string()))?; .ok_or_else(|| Error::BadRequest("product name is required".to_string()))?;
let currency = form.text("currency").unwrap_or_else(|| "EUR".to_string());
let description = form.text("description"); let description = form.text("description");
let short_description = form.text("short_description");
let category_id = form.text("category_id").and_then(|s| s.parse::<i32>().ok()); let category_id = form.text("category_id").and_then(|s| s.parse::<i32>().ok());
let published = form.checked("published"); let published = form.checked("published");
@@ -91,7 +91,7 @@ async fn parse_product_fields(
name, name,
slug, slug,
description, description,
currency, short_description,
category_id, category_id,
published, published,
}) })
@@ -105,30 +105,9 @@ struct VariantInput {
/// `None` = available but not inventory-tracked. /// `None` = available but not inventory-tracked.
stock: Option<i32>, stock: Option<i32>,
price_cents: i64, price_cents: i64,
business_sale_cents: Option<i64>,
position: i32, position: i32,
} }
/// The optional business-sale price field on a variant row: blank means "no
/// business quick-sale", a value must parse and be below the regular price.
fn parse_optional_sale(
form: &MultipartForm,
i: usize,
key: &str,
price_cents: i64,
) -> Result<Option<i64>> {
let Some(raw) = form.text(&format!("variants[{i}][{key}]")) else {
return Ok(None);
};
let cents = parse_price_to_cents(&raw)?;
if cents <= 0 || cents >= price_cents {
return Err(Error::BadRequest(
"a sale price must be positive and below the regular price".to_string(),
));
}
Ok(Some(cents))
}
/// Parse the repeated variant rows from the form, in submission order. Blank /// Parse the repeated variant rows from the form, in submission order. Blank
/// rows (no price and no label) are skipped; at least one valid row is required. /// rows (no price and no label) are skipped; at least one valid row is required.
fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> { fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
@@ -168,7 +147,6 @@ fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
.ok_or_else(|| Error::BadRequest("stock must be 0 or more".to_string()))?, .ok_or_else(|| Error::BadRequest("stock must be 0 or more".to_string()))?,
), ),
}; };
let business_sale_cents = parse_optional_sale(form, i, "business_sale", price_cents)?;
let id = form let id = form
.text(&format!("variants[{i}][id]")) .text(&format!("variants[{i}][id]"))
.and_then(|s| s.parse::<i32>().ok()); .and_then(|s| s.parse::<i32>().ok());
@@ -179,7 +157,6 @@ fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
sku, sku,
stock, stock,
price_cents, price_cents,
business_sale_cents,
position: out.len() as i32, position: out.len() as i32,
}); });
} }
@@ -197,9 +174,9 @@ fn apply_variant(active: &mut product_variants::ActiveModel, input: &VariantInpu
active.sku = Set(input.sku.clone()); active.sku = Set(input.sku.clone());
active.stock = Set(input.stock); active.stock = Set(input.stock);
active.price_cents = Set(input.price_cents); active.price_cents = Set(input.price_cents);
// The per-variant public sale price was removed from the UI; keep it cleared. // Discounts (public + business sale) are owned by the discount page and keyed
active.sale_price_cents = Set(None); // per option/audience; the product form must leave those columns untouched so
active.business_sale_price_cents = Set(input.business_sale_cents); // it never clobbers a discount. New variants default them to NULL.
active.position = Set(input.position); active.position = Set(input.position);
} }
@@ -257,7 +234,6 @@ fn variant_form_json(variant: &product_variants::Model) -> serde_json::Value {
"sku": variant.sku, "sku": variant.sku,
"stock": variant.stock, "stock": variant.stock,
"price": format_price(variant.price_cents), "price": format_price(variant.price_cents),
"business_sale": variant.business_sale_price_cents.map(format_price),
}) })
} }
@@ -281,10 +257,17 @@ async fn index(
.map(|c| (c.id, c.name.clone())) .map(|c| (c.id, c.name.clone()))
.collect(); .collect();
let list = products::Entity::find() // Optional text search (drafts included), otherwise the full catalog newest
// first. Reuses the storefront's hybrid full-text + fuzzy product search.
let query = params.get("q").map(String::as_str).unwrap_or("").trim().to_string();
let list = if query.is_empty() {
products::Entity::find()
.order_by_desc(products::Column::CreatedAt) .order_by_desc(products::Column::CreatedAt)
.all(&ctx.db) .all(&ctx.db)
.await?; .await?
} else {
products::Entity::search(&ctx.db, &query, 1000, false).await?
};
let ids: Vec<i32> = list.iter().map(|p| p.id).collect(); let ids: Vec<i32> = list.iter().map(|p| p.id).collect();
let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &ids).await?; let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &ids).await?;
@@ -333,9 +316,13 @@ async fn index(
.sum::<i32>() .sum::<i32>()
.to_string() .to_string()
}; };
// The product is "on sale" for this audience if any option carries a
// discount; the per-option amounts live on the discount page.
let on_sale = variants.iter().any(|v| current_value(v, audience).is_some());
rows.push(product_row( rows.push(product_row(
product, product,
priced, priced,
on_sale,
variants.len(), variants.len(),
stock_display, stock_display,
image, image,
@@ -352,6 +339,7 @@ async fn index(
"audience": audience, "audience": audience,
"category_groups": category_groups, "category_groups": category_groups,
"selected_category": selected_category, "selected_category": selected_category,
"query": query,
"total_count": list.len(), "total_count": list.len(),
"uncategorized_count": category_ids.iter().filter(|c| c.is_none()).count(), "uncategorized_count": category_ids.iter().filter(|c| c.is_none()).count(),
"lang": current_lang(&jar), "lang": current_lang(&jar),
@@ -365,6 +353,7 @@ async fn index(
fn product_row( fn product_row(
product: &products::Model, product: &products::Model,
effective: &pricing::PricedProduct, effective: &pricing::PricedProduct,
on_sale: bool,
variant_count: usize, variant_count: usize,
stock_display: String, stock_display: String,
image: Option<String>, image: Option<String>,
@@ -374,7 +363,6 @@ fn product_row(
"id": product.id, "id": product.id,
"name": product.name, "name": product.name,
"slug": product.slug, "slug": product.slug,
"currency": product.currency,
"stock": stock_display, "stock": stock_display,
"variant_count": variant_count, "variant_count": variant_count,
"has_options": variant_count > 1, "has_options": variant_count > 1,
@@ -382,6 +370,7 @@ fn product_row(
"image": image, "image": image,
"category_name": category_name, "category_name": category_name,
"regular_price": format_price(effective.regular_cents), "regular_price": format_price(effective.regular_cents),
"on_sale": on_sale,
"effective_price": format_price(effective.price_cents), "effective_price": format_price(effective.price_cents),
"effective_reduced": effective.is_reduced(), "effective_reduced": effective.is_reduced(),
"effective_percent_off": percent_off(effective.regular_cents, effective.price_cents), "effective_percent_off": percent_off(effective.regular_cents, effective.price_cents),
@@ -448,7 +437,7 @@ async fn create(
name: Set(fields.name), name: Set(fields.name),
slug: Set(fields.slug), slug: Set(fields.slug),
description: Set(fields.description), description: Set(fields.description),
currency: Set(fields.currency), short_description: Set(fields.short_description),
view_count: Set(0), view_count: Set(0),
published: Set(fields.published), published: Set(fields.published),
published_at: Set(fields.published.then(|| chrono::Utc::now().into())), published_at: Set(fields.published.then(|| chrono::Utc::now().into())),
@@ -562,7 +551,7 @@ async fn update(
product.name = Set(fields.name); product.name = Set(fields.name);
product.slug = Set(fields.slug); product.slug = Set(fields.slug);
product.description = Set(fields.description); product.description = Set(fields.description);
product.currency = Set(fields.currency); product.short_description = Set(fields.short_description);
product.category_id = Set(fields.category_id); product.category_id = Set(fields.category_id);
product.published = Set(fields.published); product.published = Set(fields.published);
if fields.published && !was_published { if fields.published && !was_published {
@@ -611,6 +600,30 @@ fn list_redirect(audience: &str) -> Result<Response> {
format::redirect(&format!("/admin/catalog/products?audience={audience}")) format::redirect(&format!("/admin/catalog/products?audience={audience}"))
} }
/// Resolve a percentage off the regular price into a fixed sale price in cents.
fn percent_to_sale_cents(regular_cents: i64, percent: f64) -> i64 {
let off = (regular_cents as f64 * percent / 100.0).round() as i64;
regular_cents - off
}
/// Which discount value an audience tab sees on a variant.
fn current_value(variant: &product_variants::Model, audience: &str) -> Option<i64> {
if audience == BUSINESS {
variant.business_sale_price_cents
} else {
variant.sale_price_cents
}
}
/// Set the discount column on a variant for a given audience.
fn set_value(active: &mut product_variants::ActiveModel, audience: &str, value: Option<i64>) {
if audience == BUSINESS {
active.business_sale_price_cents = Set(value);
} else {
active.sale_price_cents = Set(value);
}
}
/// Percent off the regular price, rounded to a whole number. /// Percent off the regular price, rounded to a whole number.
fn percent_off(regular_cents: i64, sale_cents: i64) -> i64 { fn percent_off(regular_cents: i64, sale_cents: i64) -> i64 {
if regular_cents <= 0 { if regular_cents <= 0 {
@@ -685,7 +698,6 @@ async fn profiles_preview(
} }
rows.push(json!({ rows.push(json!({
"id": product.id, "id": product.id,
"currency": product.currency,
"effective_price": format_price(priced.price_cents), "effective_price": format_price(priced.price_cents),
"effective_reduced": priced.is_reduced(), "effective_reduced": priced.is_reduced(),
"effective_percent_off": percent_off(priced.regular_cents, priced.price_cents), "effective_percent_off": percent_off(priced.regular_cents, priced.price_cents),
@@ -734,6 +746,229 @@ async fn sync_profiles(
list_redirect(audience) list_redirect(audience)
} }
// --- Per-variant discounts ---------------------------------------------------
//
// Each product is sold as one or more options (variants). A discount can be set
// on every option individually, for the active audience: personal writes the
// public `sale_price_cents`, business writes `business_sale_price_cents`. Per
// option the admin picks a fixed sale price or a percentage off the regular
// price; an empty value clears that option's discount.
/// One option row in the discount form. Carries enough to pre-fill the editor
/// and to survive a validation-error round-trip.
struct DiscountRow {
id: i32,
label: String,
regular_cents: i64,
mode: String,
fixed: String,
percent: String,
has_discount: bool,
}
impl DiscountRow {
/// Pre-fill from the discount stored for this audience.
fn from_db(v: &product_variants::Model, audience: &str) -> Self {
let sale = current_value(v, audience);
DiscountRow {
id: v.id,
label: v.label.clone(),
regular_cents: v.price_cents,
mode: "fixed".to_string(),
fixed: sale.map(format_price).unwrap_or_default(),
percent: String::new(),
has_discount: sale.is_some(),
}
}
/// Pre-fill from the submitted values, to repaint the form after an error.
fn from_submitted(
v: &product_variants::Model,
audience: &str,
pairs: &HashMap<String, String>,
) -> Self {
let get = |key: &str| {
pairs
.get(&format!("v[{}][{key}]", v.id))
.map(|s| s.trim().to_string())
.unwrap_or_default()
};
let mode = get("mode");
DiscountRow {
id: v.id,
label: v.label.clone(),
regular_cents: v.price_cents,
mode: if mode == "percent" {
mode
} else {
"fixed".to_string()
},
fixed: get("fixed"),
percent: get("percent"),
has_discount: current_value(v, audience).is_some(),
}
}
fn to_json(&self) -> serde_json::Value {
json!({
"id": self.id,
"label": self.label,
"regular_cents": self.regular_cents,
"regular_price": format_price(self.regular_cents),
"mode": self.mode,
"fixed": self.fixed,
"percent": self.percent,
"has_discount": self.has_discount,
})
}
}
/// Resolve one submitted option into the sale price to store. `Ok(None)` clears
/// the discount; `Err` is an i18n key for the validation message.
fn resolve_row(
regular_cents: i64,
mode: &str,
fixed: &str,
percent: &str,
) -> std::result::Result<Option<i64>, &'static str> {
let sale_cents = if mode == "percent" {
if percent.is_empty() {
return Ok(None);
}
let pct = parse_percent(percent).ok_or("discount-invalid")?;
if pct <= 0.0 || pct >= 100.0 {
return Err("discount-percent-range");
}
percent_to_sale_cents(regular_cents, pct)
} else {
if fixed.is_empty() {
return Ok(None);
}
parse_price_to_cents(fixed).map_err(|_| "discount-invalid")?
};
if sale_cents <= 0 {
return Err("discount-must-be-positive");
}
if sale_cents >= regular_cents {
return Err("discount-below-regular");
}
Ok(Some(sale_cents))
}
async fn discount_view(
v: &TeraView,
jar: &CookieJar,
product: &products::Model,
rows: &[DiscountRow],
audience: &str,
error: Option<&str>,
) -> Result<Response> {
let rows_json: Vec<_> = rows.iter().map(DiscountRow::to_json).collect();
let has_discount = rows.iter().any(|r| r.has_discount);
format::view(
v,
"admin/catalog/discount_form.html",
json!({
"product": {
"id": product.id,
"name": product.name,
},
"rows": rows_json,
"audience": audience,
"has_discount": has_discount,
"error": error.map(|e| e.to_string()),
"lang": current_lang(jar),
}),
)
}
#[debug_handler]
async fn discount_show(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<i32>,
Query(params): Query<HashMap<String, String>>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let audience = read_audience(&params);
let product = product_by_id(&ctx, id).await?;
let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
let rows: Vec<DiscountRow> = variants
.iter()
.map(|variant| DiscountRow::from_db(variant, audience))
.collect();
discount_view(&v, &jar, &product, &rows, audience, None).await
}
#[debug_handler]
async fn discount_update(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<i32>,
Query(params): Query<HashMap<String, String>>,
State(ctx): State<AppContext>,
body: String,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let audience = read_audience(&params);
let product = product_by_id(&ctx, id).await?;
let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
let pairs: HashMap<String, String> = form_urlencoded::parse(body.as_bytes())
.into_owned()
.collect();
// Resolve every option before persisting anything, so one bad row can't leave
// the product half-discounted. On the first error, repaint with the inputs.
let mut resolved: Vec<(product_variants::Model, Option<i64>)> = Vec::new();
for variant in &variants {
let row = DiscountRow::from_submitted(variant, audience, &pairs);
match resolve_row(variant.price_cents, &row.mode, &row.fixed, &row.percent) {
Ok(value) => resolved.push((variant.clone(), value)),
Err(key) => {
let rows: Vec<DiscountRow> = variants
.iter()
.map(|v| DiscountRow::from_submitted(v, audience, &pairs))
.collect();
return discount_view(&v, &jar, &product, &rows, audience, Some(key)).await;
}
}
}
let txn = ctx.db.begin().await?;
for (variant, value) in &resolved {
let mut active = variant.clone().into_active_model();
set_value(&mut active, audience, *value);
active.update(&txn).await?;
}
txn.commit().await?;
list_redirect(audience)
}
#[debug_handler]
async fn discount_remove(
auth: auth::JWT,
Path(id): Path<i32>,
Query(params): Query<HashMap<String, String>>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let audience = read_audience(&params);
let _product = product_by_id(&ctx, id).await?;
let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
let txn = ctx.db.begin().await?;
for variant in &variants {
let mut active = variant.clone().into_active_model();
set_value(&mut active, audience, None);
active.update(&txn).await?;
}
txn.commit().await?;
list_redirect(audience)
}
pub fn routes() -> Routes { pub fn routes() -> Routes {
// Several images may be uploaded in one submission; allow a generous total // Several images may be uploaded in one submission; allow a generous total
// (per-file size is still capped at IMAGE_MAX_BYTES while reading). // (per-file size is still capped at IMAGE_MAX_BYTES while reading).
@@ -756,4 +991,16 @@ pub fn routes() -> Routes {
post(update).layer(image_limit), post(update).layer(image_limit),
) )
.add("/admin/catalog/products/{id}/delete", post(delete)) .add("/admin/catalog/products/{id}/delete", post(delete))
.add(
"/admin/catalog/products/{id}/discount/edit",
get(discount_show),
)
.add(
"/admin/catalog/products/{id}/discount",
post(discount_update),
)
.add(
"/admin/catalog/products/{id}/discount/remove",
post(discount_remove),
)
} }

View File

@@ -16,6 +16,7 @@ use crate::{
shared::{ shared::{
guard, guard,
money::{format_price, parse_price_to_cents}, money::{format_price, parse_price_to_cents},
shipping as shipping_rules,
}, },
}; };
@@ -37,13 +38,17 @@ async fn index(
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
guard::current_admin(auth, &ctx).await?; guard::current_admin(auth, &ctx).await?;
shipping_rules::disable_packeta_if_unconfigured(&ctx).await?;
let methods = shipping_methods::Entity::find() let methods = shipping_methods::Entity::find()
.order_by_asc(shipping_methods::Column::Position) .order_by_asc(shipping_methods::Column::Position)
.all(&ctx.db) .all(&ctx.db)
.await?; .await?;
let packeta_ready = shipping_rules::packeta_ready(&ctx);
let rows: Vec<serde_json::Value> = methods let rows: Vec<serde_json::Value> = methods
.iter() .iter()
.map(|m| { .map(|m| {
let packeta_not_ready = m.carrier == "packeta" && !packeta_ready;
let locked = packeta_not_ready && !m.enabled;
json!({ json!({
"id": m.id, "id": m.id,
"code": m.code, "code": m.code,
@@ -52,6 +57,9 @@ async fn index(
"carrier": m.carrier, "carrier": m.carrier,
"requires_pickup_point": m.requires_pickup_point, "requires_pickup_point": m.requires_pickup_point,
"enabled": m.enabled, "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(); .collect();
@@ -74,9 +82,15 @@ async fn update(
.one(&ctx.db) .one(&ctx.db)
.await? .await?
.ok_or_else(|| Error::NotFound)?; .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(); let mut active = method.into_active_model();
active.price_cents = Set(parse_price_to_cents(&form.price)?); 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?; active.update(&ctx.db).await?;
format::redirect("/admin/shipping") format::redirect("/admin/shipping")
} }

View File

@@ -1,10 +1,11 @@
use crate::{ use crate::{
controllers::cart,
models::users::{self, LoginParams, RegisterParams}, models::users::{self, LoginParams, RegisterParams},
views::auth::{CurrentResponse, LoginResponse}, views::auth::{CurrentResponse, LoginResponse},
mailers::auth::AuthMailer, mailers::auth::AuthMailer,
shared::guard::is_admin, shared::guard::is_admin,
}; };
use axum_extra::extract::cookie::{Cookie, SameSite}; use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use loco_rs::prelude::*; use loco_rs::prelude::*;
use regex::Regex; use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -88,6 +89,7 @@ pub struct ResendVerificationParams {
/// welcome email to the user /// welcome email to the user
#[debug_handler] #[debug_handler]
async fn register( async fn register(
jar: CookieJar,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
Json(params): Json<RegisterParams>, Json(params): Json<RegisterParams>,
) -> Result<Response> { ) -> Result<Response> {
@@ -109,6 +111,7 @@ async fn register(
.into_active_model() .into_active_model()
.set_email_verification_sent(&ctx.db) .set_email_verification_sent(&ctx.db)
.await?; .await?;
cart::claim_guest_cart(&ctx, &jar, user.id).await?;
AuthMailer::send_welcome(&ctx, &user).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) .generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?; .or_else(|_| unauthorized("unauthorized!"))?;
let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?;
format::render() 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))) .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] #[debug_handler]
async fn logout() -> Result<Response> { 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. /// 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) .generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
.or_else(|_| unauthorized("unauthorized!"))?; .or_else(|_| unauthorized("unauthorized!"))?;
let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?;
format::render() 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))) .json(LoginResponse::new(&user, &token, is_admin(&ctx, &user)))
} }

View File

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

View File

@@ -1,4 +1,11 @@
use crate::{controllers::i18n::current_lang, shared::{guard, money::format_price, 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::{ use axum::{
http::{HeaderMap, StatusCode}, http::{HeaderMap, StatusCode},
response::Redirect, response::Redirect,
@@ -64,8 +71,77 @@ fn cart_cookie(value: String) -> Cookie<'static> {
.build() .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 /// Look up a variant whose product is published, returning the variant together
/// with its parent product (for name/slug/currency). /// with its parent product (for name/slug).
async fn published_variant( async fn published_variant(
ctx: &AppContext, ctx: &AppContext,
variant_id: i32, variant_id: i32,
@@ -94,7 +170,8 @@ async fn add(
return Err(Error::NotFound); 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); let add_qty = form.quantity.unwrap_or(1).max(1);
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == variant.id) { if let Some(entry) = items.iter_mut().find(|(id, _)| *id == variant.id) {
entry.1 = variant.cap(entry.1 + add_qty); entry.1 = variant.cap(entry.1 + add_qty);
@@ -103,7 +180,7 @@ async fn add(
} }
items.retain(|(_, qty)| *qty > 0); 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 // 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 // 204 (the header cart badge updates client-side), and a no-JS submit goes
@@ -135,13 +212,14 @@ async fn update(
None => 0, 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) { if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.variant_id) {
entry.1 = clamped; entry.1 = clamped;
} }
items.retain(|(_, qty)| *qty > 0); 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 cart_response(&ctx, &v, jar, &headers).await
} }
@@ -153,10 +231,11 @@ async fn remove(
headers: HeaderMap, headers: HeaderMap,
Form(form): Form<RemoveForm>, Form(form): Form<RemoveForm>,
) -> Result<Response> { ) -> 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); 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 cart_response(&ctx, &v, jar, &headers).await
} }
@@ -173,39 +252,37 @@ async fn cart_response(
return Ok((jar, Redirect::to("/cart")).into_response()); return Ok((jar, Redirect::to("/cart")).into_response());
} }
let (lines, valid, total) = resolve_cart(ctx, &jar).await?; let cur = currency::resolve(ctx, &jar).await;
let currency = lines let (lines, valid, total) = resolve_cart(ctx, &jar, &cur).await?;
.first()
.and_then(|line| line["currency"].as_str())
.unwrap_or("EUR")
.to_string();
// Persist the re-validated cookie (drops now-invalid lines). // Persist the re-validated cookie (drops now-invalid lines).
let jar = jar.add(cart_cookie(serialize_cart(&valid))); let user = guard::current_user(ctx, &jar).await;
let jar = persist_cart(ctx, jar, user.as_ref(), &valid).await?;
let response = format::view( let response = format::view(
v, v,
"shop/_cart_body.html", "shop/_cart_body.html",
json!({ json!({
"items": lines, "items": lines,
"total": format_price(total), "total": cur.format(total),
"currency": currency, "currency_symbol": cur.symbol,
"lang": current_lang(&jar), "lang": current_lang(&jar),
}), }),
)?; )?;
Ok((jar, response).into_response()) Ok((jar, response).into_response())
} }
/// Resolve the cart cookie into priced line items, dropping anything that is no /// Resolve the active cart into priced line items, dropping anything that is no
/// longer purchasable and clamping quantities to current stock. Returns the /// longer purchasable and clamping quantities to current stock. Guests resolve
/// (re-validated) lines, the rebuilt cookie value, and the total in cents. /// from the cookie; authenticated users resolve from their account cart.
pub(crate) async fn resolve_cart( pub(crate) async fn resolve_cart(
ctx: &AppContext, ctx: &AppContext,
jar: &CookieJar, jar: &CookieJar,
cur: &Currency,
) -> Result<(Vec<serde_json::Value>, Vec<(i32, i32)>, i64)> { ) -> Result<(Vec<serde_json::Value>, Vec<(i32, i32)>, i64)> {
// Resolve the cart entries to in-stock products first, then price them all // Resolve the cart entries to in-stock products first, then price them all
// for the current viewer in one batch (the price depends on who's logged in). // for the current viewer in one batch (the price depends on who's logged in).
let user = guard::current_user(ctx, jar).await; let user = guard::current_user(ctx, jar).await;
let mut items: Vec<(product_variants::Model, products::Model, i32)> = Vec::new(); 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 { let Some((variant, product)) = published_variant(ctx, id).await? else {
continue; continue;
}; };
@@ -232,16 +309,19 @@ pub(crate) async fn resolve_cart(
"name": product.name, "name": product.name,
"variant_label": variant.label, "variant_label": variant.label,
"slug": product.slug, "slug": product.slug,
"price": format_price(unit_price), "price": cur.format(unit_price),
"regular_price": format_price(priced.regular_cents), "regular_price": cur.format(priced.regular_cents),
"on_sale": priced.is_reduced(), "on_sale": priced.is_reduced(),
"currency": product.currency,
"quantity": qty, "quantity": qty,
"stock": variant.stock, "stock": variant.stock,
"line_total": format_price(line_total), "line_total": cur.format(line_total),
})); }));
} }
if let Some(user) = user.as_ref() {
account_cart_items::Model::replace_for_user(&ctx.db, user.id, &valid).await?;
}
Ok((lines, valid, total)) Ok((lines, valid, total))
} }
@@ -251,32 +331,29 @@ async fn show(
ViewEngine(v): ViewEngine<TeraView>, ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
let (lines, valid, total) = resolve_cart(&ctx, &jar).await?; let cur = currency::resolve(&ctx, &jar).await;
let currency = lines let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?;
.first()
.and_then(|line| line["currency"].as_str())
.unwrap_or("EUR")
.to_string();
// Drop any now-invalid lines from the cookie so the badge stays accurate.
let rebuilt = serialize_cart(&valid);
let c = guard::chrome(&ctx, &jar).await; let c = guard::chrome(&ctx, &jar).await;
let response = format::view( let response = format::view(
&v, &v,
"shop/cart.html", "shop/cart.html",
json!({ json!({
"items": lines, "items": lines,
"total": format_price(total), "total": cur.format(total),
"currency": currency, "currency_symbol": cur.symbol,
"logged_in_admin": c.logged_in_admin, "logged_in_admin": c.logged_in_admin,
"logged_in_customer": c.logged_in_customer, "logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name, "customer_name": c.customer_name,
"customer_account_type": c.customer_account_type, "customer_account_type": c.customer_account_type,
"customer_avatar": c.customer_avatar,
"lang": current_lang(&jar), "lang": current_lang(&jar),
}), }),
)?; )?;
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 /// Mini-cart preview for the navbar hover dropdown. Lazy-loaded via htmx from
@@ -287,24 +364,21 @@ async fn preview(
ViewEngine(v): ViewEngine<TeraView>, ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
let (lines, valid, total) = resolve_cart(&ctx, &jar).await?; let cur = currency::resolve(&ctx, &jar).await;
let currency = lines let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?;
.first()
.and_then(|line| line["currency"].as_str())
.unwrap_or("EUR")
.to_string();
let rebuilt = serialize_cart(&valid);
let response = format::view( let response = format::view(
&v, &v,
"shop/_cart_preview.html", "shop/_cart_preview.html",
json!({ json!({
"items": lines, "items": lines,
"total": format_price(total), "total": cur.format(total),
"currency": currency, "currency_symbol": cur.symbol,
"lang": current_lang(&jar), "lang": current_lang(&jar),
}), }),
)?; )?;
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 { pub fn routes() -> Routes {

View File

@@ -2,28 +2,24 @@
//! confirmation page. //! confirmation page.
use axum::extract::Query; use axum::extract::Query;
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*; use loco_rs::prelude::*;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder}; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use time::Duration as TimeDuration;
use crate::{ use crate::{
controllers::cart::{resolve_cart, CART_COOKIE}, controllers::cart::{self, resolve_cart},
mailers::auth::AuthMailer, mailers::auth::AuthMailer,
models::{ models::{
customer_profiles::{self, ProfileFields}, customer_profiles::{self, ProfileFields},
order_items, orders, shipping_methods, order_items, orders, payment_methods, shipping_methods,
users::{self, normalize_account_type}, users::{self, normalize_account_type},
}, },
controllers::i18n::current_lang, controllers::i18n::current_lang,
shared::{guard, money::format_price, settings}, shared::{currency::Currency, guard, money::format_price, settings, shipping as shipping_rules},
views::checkout as view, views::checkout as view,
}; };
const PAYMENT_METHODS: [&str; 2] = ["cod", "bank_transfer"];
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct CheckoutForm { struct CheckoutForm {
email: String, email: String,
@@ -35,10 +31,15 @@ struct CheckoutForm {
company_id: Option<String>, company_id: Option<String>,
tax_id: Option<String>, tax_id: Option<String>,
vat_id: Option<String>, vat_id: Option<String>,
address: String, residence_address: String,
city: String, residence_city: String,
zip: String, residence_zip: String,
country: 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>, note: Option<String>,
payment_method: String, payment_method: String,
carrier_code: String, carrier_code: String,
@@ -55,20 +56,21 @@ fn trimmed(value: &str) -> Option<String> {
(!value.is_empty()).then(|| value.to_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>> { 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() Ok(shipping_methods::Entity::find()
.filter(shipping_methods::Column::Enabled.eq(true)) .filter(shipping_methods::Column::Enabled.eq(true))
.order_by_asc(shipping_methods::Column::Position) .order_by_asc(shipping_methods::Column::Position)
.all(&ctx.db) .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] #[debug_handler]
@@ -77,15 +79,12 @@ async fn checkout_page(
ViewEngine(v): ViewEngine<TeraView>, ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar).await?; // Checkout and everything past it (orders, confirmation) stay in the EUR
// base — the settlement currency — even when the buyer browsed in another.
let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
if lines.is_empty() { if lines.is_empty() {
return format::redirect("/cart"); return format::redirect("/cart");
} }
let currency = lines
.first()
.and_then(|line| line["currency"].as_str())
.unwrap_or("EUR")
.to_string();
let methods: Vec<serde_json::Value> = enabled_shipping_methods(&ctx) let methods: Vec<serde_json::Value> = enabled_shipping_methods(&ctx)
.await? .await?
@@ -100,6 +99,16 @@ async fn checkout_page(
}) })
}) })
.collect(); .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 // 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). // the user account, the address/phone from their saved profile (if any).
@@ -113,7 +122,7 @@ async fn checkout_page(
let p = |get: fn(&customer_profiles::Model) -> Option<String>| { let p = |get: fn(&customer_profiles::Model) -> Option<String>| {
profile.as_ref().and_then(get) 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 // 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. // filled in advance), so it's hidden and the existing profile is left alone.
let profile_filled = profile let profile_filled = profile
@@ -127,8 +136,8 @@ async fn checkout_page(
"items": lines, "items": lines,
"subtotal": format_price(subtotal), "subtotal": format_price(subtotal),
"subtotal_cents": subtotal, "subtotal_cents": subtotal,
"currency": currency,
"shipping_methods": methods, "shipping_methods": methods,
"payment_methods": payments,
"packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""), "packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""),
"logged_in_admin": is_admin, "logged_in_admin": is_admin,
"logged_in_customer": is_customer, "logged_in_customer": is_customer,
@@ -136,6 +145,7 @@ async fn checkout_page(
// logged_in_customer is true); None for admins/guests. // logged_in_customer is true); None for admins/guests.
"customer_name": user.as_ref().filter(|_| is_customer).map(|u| u.name.clone()), "customer_name": user.as_ref().filter(|_| is_customer).map(|u| u.name.clone()),
"customer_account_type": user.as_ref().filter(|_| is_customer).map(|u| u.account_type.clone()), "customer_account_type": user.as_ref().filter(|_| is_customer).map(|u| u.account_type.clone()),
"customer_avatar": user.as_ref().filter(|_| is_customer).and_then(|u| u.avatar_id.clone()),
"profile_filled": profile_filled, "profile_filled": profile_filled,
// A logged-in customer's account type is fixed; only guests pick it // A logged-in customer's account type is fixed; only guests pick it
// and may opt to create an account from the order. // and may opt to create an account from the order.
@@ -150,10 +160,10 @@ async fn checkout_page(
"prefill_vat_id": p(|x| x.vat_id.clone()), "prefill_vat_id": p(|x| x.vat_id.clone()),
"prefill_phone_prefix": p(|x| x.phone_prefix.clone()), "prefill_phone_prefix": p(|x| x.phone_prefix.clone()),
"prefill_phone": p(|x| x.phone.clone()), "prefill_phone": p(|x| x.phone.clone()),
"prefill_address": p(|x| x.address.clone()), "prefill_residence_address": p(|x| x.address.clone()),
"prefill_city": p(|x| x.city.clone()), "prefill_residence_city": p(|x| x.city.clone()),
"prefill_zip": p(|x| x.zip.clone()), "prefill_residence_zip": p(|x| x.zip.clone()),
"prefill_country": p(|x| x.country.clone()), "prefill_residence_country": p(|x| x.country.clone()),
"lang": current_lang(&jar), "lang": current_lang(&jar),
}), }),
) )
@@ -165,7 +175,7 @@ async fn place_order(
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
Form(form): Form<CheckoutForm>, Form(form): Form<CheckoutForm>,
) -> Result<Response> { ) -> Result<Response> {
let (_lines, valid, _total) = resolve_cart(&ctx, &jar).await?; let (_lines, valid, _total) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
if valid.is_empty() { if valid.is_empty() {
return format::redirect("/cart"); return format::redirect("/cart");
} }
@@ -180,16 +190,37 @@ async fn place_order(
None => number.clone(), 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`). // browser via `required`).
let require = |value: &str, field: &str| -> Result<String> { let require = |value: &str, field: &str| -> Result<String> {
trimmed(value).ok_or_else(|| Error::BadRequest(format!("{field} is required"))) 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 customer_name = require(&form.customer_name, "name")?;
let address = require(&form.address, "address")?; let residence_address = require(&form.residence_address, "residence address")?;
let city = require(&form.city, "city")?; let residence_city = require(&form.residence_city, "residence city")?;
let zip = require(&form.zip, "zip")?; let residence_zip = require(&form.residence_zip, "residence zip")?;
let country = require(&form.country, "country")?; 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 // 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 // account, never the form); a guest picks it on the form. Admins are treated
@@ -216,7 +247,7 @@ async fn place_order(
(None, None, None, None) (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())); return Err(Error::BadRequest("invalid payment method".to_string()));
} }
@@ -228,6 +259,9 @@ async fn place_order(
.one(&ctx.db) .one(&ctx.db)
.await? .await?
.ok_or_else(|| Error::BadRequest("invalid shipping method".to_string()))?; .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 (pickup_point_id, pickup_point_name) = if method.requires_pickup_point {
let id = form let id = form
@@ -249,10 +283,10 @@ async fn place_order(
vat_id: vat_id.clone(), vat_id: vat_id.clone(),
phone_prefix: trimmed(&form.phone_prefix), phone_prefix: trimmed(&form.phone_prefix),
phone: Some(number.clone()), phone: Some(number.clone()),
address: Some(address.clone()), address: Some(residence_address.clone()),
city: Some(city.clone()), city: Some(residence_city.clone()),
zip: Some(zip.clone()), zip: Some(residence_zip.clone()),
country: Some(country.clone()), country: Some(residence_country.clone()),
}; };
// Resolve the account that will own this order. A logged-in customer always // Resolve the account that will own this order. A logged-in customer always
@@ -321,6 +355,10 @@ async fn place_order(
company_id, company_id,
tax_id, tax_id,
vat_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), address: Some(address),
city: Some(city), city: Some(city),
zip: Some(zip), zip: Some(zip),
@@ -340,8 +378,11 @@ async fn place_order(
} else { } else {
format!("/orders/{}", order.order_number) format!("/orders/{}", order.order_number)
}; };
if let Some(user) = logged_in_customer {
cart::clear_account_cart(&ctx, user.id).await?;
}
format::render() format::render()
.cookies(&[cleared_cart_cookie()])? .cookies(&[cart::cleared_cart_cookie()])?
.redirect(&target) .redirect(&target)
} }
@@ -365,20 +406,22 @@ async fn order_confirmation(
let c = guard::chrome(&ctx, &jar).await; let c = guard::chrome(&ctx, &jar).await;
let account_created = params.contains_key("account_created"); let account_created = params.contains_key("account_created");
let (bank_iban, bank_account_name) = settings::bank_details(&ctx).await?;
format::view( format::view(
&v, &v,
"shop/order_confirmed.html", "shop/order_confirmed.html",
json!({ json!({
"order": view::detail( "order": view::detail(
&order, &order,
settings::get(&ctx, "bank_iban").unwrap_or(""), &bank_iban,
settings::get(&ctx, "bank_account_name").unwrap_or(""), &bank_account_name,
), ),
"items": view::items(&items), "items": view::items(&items),
"logged_in_admin": c.logged_in_admin, "logged_in_admin": c.logged_in_admin,
"logged_in_customer": c.logged_in_customer, "logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name, "customer_name": c.customer_name,
"customer_account_type": c.customer_account_type, "customer_account_type": c.customer_account_type,
"customer_avatar": c.customer_avatar,
"account_created": account_created, "account_created": account_created,
"lang": current_lang(&jar), "lang": current_lang(&jar),
}), }),

View File

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

View File

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

View File

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

View File

@@ -3,16 +3,20 @@ pub mod auth;
pub mod auth_pages; pub mod auth_pages;
pub mod oauth2; pub mod oauth2;
pub mod admin_categories; pub mod admin_categories;
pub mod admin_currencies;
pub mod admin_customers; pub mod admin_customers;
pub mod admin_dashboard; pub mod admin_dashboard;
pub mod admin_discount_profiles; pub mod admin_discount_profiles;
pub mod admin_form; pub mod admin_form;
pub mod admin_orders; pub mod admin_orders;
pub mod admin_payments;
pub mod admin_products; pub mod admin_products;
pub mod admin_shipping; pub mod admin_shipping;
pub mod cart; pub mod cart;
pub mod checkout; pub mod checkout;
pub mod currency;
pub mod home; pub mod home;
pub mod i18n; pub mod i18n;
pub mod media; pub mod media;
pub mod pages;
pub mod shop; pub mod shop;

View File

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

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

View File

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

View File

@@ -1,5 +1,7 @@
pub mod admin_seeder; pub mod admin_seeder;
pub mod currency_seeder;
pub mod oauth2; pub mod oauth2;
pub mod oauth2_session; pub mod oauth2_session;
pub mod payment_seeder;
pub mod shipping_seeder; pub mod shipping_seeder;
pub mod view_engine; 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 loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set}; 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)` /// `(code, name, carrier, requires_pickup_point, default_price_cents, position)`
const BUILTINS: [(&str, &str, &str, bool, i64, i32); 2] = [ const BUILTINS: [(&str, &str, &str, bool, i64, i32); 2] = [
@@ -49,6 +49,6 @@ impl Initializer for ShippingSeeder {
.await?; .await?;
tracing::info!(carrier = code, "seeded built-in delivery option"); tracing::info!(carrier = code, "seeded built-in delivery option");
} }
Ok(()) shipping_rules::disable_packeta_if_unconfigured(ctx).await
} }
} }

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

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

View File

@@ -2,22 +2,26 @@
pub mod prelude; pub mod prelude;
pub mod account_cart_items;
pub mod account_discount_profiles; pub mod account_discount_profiles;
pub mod account_product_prices; pub mod account_product_prices;
pub mod account_product_resolutions; pub mod account_product_resolutions;
pub mod audience_discount_profiles; pub mod audience_discount_profiles;
pub mod audit_logs; pub mod audit_logs;
pub mod categories; pub mod categories;
pub mod currencies;
pub mod customer_profiles; pub mod customer_profiles;
pub mod discount_profile_products; pub mod discount_profile_products;
pub mod discount_profiles; pub mod discount_profiles;
pub mod o_auth2_sessions; pub mod o_auth2_sessions;
pub mod order_items; pub mod order_items;
pub mod orders; pub mod orders;
pub mod payment_methods;
pub mod product_images; pub mod product_images;
pub mod product_product_tags; pub mod product_product_tags;
pub mod product_tags; pub mod product_tags;
pub mod product_variants; pub mod product_variants;
pub mod products; pub mod products;
pub mod shipping_methods; pub mod shipping_methods;
pub mod shop_settings;
pub mod users; pub mod users;

View File

@@ -16,7 +16,6 @@ pub struct Model {
pub customer_name: Option<String>, pub customer_name: Option<String>,
pub status: String, pub status: String,
pub total_cents: i64, pub total_cents: i64,
pub currency: String,
pub address: Option<String>, pub address: Option<String>,
pub city: Option<String>, pub city: Option<String>,
pub zip: Option<String>, pub zip: Option<String>,
@@ -39,6 +38,10 @@ pub struct Model {
pub tax_id: Option<String>, pub tax_id: Option<String>,
pub vat_id: Option<String>, pub vat_id: Option<String>,
pub user_id: Option<i32>, 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)] #[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,21 +1,25 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19 //! `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_discount_profiles::Entity as AccountDiscountProfiles;
pub use super::account_product_prices::Entity as AccountProductPrices; pub use super::account_product_prices::Entity as AccountProductPrices;
pub use super::account_product_resolutions::Entity as AccountProductResolutions; pub use super::account_product_resolutions::Entity as AccountProductResolutions;
pub use super::audience_discount_profiles::Entity as AudienceDiscountProfiles; pub use super::audience_discount_profiles::Entity as AudienceDiscountProfiles;
pub use super::audit_logs::Entity as AuditLogs; pub use super::audit_logs::Entity as AuditLogs;
pub use super::categories::Entity as Categories; pub use super::categories::Entity as Categories;
pub use super::currencies::Entity as Currencies;
pub use super::customer_profiles::Entity as CustomerProfiles; pub use super::customer_profiles::Entity as CustomerProfiles;
pub use super::discount_profile_products::Entity as DiscountProfileProducts; pub use super::discount_profile_products::Entity as DiscountProfileProducts;
pub use super::discount_profiles::Entity as DiscountProfiles; pub use super::discount_profiles::Entity as DiscountProfiles;
pub use super::o_auth2_sessions::Entity as OAuth2Sessions; pub use super::o_auth2_sessions::Entity as OAuth2Sessions;
pub use super::order_items::Entity as OrderItems; pub use super::order_items::Entity as OrderItems;
pub use super::orders::Entity as Orders; 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_images::Entity as ProductImages;
pub use super::product_product_tags::Entity as ProductProductTags; pub use super::product_product_tags::Entity as ProductProductTags;
pub use super::product_tags::Entity as ProductTags; pub use super::product_tags::Entity as ProductTags;
pub use super::product_variants::Entity as ProductVariants; pub use super::product_variants::Entity as ProductVariants;
pub use super::products::Entity as Products; pub use super::products::Entity as Products;
pub use super::shipping_methods::Entity as ShippingMethods; pub use super::shipping_methods::Entity as ShippingMethods;
pub use super::shop_settings::Entity as ShopSettings;
pub use super::users::Entity as Users; pub use super::users::Entity as Users;

View File

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

View File

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

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>, pub totp_enabled_at: Option<DateTimeWithTimeZone>,
#[sea_orm(column_type = "Text", nullable)] #[sea_orm(column_type = "Text", nullable)]
pub totp_backup_codes: Option<String>, pub totp_backup_codes: Option<String>,
pub avatar_id: Option<String>,
} }
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

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

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

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

View File

@@ -6,22 +6,26 @@
pub mod _entities; pub mod _entities;
pub mod account_cart_items;
pub mod account_discount_profiles; pub mod account_discount_profiles;
pub mod account_product_prices; pub mod account_product_prices;
pub mod account_product_resolutions; pub mod account_product_resolutions;
pub mod audience_discount_profiles; pub mod audience_discount_profiles;
pub mod audit_logs; pub mod audit_logs;
pub mod categories; pub mod categories;
pub mod currencies;
pub mod discount_profile_products; pub mod discount_profile_products;
pub mod discount_profiles; pub mod discount_profiles;
pub mod customer_profiles; pub mod customer_profiles;
pub mod o_auth2_sessions; pub mod o_auth2_sessions;
pub mod order_items; pub mod order_items;
pub mod orders; pub mod orders;
pub mod payment_methods;
pub mod product_images; pub mod product_images;
pub mod product_product_tags; pub mod product_product_tags;
pub mod product_tags; pub mod product_tags;
pub mod products; pub mod products;
pub mod shipping_methods; pub mod shipping_methods;
pub mod shop_settings;
pub mod users; pub mod users;
pub mod product_variants; pub mod product_variants;

View File

@@ -24,6 +24,10 @@ pub struct Checkout {
pub company_id: Option<String>, pub company_id: Option<String>,
pub tax_id: Option<String>, pub tax_id: Option<String>,
pub vat_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 address: Option<String>,
pub city: Option<String>, pub city: Option<String>,
pub zip: Option<String>, pub zip: Option<String>,
@@ -53,7 +57,6 @@ pub async fn place(
let txn = ctx.db.begin().await?; let txn = ctx.db.begin().await?;
let mut subtotal: i64 = 0; let mut subtotal: i64 = 0;
let mut currency = "EUR".to_string();
let mut snapshots = Vec::new(); let mut snapshots = Vec::new();
for (variant_id, qty) in items { for (variant_id, qty) in items {
let variant = product_variants::Entity::find_by_id(*variant_id) let variant = product_variants::Entity::find_by_id(*variant_id)
@@ -75,7 +78,6 @@ pub async fn place(
))); )));
} }
} }
currency = product.currency.clone();
// Snapshot the price the buyer actually pays — public sale or, for a // Snapshot the price the buyer actually pays — public sale or, for a
// business account, their negotiated/lowest price (same resolver the // business account, their negotiated/lowest price (same resolver the
// cart and storefront use). // cart and storefront use).
@@ -98,13 +100,16 @@ pub async fn place(
customer_name: Set(details.customer_name), customer_name: Set(details.customer_name),
status: Set("pending".to_string()), status: Set("pending".to_string()),
total_cents: Set(subtotal + details.method.price_cents), total_cents: Set(subtotal + details.method.price_cents),
currency: Set(currency),
user_id: Set(details.user_id), user_id: Set(details.user_id),
account_type: Set(details.account_type), account_type: Set(details.account_type),
company_name: Set(details.company_name), company_name: Set(details.company_name),
company_id: Set(details.company_id), company_id: Set(details.company_id),
tax_id: Set(details.tax_id), tax_id: Set(details.tax_id),
vat_id: Set(details.vat_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), address: Set(details.address),
city: Set(details.city), city: Set(details.city),
zip: Set(details.zip), zip: Set(details.zip),
@@ -163,4 +168,45 @@ impl Model {}
impl ActiveModel {} impl ActiveModel {}
// implement your custom finders, selectors oriented logic here // implement your custom finders, selectors oriented logic here
impl Entity {} impl Entity {
/// Admin order search: a diacritic- and case-insensitive substring match over
/// the free-text order fields an admin would actually type — order number,
/// email, customer name, company name, phone and tracking number. Backed by
/// the trigram indexes from the `order_search_indexes` migration. Newest
/// first, capped at `limit`. A blank query returns nothing (callers fall back
/// to the full list).
pub async fn search<C: sea_orm::ConnectionTrait>(
db: &C,
query: &str,
limit: u64,
) -> Result<Vec<Model>, DbErr> {
let q = query.trim();
if q.is_empty() {
return Ok(Vec::new());
}
// Treat the query literally: escape LIKE wildcards, then wrap in %…%.
let escaped = q.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
let pattern = format!("%{escaped}%");
let sql = r#"
SELECT * FROM orders o
WHERE f_unaccent(o.order_number) ILIKE f_unaccent($1)
OR f_unaccent(o.email) ILIKE f_unaccent($1)
OR f_unaccent(COALESCE(o.customer_name,'')) ILIKE f_unaccent($1)
OR f_unaccent(COALESCE(o.company_name,'')) ILIKE f_unaccent($1)
OR f_unaccent(COALESCE(o.phone,'')) ILIKE f_unaccent($1)
OR f_unaccent(COALESCE(o.tracking_number,'')) ILIKE f_unaccent($1)
ORDER BY o.created_at DESC
LIMIT $2
"#;
Entity::find()
.from_raw_sql(sea_orm::Statement::from_sql_and_values(
db.get_database_backend(),
sql,
[pattern.into(), (limit as i64).into()],
))
.all(db)
.await
}
}

View File

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

@@ -37,7 +37,14 @@ impl Entity {
/// `f_unaccent`, so diacritics never matter. Results are ranked by full-text /// `f_unaccent`, so diacritics never matter. Results are ranked by full-text
/// rank, then trigram closeness of the name, then recency. An empty/blank /// rank, then trigram closeness of the name, then recency. An empty/blank
/// query returns nothing — callers fall back to the plain listing. /// query returns nothing — callers fall back to the plain listing.
pub async fn search<C: ConnectionTrait>(db: &C, query: &str) -> Result<Vec<Model>, DbErr> { /// `published_only` filters to the storefront-visible set; pass `false` for
/// admin tools that also need to find drafts.
pub async fn search<C: ConnectionTrait>(
db: &C,
query: &str,
limit: u64,
published_only: bool,
) -> Result<Vec<Model>, DbErr> {
let q = query.trim(); let q = query.trim();
if q.is_empty() { if q.is_empty() {
return Ok(Vec::new()); return Ok(Vec::new());
@@ -45,13 +52,14 @@ impl Entity {
// Only the model's own columns are selected; the generated `search_vector` // Only the model's own columns are selected; the generated `search_vector`
// is left out so the row maps cleanly back onto `Model`. `$1` is reused // is left out so the row maps cleanly back onto `Model`. `$1` is reused
// for every occurrence of the query term. // for every occurrence of the query term; `$2` caps the result set.
let sql = r#" let published_clause = if published_only { "p.published = TRUE AND" } else { "" };
let sql = format!(
r#"
SELECT p.created_at, p.updated_at, p.id, p.name, p.slug, p.description, SELECT p.created_at, p.updated_at, p.id, p.name, p.slug, p.description,
p.currency, p.view_count, p.published, p.published_at, p.category_id p.short_description, p.view_count, p.published, p.published_at, p.category_id
FROM products p FROM products p
WHERE p.published = TRUE WHERE {published_clause} (
AND (
p.search_vector @@ websearch_to_tsquery('sk_unaccent', $1) p.search_vector @@ websearch_to_tsquery('sk_unaccent', $1)
OR word_similarity(f_unaccent($1), f_unaccent(p.name)) > 0.3 OR word_similarity(f_unaccent($1), f_unaccent(p.name)) > 0.3
OR word_similarity(f_unaccent($1), f_unaccent(COALESCE(p.description, ''))) > 0.3 OR word_similarity(f_unaccent($1), f_unaccent(COALESCE(p.description, ''))) > 0.3
@@ -60,14 +68,15 @@ impl Entity {
ts_rank(p.search_vector, websearch_to_tsquery('sk_unaccent', $1)) DESC, ts_rank(p.search_vector, websearch_to_tsquery('sk_unaccent', $1)) DESC,
word_similarity(f_unaccent($1), f_unaccent(p.name)) DESC, word_similarity(f_unaccent($1), f_unaccent(p.name)) DESC,
p.published_at DESC NULLS LAST p.published_at DESC NULLS LAST
LIMIT 60 LIMIT $2
"#; "#
);
Entity::find() Entity::find()
.from_raw_sql(Statement::from_sql_and_values( .from_raw_sql(Statement::from_sql_and_values(
db.get_database_backend(), db.get_database_backend(),
sql, &sql,
[q.into()], [q.into(), (limit as i64).into()],
)) ))
.all(db) .all(db)
.await .await

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

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

Some files were not shown because too many files have changed in this diff Show More