56 Commits

Author SHA1 Message Date
Priec
2023b24d92 search notify where we are searching
Some checks are pending
CI / Check Style (push) Waiting to run
CI / Run Clippy (push) Waiting to run
CI / Run Tests (push) Waiting to run
2026-06-25 20:46:46 +02:00
Priec
aea4782e68 avatar 2026-06-25 19:24:50 +02:00
Priec
0c0cae2355 search changing newest to relevance on search now
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-25 17:09:02 +02:00
Priec
194e9e2de3 search needs button now 2026-06-25 16:36:50 +02:00
Priec
848042c304 page is better in shop now 2026-06-25 15:38:18 +02:00
Priec
ee8ec5c85b right sidebar is scrolled over now 2026-06-25 15:31:29 +02:00
Priec
a53bd720bd left sidebar is scrollable 2026-06-25 15:30:43 +02:00
Priec
2ed069ea63 breadcrumbs position 2026-06-25 15:03:13 +02:00
Priec
c0f4d0c93c navbar search removed where it shouldnt be 2026-06-25 14:56:19 +02:00
Priec
d68ed5ce7c search looks better now 2026-06-25 14:53:51 +02:00
Priec
72babdf74f search in the shop bar is not duplicated anymore 2026-06-25 13:59:51 +02:00
Priec
8dd9a53ad8 home search fixed 2026-06-25 13:09:32 +02:00
Priec
aae8083de1 catppuccin latte is on the light mode 2026-06-25 12:19:08 +02:00
Priec
3159c5b30b dark mode is now gruvbox 2026-06-25 12:16:25 +02:00
Priec
f51875d5f4 new ui4 2026-06-25 12:13:30 +02:00
Priec
d3d1c0d157 new ui3 2026-06-24 23:28:40 +02:00
Priec
a34fd1725b new ui2 2026-06-24 23:04:10 +02:00
Priec
f665eee96e new ui 2026-06-24 22:45:33 +02:00
Priec
ac31cdfbf3 eur czk can be disabled from now on
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-23 21:54:09 +02:00
Priec
c409e85995 CZK implemented 2026-06-23 12:54:11 +02:00
Priec
6b7422806f whole eshop is now in euro 2026-06-23 12:31:52 +02:00
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
Priec
3b9c2f7d64 search implement
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 20:37:06 +02:00
Priec
e5cac27010 card can be vertical or horizontal
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 19:50:01 +02:00
Priec
a45f9ef030 fixing product card 2026-06-22 19:40:08 +02:00
Priec
51155f2fd2 I can move the images around now 2026-06-22 19:03:33 +02:00
Priec
2d2aa012ec multiple images in the edit product
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 18:20:50 +02:00
Priec
125be1798e muiltiple images in carousel 2026-06-22 17:40:55 +02:00
Priec
f724e9763f upload picture now working well 2026-06-22 16:56:14 +02:00
Priec
681c88f85d 0 is out of stock and nothing is available 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-22 16:48:28 +02:00
Priec
6828854f24 the admin page now make sense for products
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 16:21:52 +02:00
Priec
3a1ea7cdb4 I can see the product with different options
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 16:14:04 +02:00
Priec
3f798432a0 now products have different options, like different parameters
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 15:44:02 +02:00
Priec
29854a972b save discount profile is now working perfectly 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 13:51:40 +02:00
Priec
88074c1871 indicator if applied discount profile
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 13:22:46 +02:00
Priec
68f3472760 moved products menu up to save width 2026-06-22 13:15:29 +02:00
Priec
85f1657c67 dynamic width of the products to fit on the screen 2026-06-22 13:07:17 +02:00
Priec
4a736a8c85 collapsible admin sidebar 2026-06-22 12:59:05 +02:00
Priec
77d5c0fc25 sidebar in the admin 2026-06-22 12:49:08 +02:00
Priec
09634e1cd8 added missing remove button in the admin business
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 11:33:07 +02:00
Priec
088fcb60a1 effective price is only highlighted if changed 2026-06-22 11:10:12 +02:00
Priec
bf8f8e54c9 discounts page removed, all migrated to the products page in admin 2026-06-22 09:19:38 +02:00
Priec
534ba9e8ec confirm dialogs 2026-06-22 09:11:16 +02:00
Priec
262ec1bfdb dynamic prices with dicounts 2026-06-22 08:47:22 +02:00
Priec
e98c70aa63 global discount price 2026-06-22 00:18:39 +02:00
Priec
d2b463135b discount for business and personall in discount page 2026-06-22 00:04:01 +02:00
Priec
1df8d66d5d discount profiles and discounts overall implemented and working
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-21 23:46:37 +02:00
Priec
c713627a2c personal discounts to businesses done 2026-06-21 23:21:24 +02:00
137 changed files with 8204 additions and 972 deletions

View File

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

View File

@@ -20,6 +20,7 @@ admin-audio-desc = upload songs, then group them into albums.
logout = Log out
settings = Settings
settings-language = Language
settings-currency = Currency
settings-theme = Theme
language-en = English
language-sk = Slovak
@@ -171,6 +172,8 @@ artist = Artist
release-date = Release date
cover-image = Cover image
description = Description
short-description = Short description
short-description-hint = Shown on product cards. Keep it short.
songs-in-album = Songs in this album
admin-new-album-desc = Fill in the details, then tick the songs to include.
cover-help = Optional - png, jpg, webp or gif; shown on the album page.
@@ -209,13 +212,27 @@ product = Product
name = Name
price = Price
sale-price = Sale price
variants-options = Variants / options
add-option = Add option
option-label = Option label
optional = optional
stock-untracked-hint = Leave blank = available without stock tracking
available = Available
choose-option = Choose an option
from-price = from { $price }
admin-discounts = Discounts
admin-discounts-desc = Set discounted product prices. A discount shows up as a sale in the shop.
business-discount-desc = A baseline discount for all business accounts (off the regular price). Profiles and negotiated prices apply on top (lowest price wins).
audience-personal = Personal
audience-business = Business
apply-profiles-personal-hint = These profiles lower the public price for all customers.
apply-profiles-business-hint = These profiles lower the price for all business accounts. Businesses always get the lower of the personal and business price.
on-sale = On sale
no-discount = No discount
discount = Discount
set-discount = Set discount
remove-discount = Remove discount
remove = Remove
discount-mode-fixed = Fixed price
discount-mode-percent = Percentage
discount-percent = Discount (%)
@@ -226,12 +243,55 @@ discount-invalid = Invalid price.
discount-must-be-positive = The sale price must be greater than zero.
discount-below-regular = The sale price must be below the regular price.
discount-percent-range = The percentage must be between 0 and 100.
discount-apply-confirm = Apply these discounts to the shop?
discount-remove-confirm = Remove this discount?
profile-applied = Applied
profile-will-apply = Will apply
profile-will-remove = Will remove
profiles-unsaved = Unsaved changes — Save to apply
profiles-no-changes = No changes
admin-customers = Business accounts
admin-customers-desc = Manage negotiated prices for business (B2B) accounts.
admin-no-customers = No business accounts yet.
email = Email
back = Back
negotiated-prices = Negotiated prices
negotiated-prices-hint = Set a price for a specific product for this business account. The customer always pays the lower of the public and negotiated price.
manage-prices = Manage prices
public-price = Public price
business-price = Business price
negotiated-price = Negotiated price
set-negotiated-price = Set price
negotiated-price-hint = Set a negotiated price for this product for this business account. The customer always pays the lowest of the public, business and negotiated price.
negotiated-remove-confirm = Remove this negotiated price?
effective-price = Effective price
admin-discount-profiles = Discount profiles
admin-discount-profiles-desc = Create reusable discount layers (a % over chosen products) and assign them to business accounts.
admin-no-profiles = No discount profiles yet.
new-profile = New profile
edit-profile = Edit profile
profile-name-required = Profile name is required.
scope = Scope
products = Products
scope-include = Selected products
scope-all-except = All except selected
scope-include-hint = Applies only to the products selected below.
scope-all-except-hint = Applies to every product except those selected below.
automated-price = Automated price
discount-profiles = Discount profiles
collision = Conflict
resolve = Resolve
no-profiles-assigned = No profiles assigned.
stock = Stock
sku = SKU
currency = Currency
category = Category
no-category = No category
image = Image
images = Images
main-image = Main
gallery-hint = The first image is the main one. Drag to reorder, click ✕ to remove.
add-images = Add images
slug = URL slug
slug-auto = generated automatically
position = Position
@@ -250,8 +310,36 @@ confirm-delete = Delete this for good?
shop-title = Shop
shop-subtitle = browse our products.
shop-empty = There are no products here yet.
search-placeholder = Search products…
order-search-placeholder = Search orders…
search-empty = Nothing matched your search:
results-count = { $count } products
sort-label = Sort
per-page-label = Per page
sort-relevance = Relevance
sort-newest = Newest
sort-price_asc = Price: low to high
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-list = List view
categories = Categories
all-products = All products
uncategorized = Uncategorized
cart-title = Cart
cart-empty = Your cart is empty.
cart-total = Total
@@ -299,6 +387,11 @@ profile-last-name = Surname
profile-edit = Edit profile
profile-cancel = Cancel
profile-not-set = Not set
profile-avatar = Profile picture
profile-avatar-hint = PNG, JPG, WEBP or GIF, up to 10 MB.
profile-avatar-choose = Choose a picture
profile-avatar-upload = Upload
profile-avatar-remove = Remove picture
nav-account = My account
account-orders = My orders
account-change-password = Change password
@@ -389,6 +482,14 @@ bank-amount = Amount
admin-shipping = Shipping
admin-shipping-desc = set the price and availability of each delivery option.
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-add = Add
shipping-requires-pickup = Requires pickup point
@@ -405,3 +506,29 @@ order-manual-fulfillment = Manual fulfilment — no carrier API for this option.
order-send-hint = When the goods are ready, send this order to the carrier.
order-send-to-carrier = Send to
order-send-confirm = Send this order to the carrier now?
# --- storefront chrome: top bar, header, footer ---
brand-subtitle = medical supplies
top-contact = Contact
top-sitemap = Sitemap
search-button = Search
search-scope-in = Searching in:
search-scope-all = Search the whole shop
welcome = Welcome
cart-units = items
hotline = +421 903 410 476
footer-tagline = Medical supplies for clinics, hospitals and home care. Delivery within 24 hours.
footer-info = Information
footer-account = Account
footer-contact = Contact
footer-terms = Terms and conditions
footer-about = About our company
footer-stores = Our stores
footer-shipping = Shipping and payment
footer-orders = My orders
footer-email = info@kompress.sk
footer-hours = MonFri 8:0016:00
footer-rights = © 2026 Kompress · Medical supplies
page-coming-soon = This page is coming soon. In the meantime, feel free to contact us by phone or e-mail.
page-contact-intro = We're happy to help you choose. Get in touch:
page-sitemap-intro = An overview of the shop's main sections.

View File

@@ -20,6 +20,7 @@ admin-audio-desc = nahrať skladby a potom ich zoskupiť do albumov.
logout = Odhlásiť sa
settings = Nastavenia
settings-language = Jazyk
settings-currency = Mena
settings-theme = Téma
language-en = Angličtina
language-sk = Slovenčina
@@ -171,6 +172,8 @@ artist = Interpret
release-date = Dátum vydania
cover-image = Obrázok obalu
description = Popis
short-description = Krátky popis
short-description-hint = Zobrazuje sa na kartách produktov. Najlepšie krátke.
songs-in-album = Skladby v albume
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.
@@ -209,13 +212,27 @@ product = Produkt
name = Názov
price = Cena
sale-price = Zľavnená cena
variants-options = Varianty / možnosti
add-option = Pridať možnosť
option-label = Označenie možnosti
optional = voliteľné
stock-untracked-hint = Nechajte prázdne = dostupné bez sledovania zásob
available = Dostupné
choose-option = Vyberte možnosť
from-price = od { $price }
admin-discounts = Zľavy
admin-discounts-desc = Nastavte zľavnené ceny produktov. Zľava sa v obchode zobrazí ako akcia.
business-discount-desc = Základná zľava pre všetky firemné účty (z bežnej ceny). Profily a dohodnuté ceny sa uplatnia navyše (platí najnižšia cena).
audience-personal = Osobné
audience-business = Firemné
apply-profiles-personal-hint = Tieto profily znížia verejnú cenu pre všetkých zákazníkov.
apply-profiles-business-hint = Tieto profily znížia cenu pre všetky firemné účty. Firmy vždy dostanú nižšiu z osobnej a firemnej ceny.
on-sale = V akcii
no-discount = Bez zľavy
discount = Zľava
set-discount = Nastaviť zľavu
remove-discount = Zrušiť zľavu
remove = Odstrániť
discount-mode-fixed = Pevná cena
discount-mode-percent = Percentá
discount-percent = Zľava (%)
@@ -226,12 +243,55 @@ discount-invalid = Neplatná cena.
discount-must-be-positive = Zľavnená cena musí byť väčšia ako nula.
discount-below-regular = Zľavnená cena musí byť nižšia ako bežná cena.
discount-percent-range = Percento musí byť medzi 0 a 100.
discount-apply-confirm = Uplatniť tieto zľavy v obchode?
discount-remove-confirm = Zrušiť túto zľavu?
profile-applied = Uplatnené
profile-will-apply = Bude uplatnené
profile-will-remove = Bude zrušené
profiles-unsaved = Neuložené zmeny — uložte na uplatnenie
profiles-no-changes = Žiadne zmeny
admin-customers = Firemné účty
admin-customers-desc = Spravujte dohodnuté ceny pre firemné (B2B) účty.
admin-no-customers = Zatiaľ žiadne firemné účty.
email = E-mail
back = Späť
negotiated-prices = Dohodnuté ceny
negotiated-prices-hint = Nastavte cenu pre konkrétny produkt pre tento firemný účet. Zákazník vždy zaplatí najnižšiu z verejnej a dohodnutej ceny.
manage-prices = Spravovať ceny
public-price = Verejná cena
business-price = Firemná cena
negotiated-price = Dohodnutá cena
set-negotiated-price = Nastaviť cenu
negotiated-price-hint = Nastavte dohodnutú cenu tohto produktu pre tento firemný účet. Zákazník vždy zaplatí najnižšiu z verejnej, firemnej a dohodnutej ceny.
negotiated-remove-confirm = Zrušiť túto dohodnutú cenu?
effective-price = Výsledná cena
admin-discount-profiles = Zľavové profily
admin-discount-profiles-desc = Vytvorte opakovane použiteľné zľavové vrstvy (% na vybrané produkty) a priraďte ich firemným účtom.
admin-no-profiles = Zatiaľ žiadne zľavové profily.
new-profile = Nový profil
edit-profile = Upraviť profil
profile-name-required = Názov profilu je povinný.
scope = Rozsah
products = Produkty
scope-include = Vybrané produkty
scope-all-except = Všetky okrem vybraných
scope-include-hint = Platí len pre vybrané produkty nižšie.
scope-all-except-hint = Platí pre všetky produkty okrem vybraných nižšie.
automated-price = Automatická cena
discount-profiles = Zľavové profily
collision = Konflikt
resolve = Vyriešiť
no-profiles-assigned = Žiadne priradené profily.
stock = Sklad
sku = Kód (SKU)
currency = Mena
category = Kategória
no-category = Bez kategórie
image = Obrázok
images = Obrázky
main-image = Hlavný
gallery-hint = Prvý obrázok je hlavný. Potiahnutím zmeníte poradie, krížikom obrázok odstránite.
add-images = Pridať obrázky
slug = URL adresa
slug-auto = vygeneruje sa automaticky
position = Poradie
@@ -250,8 +310,36 @@ confirm-delete = Naozaj zmazať?
shop-title = Obchod
shop-subtitle = prezrite si našu ponuku produktov.
shop-empty = Zatiaľ tu nie sú žiadne produkty.
search-placeholder = Hľadať produkty…
order-search-placeholder = Hľadať objednávky…
search-empty = Pre váš výraz sme nič nenašli:
results-count = { $count } produktov
sort-label = Zoradiť
per-page-label = Na stránku
sort-relevance = Relevancia
sort-newest = Najnovšie
sort-price_asc = Cena: od najnižšej
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-list = Zobrazenie v zozname
categories = Kategórie
all-products = Všetky produkty
uncategorized = Bez kategórie
cart-title = Košík
cart-empty = Váš košík je prázdny.
cart-total = Spolu
@@ -299,6 +387,11 @@ profile-last-name = Priezvisko
profile-edit = Upraviť profil
profile-cancel = Zrušiť
profile-not-set = Neuvedené
profile-avatar = Profilová fotka
profile-avatar-hint = PNG, JPG, WEBP alebo GIF, max. 10 MB.
profile-avatar-choose = Vybrať fotku
profile-avatar-upload = Nahrať
profile-avatar-remove = Odstrániť fotku
nav-account = Môj účet
account-orders = Moje objednávky
account-change-password = Zmeniť heslo
@@ -389,6 +482,14 @@ bank-amount = Suma
admin-shipping = Doprava
admin-shipping-desc = nastaviť cenu a dostupnosť jednotlivých možností dopravy.
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-add = Pridať
shipping-requires-pickup = Vyžaduje výdajné miesto
@@ -405,3 +506,29 @@ order-manual-fulfillment = Manuálne spracovanie — táto možnosť nemá API d
order-send-hint = Keď je tovar pripravený, odošlite objednávku dopravcovi.
order-send-to-carrier = Odoslať dopravcovi
order-send-confirm = Odoslať túto objednávku dopravcovi teraz?
# --- storefront chrome: top bar, header, footer ---
brand-subtitle = zdravotnícke potreby
top-contact = Kontakt
top-sitemap = Mapa stránky
search-button = Hľadať
search-scope-in = Hľadáte v kategórii:
search-scope-all = Hľadať v celom obchode
welcome = Vitajte
cart-units = ks
hotline = +421 903 410 476
footer-tagline = Zdravotnícke potreby pre ambulancie, nemocnice a domácu starostlivosť. Dodanie do 24 hodín.
footer-info = Informácie
footer-account = Účet
footer-contact = Kontakt
footer-terms = Obchodné podmienky
footer-about = O našej spoločnosti
footer-stores = Naše obchody
footer-shipping = Doprava a platba
footer-orders = Moje objednávky
footer-email = info@kompress.sk
footer-hours = PoPia 8:0016:00
footer-rights = © 2026 Kompress · Zdravotnícke potreby
page-coming-soon = Túto stránku práve pripravujeme. Medzitým nás môžete kontaktovať telefonicky alebo e-mailom.
page-contact-intro = Radi vám poradíme s výberom. Ozvite sa nám:
page-sitemap-intro = Prehľad hlavných sekcií obchodu.

File diff suppressed because one or more lines are too long

View File

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

@@ -29,19 +29,19 @@
<ul class="space-y-2 pb-3 text-sm">
{% for item in items %}
<li class="flex justify-between gap-2">
<span class="text-on-surface/80 dark:text-on-surface-dark/80">{{ item.product_name }} × {{ item.quantity }}</span>
<span class="tabular-nums">{{ item.line_total }} {{ order.currency }}</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 }} </span>
</li>
{% endfor %}
</ul>
<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">{{ 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">{{ 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 }} </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 %}
</div>
<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 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>
@@ -68,7 +68,7 @@
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-account-name", lang=lang | default(value='sk')) }}</span><span class="font-medium">{{ order.bank_account_name }}</span>
<span class="text-on-surface/70 dark:text-on-surface-dark/70">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-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>
{% endif %}

View File

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

View File

@@ -46,7 +46,14 @@
</head>
<body
hx-headers='{"X-CSRF-Token": "{{ csrf_token() }}"}'
x-data="{ showSidebar: false }"
x-data="{
showSidebar: false,
collapsed: localStorage.getItem('adminSidebarCollapsed') === '1',
toggleCollapsed() {
this.collapsed = !this.collapsed;
localStorage.setItem('adminSidebarCollapsed', this.collapsed ? '1' : '0');
}
}"
class="min-h-screen bg-surface text-on-surface antialiased dark:bg-surface-dark dark:text-on-surface-dark">
<!-- dark overlay for the open sidebar on small screens -->
@@ -56,8 +63,8 @@
<!-- sidebar -->
<nav aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}"
x-bind:class="showSidebar ? 'translate-x-0' : '-translate-x-60'"
class="fixed inset-y-0 left-0 z-40 flex w-60 flex-col border-r border-outline bg-surface-alt transition-transform duration-300 md:translate-x-0 dark:border-outline-dark dark:bg-surface-dark-alt">
x-bind:class="(showSidebar ? 'translate-x-0' : '-translate-x-60') + ' ' + (collapsed ? 'md:-translate-x-60' : 'md:translate-x-0')"
class="fixed inset-y-0 left-0 z-40 flex w-60 flex-col border-r border-outline bg-surface-alt transition-transform duration-300 dark:border-outline-dark dark:bg-surface-dark-alt">
{# Sidebar nav links — adapted from the vendored Penguin UI component
penguinui-components/sidebar/simple-sidebar.html: Penguin's link
@@ -78,9 +85,9 @@
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-products", lang=lang | default(value='sk')) }}
</a>
<a href="/admin/catalog/discounts" data-nav="/admin/catalog/discounts"
<a href="/admin/catalog/discount-profiles" data-nav="/admin/catalog/discount-profiles"
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-discounts", lang=lang | default(value='sk')) }}
{{ t(key="admin-discount-profiles", lang=lang | default(value='sk')) }}
</a>
<a href="/admin/catalog/categories" data-nav="/admin/catalog/categories"
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">
@@ -90,10 +97,18 @@
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-orders", lang=lang | default(value='sk')) }}
</a>
<a href="/admin/customers" data-nav="/admin/customers"
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-customers", lang=lang | default(value='sk')) }}
</a>
<a href="/admin/shipping" data-nav="/admin/shipping"
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}
</a>
<a href="/admin/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 class="border-t border-outline p-4 dark:border-outline-dark">
@@ -110,7 +125,8 @@
</nav>
<!-- content column -->
<div class="flex min-h-screen flex-col md:ml-60">
<div :class="collapsed ? 'md:ml-0' : 'md:ml-60'"
class="flex min-h-screen flex-col transition-[margin] duration-300">
<header class="sticky top-0 z-20 flex h-16 items-center gap-4 border-b border-outline bg-surface/95 px-4 backdrop-blur dark:border-outline-dark dark:bg-surface-dark/95">
<!-- Penguin animated hamburger (bars ↔ X) in our ghost-square shell -->
<button type="button" @click="showSidebar = !showSidebar" :aria-expanded="showSidebar" aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}"
@@ -119,6 +135,12 @@
{{ ui::icon(name="close", size="size-6", attrs='x-cloak x-show="showSidebar"') }}
</button>
<!-- desktop sidebar collapse toggle (chevron flips when collapsed) -->
<button type="button" @click="toggleCollapsed()" :aria-expanded="(!collapsed).toString()" aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}"
class="hidden 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:inline-flex dark:text-secondary-dark dark:focus-visible:outline-secondary-dark">
{{ ui::icon(name="chevron-double-left", size="size-6", extra="transition-transform duration-300", attrs=`x-bind:class="collapsed ? 'rotate-180' : ''"`) }}
</button>
<span class="text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">
{% block crumb %}{{ t(key="admin-title", lang=lang | default(value='sk')) }}{% endblock crumb %}
</span>
@@ -129,7 +151,7 @@
</div>
</header>
<main class="mx-auto w-full max-w-5xl flex-1 px-4 py-8">
<main class="mx-auto w-full flex-1 px-4 py-8 {% block main_class %}max-w-5xl{% endblock main_class %}">
{% block content %}{% endblock content %}
</main>
</div>

View File

@@ -0,0 +1,8 @@
{# OOB fragment: effective-price cells recomputed from the unsaved profile
selection on the products page. Each span replaces the matching #eff-<id>
span in the table via htmx out-of-band swap. Rendered by
admin_products::profiles_preview. #}
{% import "macros/ui.html" as ui %}
{% for product in products %}
<span id="eff-{{ product.id }}" hx-swap-oob="true">{{ ui::eff_price(p=product, preview=true) }}</span>
{% endfor %}

View File

@@ -6,92 +6,126 @@
{% block content %}
<div class="flex items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h1>
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/discounts", size="px-3 py-2 text-sm") }}
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">
{% if audience == "business" %}{{ t(key="audience-business", lang=lang | default(value='sk')) }}{% else %}{{ t(key="audience-personal", lang=lang | default(value='sk')) }}{% endif %}
</p>
</div>
{{ 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>
<form method="post" action="/admin/catalog/discounts/{{ product.id }}"
x-data="{
mode: '{{ mode }}',
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">
{# 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 }}"
x-data="discountEditor(JSON.parse(document.getElementById('discount-data').textContent))"
class="mt-6 max-w-2xl space-y-5">
{{ ui::csrf_field() }}
{% if error %}
{{ ui::alert_danger(message=t(key=error, lang=lang | default(value='sk'))) }}
{% endif %}
<div class="flex items-center justify-between gap-3 rounded-radius bg-surface-alt px-4 py-3 dark:bg-surface-dark/40">
<span class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="price", lang=lang | default(value='sk')) }}</span>
<span class="font-semibold tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.regular_price }} {{ product.currency }}</span>
<template x-for="row in rows" :key="row.id">
<div class="space-y-4 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
<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>
<input type="hidden" :name="`v[${row.id}][mode]`" :value="row.mode">
<div class="grid gap-4 sm:grid-cols-2">
<!-- mode toggle -->
<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"
: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'">
<input type="radio" name="mode" value="fixed" x-model="mode" class="sr-only">
: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-ui-${row.id}`" value="fixed" x-model="row.mode" class="sr-only">
{{ t(key="discount-mode-fixed", lang=lang | default(value='sk')) }}
</label>
<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'">
<input type="radio" name="mode" value="percent" x-model="mode" class="sr-only">
: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-ui-${row.id}`" value="percent" x-model="row.mode" class="sr-only">
{{ t(key="discount-mode-percent", lang=lang | default(value='sk')) }}
</label>
</div>
<!-- fixed price input -->
<div class="space-y-1.5" x-show="mode === 'fixed'">
<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>
{{ ui::input(name="sale_price", id="sale_price", value=fixed, placeholder="0.00", attrs='inputmode="decimal" x-model="fixed"') }}
<!-- value input: both fields stay in the DOM and submit; the server reads
whichever matches the row's mode -->
<div class="space-y-1.5">
<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>
<!-- 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>
<!-- live preview -->
<div x-show="afterCents !== 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">
<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-before", lang=lang | default(value='sk')) }}</span>
<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>
</div>
<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="discount-preview-after", lang=lang | default(value='sk')) }}</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 }}
<div x-show="afterCents(row) !== null" x-cloak
class="flex flex-wrap items-center justify-between gap-3 rounded-radius border border-outline bg-surface-alt px-4 py-2.5 text-sm dark:border-outline-dark dark:bg-surface-dark/40">
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="discount-preview-after", lang=lang | default(value='sk')) }}</span>
<span class="flex items-center gap-2">
<span class="tabular-nums text-on-surface/50 line-through dark:text-on-surface-dark/50" x-text="money(row.regular_cents) + ' €'"></span>
<span class="text-base font-semibold tabular-nums" :class="valid(row) ? 'text-danger' : 'text-on-surface/40 dark:text-on-surface-dark/40'"
x-text="money(afterCents(row)) + ' €'"></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>
</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">
<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>
<p x-show="afterCents(row) !== null && !valid(row)" class="text-xs text-danger">{{ t(key="discount-below-regular", lang=lang | default(value='sk')) }}</p>
</div>
</template>
<div class="flex flex-wrap gap-3 pt-2">
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit") }}
{% if product.on_sale %}
{{ ui::button(variant="outline-danger", label=t(key="remove-discount", lang=lang | default(value='sk')), type="submit", attrs='formaction="/admin/catalog/discounts/' ~ product.id ~ '/remove"') }}
{{ 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')) ~ `')"`) }}
{% if has_discount %}
{{ ui::button(variant="outline-danger", label=t(key="remove-discount", lang=lang | default(value='sk')), type="submit", attrs=`formaction="/admin/catalog/products/` ~ product.id ~ `/discount/remove?audience=` ~ audience ~ `" onclick="return confirm('` ~ t(key="discount-remove-confirm", lang=lang | default(value='sk')) ~ `')"`) }}
{% endif %}
</div>
</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 %}

View File

@@ -0,0 +1,71 @@
{% extends "admin/base.html" %}
{% import "macros/ui.html" as ui %}
{% block title %}{% if profile %}{{ t(key="edit-profile", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-profile", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
{% block crumb %}{{ t(key="admin-discount-profiles", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% block content %}
<div class="flex items-center justify-between gap-3">
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
{% if profile %}{{ t(key="edit-profile", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-profile", lang=lang | default(value='sk')) }}{% endif %}
</h1>
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/discount-profiles", size="px-3 py-2 text-sm") }}
</div>
{% if profile %}{% set v_name = profile.name %}{% set v_percent = profile.percent %}{% set v_scope = profile.scope_type %}
{% else %}{% set v_name = "" %}{% set v_percent = "" %}{% set v_scope = "include" %}{% endif %}
<form method="post"
action="{% if profile %}/admin/catalog/discount-profiles/{{ profile.id }}{% else %}/admin/catalog/discount-profiles{% endif %}"
class="mt-6 space-y-5 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
{{ ui::csrf_field() }}
{% if error %}{{ ui::alert_danger(message=t(key=error, lang=lang | default(value='sk'))) }}{% endif %}
<div class="grid gap-5 sm:grid-cols-2">
<div class="space-y-1.5">
<label for="name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="name", lang=lang | default(value='sk')) }}</label>
{{ ui::input(name="name", id="name", required=true, value=v_name) }}
</div>
<div class="space-y-1.5">
<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", required=true, value=v_percent, placeholder="0", attrs='inputmode="decimal" min="0" max="100"') }}
</div>
</div>
<fieldset class="space-y-2">
<legend class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="scope", lang=lang | default(value='sk')) }}</legend>
<label class="flex items-center gap-2 text-sm text-on-surface dark:text-on-surface-dark">
<input type="radio" name="scope_type" value="include" {% if v_scope != "all_except" %}checked{% endif %}>
{{ t(key="scope-include-hint", lang=lang | default(value='sk')) }}
</label>
<label class="flex items-center gap-2 text-sm text-on-surface dark:text-on-surface-dark">
<input type="radio" name="scope_type" value="all_except" {% if v_scope == "all_except" %}checked{% endif %}>
{{ t(key="scope-all-except-hint", lang=lang | default(value='sk')) }}
</label>
</fieldset>
<div class="space-y-1.5">
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="products", lang=lang | default(value='sk')) }}</span>
<div class="max-h-72 overflow-y-auto rounded-radius border border-outline p-3 dark:border-outline-dark">
{% if products | length > 0 %}
<div class="grid gap-2 sm:grid-cols-2">
{% for product in products %}
<label class="flex items-center gap-2 text-sm text-on-surface dark:text-on-surface-dark">
<input type="checkbox" name="product_ids" value="{{ product.id }}" {% if product.selected %}checked{% endif %}>
{{ product.name }}
</label>
{% endfor %}
</div>
{% else %}
<p class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="admin-no-products", lang=lang | default(value='sk')) }}</p>
{% endif %}
</div>
</div>
<div class="flex gap-3 pt-2">
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit") }}
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/discount-profiles") }}
</div>
</form>
{% endblock content %}

View File

@@ -0,0 +1,58 @@
{% extends "admin/base.html" %}
{% import "macros/ui.html" as ui %}
{% block title %}{{ t(key="admin-discount-profiles", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}{{ t(key="admin-discount-profiles", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% block content %}
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-discount-profiles", lang=lang | default(value='sk')) }}</h1>
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-discount-profiles-desc", lang=lang | default(value='sk')) }}</p>
</div>
{{ ui::button(label=t(key="new-profile", lang=lang | default(value='sk')), href="/admin/catalog/discount-profiles/new") }}
</div>
<div class="mt-6 {{ ui::table_wrap_cls() }}">
{% if profiles | length > 0 %}
<table class="{{ ui::table_cls() }}">
<thead class="{{ ui::thead_cls() }}">
<tr>
{{ ui::th(label=t(key="name", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="discount-percent", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="scope", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="products", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="actions", lang=lang | default(value='sk')), align="text-right") }}
</tr>
</thead>
<tbody class="{{ ui::tbody_cls() }}">
{% for profile in profiles %}
<tr class="{{ ui::row_cls() }}">
<td class="px-4 py-3 font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ profile.name }}</td>
<td class="px-4 py-3 tabular-nums">{{ profile.percent }}%</td>
<td class="px-4 py-3">
{% if profile.scope_type == "all_except" %}{{ t(key="scope-all-except", lang=lang | default(value='sk')) }}{% else %}{{ t(key="scope-include", lang=lang | default(value='sk')) }}{% endif %}
</td>
<td class="px-4 py-3 tabular-nums">{{ profile.product_count }}</td>
<td class="px-4 py-3">
<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/discount-profiles/" ~ profile.id ~ "/edit", size="px-3 py-1.5 text-xs") }}
<form method="post" action="/admin/catalog/discount-profiles/{{ profile.id }}/delete"
onsubmit="return confirm('{{ t(key="confirm-delete", lang=lang | default(value='sk')) }}')">
{{ ui::csrf_field() }}
{{ ui::button(variant="outline-danger", label=t(key="delete", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="flex flex-col items-center gap-3 px-4 py-16 text-center">
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-no-profiles", lang=lang | default(value='sk')) }}</p>
{{ ui::button(label=t(key="new-profile", lang=lang | default(value='sk')), href="/admin/catalog/discount-profiles/new") }}
</div>
{% endif %}
</div>
{% endblock content %}

View File

@@ -1,70 +0,0 @@
{% extends "admin/base.html" %}
{% import "macros/ui.html" as ui %}
{% block title %}{{ t(key="admin-discounts", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}{{ t(key="admin-discounts", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% block content %}
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-discounts", lang=lang | default(value='sk')) }}</h1>
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-discounts-desc", lang=lang | default(value='sk')) }}</p>
</div>
</div>
<div class="mt-6 {{ ui::table_wrap_cls() }}">
{% if products | length > 0 %}
<table class="{{ ui::table_cls() }}">
<thead class="{{ ui::thead_cls() }}">
<tr>
{{ ui::th(label=t(key="product", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="price", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="sale-price", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="status", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="actions", lang=lang | default(value='sk')), align="text-right") }}
</tr>
</thead>
<tbody class="{{ ui::tbody_cls() }}">
{% for product in products %}
<tr class="{{ ui::row_cls() }}">
<td class="px-4 py-3">
<div class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</div>
</td>
<td class="px-4 py-3 tabular-nums">{{ product.regular_price }} {{ product.currency }}</td>
<td class="px-4 py-3 tabular-nums">
{% if product.on_sale %}
<span class="font-medium text-danger">{{ product.sale_price }} {{ product.currency }}</span>
<span class="ml-1 text-xs text-on-surface/60 dark:text-on-surface-dark/60">({{ product.percent_off }}%)</span>
{% else %}
<span class="text-on-surface/40 dark:text-on-surface-dark/40"></span>
{% endif %}
</td>
<td class="px-4 py-3">
{% if product.on_sale %}
{{ ui::badge(label=t(key="on-sale", lang=lang | default(value='sk')), variant="danger") }}
{% else %}
{{ ui::badge(label=t(key="no-discount", lang=lang | default(value='sk')), variant="neutral") }}
{% endif %}
</td>
<td class="px-4 py-3">
<div class="flex flex-wrap justify-end gap-2">
{{ ui::button(variant="outline-secondary", label=t(key="set-discount", lang=lang | default(value='sk')), href="/admin/catalog/discounts/" ~ product.id ~ "/edit", size="px-3 py-1.5 text-xs") }}
{% if product.on_sale %}
<form method="post" action="/admin/catalog/discounts/{{ product.id }}/remove">
{{ 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 %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="flex flex-col items-center gap-3 px-4 py-16 text-center">
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-no-products", lang=lang | default(value='sk')) }}</p>
</div>
{% endif %}
</div>
{% 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 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 %}
<div class="flex items-center justify-between gap-3">
@@ -18,38 +21,85 @@
{{ ui::csrf_field() }}
{% if product %}
{% set v_name = product.name %}{% set v_price = product.price %}{% set v_currency = product.currency %}{% set v_stock = product.stock %}{% set v_sku = product.sku | default(value="") %}{% 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 %}
{% set v_name = "" %}{% set v_price = "" %}{% set v_currency = "EUR" %}{% set v_stock = 0 %}{% set v_sku = "" %}{% set v_desc = "" %}{% set v_pub = false %}
{% set v_name = "" %}{% set v_desc = "" %}{% set v_short = "" %}{% set v_pub = false %}
{% 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 sublabel = "text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70" %}
<div class="space-y-1.5">
<label for="name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="name", lang=lang | default(value='sk')) }}</label>
{{ ui::input(name="name", id="name", required=true, value=v_name) }}
</div>
<div class="grid gap-5 sm:grid-cols-2">
<div class="space-y-1.5">
<label for="price" 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", required=true, value=v_price, placeholder="0.00", attrs='inputmode="decimal"') }}
{# --- Variants / options editor ------------------------------------------- #}
{# 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 #}
{# optional — leave it blank ("∞") to mark the option simply available (not #}
{# inventory-tracked). SKU and business price are optional too. Rows are #}
{# managed client-side; names are indexed (variants[i][…]) and read back by #}
{# the controller. #}
{% set opt = " (" ~ t(key="optional", lang=lang | default(value='sk')) ~ ")" %}
<script id="variants-data" type="application/json">{{ variants | json_encode() | safe }}</script>
<div class="space-y-3" x-data="variantEditor(JSON.parse(document.getElementById('variants-data').textContent))">
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="variants-options", lang=lang | default(value='sk')) }}</span>
<button type="button" @click="add()"
class="rounded-radius border border-outline px-3 py-1.5 text-sm font-medium text-on-surface hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt/50">
+ {{ t(key="add-option", lang=lang | default(value='sk')) }}
</button>
</div>
<div class="space-y-1.5">
<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") }}
<template x-for="(row, i) in rows" :key="i">
<div class="flex items-end gap-3 rounded-radius border border-outline bg-surface-alt/40 p-3 dark:border-outline-dark dark:bg-surface-dark-alt/30">
<input type="hidden" :name="`variants[${i}][id]`" :value="row.id">
{# items-end bottom-aligns every input regardless of how many lines each
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="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>
<input :name="`variants[${i}][label]`" x-model="row.label" class="{{ inp }}" placeholder="napr. 10cm x 13cm">
</div>
<div class="space-y-1 sm:col-span-2">
<label class="{{ sublabel }} block truncate">{{ t(key="sku", lang=lang | default(value='sk')) }}{{ opt }}</label>
<input :name="`variants[${i}][sku]`" x-model="row.sku" class="{{ inp }}">
</div>
<div class="space-y-1 sm:col-span-2">
<label class="{{ sublabel }} block truncate">{{ t(key="stock", lang=lang | default(value='sk')) }}{{ opt }}</label>
<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 class="space-y-1 sm:col-span-2">
<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">
</div>
</div>
<div class="grid gap-5 sm:grid-cols-2">
<div class="space-y-1.5">
<label for="stock" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="stock", lang=lang | default(value='sk')) }}</label>
{{ ui::input(name="stock", id="stock", type="number", value=v_stock, attrs='min="0"') }}
</div>
<div class="space-y-1.5">
<label for="sku" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="sku", lang=lang | default(value='sk')) }}</label>
{{ ui::input(name="sku", id="sku", value=v_sku) }}
<button type="button" @click="remove(i)"
class="mb-1 shrink-0 rounded-radius px-2 py-2 text-sm text-danger hover:bg-danger/10" title="{{ t(key='delete', lang=lang | default(value='sk')) }}"></button>
</div>
</template>
</div>
<script>
function variantEditor(initial) {
const blank = () => ({ id: '', label: '', sku: '', stock: '', price: '' });
return {
rows: (initial || []).map(r => ({
id: r.id || '',
label: r.label || '',
sku: r.sku || '',
stock: (r.stock === null || r.stock === undefined) ? '' : r.stock,
price: r.price || '',
})),
init() { if (this.rows.length === 0) this.add(); },
add() { this.rows.push(blank()); },
remove(i) { this.rows.splice(i, 1); if (this.rows.length === 0) this.add(); },
};
}
</script>
<div class="space-y-1.5">
<label for="category_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="category", lang=lang | default(value='sk')) }}</label>
<div class="relative">
@@ -65,16 +115,96 @@
</div>
<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>
{{ ui::textarea(name="description", id="description", rows="5", value=v_desc) }}
<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>
<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">
<label for="image" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="image", lang=lang | default(value='sk')) }}</label>
{% if product and product.image %}
<img src="/images/{{ product.image }}" alt="" class="size-24 rounded-radius object-cover">
{% endif %}
{{ ui::file_input(name="image", id="image", accept="image/*") }}
<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>
{# --- Images gallery ------------------------------------------------------- #}
{# Unified drag-orderable gallery: existing images (with id) and new uploads #}
{# (placeholder blobs) live in a single list. The full order is submitted as #}
{# repeated `image_order` fields — an integer id for kept images or `new` for #}
{# each uploaded file. The DataTransfer backing the hidden `image` file input #}
{# is rebuilt after every reorder / add / remove so the file-part order matches #}
{# the relative order of `new` slots in `image_order`. #}
<script id="images-data" type="application/json">{% if product %}{{ product.images | json_encode() | safe }}{% else %}[]{% endif %}</script>
<div class="space-y-2" x-data="{
init() {
const existing = JSON.parse(document.getElementById('images-data').textContent);
this.items = existing.map(im => ({ type: 'existing', id: im.id, image_id: im.image_id }));
},
items: [],
dt: new DataTransfer(),
dragIndex: null,
rebuildDt() {
this.dt = new DataTransfer();
for (const it of this.items) {
if (it.type === 'new') this.dt.items.add(it.file);
}
this.$refs.holder.files = this.dt.files;
},
onDrop(i) {
if (this.dragIndex === null || this.dragIndex === i) { this.dragIndex = null; return; }
this.items.splice(i, 0, this.items.splice(this.dragIndex, 1)[0]);
this.dragIndex = null;
this.rebuildDt();
},
addFiles(e) {
for (const f of e.target.files) {
this.items.push({ type: 'new', file: f, url: URL.createObjectURL(f) });
}
this.rebuildDt();
e.target.value = '';
},
remove(i) {
const it = this.items[i];
if (it.type === 'new') URL.revokeObjectURL(it.url);
this.items.splice(i, 1);
this.rebuildDt();
},
}">
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="images", lang=lang | default(value='sk')) }}</span>
<p class="{{ sublabel }}">{{ t(key="gallery-hint", lang=lang | default(value='sk')) }}</p>
<div class="flex flex-wrap gap-3" x-show="items.length">
<template x-for="(it, i) in items" :key="it.type === 'existing' ? it.id : it.url">
<div draggable="true"
@dragstart="dragIndex = i"
@dragover.prevent
@drop.prevent="onDrop(i)"
:class="dragIndex === i ? 'opacity-50' : ''"
class="group relative size-24 cursor-move overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
<input type="hidden" name="image_order" :value="it.type === 'existing' ? it.id : 'new'">
<img :src="it.type === 'existing' ? `/images/${it.image_id}` : it.url" alt="" class="size-full object-cover">
<span x-show="i === 0"
class="absolute left-1 top-1 rounded-radius bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-on-primary dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="main-image", lang=lang | default(value='sk')) }}</span>
<button type="button" @click="remove(i)"
class="absolute right-1 top-1 flex size-5 items-center justify-center rounded-full bg-surface/70 text-xs text-danger opacity-0 transition group-hover:opacity-100 dark:bg-surface-dark/70"
title="{{ t(key='delete', lang=lang | default(value='sk')) }}"></button>
</div>
</template>
</div>
{# Hidden input carries the accumulated files on submit; the visible picker #}
{# only feeds addFiles() and is reset after each pick so selections stack. #}
<input type="file" name="image" multiple class="hidden" x-ref="holder">
<input type="file" accept="image/*" multiple class="hidden" x-ref="picker" @change="addFiles($event)">
<button type="button" @click="$refs.picker.click()"
class="rounded-radius border border-outline px-3 py-1.5 text-sm font-medium text-on-surface hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt/50">
+ {{ t(key="add-images", lang=lang | default(value='sk')) }}
</button>
</div>
{{ ui::checkbox(name="published", id="published", label=t(key="published", lang=lang | default(value='sk')), checked=v_pub) }}
@@ -84,4 +214,6 @@
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products") }}
</div>
</form>
<script src="/static/vendor/quill/quill.js"></script>
<script src="/static/js/rich-editor.js"></script>
{% endblock content %}

View File

@@ -3,8 +3,12 @@
{% block title %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% block main_class %}max-w-none{% endblock main_class %}
{% block content %}
{% 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>
<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>
@@ -13,13 +17,90 @@
{{ ui::button(label=t(key="new-product", lang=lang | default(value='sk')), href="/admin/catalog/products/new") }}
</div>
<div class="mt-6 {{ ui::table_wrap_cls() }}">
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
<!-- audience tabs -->
<div class="inline-flex rounded-radius border border-outline p-1 dark:border-outline-dark">
<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 %}">
{{ t(key="audience-personal", lang=L) }}
</a>
<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 %}">
{{ t(key="audience-business", lang=L) }}
</a>
</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_suffix = "&audience=" ~ audience ~ "&q=" ~ q_enc %}
<div class="mt-4 flex flex-col gap-6 md:flex-row md:items-start">
{% include "admin/partials/category_filter.html" %}
<!-- discount profiles applied to this audience -->
<section class="min-w-0 flex-1 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<h2 class="text-lg font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="discount-profiles", lang=lang | default(value='sk')) }}</h2>
<p class="mt-1 text-sm text-on-surface/70 dark:text-on-surface-dark/70">
{% if business %}{{ t(key="apply-profiles-business-hint", lang=lang | default(value='sk')) }}{% else %}{{ t(key="apply-profiles-personal-hint", lang=lang | default(value='sk')) }}{% endif %}
</p>
{% if profiles | length > 0 %}
<form method="post" action="/admin/catalog/products/profiles?audience={{ audience }}" class="mt-3 space-y-3"
hx-post="/admin/catalog/products/profiles/preview?audience={{ audience }}&category={{ selected_category }}"
hx-trigger="change"
hx-swap="none"
x-data="{
orig: { {% for p in profiles %}'{{ p.id }}': {% if p.assigned %}true{% else %}false{% endif %}{% if not loop.last %}, {% endif %}{% endfor %} },
sel: { {% for p in profiles %}'{{ p.id }}': {% if p.assigned %}true{% else %}false{% endif %}{% if not loop.last %}, {% endif %}{% endfor %} },
get changed() { return Object.keys(this.orig).some(k => this.orig[k] !== this.sel[k]) }
}"
onsubmit="return confirm('{{ t(key="discount-apply-confirm", lang=lang | default(value='sk')) }}')">
{{ ui::csrf_field() }}
<div class="grid gap-2 sm:grid-cols-2">
{% for profile in profiles %}
<label class="flex items-center gap-2 text-sm text-on-surface dark:text-on-surface-dark">
<input type="checkbox" name="profile_ids" value="{{ profile.id }}" x-model="sel['{{ profile.id }}']" {% if profile.assigned %}checked{% endif %}>
<span>{{ profile.name }} <span class="text-on-surface/60 dark:text-on-surface-dark/60">({{ profile.percent }}%, {% if profile.scope_type == "all_except" %}{{ t(key="scope-all-except", lang=lang | default(value='sk')) }}{% else %}{{ t(key="scope-include", lang=lang | default(value='sk')) }}{% endif %})</span></span>
<span x-cloak x-show="sel['{{ profile.id }}'] && orig['{{ profile.id }}']" class="inline-flex items-center rounded-radius border border-success px-1.5 py-0.5 text-xs font-medium text-success">{{ t(key="profile-applied", lang=lang | default(value='sk')) }}</span>
<span x-cloak x-show="sel['{{ profile.id }}'] && !orig['{{ profile.id }}']" class="inline-flex items-center rounded-radius border border-primary px-1.5 py-0.5 text-xs font-medium text-primary dark:border-primary-dark dark:text-primary-dark">{{ t(key="profile-will-apply", lang=lang | default(value='sk')) }}</span>
<span x-cloak x-show="!sel['{{ profile.id }}'] && orig['{{ profile.id }}']" class="inline-flex items-center rounded-radius border border-warning px-1.5 py-0.5 text-xs font-medium text-warning">{{ t(key="profile-will-remove", lang=lang | default(value='sk')) }}</span>
</label>
{% endfor %}
</div>
<div class="flex items-center gap-3">
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", size="px-4 py-2 text-sm", attrs='x-bind:disabled="!changed"') }}
<span x-cloak x-show="changed" class="text-xs font-medium text-warning">{{ t(key="profiles-unsaved", lang=lang | default(value='sk')) }}</span>
<span x-cloak x-show="!changed" class="text-xs text-on-surface/50 dark:text-on-surface-dark/50">{{ t(key="profiles-no-changes", lang=lang | default(value='sk')) }}</span>
</div>
</form>
{% else %}
<p class="mt-2 text-sm text-on-surface/70 dark:text-on-surface-dark/70">
{{ t(key="admin-no-profiles", lang=lang | default(value='sk')) }}
<a href="/admin/catalog/discount-profiles/new" class="text-primary dark:text-primary-dark">{{ t(key="new-profile", lang=lang | default(value='sk')) }}</a>
</p>
{% endif %}
</section>
</div>
<div class="mt-4 {{ ui::table_wrap_cls() }}">
{% if products | length > 0 %}
<table class="{{ ui::table_cls() }}">
<thead class="{{ ui::thead_cls() }}">
<tr>
{{ ui::th(label=t(key="product", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="price", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="variants-options", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="effective-price", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="stock", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="status", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="actions", lang=lang | default(value='sk')), align="text-right") }}
@@ -41,13 +122,10 @@
</div>
</div>
</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">
{% if product.on_sale %}
<span class="font-medium text-danger">{{ product.price }} {{ product.currency }}</span>
<span class="text-xs text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }}</span>
{% else %}
{{ product.price }} {{ product.currency }}
{% endif %}
<span id="eff-{{ product.id }}">{{ ui::eff_price(p=product) }}</span>
</td>
<td class="px-4 py-3 tabular-nums">{{ product.stock }}</td>
<td class="px-4 py-3">
@@ -60,7 +138,14 @@
<td class="px-4 py-3">
<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="discount", lang=lang | default(value='sk')), href="/admin/catalog/discounts/" ~ 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") }}
<form method="post" action="/admin/catalog/products/{{ product.id }}/delete"
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

@@ -0,0 +1,45 @@
{% extends "admin/base.html" %}
{% import "macros/ui.html" as ui %}
{% block title %}{{ t(key="admin-customers", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}{{ t(key="admin-customers", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% block content %}
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-customers", lang=lang | default(value='sk')) }}</h1>
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-customers-desc", lang=lang | default(value='sk')) }}</p>
</div>
</div>
<div class="mt-6 {{ ui::table_wrap_cls() }}">
{% if customers | length > 0 %}
<table class="{{ ui::table_cls() }}">
<thead class="{{ ui::thead_cls() }}">
<tr>
{{ ui::th(label=t(key="name", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="email", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="negotiated-prices", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="actions", lang=lang | default(value='sk')), align="text-right") }}
</tr>
</thead>
<tbody class="{{ ui::tbody_cls() }}">
{% for customer in customers %}
<tr class="{{ ui::row_cls() }}">
<td class="px-4 py-3 font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ customer.name }}</td>
<td class="px-4 py-3 text-on-surface/70 dark:text-on-surface-dark/70">{{ customer.email }}</td>
<td class="px-4 py-3 tabular-nums">{{ customer.negotiated_count }}</td>
<td class="px-4 py-3 text-right">
{{ ui::button(variant="outline-secondary", label=t(key="manage-prices", lang=lang | default(value='sk')), href="/admin/customers/" ~ customer.id, size="px-3 py-1.5 text-xs") }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="flex flex-col items-center gap-3 px-4 py-16 text-center">
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-no-customers", lang=lang | default(value='sk')) }}</p>
</div>
{% endif %}
</div>
{% endblock content %}

View File

@@ -0,0 +1,97 @@
{% extends "admin/base.html" %}
{% import "macros/ui.html" as ui %}
{% block title %}{{ t(key="set-negotiated-price", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}{{ t(key="admin-customers", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% block content %}
<div class="flex items-center justify-between gap-3">
<div>
<div class="flex items-center gap-2">
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}{% if product.variant_label %} <span class="text-on-surface/60 dark:text-on-surface-dark/60">· {{ product.variant_label }}</span>{% endif %}</h1>
{{ ui::badge(label=t(key="negotiated-price", lang=lang | default(value='sk')), variant="info") }}
</div>
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ customer.name }}</p>
</div>
{{ ui::button(variant="outline-secondary", label=t(key="back", lang=lang | default(value='sk')), href="/admin/customers/" ~ customer.id, size="px-3 py-2 text-sm") }}
</div>
{% if error %}
<div class="mt-4 max-w-md">{{ ui::alert_danger(message=t(key=error, lang=lang | default(value='sk'))) }}</div>
{% endif %}
<form method="post" action="/admin/customers/{{ customer.id }}/prices/{{ product.variant_id }}"
x-data="{
price: '{{ negotiated }}',
regular: {{ product.regular_cents }},
num(v) { let n = parseFloat(String(v).replace(',', '.')); return isFinite(n) ? n : null; },
get afterCents() { let f = this.num(this.price); return f === null ? null : Math.round(f * 100); },
money(c) { return (c / 100).toFixed(2); },
get valid() { let a = this.afterCents; return a !== null && a > 0; }
}"
class="mt-6 max-w-md space-y-5 rounded-radius border-2 border-secondary/60 bg-surface p-6 dark:border-secondary-dark/60 dark:bg-surface-dark-alt">
{{ ui::csrf_field() }}
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="negotiated-price-hint", lang=lang | default(value='sk')) }}</p>
<!-- reference prices -->
<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">
<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 }} €</span>
</div>
<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="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 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="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>
<!-- negotiated price input -->
<div class="space-y-1.5">
<label for="price" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="negotiated-price", lang=lang | default(value='sk')) }}</label>
{{ ui::input(name="price", id="price", value=negotiated, placeholder="0.00", attrs='inputmode="decimal" x-model="price"') }}
</div>
<!-- live preview -->
<div x-show="afterCents !== 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">
<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-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>
</span>
</div>
<p x-show="!valid" class="text-xs text-danger">{{ t(key="discount-must-be-positive", lang=lang | default(value='sk')) }}</p>
</div>
<div class="flex flex-wrap gap-3 pt-2">
{{ ui::button(variant="secondary", label=t(key="save", lang=lang | default(value='sk')), type="submit") }}
{% if has_negotiated %}
{{ ui::button(variant="outline-danger", label=t(key="remove", lang=lang | default(value='sk')), type="submit", attrs=`formaction="/admin/customers/` ~ customer.id ~ `/prices/` ~ product.variant_id ~ `/remove" onclick="return confirm('` ~ t(key="negotiated-remove-confirm", lang=lang | default(value='sk')) ~ `')"`) }}
{% endif %}
</div>
</form>
{% if collision %}
<!-- collision resolution: two assigned profiles cover this product -->
<section class="mt-4 max-w-md rounded-radius border border-warning/60 bg-surface p-6 dark:bg-surface-dark-alt">
<div class="flex items-center gap-2">
<h2 class="text-lg font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="automated-price", lang=lang | default(value='sk')) }}</h2>
{{ ui::badge(label=t(key="collision", lang=lang | default(value='sk')), variant="warning") }}
</div>
<form method="post" action="/admin/customers/{{ customer.id }}/resolutions/{{ product.variant_id }}" class="mt-3 flex items-center gap-2">
{{ ui::csrf_field() }}
<select name="profile_id" class="rounded-radius border border-outline bg-surface-alt px-2 py-1.5 text-sm dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark">
{% for c in covering %}
<option value="{{ c.id }}" {% if c.id == auto_profile_id %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
{{ ui::button(label=t(key="resolve", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-sm") }}
</form>
</section>
{% endif %}
{% endblock content %}

View File

@@ -0,0 +1,118 @@
{% extends "admin/base.html" %}
{% import "macros/ui.html" as ui %}
{% block title %}{{ customer.name }}{% endblock title %}
{% block crumb %}{{ t(key="admin-customers", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% 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>
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ customer.name }}</h1>
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ customer.email }}</p>
</div>
{{ ui::button(variant="outline-secondary", label=t(key="back", lang=lang | default(value='sk')), href="/admin/customers", size="px-3 py-2 text-sm") }}
</div>
{% if error %}
<div class="mt-4">{{ ui::alert_danger(message=t(key=error, lang=lang | default(value='sk'))) }}</div>
{% endif %}
<!-- assigned discount profiles -->
<section class="mt-6 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<h2 class="text-lg font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="discount-profiles", lang=lang | default(value='sk')) }}</h2>
{% if profiles | length > 0 %}
<form method="post" action="/admin/customers/{{ customer.id }}/profiles" class="mt-3 space-y-3">
{{ ui::csrf_field() }}
<div class="grid gap-2 sm:grid-cols-2">
{% for profile in profiles %}
<label class="flex items-center gap-2 text-sm text-on-surface dark:text-on-surface-dark">
<input type="checkbox" name="profile_ids" value="{{ profile.id }}" {% if profile.assigned %}checked{% endif %}>
<span>{{ profile.name }} <span class="text-on-surface/60 dark:text-on-surface-dark/60">({{ profile.percent }}%, {% if profile.scope_type == "all_except" %}{{ t(key="scope-all-except", lang=lang | default(value='sk')) }}{% else %}{{ t(key="scope-include", lang=lang | default(value='sk')) }}{% endif %})</span></span>
</label>
{% endfor %}
</div>
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", size="px-4 py-2 text-sm") }}
</form>
{% else %}
<p class="mt-2 text-sm text-on-surface/70 dark:text-on-surface-dark/70">
{{ t(key="admin-no-profiles", lang=lang | default(value='sk')) }}
<a href="/admin/catalog/discount-profiles/new" class="text-primary dark:text-primary-dark">{{ t(key="new-profile", lang=lang | default(value='sk')) }}</a>
</p>
{% endif %}
</section>
<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_suffix = "&q=" ~ q_enc %}
<div class="mt-3 flex flex-col gap-6 md:flex-row md:items-start">
{% include "admin/partials/category_filter.html" %}
<div class="min-w-0 flex-1 {{ ui::table_wrap_cls() }}">
{% if products | length > 0 %}
<table class="{{ ui::table_cls() }}">
<thead class="{{ ui::thead_cls() }}">
<tr>
{{ ui::th(label=t(key="product", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="business-price", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="effective-price", lang=lang | default(value='sk'))) }}
{{ ui::th(label=t(key="actions", lang=lang | default(value='sk')), align="text-right") }}
</tr>
</thead>
<tbody class="{{ ui::tbody_cls() }}">
{% for product in products %}
<tr class="{{ ui::row_cls() }}">
<td class="px-4 py-3 font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
{{ product.name }}
{% if product.variant_label %}<span class="block text-xs font-normal text-on-surface/60 dark:text-on-surface-dark/60">{{ product.variant_label }}</span>{% endif %}
</td>
<td class="px-4 py-3 tabular-nums">
{% if product.business_reduced %}
<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>
{% else %}
{{ product.business_price }} €
{% endif %}
</td>
<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 }} €</span>
{% if product.collision %}<span class="ml-1">{{ ui::badge(label=t(key="collision", lang=lang | default(value='sk')), variant="warning") }}</span>{% endif %}
</td>
<td class="px-4 py-3">
<div class="flex flex-wrap justify-end gap-2">
{{ ui::button(variant="outline-secondary", label=t(key="set-negotiated-price", lang=lang | default(value='sk')), href="/admin/customers/" ~ customer.id ~ "/prices/" ~ product.variant_id ~ "/edit", size="px-3 py-1.5 text-xs") }}
{% if product.has_negotiated %}
<form method="post" action="/admin/customers/{{ customer.id }}/prices/{{ product.variant_id }}/remove"
onsubmit="return confirm('{{ t(key="negotiated-remove-confirm", lang=lang | default(value='sk')) }}')">
{{ ui::csrf_field() }}
{{ ui::button(variant="outline-danger", label=t(key="remove", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="flex flex-col items-center gap-3 px-4 py-16 text-center">
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-no-products", lang=lang | default(value='sk')) }}</p>
</div>
{% endif %}
</div>
</div>
{% endblock content %}

View File

@@ -5,7 +5,24 @@
{% block crumb %}{{ t(key="admin-orders", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% 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() }}">
{% if orders | length > 0 %}
@@ -27,7 +44,7 @@
<td class="px-4 py-3">
{{ ui::badge(label=t(key="order-status-" ~ order.status, lang=lang | default(value='sk')), variant="neutral") }}
</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">
{{ 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>

View File

@@ -36,16 +36,16 @@
<tbody class="{{ ui::tbody_cls() }}">
{% for item in items %}
<tr>
<td class="px-4 py-3">{{ item.product_name }}</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 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>
{% endfor %}
</tbody>
<tfoot class="{{ ui::tfoot_cls() }}">
<tr>
<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>
</tfoot>
</table>
@@ -75,7 +75,7 @@
</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-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 %}
</div>
<div>

View File

@@ -0,0 +1,75 @@
{# Category-filter sidebar for admin product listings. Clicking a category
reloads the page with `?category=<id>` so the table server-side filters to
that category and its descendants. Expects in context:
- category_groups: [{ id, name, count, children: [{ id, name, count }] }]
(from views::shop::admin_category_groups)
- selected_category: "all" | "none" | "<id>" — the active filter
- total_count, uncategorized_count: ints
- category_base: page path, e.g. "/admin/catalog/products"
- category_suffix: extra query appended after the category param, e.g.
"&audience=business", or "" — set by the including template.
The link treatment mirrors shop/_sidebar.html (Penguin UI), but active state
is server-driven via aria-current (these links share a path, differing only
by query, so markActiveNav() can't pick the active one — hence no data-nav).
Numeric compare uses `| int(default=0)` because Tera string==number is false. #}
{% set sel = selected_category | int(default=0) %}
{% set link_cls = "flex flex-1 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" %}
<aside class="w-full shrink-0 md:w-56">
<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="categories", lang=lang | default(value='sk')) }}
</p>
<div class="flex flex-col gap-1">
<a href="{{ category_base }}?category=all{{ category_suffix }}"
{% if selected_category == "all" %}aria-current="page"{% endif %} class="{{ link_cls }}">
<span class="flex-1 truncate">{{ t(key="all-products", lang=lang | default(value='sk')) }}</span>
<span class="text-xs text-on-surface/50 dark:text-on-surface-dark/50">{{ total_count }}</span>
</a>
{% for group in category_groups %}
{% set open_group = sel == group.id %}
{% for child in group.children %}{% if sel == child.id %}{% set_global open_group = true %}{% endif %}{% endfor %}
{% if group.children | length > 0 %}
<div x-data="{ open: {% if open_group %}true{% else %}false{% endif %} }" class="flex flex-col">
<div class="flex items-stretch">
<a href="{{ category_base }}?category={{ group.id }}{{ category_suffix }}"
{% if sel == group.id %}aria-current="page"{% endif %} class="{{ link_cls }} rounded-l-radius">
<span class="flex-1 truncate">{{ group.name }}</span>
<span class="text-xs text-on-surface/50 dark:text-on-surface-dark/50">{{ group.count }}</span>
</a>
<button type="button" x-on:click="open = ! open" x-bind:aria-expanded="open ? 'true' : 'false'"
aria-label="{{ group.name }}"
class="inline-flex w-8 shrink-0 items-center justify-center rounded-r-radius text-on-surface/60 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark/60 dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
class="size-5 shrink-0 transition-transform rotate-0" x-bind:class="open ? 'rotate-180' : 'rotate-0'" aria-hidden="true">
<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
</svg>
</button>
</div>
<ul x-show="open" x-cloak x-transition class="ml-3 mt-0.5 flex flex-col gap-0.5 border-l border-outline pl-1 dark:border-outline-dark">
{% for child in group.children %}
<li class="flex">
<a href="{{ category_base }}?category={{ child.id }}{{ category_suffix }}"
{% if sel == child.id %}aria-current="page"{% endif %}
class="flex flex-1 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">
<span class="flex-1 truncate">{{ child.name }}</span>
<span class="text-xs text-on-surface/50 dark:text-on-surface-dark/50">{{ child.count }}</span>
</a>
</li>
{% endfor %}
</ul>
</div>
{% else %}
<a href="{{ category_base }}?category={{ group.id }}{{ category_suffix }}"
{% if sel == group.id %}aria-current="page"{% endif %} class="{{ link_cls }}">
<span class="flex-1 truncate">{{ group.name }}</span>
<span class="text-xs text-on-surface/50 dark:text-on-surface-dark/50">{{ group.count }}</span>
</a>
{% endif %}
{% endfor %}
<a href="{{ category_base }}?category=none{{ category_suffix }}"
{% if selected_category == "none" %}aria-current="page"{% endif %} class="{{ link_cls }}">
<span class="flex-1 truncate">{{ t(key="uncategorized", lang=lang | default(value='sk')) }}</span>
<span class="text-xs text-on-surface/50 dark:text-on-surface-dark/50">{{ uncategorized_count }}</span>
</a>
</div>
</aside>

View File

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

View File

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

View File

@@ -81,6 +81,10 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" /></svg>
{%- elif name == "close" -%}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>
{%- elif name == "chevron-double-left" -%}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><path stroke-linecap="round" stroke-linejoin="round" d="m18.75 4.5-7.5 7.5 7.5 7.5m-6-15L5.25 12l7.5 7.5" /></svg>
{%- elif name == "search" -%}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
{%- else -%}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /></svg>
{%- endif -%}
@@ -120,6 +124,21 @@
{%- endif %}
{%- endmacro badge %}
{# Effective-price cell content for the admin products table. The value is
colored only when it differs from the regular price (effective_reduced);
when equal it renders in the plain text color, unified with the Price column.
`preview=true` uses the info color (an unsaved profile-toggle preview) instead
of the saved primary color. No t() calls, so it is safe inside a macro. #}
{% macro eff_price(p, preview=false) -%}
{%- if preview -%}{% set strong = "text-info" %}{%- else -%}{% set strong = "text-primary dark:text-primary-dark" %}{%- endif -%}
{% if p.effective_reduced %}
<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>
{% else %}
{{ p.effective_price }} €
{% endif %}
{%- endmacro eff_price %}
{# ---- Form controls. Verbatim Penguin classes from
penguinui/{text-input,text-area,select,checkbox,file-input}/default-*.html.
These macros emit only the control (callers keep their own <label>/layout), so
@@ -134,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>
{%- 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. #}
{% 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 }}/>
@@ -220,3 +267,42 @@ border-t border-outline dark:border-outline-dark
{%- endif -%}
<a href="{{ href }}"{% if data_nav %} data-nav="{{ data_nav }}"{% endif %} class="text-sm font-medium underline-offset-2 transition focus:outline-hidden focus-visible:underline {{ c }}" {{ attrs | safe }}>{{ label }}</a>
{%- endmacro nav_link %}
{# Breadcrumbs (Kompress design: chevron separators). Build a trail by emitting
one ui::crumb(label, href) per ancestor and a final ui::crumb_current(label)
for the active page, all inside <nav><ol></ol></nav>:
<nav aria-label="breadcrumb" class="text-sm">
<ol class="flex flex-wrap items-center gap-1.5 text-on-surface/60 dark:text-on-surface-dark/60">
{{ ui::crumb(label="Domov", href="/") }}
{{ ui::crumb(label="Obchod", href="/shop") }}
{{ ui::crumb_current(label=category.name) }}
</ol>
</nav>
Adapted from penguinui/breadcrumbs/breadcrumb-with-chevron.html. #}
{% macro crumb(label, href) -%}
<li class="flex items-center gap-1.5">
<a href="{{ href }}" class="transition hover:text-primary dark:hover:text-primary-dark">{{ label }}</a>
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-3.5 shrink-0 text-on-surface/30 dark:text-on-surface-dark/30" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /></svg>
</li>
{%- endmacro crumb %}
{% macro crumb_current(label) -%}
<li class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong" aria-current="page">{{ label }}</li>
{%- endmacro crumb_current %}
{# Title for the static info pages (controllers/pages.rs → pages/info.html),
resolved from the `page` slug. Lives in a macro because a child template's
top-level {% set %} isn't visible inside its {% block %}s under `extends`;
the macro can be called from both the title and content blocks. #}
{% macro page_title(page, lang) -%}
{%- if page == "contact" -%}{{ t(key="top-contact", lang=lang) }}
{%- elif page == "sitemap" -%}{{ t(key="top-sitemap", lang=lang) }}
{%- elif page == "terms" -%}{{ t(key="footer-terms", lang=lang) }}
{%- elif page == "about" -%}{{ t(key="footer-about", lang=lang) }}
{%- elif page == "stores" -%}{{ t(key="footer-stores", lang=lang) }}
{%- elif page == "shipping" -%}{{ t(key="footer-shipping", lang=lang) }}
{%- else -%}{{ t(key="brand", lang=lang) }}
{%- endif -%}
{%- endmacro page_title %}

View File

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

View File

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

View File

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

View File

@@ -1,40 +1,77 @@
{# Imported locally (not just inherited from base.html) so the card also renders
inside standalone htmx fragments like shop/_results.html, where Tera's import
chain from the layout isn't present. #}
{% import "macros/ui.html" as ui %}
{# Adapted from the vendored Penguin UI component
(penguinui-components/card/ecommerce-product-card.html):
wired to our product data + i18n + htmx add-to-cart + toast. The demo rating
stars, hardcoded title/price/description/image and the `max-w-sm` (which fights
the shop grid) are dropped; the whole card links to the product page. #}
{# Layout adapts to the `view` Alpine state set by _product_grid.html:
'grid' (default) → vertical card; 'list' → horizontal row. On pages without
that state (e.g. home) `view` is undefined, so the grid layout applies. #}
<article
class="group flex flex-col 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">
<a href="/shop/{{ product.slug }}" class="flex flex-1 flex-col">
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'">
<a href="/shop/{{ product.slug }}" class="flex min-w-0 flex-1"
:class="view === 'list' ? 'flex-row' : 'flex-col'">
<!-- Image -->
<div class="h-44 overflow-hidden bg-surface-alt md:h-64 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'">
{% if product.on_sale and product.percent_off > 0 %}
<span class="absolute left-2 top-2 z-10 rounded-full bg-danger px-2 py-0.5 text-[11px] font-bold text-on-danger shadow-sm">{{ product.percent_off }} %</span>
{% endif %}
{% if product.image %}
<img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover transition duration-700 ease-out group-hover:scale-105">
{% endif %}
</div>
<!-- Content -->
<div class="flex flex-1 flex-col gap-1 p-6 pb-2">
<!-- Header: Title & Price -->
<div class="flex justify-between gap-4">
<h3 class="text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
<div class="flex min-w-0 flex-1 flex-col gap-1"
:class="view === 'list' ? 'p-4 sm:p-5' : 'p-6 pb-2'">
<!-- Header: Title & Price (stacked so neither overflows the narrow card) -->
<h3 class="break-words text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
{# Short blurb for the card; falls back to the full description (clamped)
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 %}
<span class="flex flex-col items-end whitespace-nowrap leading-tight">
<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-xl font-semibold text-danger"><span class="sr-only">Price</span>{{ product.price }} {{ product.currency }}</span>
<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 %} {{ currency_symbol }}</span>
<span class="text-sm text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }} {{ currency_symbol }}</span>
</div>
{% else %}
<span class="break-words text-xl"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ currency_symbol }}</span>
{% endif %}
<!-- stock pill (Kompress design): green "in stock" / red "sold out" -->
<div class="mt-0.5">
{% if product.in_stock %}
<span class="inline-flex items-center gap-1.5 rounded-full bg-success/10 px-2 py-0.5 text-xs font-semibold text-success">
<span class="size-1.5 rounded-full bg-success" aria-hidden="true"></span>{{ t(key="in-stock", lang=lang | default(value='sk')) }}
</span>
{% else %}
<span class="whitespace-nowrap text-xl"><span class="sr-only">Price</span>{{ product.price }} {{ product.currency }}</span>
<span class="inline-flex items-center gap-1.5 rounded-full bg-danger/10 px-2 py-0.5 text-xs font-semibold text-danger">
<span class="size-1.5 rounded-full bg-danger" aria-hidden="true"></span>{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}
</span>
{% endif %}
</div>
</div>
</a>
<div class="flex flex-col gap-2 p-6 pt-0">
{% if product.stock > 0 %}
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}</p>
<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'">
{% if product.has_options %}
{# Multiple variants: customer must pick on the product page. #}
{{ ui::button(label=t(key="choose-option", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, extra="w-full") }}
{% elif product.in_stock %}
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{% if product.tracked %}{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}{% else %}{{ t(key="available", lang=lang | default(value='sk')) }}{% endif %}</p>
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none"
hx-on::after-request="if (event.detail.successful) toast('{{ t(key='cart-added', lang=lang | default(value='sk')) }}')">
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
<input type="hidden" name="product_id" value="{{ product.id }}">
<input type="hidden" name="variant_id" value="{{ product.variant_id }}">
<input type="hidden" name="quantity" value="1">
{{ ui::button(label=t(key="add-to-cart", lang=lang | default(value='sk')), type="submit", extra="w-full", icon='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" class="size-3.5"><path fill-rule="evenodd" d="M5 4a3 3 0 0 1 6 0v1h.643a1.5 1.5 0 0 1 1.492 1.35l.7 7A1.5 1.5 0 0 1 12.342 15H3.657a1.5 1.5 0 0 1-1.492-1.65l.7-7A1.5 1.5 0 0 1 4.357 5H5V4Zm4.5 0v1h-3V4a1.5 1.5 0 0 1 3 0Zm-3 3.75a.75.75 0 0 0-1.5 0v1a3 3 0 1 0 6 0v-1a.75.75 0 0 0-1.5 0v1a1.5 1.5 0 1 1-3 0v-1Z" clip-rule="evenodd" /></svg>') }}
</form>

View File

@@ -19,8 +19,16 @@
<tr>
<td class="px-4 py-3">
<a href="/shop/{{ item.slug }}" class="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 text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ item.variant_label }}</span>{% endif %}
</td>
<td class="px-4 py-3 tabular-nums">
{% if item.on_sale %}
<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>
{% else %}
{{ item.price }} {{ currency_symbol }}
{% endif %}
</td>
<td class="px-4 py-3 tabular-nums">{{ item.price }} {{ item.currency }}</td>
<td class="px-4 py-3">
{# Changing the quantity posts via htmx (custom `cartchange` event) and
swaps only #cart-body. Dropping to 0 asks for confirmation first,
@@ -28,8 +36,8 @@
<form method="post" action="/cart/update"
hx-post="/cart/update" hx-trigger="cartchange" hx-target="#cart-body" hx-swap="innerHTML">
{{ ui::csrf_field() }}
<input type="hidden" name="product_id" value="{{ item.id }}">
<input type="number" name="quantity" min="0" max="{{ item.stock }}" value="{{ item.quantity }}"
<input type="hidden" name="variant_id" value="{{ item.id }}">
<input type="number" name="quantity" min="0" {% if item.stock %}max="{{ item.stock }}"{% endif %} value="{{ item.quantity }}"
@change="
if (parseInt($el.value || '0') <= 0 && !window.confirm('{{ t(key='cart-remove-confirm', lang=lang | default(value='sk')) }}')) {
$el.value = '{{ item.quantity }}';
@@ -40,12 +48,12 @@
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>
</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">
<form method="post" action="/cart/remove"
hx-post="/cart/remove" hx-target="#cart-body" hx-swap="innerHTML">
{{ ui::csrf_field() }}
<input type="hidden" name="product_id" value="{{ item.id }}">
<input type="hidden" name="variant_id" value="{{ item.id }}">
{{ ui::button(variant="ghost-danger", label=t(key="cart-remove", lang=lang | default(value='sk')), type="submit", size="px-2 py-1 text-xs") }}
</form>
</td>
@@ -55,7 +63,7 @@
<tfoot class="{{ ui::tfoot_cls() }}">
<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 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>
</tr>
</tfoot>

View File

@@ -1,6 +1,6 @@
{# 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
base.html. Receives: items[], total, currency, lang. #}
base.html. Receives: items[], total, lang. #}
{% import "macros/ui.html" as ui %}
{% if items | length > 0 %}
<div class="max-h-80 divide-y divide-outline overflow-y-auto dark:divide-outline-dark">
@@ -8,16 +8,17 @@
<div class="flex items-start gap-3 px-4 py-3">
<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>
<p class="mt-0.5 text-xs tabular-nums text-on-surface dark:text-on-surface-dark">{{ item.quantity }} × {{ item.price }} {{ item.currency }}</p>
{% 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 }} {{ currency_symbol }}</p>
</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>
{% endfor %}
</div>
<div class="border-t border-outline px-4 py-3 dark:border-outline-dark">
<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-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 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"') }}

View File

@@ -0,0 +1,9 @@
{# Product collection. The grid / list `view` state is provided by the Alpine
wrapper in _search.html (it persists across htmx swaps and is shared with the
sort + view-toggle row); `_card.html` reads the same `view` to switch its own
layout between a vertical card and a horizontal row. #}
<div :class="view === 'list' ? 'flex flex-col gap-5' : 'grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4'">
{% for product in products %}
{% include "shop/_card.html" %}
{% endfor %}
</div>

View File

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

View File

@@ -0,0 +1,126 @@
{# 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') === 'list' ? 'list' : 'grid' }"
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="flex 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-primary px-5 text-sm font-bold text-on-primary transition hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:bg-primary-dark dark:text-on-primary-dark dark:focus-visible:outline-primary-dark">
{{ t(key="search-button", lang=L) }}
</button>
</div>
{# 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>
<a href="/search{% if query %}?q={{ query | urlencode }}{% endif %}"
class="font-medium text-on-surface/60 underline-offset-2 hover:text-primary hover:underline dark:text-on-surface-dark/60 dark:hover:text-primary-dark">
{{ 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

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

View File

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

View File

@@ -3,18 +3,24 @@
{% block title %}{{ category.name }}{% endblock title %}
{% block content %}
<div class="space-y-8">
<header class="space-y-2">
<nav class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">
<a href="/shop" class="hover:text-primary dark:hover:text-primary-dark">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</a>
{% block breadcrumbs %}
{% set L = lang | default(value='sk') %}
<nav aria-label="breadcrumb" class="mb-5 text-sm">
<ol class="flex flex-wrap items-center gap-1.5 text-on-surface/60 dark:text-on-surface-dark/60">
{{ ui::crumb(label=t(key="nav-home", lang=L), href="/") }}
{{ ui::crumb(label=t(key="nav-shop", lang=L), href="/shop") }}
{% for crumb in breadcrumbs %}
<span class="px-1">/</span>
<a href="/category/{{ crumb.slug }}" class="hover:text-primary dark:hover:text-primary-dark">{{ crumb.name }}</a>
{{ ui::crumb(label=crumb.name, href="/category/" ~ crumb.slug) }}
{% endfor %}
<span class="px-1">/</span>
<span>{{ category.name }}</span>
{{ ui::crumb_current(label=category.name) }}
</ol>
</nav>
{% endblock breadcrumbs %}
{% block content %}
{% set L = lang | default(value='sk') %}
<div class="space-y-6">
<header class="space-y-2">
<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 %}
@@ -28,16 +34,7 @@
{% endif %}
</header>
{% if products | length > 0 %}
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4">
{% for product in products %}
{% include "shop/_card.html" %}
{% endfor %}
</div>
{% 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 %}
{# Same search + filters as the shop, with this category preselected. #}
{% include "shop/_search.html" %}
</div>
{% endblock content %}

View File

@@ -188,7 +188,7 @@
class="before:content[''] relative h-4 w-4 appearance-none rounded-full border border-outline bg-surface before:invisible before:absolute before:left-1/2 before:top-1/2 before:h-1.5 before:w-1.5 before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:bg-on-primary checked:border-primary checked:bg-primary checked:before:visible focus:outline-2 focus:outline-offset-2 focus:outline-outline-strong checked:focus:outline-primary disabled:cursor-not-allowed dark:border-outline-dark dark:bg-surface-dark dark:before:bg-on-primary-dark dark:checked:border-primary-dark dark:checked:bg-primary-dark dark:focus:outline-outline-dark-strong dark:checked:focus:outline-primary-dark">
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ m.name }}</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>
{% endfor %}
@@ -252,23 +252,23 @@
{% for item in items %}
<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="tabular-nums">{{ item.line_total }} {{ item.currency }}</span>
<span class="tabular-nums">{{ item.line_total }} </span>
</li>
{% endfor %}
</ul>
<div class="space-y-1 border-t border-outline pt-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">{{ subtotal }} {{ currency }}</span>
<span class="tabular-nums">{{ subtotal }} </span>
</div>
<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="tabular-nums" x-text="fmt(carrierPrice) + ' {{ currency }}'"></span>
<span class="tabular-nums" x-text="fmt(carrierPrice) + ' '"></span>
</div>
</div>
<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 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>
{{ 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>

View File

@@ -3,23 +3,24 @@
{% block title %}{{ t(key="nav-shop", lang=lang | default(value='sk')) }}{% endblock title %}
{% block breadcrumbs %}
{% set L = lang | default(value='sk') %}
<nav aria-label="breadcrumb" class="mb-5 text-sm">
<ol class="flex flex-wrap items-center gap-1.5 text-on-surface/60 dark:text-on-surface-dark/60">
{{ ui::crumb(label=t(key="nav-home", lang=L), href="/") }}
{{ ui::crumb_current(label=t(key="nav-shop", lang=L)) }}
</ol>
</nav>
{% endblock breadcrumbs %}
{% block content %}
<div class="space-y-8">
<header class="space-y-2">
<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>
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-subtitle", lang=lang | default(value='sk')) }}</p>
{% set L = lang | default(value='sk') %}
<div class="space-y-6">
<header class="space-y-1">
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shop-title", lang=L) }}</h1>
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-subtitle", lang=L) }}</p>
</header>
{% if products | length > 0 %}
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4">
{% for product in products %}
{% include "shop/_card.html" %}
{% endfor %}
</div>
{% 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 %}
{% include "shop/_search.html" %}
</div>
{% endblock content %}

View File

@@ -29,19 +29,19 @@
<ul class="space-y-2 py-3 text-sm">
{% for item in items %}
<li class="flex justify-between gap-2">
<span class="text-on-surface/80 dark:text-on-surface-dark/80">{{ item.product_name }} × {{ item.quantity }}</span>
<span class="tabular-nums">{{ item.line_total }} {{ order.currency }}</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 }} </span>
</li>
{% endfor %}
</ul>
<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">{{ 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">{{ 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 }} </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 %}
</div>
<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 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>
@@ -52,7 +52,7 @@
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-account-name", lang=lang | default(value='sk')) }}</span><span class="font-medium">{{ order.bank_account_name }}</span>
<span class="text-on-surface/70 dark:text-on-surface-dark/70">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-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>
{% else %}

View File

@@ -49,39 +49,86 @@
</div>
<!-- details -->
<div class="space-y-6">
{% 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" %}
<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))">
{% if category %}
<a href="/category/{{ category.slug }}" class="text-sm font-medium text-primary dark:text-primary-dark">{{ category.name }}</a>
{% endif %}
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h1>
{% if product.on_sale %}
<div class="flex items-baseline gap-3">
<p class="text-2xl font-semibold text-danger">{{ product.price }} {{ product.currency }}</p>
<p class="text-lg text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }} {{ product.currency }}</p>
<template x-if="current">
<div class="space-y-6">
<!-- option picker (only when there's a real choice); first option is
selected by default and switching it updates the price + buy form -->
<template x-if="variants.length > 1">
<div class="max-w-sm space-y-1.5">
<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 }}">
<template x-for="(v, i) in variants" :key="v.id">
<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>
</select>
</div>
</template>
<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'">
<span x-text="current.price"></span> {{ currency_symbol }}
</p>
<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> {{ currency_symbol }}</p>
</template>
</div>
{% else %}
<p class="text-2xl font-semibold text-primary dark:text-primary-dark">{{ product.price }} {{ product.currency }}</p>
{% endif %}
{% if product.description %}
<div class="whitespace-pre-line leading-relaxed text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description }}</div>
{# Authored as rich text (Quill) in the admin; render the stored HTML. #}
<div class="rich-content text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description | safe }}</div>
{% endif %}
{% if product.stock > 0 %}
<template x-if="current.in_stock">
<div class="space-y-2">
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none" class="flex flex-wrap items-end gap-3"
hx-on::after-request="if (event.detail.successful) toast('{{ t(key='cart-added', lang=lang | default(value='sk')) }}')">
{{ ui::csrf_field() }}
<input type="hidden" name="product_id" value="{{ product.id }}">
<input type="hidden" name="variant_id" :value="current.id">
<div class="space-y-1.5">
<label for="quantity" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="quantity", lang=lang | default(value='sk')) }}</label>
{{ ui::input(name="quantity", id="quantity", type="number", value="1", width="w-24", attrs='min="1" max="' ~ product.stock ~ '"') }}
<input type="number" id="quantity" name="quantity" value="1" min="1" :max="current.stock" class="{{ fld }} w-24">
</div>
{{ ui::button(label=t(key="add-to-cart", lang=lang | default(value='sk')), type="submit", size="px-5 py-2 text-sm") }}
<button type="submit" class="{{ btn }}">{{ t(key="add-to-cart", lang=lang | default(value='sk')) }}</button>
</form>
<p class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}</p>
{% else %}
<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>
{% endif %}
<p class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">
<template x-if="current.tracked">
<span>{{ t(key="in-stock", lang=lang | default(value='sk')) }}: <span x-text="current.stock"></span></span>
</template>
<template x-if="!current.tracked">
<span>{{ t(key="available", lang=lang | default(value='sk')) }}</span>
</template>
</p>
</div>
</template>
<template x-if="!current.in_stock">
<p class="inline-flex rounded-radius bg-danger/10 px-3 py-2 text-sm font-medium text-danger">{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}</p>
</template>
</div>
</template>
<template x-if="!current">
<p class="inline-flex rounded-radius bg-danger/10 px-3 py-2 text-sm font-medium text-danger">{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}</p>
</template>
</div>
<script>
function productBuy(variants) {
return {
variants: variants || [],
// Default to the first in-stock variant, else the first.
sel: Math.max(0, (variants || []).findIndex(v => v.in_stock)),
get current() { return this.variants[this.sel] || null; },
};
}
</script>
</div>
{% endblock content %}

184
design-parity.md Normal file
View File

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

View File

@@ -36,6 +36,20 @@ mod m20260618_000003_account_type;
mod m20260618_000004_account_ownership;
mod m20260620_000001_add_totp_to_users;
mod m20260621_000001_add_sale_price_to_products;
mod m20260621_000002_account_product_prices;
mod m20260621_000003_discount_profiles;
mod m20260621_000004_add_business_sale_price_to_products;
mod m20260622_000001_audience_discount_profiles;
mod m20260622_000002_product_variants;
mod m20260622_000003_variant_stock_nullable;
mod m20260622_000004_product_search;
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;
pub struct Migrator;
#[async_trait::async_trait]
@@ -76,6 +90,20 @@ impl MigratorTrait for Migrator {
Box::new(m20260618_000004_account_ownership::Migration),
Box::new(m20260620_000001_add_totp_to_users::Migration),
Box::new(m20260621_000001_add_sale_price_to_products::Migration),
Box::new(m20260621_000002_account_product_prices::Migration),
Box::new(m20260621_000003_discount_profiles::Migration),
Box::new(m20260621_000004_add_business_sale_price_to_products::Migration),
Box::new(m20260622_000001_audience_discount_profiles::Migration),
Box::new(m20260622_000002_product_variants::Migration),
Box::new(m20260622_000003_variant_stock_nullable::Migration),
Box::new(m20260622_000004_product_search::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),
// inject-above (do not remove this comment)
]
}

View File

@@ -0,0 +1,40 @@
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 manually negotiated price (in minor units) for one product, for one
// business account — the "personal agreement" layer. `user`/`product`
// add the user_id/product_id FKs; the unique index below keeps it to one
// row per (account, product).
create_table(
m,
"account_product_prices",
&[
("id", ColType::PkAuto),
("price_cents", ColType::BigInteger),
],
&[("user", ""), ("product", "")],
)
.await?;
m.create_index(
Index::create()
.name("idx_account_product_prices_user_product_unique")
.table(Alias::new("account_product_prices"))
.col(Alias::new("user_id"))
.col(Alias::new("product_id"))
.unique()
.to_owned(),
)
.await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "account_product_prices").await
}
}

View File

@@ -0,0 +1,91 @@
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 reusable, named discount layer: a percentage (basis points, 5% = 500)
// over a product scope. `scope_type` is 'include' (covers the listed
// products) or 'all_except' (covers everything but the listed products).
create_table(
m,
"discount_profiles",
&[
("id", ColType::PkAuto),
("name", ColType::String),
("percent_bp", ColType::Integer),
("scope_type", ColType::StringWithDefault("include".to_string())),
],
&[],
)
.await?;
// Which products the scope lists (meaning depends on scope_type).
create_table(
m,
"discount_profile_products",
&[("id", ColType::PkAuto)],
&[("discount_profile", ""), ("product", "")],
)
.await?;
m.create_index(
Index::create()
.name("idx_discount_profile_products_unique")
.table(Alias::new("discount_profile_products"))
.col(Alias::new("discount_profile_id"))
.col(Alias::new("product_id"))
.unique()
.to_owned(),
)
.await?;
// Which profiles a business account has (mixable).
create_table(
m,
"account_discount_profiles",
&[("id", ColType::PkAuto)],
&[("user", ""), ("discount_profile", "")],
)
.await?;
m.create_index(
Index::create()
.name("idx_account_discount_profiles_unique")
.table(Alias::new("account_discount_profiles"))
.col(Alias::new("user_id"))
.col(Alias::new("discount_profile_id"))
.unique()
.to_owned(),
)
.await?;
// The admin's chosen winning profile when two assigned profiles cover the
// same product for an account (collision resolution).
create_table(
m,
"account_product_resolutions",
&[("id", ColType::PkAuto)],
&[("user", ""), ("product", ""), ("discount_profile", "")],
)
.await?;
m.create_index(
Index::create()
.name("idx_account_product_resolutions_unique")
.table(Alias::new("account_product_resolutions"))
.col(Alias::new("user_id"))
.col(Alias::new("product_id"))
.unique()
.to_owned(),
)
.await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "account_product_resolutions").await?;
drop_table(m, "account_discount_profiles").await?;
drop_table(m, "discount_profile_products").await?;
drop_table(m, "discount_profiles").await
}
}

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> {
// Optional per-product discounted price (minor units) shown to ALL
// business (company) accounts as a baseline, computed off the regular
// price like the personal sale. Per-company profiles/negotiated prices
// still layer on top (lowest price wins).
add_column(m, "products", "business_sale_price_cents", ColType::BigIntegerNull).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
remove_column(m, "products", "business_sale_price_cents").await
}
}

View File

@@ -0,0 +1,40 @@
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> {
// Discount profiles applied globally to a whole audience, set on the
// discounts page: "personal" lowers the public price for everyone,
// "business" lowers the price for all company accounts. Per-company
// assignments (account_discount_profiles) still layer on top.
create_table(
m,
"audience_discount_profiles",
&[
("id", ColType::PkAuto),
("audience", ColType::String),
],
&[("discount_profile", "")],
)
.await?;
m.create_index(
Index::create()
.name("idx_audience_discount_profiles_unique")
.table(Alias::new("audience_discount_profiles"))
.col(Alias::new("audience"))
.col(Alias::new("discount_profile_id"))
.unique()
.to_owned(),
)
.await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "audience_discount_profiles").await
}
}

View File

@@ -0,0 +1,204 @@
//! Introduce product variants as the purchasable unit.
//!
//! A product becomes a presentation grouping (name, description, images,
//! category, tags, percentage discount profiles). Each product owns one or more
//! `product_variants`, and the variant is what carries the things that actually
//! differ between options: a free-text `label` (e.g. "rolovaná 90cm x 10m",
//! "5ml"), its own `sku`, `stock`, regular `price_cents`, and its own optional
//! public/business quick-sale prices.
//!
//! This migration:
//! 1. creates `product_variants`,
//! 2. backfills one variant per existing product from the product's current
//! price/stock/sku/sale columns,
//! 3. moves the per-account negotiated price and collision-resolution tables
//! from keying on `product_id` to `variant_id`,
//! 4. snapshots the variant onto `order_items`,
//! 5. drops the now-moved purchasable columns from `products`.
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();
// 1. The variants table.
db.execute_unprepared(
r#"
CREATE TABLE product_variants (
id SERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
label VARCHAR NOT NULL DEFAULT '',
position INTEGER NOT NULL DEFAULT 0,
sku VARCHAR,
stock INTEGER NOT NULL DEFAULT 0,
price_cents BIGINT NOT NULL,
sale_price_cents BIGINT,
business_sale_price_cents BIGINT
);
CREATE INDEX idx_product_variants_product ON product_variants (product_id);
"#,
)
.await?;
// 2. One variant per existing product, carrying its current pricing.
db.execute_unprepared(
r#"
INSERT INTO product_variants
(product_id, label, position, sku, stock,
price_cents, sale_price_cents, business_sale_price_cents)
SELECT id, '', 0, sku, stock,
price_cents, sale_price_cents, business_sale_price_cents
FROM products;
"#,
)
.await?;
// 3a. Negotiated prices: product_id -> variant_id.
db.execute_unprepared(
r#"
ALTER TABLE account_product_prices ADD COLUMN variant_id INTEGER;
UPDATE account_product_prices a
SET variant_id = pv.id
FROM product_variants pv
WHERE pv.product_id = a.product_id;
DROP INDEX IF EXISTS idx_account_product_prices_user_product_unique;
ALTER TABLE account_product_prices DROP COLUMN product_id;
ALTER TABLE account_product_prices ALTER COLUMN variant_id SET NOT NULL;
ALTER TABLE account_product_prices
ADD CONSTRAINT fk_account_product_prices_variant
FOREIGN KEY (variant_id) REFERENCES product_variants(id) ON DELETE CASCADE;
CREATE UNIQUE INDEX idx_account_product_prices_user_variant_unique
ON account_product_prices (user_id, variant_id);
"#,
)
.await?;
// 3b. Collision resolutions: product_id -> variant_id.
db.execute_unprepared(
r#"
ALTER TABLE account_product_resolutions ADD COLUMN variant_id INTEGER;
UPDATE account_product_resolutions a
SET variant_id = pv.id
FROM product_variants pv
WHERE pv.product_id = a.product_id;
DROP INDEX IF EXISTS idx_account_product_resolutions_unique;
ALTER TABLE account_product_resolutions DROP COLUMN product_id;
ALTER TABLE account_product_resolutions ALTER COLUMN variant_id SET NOT NULL;
ALTER TABLE account_product_resolutions
ADD CONSTRAINT fk_account_product_resolutions_variant
FOREIGN KEY (variant_id) REFERENCES product_variants(id) ON DELETE CASCADE;
CREATE UNIQUE INDEX idx_account_product_resolutions_unique
ON account_product_resolutions (user_id, variant_id);
"#,
)
.await?;
// 4. Snapshot the variant on order lines (label is frozen at order time;
// the FK is nullable + SET NULL so deleting a variant keeps history).
db.execute_unprepared(
r#"
ALTER TABLE order_items ADD COLUMN variant_label VARCHAR NOT NULL DEFAULT '';
ALTER TABLE order_items ADD COLUMN variant_id INTEGER
REFERENCES product_variants(id) ON DELETE SET NULL;
"#,
)
.await?;
// 5. Drop the purchasable columns now owned by the variant.
db.execute_unprepared(
r#"
ALTER TABLE products DROP COLUMN price_cents;
ALTER TABLE products DROP COLUMN sale_price_cents;
ALTER TABLE products DROP COLUMN business_sale_price_cents;
ALTER TABLE products DROP COLUMN sku;
ALTER TABLE products DROP COLUMN stock;
"#,
)
.await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
let db = m.get_connection();
// Restore product columns from each product's first variant.
db.execute_unprepared(
r#"
ALTER TABLE products ADD COLUMN price_cents BIGINT NOT NULL DEFAULT 0;
ALTER TABLE products ADD COLUMN sale_price_cents BIGINT;
ALTER TABLE products ADD COLUMN business_sale_price_cents BIGINT;
ALTER TABLE products ADD COLUMN sku VARCHAR;
ALTER TABLE products ADD COLUMN stock INTEGER NOT NULL DEFAULT 0;
UPDATE products p SET
price_cents = pv.price_cents,
sale_price_cents = pv.sale_price_cents,
business_sale_price_cents = pv.business_sale_price_cents,
sku = pv.sku,
stock = pv.stock
FROM (
SELECT DISTINCT ON (product_id) product_id, price_cents,
sale_price_cents, business_sale_price_cents, sku, stock
FROM product_variants ORDER BY product_id, position, id
) pv
WHERE pv.product_id = p.id;
"#,
)
.await?;
db.execute_unprepared(
r#"
ALTER TABLE order_items DROP COLUMN variant_id;
ALTER TABLE order_items DROP COLUMN variant_label;
"#,
)
.await?;
db.execute_unprepared(
r#"
ALTER TABLE account_product_resolutions ADD COLUMN product_id INTEGER;
UPDATE account_product_resolutions a
SET product_id = pv.product_id
FROM product_variants pv WHERE pv.id = a.variant_id;
DROP INDEX IF EXISTS idx_account_product_resolutions_unique;
ALTER TABLE account_product_resolutions DROP COLUMN variant_id;
ALTER TABLE account_product_resolutions ALTER COLUMN product_id SET NOT NULL;
ALTER TABLE account_product_resolutions
ADD CONSTRAINT fk_account_product_resolutions_product
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE;
CREATE UNIQUE INDEX idx_account_product_resolutions_unique
ON account_product_resolutions (user_id, product_id);
"#,
)
.await?;
db.execute_unprepared(
r#"
ALTER TABLE account_product_prices ADD COLUMN product_id INTEGER;
UPDATE account_product_prices a
SET product_id = pv.product_id
FROM product_variants pv WHERE pv.id = a.variant_id;
DROP INDEX IF EXISTS idx_account_product_prices_user_variant_unique;
ALTER TABLE account_product_prices DROP COLUMN variant_id;
ALTER TABLE account_product_prices ALTER COLUMN product_id SET NOT NULL;
ALTER TABLE account_product_prices
ADD CONSTRAINT fk_account_product_prices_product
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE;
CREATE UNIQUE INDEX idx_account_product_prices_user_product_unique
ON account_product_prices (user_id, product_id);
"#,
)
.await?;
db.execute_unprepared("DROP TABLE product_variants;").await?;
Ok(())
}
}

View File

@@ -0,0 +1,36 @@
//! Make `product_variants.stock` nullable: a NULL stock means the variant is
//! "available" but not inventory-tracked — always purchasable, no quantity cap,
//! and never decremented on order. A numeric stock is tracked/capped as before.
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#"
ALTER TABLE product_variants ALTER COLUMN stock DROP DEFAULT;
ALTER TABLE product_variants ALTER COLUMN stock DROP NOT NULL;
"#,
)
.await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
m.get_connection()
.execute_unprepared(
r#"
UPDATE product_variants SET stock = 0 WHERE stock IS NULL;
ALTER TABLE product_variants ALTER COLUMN stock SET DEFAULT 0;
ALTER TABLE product_variants ALTER COLUMN stock SET NOT NULL;
"#,
)
.await?;
Ok(())
}
}

View File

@@ -0,0 +1,92 @@
//! Full-text + fuzzy search over the product catalog.
//!
//! Storefront search has to cope with Slovak text (diacritics, ad-hoc spelling)
//! and customer typos, while staying entirely inside Postgres — the catalog is
//! small (hundreds of products), so a separate search engine would be pure
//! operational overhead. This migration sets up:
//!
//! 1. `unaccent` + `pg_trgm` extensions, and an IMMUTABLE `f_unaccent` wrapper
//! (the stock `unaccent` is only STABLE, so it can't be used in an index
//! expression without wrapping it).
//! 2. a `sk_unaccent` text-search configuration: the `simple` dictionary
//! (no English stemming, which would mangle Slovak) folded through
//! `unaccent` so "kompresor" and "kompresór" tokenize identically.
//! 3. a STORED generated `products.search_vector`, weighting the name above
//! the description, with a GIN index for `@@` matching.
//! 4. a trigram GIN index on the (unaccented) name for fuzzy matching.
//!
//! The matching query itself lives in `products::Entity::search`.
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 EXTENSION IF NOT EXISTS unaccent;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- IMMUTABLE wrapper so unaccent() can be used in generated columns
-- and index expressions (the extension's own unaccent() is STABLE).
CREATE OR REPLACE FUNCTION f_unaccent(text)
RETURNS text
LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT AS
$func$ SELECT public.unaccent('public.unaccent', $1) $func$;
-- 'simple' (no stemming) + unaccent: a good fit for Slovak, where
-- English stemming is wrong and accents are typed inconsistently.
DROP TEXT SEARCH CONFIGURATION IF EXISTS sk_unaccent;
CREATE TEXT SEARCH CONFIGURATION sk_unaccent ( COPY = simple );
ALTER TEXT SEARCH CONFIGURATION sk_unaccent
ALTER MAPPING FOR hword, hword_part, word
WITH unaccent, simple;
"#,
)
.await?;
db.execute_unprepared(
r#"
ALTER TABLE products
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('sk_unaccent', COALESCE(name, '')), 'A') ||
setweight(to_tsvector('sk_unaccent', COALESCE(description, '')), 'B')
) STORED;
CREATE INDEX idx_products_search_vector
ON products USING GIN (search_vector);
CREATE INDEX idx_products_name_trgm
ON products USING GIN (f_unaccent(name) gin_trgm_ops);
"#,
)
.await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
let db = m.get_connection();
// Drop the trigram index (it depends on f_unaccent) before the function;
// dropping the column takes its own GIN index with it.
db.execute_unprepared(
r#"
DROP INDEX IF EXISTS idx_products_name_trgm;
ALTER TABLE products DROP COLUMN IF EXISTS search_vector;
DROP TEXT SEARCH CONFIGURATION IF EXISTS sk_unaccent;
DROP FUNCTION IF EXISTS f_unaccent(text);
"#,
)
.await?;
// The unaccent / pg_trgm extensions are left installed: other objects may
// rely on them and they are harmless on their own.
Ok(())
}
}

View File

@@ -0,0 +1,232 @@
//! Broaden product search to the whole purchasable surface.
//!
//! The `product_search` migration could only index columns living on `products`
//! itself (name, description), because a STORED generated column may not read
//! other tables. To also match by tag, variant label and SKU, `search_vector`
//! becomes a plain column maintained by triggers:
//!
//! * `kompress_build_product_search(name, description, id)` builds the weighted
//! vector for one product, pulling tags + variant labels + SKUs by id
//! (name = A, tags + labels = B, description + SKU = C).
//! * a BEFORE trigger on `products` keeps a product's own row in sync, and
//! * AFTER triggers on `product_variants`, `product_product_tags` and tag
//! renames refresh the affected product(s).
//!
//! The result is one `products.search_vector` that every search query can reuse,
//! always consistent with the catalog.
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();
// Swap the generated column (name + description only) for a plain column
// the triggers can own. Dropping it takes its GIN index with it.
db.execute_unprepared(
r#"
ALTER TABLE products DROP COLUMN IF EXISTS search_vector;
ALTER TABLE products ADD COLUMN search_vector tsvector;
"#,
)
.await?;
// Single source of truth for a product's search document.
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$;
-- Refresh one product's stored vector (used by the satellite triggers).
CREATE OR REPLACE FUNCTION kompress_refresh_product_search(p_id integer)
RETURNS void LANGUAGE sql AS $func$
UPDATE products
SET search_vector = kompress_build_product_search(name, description, id)
WHERE id = p_id;
$func$;
"#,
)
.await?;
// BEFORE trigger on products: recompute on its own writes. When a refresh
// only touches search_vector (name + description unchanged) it skips the
// recompute and keeps the supplied value — which also breaks recursion.
db.execute_unprepared(
r#"
CREATE OR REPLACE FUNCTION kompress_products_search_tg() RETURNS trigger
LANGUAGE plpgsql AS $func$
BEGIN
IF TG_OP = 'UPDATE'
AND NEW.name IS NOT DISTINCT FROM OLD.name
AND NEW.description IS NOT DISTINCT FROM OLD.description
AND NEW.search_vector IS DISTINCT FROM OLD.search_vector THEN
RETURN NEW;
END IF;
NEW.search_vector :=
kompress_build_product_search(NEW.name, NEW.description, NEW.id);
RETURN NEW;
END;
$func$;
DROP TRIGGER IF EXISTS products_search_tg ON products;
CREATE TRIGGER products_search_tg
BEFORE INSERT OR UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION kompress_products_search_tg();
"#,
)
.await?;
// Variants: any change refreshes the owning product (both, on reparent).
db.execute_unprepared(
r#"
CREATE OR REPLACE FUNCTION kompress_variants_search_tg() RETURNS trigger
LANGUAGE plpgsql AS $func$
BEGIN
IF TG_OP = 'DELETE' THEN
PERFORM kompress_refresh_product_search(OLD.product_id);
RETURN OLD;
END IF;
PERFORM kompress_refresh_product_search(NEW.product_id);
IF TG_OP = 'UPDATE' AND NEW.product_id IS DISTINCT FROM OLD.product_id THEN
PERFORM kompress_refresh_product_search(OLD.product_id);
END IF;
RETURN NEW;
END;
$func$;
DROP TRIGGER IF EXISTS product_variants_search_tg ON product_variants;
CREATE TRIGGER product_variants_search_tg
AFTER INSERT OR UPDATE OR DELETE ON product_variants
FOR EACH ROW EXECUTE FUNCTION kompress_variants_search_tg();
"#,
)
.await?;
// Tag links: attaching/detaching a tag refreshes the product.
db.execute_unprepared(
r#"
CREATE OR REPLACE FUNCTION kompress_product_tags_link_search_tg() RETURNS trigger
LANGUAGE plpgsql AS $func$
BEGIN
IF TG_OP = 'DELETE' THEN
PERFORM kompress_refresh_product_search(OLD.product_id);
RETURN OLD;
END IF;
PERFORM kompress_refresh_product_search(NEW.product_id);
RETURN NEW;
END;
$func$;
DROP TRIGGER IF EXISTS product_product_tags_search_tg ON product_product_tags;
CREATE TRIGGER product_product_tags_search_tg
AFTER INSERT OR UPDATE OR DELETE ON product_product_tags
FOR EACH ROW EXECUTE FUNCTION kompress_product_tags_link_search_tg();
"#,
)
.await?;
// Renaming a tag refreshes every product carrying it.
db.execute_unprepared(
r#"
CREATE OR REPLACE FUNCTION kompress_tag_rename_search_tg() RETURNS trigger
LANGUAGE plpgsql AS $func$
BEGIN
UPDATE products p
SET search_vector =
kompress_build_product_search(p.name, p.description, p.id)
WHERE p.id IN (
SELECT ppt.product_id FROM product_product_tags ppt
WHERE ppt.product_tag_id = NEW.id
);
RETURN NEW;
END;
$func$;
DROP TRIGGER IF EXISTS product_tags_rename_search_tg ON product_tags;
CREATE TRIGGER product_tags_rename_search_tg
AFTER UPDATE OF name ON product_tags
FOR EACH ROW EXECUTE FUNCTION kompress_tag_rename_search_tg();
"#,
)
.await?;
// Backfill existing rows, then (re)create the GIN index for `@@`.
db.execute_unprepared(
r#"
UPDATE products
SET search_vector = kompress_build_product_search(name, description, id);
CREATE INDEX idx_products_search_vector
ON products USING GIN (search_vector);
"#,
)
.await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
let db = m.get_connection();
db.execute_unprepared(
r#"
DROP TRIGGER IF EXISTS product_tags_rename_search_tg ON product_tags;
DROP TRIGGER IF EXISTS product_product_tags_search_tg ON product_product_tags;
DROP TRIGGER IF EXISTS product_variants_search_tg ON product_variants;
DROP TRIGGER IF EXISTS products_search_tg ON products;
DROP FUNCTION IF EXISTS kompress_tag_rename_search_tg();
DROP FUNCTION IF EXISTS kompress_product_tags_link_search_tg();
DROP FUNCTION IF EXISTS kompress_variants_search_tg();
DROP FUNCTION IF EXISTS kompress_products_search_tg();
DROP FUNCTION IF EXISTS kompress_refresh_product_search(integer);
DROP FUNCTION IF EXISTS kompress_build_product_search(text, text, integer);
ALTER TABLE products DROP COLUMN IF EXISTS search_vector;
"#,
)
.await?;
// Restore the name + description generated column from the prior migration.
db.execute_unprepared(
r#"
ALTER TABLE products
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('sk_unaccent', COALESCE(name, '')), 'A') ||
setweight(to_tsvector('sk_unaccent', COALESCE(description, '')), 'B')
) STORED;
CREATE INDEX idx_products_search_vector
ON products USING GIN (search_vector);
"#,
)
.await?;
Ok(())
}
}

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

@@ -17,9 +17,10 @@ use std::{path::Path, sync::Arc};
#[allow(unused_imports)]
use crate::{
controllers::{
account, admin_categories, admin_dashboard, admin_discounts, admin_form, admin_orders,
admin_products, admin_shipping, auth, auth_pages, cart, checkout, home, i18n, media, oauth2,
shop,
account, admin_categories, admin_currencies, admin_customers, admin_dashboard,
admin_discount_profiles, admin_form, admin_orders, admin_products, admin_shipping,
auth, auth_pages, cart, checkout, currency, home, i18n, media, oauth2,
pages, shop,
},
initializers,
models::_entities::users,
@@ -82,6 +83,7 @@ impl Hooks for App {
Box::new(initializers::view_engine::ViewEngineInitializer),
Box::new(initializers::admin_seeder::AdminSeeder),
Box::new(initializers::shipping_seeder::ShippingSeeder),
Box::new(initializers::currency_seeder::CurrencySeeder),
Box::new(initializers::oauth2::OAuth2StoreInitializer),
Box::new(initializers::oauth2_session::OAuth2SessionInitializer),
])
@@ -94,6 +96,8 @@ impl Hooks for App {
.add_route(shop::routes())
.add_route(cart::routes())
.add_route(checkout::routes())
.add_route(currency::routes())
.add_route(pages::routes())
// cross-cutting
.add_route(auth::routes())
.add_route(auth_pages::routes())
@@ -104,10 +108,12 @@ impl Hooks for App {
// admin
.add_route(admin_dashboard::routes())
.add_route(admin_products::routes())
.add_route(admin_discounts::routes())
.add_route(admin_discount_profiles::routes())
.add_route(admin_categories::routes())
.add_route(admin_orders::routes())
.add_route(admin_customers::routes())
.add_route(admin_shipping::routes())
.add_route(admin_currencies::routes())
}
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
//! profile only edits the type-specific details (company identity + address).
use axum::extract::{DefaultBodyLimit, Multipart};
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::QueryOrder;
@@ -14,7 +15,11 @@ use serde::Deserialize;
use serde_json::json;
use crate::{
controllers::i18n::current_lang,
controllers::{
admin_form::{read_multipart_form, store_image},
i18n::current_lang,
media::IMAGE_MAX_BYTES,
},
models::{
customer_profiles::{self, ProfileFields},
order_items, orders, users,
@@ -128,6 +133,8 @@ fn profile_view(
"account_nav": true,
"customer_name": user.name,
"customer_account_type": user.account_type,
"customer_avatar": user.avatar_id,
"avatar_id": user.avatar_id,
"saved": saved,
"error": error,
"name": user.name,
@@ -202,6 +209,64 @@ async fn save_profile(
profile_view(&v, &jar, &user, &fields, true, false)
}
/// Persist `avatar_id` (a stored image filename, or `None` to clear) on the
/// signed-in customer and re-render the profile page with the success banner.
async fn set_avatar(
v: &TeraView,
jar: &CookieJar,
ctx: &AppContext,
user: users::Model,
avatar_id: Option<String>,
) -> Result<Response> {
let mut active = user.clone().into_active_model();
active.avatar_id = ActiveValue::set(avatar_id.clone());
let user = active.update(&ctx.db).await?;
let profile = customer_profiles::Model::find_for_user(&ctx.db, user.id).await?;
profile_view(v, jar, &user, &fields_of(profile.as_ref()), true, false)
}
/// Upload (or replace) the signed-in customer's avatar picture. The single
/// `image` file part is validated and stored through the shared image storage,
/// then its generated filename is saved as the user's `avatar_id`.
#[debug_handler]
async fn upload_avatar(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
multipart: Multipart,
) -> Result<Response> {
let Some(user) = guard::current_user(&ctx, &jar).await else {
return format::redirect("/login");
};
if guard::is_admin(&ctx, &user) {
return format::redirect("/admin/dashboard");
}
let form = read_multipart_form(multipart).await?;
let Some(image) = form.single_image() else {
// No file chosen — nothing to do, just re-show the profile.
let profile = customer_profiles::Model::find_for_user(&ctx.db, user.id).await?;
return profile_view(&v, &jar, &user, &fields_of(profile.as_ref()), false, false);
};
let filename = store_image(&ctx, image).await?;
set_avatar(&v, &jar, &ctx, user, Some(filename)).await
}
/// Remove the signed-in customer's avatar, reverting to the initials fallback.
#[debug_handler]
async fn remove_avatar(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let Some(user) = guard::current_user(&ctx, &jar).await else {
return format::redirect("/login");
};
if guard::is_admin(&ctx, &user) {
return format::redirect("/admin/dashboard");
}
set_avatar(&v, &jar, &ctx, user, None).await
}
/// Lists the signed-in customer's orders, split into still-active and past.
#[debug_handler]
async fn orders_page(
@@ -236,6 +301,7 @@ async fn orders_page(
"account_nav": true,
"customer_name": user.name,
"customer_account_type": user.account_type,
"customer_avatar": user.avatar_id,
"active_orders": shape(active),
"past_orders": shape(past),
"lang": current_lang(&jar),
@@ -278,6 +344,7 @@ async fn order_detail_page(
"account_nav": true,
"customer_name": user.name,
"customer_account_type": user.account_type,
"customer_avatar": user.avatar_id,
"order": order_view::detail(
&order,
settings::get(&ctx, "bank_iban").unwrap_or(""),
@@ -312,6 +379,7 @@ fn password_view(
"account_nav": true,
"customer_name": user.name,
"customer_account_type": user.account_type,
"customer_avatar": user.avatar_id,
"changed": changed,
"error": error,
"lang": current_lang(jar),
@@ -406,6 +474,7 @@ fn security_view(
"account_nav": true,
"customer_name": user.name,
"customer_account_type": user.account_type,
"customer_avatar": user.avatar_id,
"totp_enabled": user.totp_enabled(),
"enrolling": enrolling,
"qr": qr,
@@ -538,6 +607,11 @@ pub fn routes() -> Routes {
Routes::new()
.add("/account/profile", get(profile_page))
.add("/account/profile", post(save_profile))
.add(
"/account/profile/avatar",
post(upload_avatar).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024)),
)
.add("/account/profile/avatar/remove", post(remove_avatar))
.add("/account/orders", get(orders_page))
.add("/account/orders/{order_number}", get(order_detail_page))
.add("/account/password", get(change_password_page))

View File

@@ -198,7 +198,7 @@ async fn create(
guard::current_admin(auth, &ctx).await?;
let form = read_multipart_form(multipart).await?;
let fields = parse_category_fields(&ctx, &form, None).await?;
let image_id = match form.image {
let image_id = match form.single_image() {
Some(data) => Some(store_image(&ctx, data).await?),
None => None,
};
@@ -252,7 +252,7 @@ async fn update(
category.position = Set(fields.position);
category.published = Set(fields.published);
category.parent_id = Set(fields.parent_id);
if let Some(data) = form.image {
if let Some(data) = form.single_image() {
category.image_id = Set(Some(store_image(&ctx, data).await?));
}
category.update(&ctx.db).await?;

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

@@ -0,0 +1,427 @@
//! Admin management of business (company) accounts and their pricing.
//!
//! Per company the admin can: assign reusable discount profiles (the automated
//! layer), resolve per-product collisions when two assigned profiles cover the
//! same product, and set a manually negotiated price per product. The effective
//! price the business pays is always resolved by [`crate::shared::pricing`]
//! (lowest of public / automated / negotiated), shown here for reference.
use std::collections::{HashMap, HashSet};
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter,
QueryOrder, Set, TransactionTrait,
};
use serde::Deserialize;
use serde_json::json;
use crate::{
controllers::i18n::current_lang,
models::{
account_discount_profiles, account_product_prices, account_product_resolutions,
categories, discount_profiles, product_variants, products, _entities::users,
},
shared::{
guard,
money::{format_bp, format_price, parse_price_to_cents},
pricing,
},
views::shop as view,
};
const COMPANY: &str = "company";
const BUSINESS_AUDIENCE: &str = "business";
#[derive(Debug, Deserialize)]
struct PriceForm {
price: String,
}
#[derive(Debug, Deserialize)]
struct ResolutionForm {
profile_id: i32,
}
async fn company_by_id(ctx: &AppContext, id: i32) -> Result<users::Model> {
let user = users::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
if user.account_type != COMPANY {
return Err(Error::NotFound);
}
Ok(user)
}
async fn assigned_profile_ids(ctx: &AppContext, user_id: i32) -> Result<HashSet<i32>> {
Ok(account_discount_profiles::Entity::find()
.filter(account_discount_profiles::Column::UserId.eq(user_id))
.all(&ctx.db)
.await?
.into_iter()
.map(|a| a.discount_profile_id)
.collect())
}
#[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 companies = users::Entity::find()
.filter(users::Column::AccountType.eq(COMPANY))
.order_by_asc(users::Column::Name)
.all(&ctx.db)
.await?;
let mut rows = Vec::with_capacity(companies.len());
for company in &companies {
let negotiated = account_product_prices::Entity::find()
.filter(account_product_prices::Column::UserId.eq(company.id))
.count(&ctx.db)
.await?;
rows.push(json!({
"id": company.id,
"name": company.name,
"email": company.email,
"negotiated_count": negotiated,
}));
}
format::view(
&v,
"admin/customers/index.html",
json!({ "customers": rows, "lang": current_lang(&jar) }),
)
}
#[debug_handler]
async fn 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 company = company_by_id(&ctx, id).await?;
// All profiles (for the assignment section + name lookup) and which are
// assigned to this company.
let all_profiles = discount_profiles::Entity::find()
.order_by_asc(discount_profiles::Column::Name)
.all(&ctx.db)
.await?;
let assigned = assigned_profile_ids(&ctx, company.id).await?;
let profiles_json: Vec<serde_json::Value> = all_profiles
.iter()
.map(|p| {
json!({
"id": p.id,
"name": p.name,
"percent": format_bp(p.percent_bp),
"scope_type": p.scope_type,
"assigned": assigned.contains(&p.id),
})
})
.collect();
let all_categories = categories::Entity::find()
.order_by_asc(categories::Column::Position)
.order_by_asc(categories::Column::Name)
.all(&ctx.db)
.await?;
// 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)
.all(&ctx.db)
.await?
} else {
products::Entity::search(&ctx.db, &query, 1000, false).await?
};
// Category sidebar tree (counts over the full, unfiltered product list) plus
// the active `?category=` filter applied to the rows.
let category_ids: Vec<Option<i32>> = list.iter().map(|p| p.category_id).collect();
let category_groups = view::admin_category_groups(&all_categories, &category_ids);
let selected_category = params
.get("category")
.map(String::as_str)
.unwrap_or("all")
.to_string();
let filter = view::category_filter_ids(&all_categories, &selected_category);
// Pricing is per variant. Flatten the (filtered) products into their variants
// in product-name then variant-position order, carrying each variant's
// product for the row's display name.
let product_ids: Vec<i32> = list.iter().map(|p| p.id).collect();
let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &product_ids).await?;
let mut variant_rows: Vec<(&products::Model, product_variants::Model)> = Vec::new();
for product in &list {
if !view::category_filter_keep(&filter, product.category_id) {
continue;
}
if let Some(variants) = grouped.get(&product.id) {
for variant in variants {
variant_rows.push((product, variant.clone()));
}
}
}
// Two prices per variant:
// - the generic business price a freshly-registered company sees (business
// baseline + business-audience profiles, no per-company deals), and
// - this company's effective price (its negotiated price + assigned profiles).
// The effective price is highlighted only when it differs from the generic one.
let variants_only: Vec<product_variants::Model> =
variant_rows.iter().map(|(_, v)| v.clone()).collect();
let business = pricing::audience_price_variants(&ctx, &variants_only, BUSINESS_AUDIENCE).await?;
let details = pricing::detail_variants(&ctx, &variants_only, Some(&company)).await?;
let rows: Vec<serde_json::Value> = variant_rows
.iter()
.zip(business.iter())
.zip(details.iter())
.map(|(((product, variant), b), d)| {
json!({
"product_id": product.id,
"variant_id": variant.id,
"name": product.name,
"variant_label": variant.label,
"regular_price": format_price(d.regular_cents),
"business_price": format_price(b.price_cents),
"business_reduced": b.price_cents < d.regular_cents,
"has_negotiated": d.manual_cents.is_some(),
"collision": d.collision,
"effective_price": format_price(d.price_cents),
"effective_differs": d.price_cents != b.price_cents,
})
})
.collect();
format::view(
&v,
"admin/customers/show.html",
json!({
"customer": { "id": company.id, "name": company.name, "email": company.email },
"profiles": profiles_json,
"products": rows,
"category_groups": category_groups,
"selected_category": selected_category,
"query": query,
"total_count": list.len(),
"uncategorized_count": category_ids.iter().filter(|c| c.is_none()).count(),
"error": params.get("error"),
"lang": current_lang(&jar),
}),
)
}
/// Dedicated per-product page for the negotiated price (and, when two assigned
/// profiles collide, the resolution selector). Mirrors the catalog "Set discount"
/// page but for a single company.
#[debug_handler]
async fn price_edit(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path((id, variant_id)): Path<(i32, i32)>,
Query(params): Query<HashMap<String, String>>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let company = company_by_id(&ctx, id).await?;
let variant = product_variants::Entity::find_by_id(variant_id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
let product = products::Entity::find_by_id(variant.product_id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
let business =
pricing::audience_price_variants(&ctx, std::slice::from_ref(&variant), BUSINESS_AUDIENCE)
.await?;
let business_cents = business[0].price_cents;
let detail =
pricing::detail_variants(&ctx, std::slice::from_ref(&variant), Some(&company)).await?;
let d = &detail[0];
// Names for the covering profiles, used by the collision resolution selector.
let covering: Vec<serde_json::Value> = if d.covering_profile_ids.is_empty() {
Vec::new()
} else {
let profiles = discount_profiles::Entity::find()
.filter(discount_profiles::Column::Id.is_in(d.covering_profile_ids.clone()))
.all(&ctx.db)
.await?;
let name: HashMap<i32, String> =
profiles.iter().map(|p| (p.id, p.name.clone())).collect();
d.covering_profile_ids
.iter()
.map(|pid| json!({ "id": pid, "name": name.get(pid) }))
.collect()
};
format::view(
&v,
"admin/customers/price_form.html",
json!({
"customer": { "id": company.id, "name": company.name },
"product": {
"id": product.id,
"variant_id": variant.id,
"name": product.name,
"variant_label": variant.label,
"regular_price": format_price(d.regular_cents),
"regular_cents": d.regular_cents,
"business_price": format_price(business_cents),
"business_reduced": business_cents < d.regular_cents,
"effective_price": format_price(d.price_cents),
"effective_differs": d.price_cents != business_cents,
},
"negotiated": d.manual_cents.map(format_price).unwrap_or_default(),
"has_negotiated": d.manual_cents.is_some(),
"collision": d.collision,
"covering": covering,
"auto_profile_id": d.auto_profile_id,
"error": params.get("error"),
"lang": current_lang(&jar),
}),
)
}
#[debug_handler]
async fn set_price(
auth: auth::JWT,
Path((id, variant_id)): Path<(i32, i32)>,
State(ctx): State<AppContext>,
Form(form): Form<PriceForm>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let company = company_by_id(&ctx, id).await?;
let entered = form.price.trim().to_string();
if entered.is_empty() {
account_product_prices::Model::clear(&ctx.db, company.id, variant_id).await?;
return format::redirect(&format!("/admin/customers/{id}"));
}
let cents = match parse_price_to_cents(&entered) {
Ok(cents) if cents > 0 => cents,
_ => {
return format::redirect(&format!(
"/admin/customers/{id}/prices/{variant_id}/edit?error=discount-must-be-positive"
))
}
};
account_product_prices::Model::upsert(&ctx.db, company.id, variant_id, cents).await?;
format::redirect(&format!("/admin/customers/{id}"))
}
#[debug_handler]
async fn remove_price(
auth: auth::JWT,
Path((id, variant_id)): Path<(i32, i32)>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let company = company_by_id(&ctx, id).await?;
account_product_prices::Model::clear(&ctx.db, company.id, variant_id).await?;
format::redirect(&format!("/admin/customers/{id}"))
}
/// Replace the company's assigned profiles with the submitted set of checkboxes
/// (`profile_ids`, a repeated field axum `Form` can't collect, parsed directly).
#[debug_handler]
async fn sync_profiles(
auth: auth::JWT,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
body: String,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let company = company_by_id(&ctx, id).await?;
let profile_ids: Vec<i32> = form_urlencoded::parse(body.as_bytes())
.filter(|(k, _)| k == "profile_ids")
.filter_map(|(_, v)| v.parse::<i32>().ok())
.collect();
let txn = ctx.db.begin().await?;
account_discount_profiles::Entity::delete_many()
.filter(account_discount_profiles::Column::UserId.eq(company.id))
.exec(&txn)
.await?;
for profile_id in profile_ids {
account_discount_profiles::ActiveModel {
user_id: Set(company.id),
discount_profile_id: Set(profile_id),
..Default::default()
}
.insert(&txn)
.await?;
}
txn.commit().await?;
format::redirect(&format!("/admin/customers/{id}"))
}
/// Record the admin's chosen winning profile for a colliding product.
#[debug_handler]
async fn set_resolution(
auth: auth::JWT,
Path((id, variant_id)): Path<(i32, i32)>,
State(ctx): State<AppContext>,
Form(form): Form<ResolutionForm>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let company = company_by_id(&ctx, id).await?;
let existing = account_product_resolutions::Entity::find()
.filter(account_product_resolutions::Column::UserId.eq(company.id))
.filter(account_product_resolutions::Column::VariantId.eq(variant_id))
.one(&ctx.db)
.await?;
let mut active = match existing {
Some(row) => row.into_active_model(),
None => account_product_resolutions::ActiveModel {
user_id: Set(company.id),
variant_id: Set(variant_id),
..Default::default()
},
};
active.discount_profile_id = Set(form.profile_id);
active.save(&ctx.db).await?;
format::redirect(&format!("/admin/customers/{id}"))
}
pub fn routes() -> Routes {
Routes::new()
.add("/admin/customers", get(index))
.add("/admin/customers/{id}", get(show))
.add("/admin/customers/{id}/profiles", post(sync_profiles))
.add(
"/admin/customers/{id}/prices/{variant_id}/edit",
get(price_edit),
)
.add("/admin/customers/{id}/prices/{variant_id}", post(set_price))
.add(
"/admin/customers/{id}/prices/{variant_id}/remove",
post(remove_price),
)
.add(
"/admin/customers/{id}/resolutions/{variant_id}",
post(set_resolution),
)
}

View File

@@ -0,0 +1,298 @@
//! Admin CRUD for reusable discount profiles (a named percentage over a product
//! scope). Profiles are assigned to business accounts on the customer page; here
//! the admin only defines them.
use std::collections::HashSet;
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, ModelTrait, PaginatorTrait,
QueryFilter, QueryOrder, Set, TransactionTrait,
};
use serde_json::json;
use crate::{
controllers::i18n::current_lang,
models::{discount_profile_products, discount_profiles, products},
shared::{
guard,
money::{format_bp, parse_percent, percent_to_bp},
},
};
/// Scalar + repeated fields parsed from the profile form. `product_ids` is a
/// repeated checkbox field, which `serde_urlencoded` (axum `Form`) can't collect,
/// so the body is parsed directly.
struct ProfileInput {
name: String,
percent: String,
scope_type: String,
product_ids: Vec<i32>,
}
fn parse_profile_form(body: &str) -> ProfileInput {
let mut name = String::new();
let mut percent = String::new();
let mut scope_type = discount_profiles::SCOPE_INCLUDE.to_string();
let mut product_ids = Vec::new();
for (key, value) in form_urlencoded::parse(body.as_bytes()) {
match key.as_ref() {
"name" => name = value.into_owned(),
"percent" => percent = value.into_owned(),
"scope_type" => scope_type = value.into_owned(),
"product_ids" => {
if let Ok(id) = value.parse::<i32>() {
product_ids.push(id);
}
}
_ => {}
}
}
ProfileInput {
name,
percent,
scope_type,
product_ids,
}
}
async fn profile_by_id(ctx: &AppContext, id: i32) -> Result<discount_profiles::Model> {
discount_profiles::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)
}
#[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 profiles = discount_profiles::Entity::find()
.order_by_asc(discount_profiles::Column::Name)
.all(&ctx.db)
.await?;
let mut rows = Vec::with_capacity(profiles.len());
for profile in &profiles {
let count = discount_profile_products::Entity::find()
.filter(discount_profile_products::Column::DiscountProfileId.eq(profile.id))
.count(&ctx.db)
.await?;
rows.push(json!({
"id": profile.id,
"name": profile.name,
"percent": format_bp(profile.percent_bp),
"scope_type": profile.scope_type,
"product_count": count,
}));
}
format::view(
&v,
"admin/catalog/discount_profiles.html",
json!({ "profiles": rows, "lang": current_lang(&jar) }),
)
}
/// Render the create/edit form. `profile` is null on create.
async fn render_form(
ctx: &AppContext,
v: &TeraView,
jar: &CookieJar,
profile: Option<&discount_profiles::Model>,
selected: &HashSet<i32>,
error: Option<&str>,
) -> Result<Response> {
let all_products = products::Entity::find()
.order_by_asc(products::Column::Name)
.all(&ctx.db)
.await?;
let product_rows: Vec<serde_json::Value> = all_products
.iter()
.map(|p| json!({ "id": p.id, "name": p.name, "selected": selected.contains(&p.id) }))
.collect();
let profile_json = match profile {
Some(p) => json!({
"id": p.id,
"name": p.name,
"percent": format_bp(p.percent_bp),
"scope_type": p.scope_type,
}),
None => serde_json::Value::Null,
};
format::view(
v,
"admin/catalog/discount_profile_form.html",
json!({
"profile": profile_json,
"products": product_rows,
"error": error,
"lang": current_lang(jar),
}),
)
}
#[debug_handler]
async fn new(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
render_form(&ctx, &v, &jar, None, &HashSet::new(), None).await
}
#[debug_handler]
async fn edit(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let profile = profile_by_id(&ctx, id).await?;
let selected = member_ids(&ctx, id).await?;
render_form(&ctx, &v, &jar, Some(&profile), &selected, None).await
}
async fn member_ids(ctx: &AppContext, profile_id: i32) -> Result<HashSet<i32>> {
Ok(discount_profile_products::Entity::find()
.filter(discount_profile_products::Column::DiscountProfileId.eq(profile_id))
.all(&ctx.db)
.await?
.into_iter()
.map(|r| r.product_id)
.collect())
}
/// Validate the parsed form into `(name, percent_bp, scope_type)`, or an error key.
fn validate(input: &ProfileInput) -> std::result::Result<(String, i32, String), &'static str> {
let name = input.name.trim().to_string();
if name.is_empty() {
return Err("profile-name-required");
}
let pct = parse_percent(&input.percent).ok_or("discount-invalid")?;
if pct <= 0.0 || pct >= 100.0 {
return Err("discount-percent-range");
}
let scope = if input.scope_type == discount_profiles::SCOPE_ALL_EXCEPT {
discount_profiles::SCOPE_ALL_EXCEPT
} else {
discount_profiles::SCOPE_INCLUDE
};
Ok((name, percent_to_bp(pct), scope.to_string()))
}
/// Replace a profile's product membership with `product_ids`.
async fn sync_membership(
ctx: &AppContext,
profile_id: i32,
product_ids: &[i32],
) -> Result<()> {
let txn = ctx.db.begin().await?;
discount_profile_products::Entity::delete_many()
.filter(discount_profile_products::Column::DiscountProfileId.eq(profile_id))
.exec(&txn)
.await?;
for product_id in product_ids {
discount_profile_products::ActiveModel {
discount_profile_id: Set(profile_id),
product_id: Set(*product_id),
..Default::default()
}
.insert(&txn)
.await?;
}
txn.commit().await?;
Ok(())
}
#[debug_handler]
async fn create(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
body: String,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let input = parse_profile_form(&body);
let (name, percent_bp, scope_type) = match validate(&input) {
Ok(values) => values,
Err(key) => {
let selected: HashSet<i32> = input.product_ids.iter().copied().collect();
return render_form(&ctx, &v, &jar, None, &selected, Some(key)).await;
}
};
let profile = discount_profiles::ActiveModel {
name: Set(name),
percent_bp: Set(percent_bp),
scope_type: Set(scope_type),
..Default::default()
}
.insert(&ctx.db)
.await?;
sync_membership(&ctx, profile.id, &input.product_ids).await?;
format::redirect("/admin/catalog/discount-profiles")
}
#[debug_handler]
async fn update(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
body: String,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let profile = profile_by_id(&ctx, id).await?;
let input = parse_profile_form(&body);
let (name, percent_bp, scope_type) = match validate(&input) {
Ok(values) => values,
Err(key) => {
let selected: HashSet<i32> = input.product_ids.iter().copied().collect();
return render_form(&ctx, &v, &jar, Some(&profile), &selected, Some(key)).await;
}
};
let mut active = profile.into_active_model();
active.name = Set(name);
active.percent_bp = Set(percent_bp);
active.scope_type = Set(scope_type);
active.update(&ctx.db).await?;
sync_membership(&ctx, id, &input.product_ids).await?;
format::redirect("/admin/catalog/discount-profiles")
}
#[debug_handler]
async fn delete(
auth: auth::JWT,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
// FK cascades remove membership, assignments and resolutions.
profile_by_id(&ctx, id).await?.delete(&ctx.db).await?;
format::redirect("/admin/catalog/discount-profiles")
}
pub fn routes() -> Routes {
Routes::new()
.add("/admin/catalog/discount-profiles", get(index))
.add("/admin/catalog/discount-profiles/new", get(new))
.add("/admin/catalog/discount-profiles", post(create))
.add("/admin/catalog/discount-profiles/{id}/edit", get(edit))
.add("/admin/catalog/discount-profiles/{id}", post(update))
.add("/admin/catalog/discount-profiles/{id}/delete", post(delete))
}

View File

@@ -1,246 +0,0 @@
//! Admin management of per-product discounts.
//!
//! Discounts live on the product (`sale_price_cents`) but are set here, in a
//! place of their own, rather than on the product editor: an admin picks a
//! product, enters a discounted price, and the storefront then shows it on sale.
//! Editing a product never touches its discount, and vice versa.
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::products,
shared::{
guard,
money::{format_price, parse_price_to_cents},
},
};
#[derive(Debug, Deserialize)]
struct DiscountForm {
/// "fixed" (enter the new price) or "percent" (enter % off). Defaults to
/// fixed for older/JSON callers.
mode: Option<String>,
sale_price: Option<String>,
percent: Option<String>,
}
/// Parse a percentage typed as "20", "20.5" or "20,5" into an `f64`.
fn parse_percent(value: &str) -> Option<f64> {
let parsed: f64 = value.trim().replace(',', ".").parse().ok()?;
parsed.is_finite().then_some(parsed)
}
/// Resolve a percentage off the regular price into a fixed sale price in cents.
/// Rounds the discount amount to the nearest cent.
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
}
async fn product_by_id(ctx: &AppContext, id: i32) -> Result<products::Model> {
products::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)
}
/// Percent off the regular price, rounded to a whole number. `0` when there is
/// no positive regular price to discount from.
fn percent_off(regular_cents: i64, sale_cents: i64) -> i64 {
if regular_cents <= 0 {
return 0;
}
let off = (regular_cents - sale_cents) as f64 / regular_cents as f64 * 100.0;
off.round() as i64
}
/// Row shape for the discounts list.
fn list_row(product: &products::Model) -> serde_json::Value {
json!({
"id": product.id,
"name": product.name,
"slug": product.slug,
"currency": product.currency,
"regular_price": format_price(product.price_cents),
"on_sale": product.on_sale(),
"sale_price": product.sale_price_cents.map(format_price),
"percent_off": product.sale_price_cents.map(|sale| percent_off(product.price_cents, sale)),
})
}
#[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 list = products::Entity::find()
.order_by_asc(products::Column::Name)
.all(&ctx.db)
.await?;
let rows: Vec<serde_json::Value> = list.iter().map(list_row).collect();
format::view(
&v,
"admin/catalog/discounts.html",
json!({ "products": rows, "lang": current_lang(&jar) }),
)
}
/// What to pre-fill the form with: the chosen input mode and the raw values for
/// each field, so a rejected submit (or a re-edit) shows what the admin had.
#[derive(Default)]
struct FormPrefill {
mode: String,
fixed: String,
percent: String,
}
/// Render the single-product discount form, optionally with a validation error.
fn render_form(
v: &TeraView,
jar: &CookieJar,
product: &products::Model,
prefill: &FormPrefill,
error: Option<&str>,
) -> Result<Response> {
let mode = if prefill.mode == "percent" { "percent" } else { "fixed" };
format::view(
v,
"admin/catalog/discount_form.html",
json!({
"product": {
"id": product.id,
"name": product.name,
"currency": product.currency,
"regular_price": format_price(product.price_cents),
"regular_cents": product.price_cents,
"on_sale": product.on_sale(),
"sale_price": product.sale_price_cents.map(format_price),
},
"mode": mode,
"fixed": prefill.fixed,
"percent": prefill.percent,
"error": error,
"lang": current_lang(jar),
}),
)
}
#[debug_handler]
async fn edit(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let product = product_by_id(&ctx, id).await?;
// Re-editing always opens in fixed mode showing the current sale price.
let prefill = FormPrefill {
mode: "fixed".to_string(),
fixed: product.sale_price_cents.map(format_price).unwrap_or_default(),
percent: String::new(),
};
render_form(&v, &jar, &product, &prefill, None)
}
#[debug_handler]
async fn update(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
Form(form): Form<DiscountForm>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let product = product_by_id(&ctx, id).await?;
let mode = match form.mode.as_deref() {
Some("percent") => "percent",
_ => "fixed",
};
let fixed = form.sale_price.unwrap_or_default().trim().to_string();
let percent = form.percent.unwrap_or_default().trim().to_string();
// Whatever the mode, both raw inputs are echoed back on error so neither tab
// loses what was typed.
let prefill = FormPrefill {
mode: mode.to_string(),
fixed: fixed.clone(),
percent: percent.clone(),
};
let render_err = |key: &str| render_form(&v, &jar, &product, &prefill, Some(key));
// Resolve the entered discount into a fixed sale price in cents. An empty
// input in the active mode clears the discount (same as the Remove action).
let sale_cents = if mode == "percent" {
if percent.is_empty() {
return clear_discount(&ctx, product).await;
}
let pct = match parse_percent(&percent) {
Some(pct) => pct,
None => return render_err("discount-invalid"),
};
if pct <= 0.0 || pct >= 100.0 {
return render_err("discount-percent-range");
}
percent_to_sale_cents(product.price_cents, pct)
} else {
if fixed.is_empty() {
return clear_discount(&ctx, product).await;
}
match parse_price_to_cents(&fixed) {
Ok(cents) => cents,
Err(_) => return render_err("discount-invalid"),
}
};
// A discount must be a positive price strictly below the regular price —
// otherwise it isn't a discount.
if sale_cents <= 0 {
return render_err("discount-must-be-positive");
}
if sale_cents >= product.price_cents {
return render_err("discount-below-regular");
}
let mut active = product.into_active_model();
active.sale_price_cents = Set(Some(sale_cents));
active.update(&ctx.db).await?;
format::redirect("/admin/catalog/discounts")
}
async fn clear_discount(ctx: &AppContext, product: products::Model) -> Result<Response> {
let mut active = product.into_active_model();
active.sale_price_cents = Set(None);
active.update(&ctx.db).await?;
format::redirect("/admin/catalog/discounts")
}
#[debug_handler]
async fn remove(
auth: auth::JWT,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let product = product_by_id(&ctx, id).await?;
clear_discount(&ctx, product).await
}
pub fn routes() -> Routes {
Routes::new()
.add("/admin/catalog/discounts", get(index))
.add("/admin/catalog/discounts/{id}/edit", get(edit))
.add("/admin/catalog/discounts/{id}", post(update))
.add("/admin/catalog/discounts/{id}/remove", post(remove))
}

View File

@@ -1,8 +1,11 @@
//! Multipart form handling shared by the product and category admin forms.
//!
//! Both forms submit a mix of text fields and an optional `image` file part;
//! this collects them into an easy-to-query [`MultipartForm`] and stores any
//! uploaded image through the configured storage driver.
//! Both forms submit a mix of text fields and `image` file part(s); this
//! collects them into an easy-to-query [`MultipartForm`] and stores any
//! uploaded image through the configured storage driver. The product form can
//! upload several images at once and submits a unified gallery order as
//! repeated `image_order` fields — each either an existing image's id or the
//! literal `new` (a placeholder consumed, in order, from the uploaded files).
use std::collections::HashMap;
@@ -18,11 +21,24 @@ fn normalize_empty(value: Option<String>) -> Option<String> {
})
}
/// Collected multipart form: text fields keyed by name, plus the raw bytes of
/// an `image` file part if one was uploaded (an empty file input is ignored).
/// One slot in the unified gallery order submitted by the product form.
#[derive(Debug, Clone)]
pub(crate) enum ImageSlot {
/// An existing image kept in the gallery.
Existing(i32),
/// A placeholder for one newly-uploaded file, consumed from [`MultipartForm::images`]
/// in the order these slots appear.
New,
}
/// Collected multipart form: text fields keyed by name, the raw bytes of every
/// `image` file part uploaded (empty file inputs are ignored, submission order
/// preserved), and the full gallery order as repeated `image_order` fields —
/// each either an existing image's id or the literal `new`.
pub(crate) struct MultipartForm {
fields: HashMap<String, String>,
pub(crate) image: Option<Vec<u8>>,
pub(crate) images: Vec<Vec<u8>>,
pub(crate) image_order: Vec<ImageSlot>,
}
impl MultipartForm {
@@ -31,6 +47,12 @@ impl MultipartForm {
normalize_empty(self.fields.get(key).cloned())
}
/// The single uploaded image, for forms (like categories) that accept only
/// one. Consumes the first uploaded part; any extras are ignored.
pub(crate) fn single_image(self) -> Option<Vec<u8>> {
self.images.into_iter().next()
}
/// Whether a checkbox-style field is checked.
pub(crate) fn checked(&self, key: &str) -> bool {
matches!(
@@ -38,11 +60,29 @@ impl MultipartForm {
Some("on" | "true" | "1")
)
}
/// The distinct row indices `N` present among `variants[N][...]` fields,
/// sorted ascending. Used to read the repeated variant rows of the product
/// form (each row's fields are uniquely keyed, so the HashMap keeps them all).
pub(crate) fn variant_indices(&self) -> Vec<usize> {
let mut idx: Vec<usize> = self
.fields
.keys()
.filter_map(|k| {
let rest = k.strip_prefix("variants[")?;
rest.split(']').next()?.parse::<usize>().ok()
})
.collect();
idx.sort_unstable();
idx.dedup();
idx
}
}
pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<MultipartForm> {
let mut fields = HashMap::new();
let mut image = None;
let mut images = Vec::new();
let mut image_order = Vec::new();
while let Some(mut field) = multipart
.next_field()
@@ -65,8 +105,20 @@ pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<Mult
)));
}
}
// An empty file part (no file chosen in a slot) is ignored.
if !data.is_empty() {
image = Some(data);
images.push(data);
}
} else if name == "image_order" {
let value = field
.text()
.await
.map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?;
let trimmed = value.trim();
if trimmed == "new" {
image_order.push(ImageSlot::New);
} else if let Ok(id) = trimmed.parse::<i32>() {
image_order.push(ImageSlot::Existing(id));
}
} else {
let value = field
@@ -77,7 +129,11 @@ pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<Mult
}
}
Ok(MultipartForm { fields, image })
Ok(MultipartForm {
fields,
images,
image_order,
})
}
/// Store an uploaded image's bytes and return its generated filename.

View File

@@ -1,5 +1,8 @@
//! 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 loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
@@ -30,18 +33,31 @@ async fn index(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Query(params): Query<HashMap<String, String>>,
State(ctx): State<AppContext>,
) -> Result<Response> {
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)
.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();
format::view(
&v,
"admin/orders/index.html",
json!({ "orders": rows, "lang": current_lang(&jar) }),
json!({
"orders": rows,
"query": query,
"total": list.len(),
"lang": current_lang(&jar),
}),
)
}
@@ -186,7 +202,6 @@ async fn ship(
country: order.country.as_deref(),
pickup_point_id: order.pickup_point_id.as_deref(),
cod_cents,
currency: &order.currency,
value_cents: goods_value,
weight_grams: DEFAULT_PARCEL_WEIGHT_GRAMS,
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
use crate::{controllers::i18n::current_lang, shared::{guard, money::format_price}, models::products};
use crate::{controllers::i18n::current_lang, shared::{currency::{self, Currency}, guard, pricing}, models::{product_variants, products}};
use axum::{
http::{HeaderMap, StatusCode},
response::Redirect,
@@ -15,22 +15,22 @@ const CART_MAX_AGE_DAYS: i64 = 30;
#[derive(Debug, Deserialize)]
struct AddForm {
product_id: i32,
variant_id: i32,
quantity: Option<i32>,
}
#[derive(Debug, Deserialize)]
struct UpdateForm {
product_id: i32,
variant_id: i32,
quantity: i32,
}
#[derive(Debug, Deserialize)]
struct RemoveForm {
product_id: i32,
variant_id: i32,
}
/// Parse the `cart` cookie ("id:qty,id:qty") into `(product_id, quantity)`
/// Parse the `cart` cookie ("id:qty,id:qty") into `(variant_id, quantity)`
/// pairs, silently dropping malformed or non-positive entries.
pub(crate) fn parse_cart(jar: &CookieJar) -> Vec<(i32, i32)> {
let Some(cookie) = jar.get(CART_COOKIE) else {
@@ -64,12 +64,23 @@ fn cart_cookie(value: String) -> Cookie<'static> {
.build()
}
/// Look up a published product, returning its current stock cap.
async fn published_product(ctx: &AppContext, id: i32) -> Result<Option<products::Model>> {
Ok(products::Entity::find_by_id(id)
/// Look up a variant whose product is published, returning the variant together
/// with its parent product (for name/slug).
async fn published_variant(
ctx: &AppContext,
variant_id: i32,
) -> Result<Option<(product_variants::Model, products::Model)>> {
let Some(variant) = product_variants::Entity::find_by_id(variant_id)
.one(&ctx.db)
.await?
else {
return Ok(None);
};
let product = products::Entity::find_by_id(variant.product_id)
.filter(products::Column::Published.eq(true))
.one(&ctx.db)
.await?)
.await?;
Ok(product.map(|p| (variant, p)))
}
#[debug_handler]
@@ -79,16 +90,16 @@ async fn add(
headers: HeaderMap,
Form(form): Form<AddForm>,
) -> Result<Response> {
let Some(product) = published_product(&ctx, form.product_id).await? else {
let Some((variant, _product)) = published_variant(&ctx, form.variant_id).await? else {
return Err(Error::NotFound);
};
let mut items = parse_cart(&jar);
let add_qty = form.quantity.unwrap_or(1).max(1);
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == product.id) {
entry.1 = (entry.1 + add_qty).min(product.stock);
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == variant.id) {
entry.1 = variant.cap(entry.1 + add_qty);
} else {
items.push((product.id, add_qty.min(product.stock)));
items.push((variant.id, variant.cap(add_qty)));
}
items.retain(|(_, qty)| *qty > 0);
@@ -117,14 +128,15 @@ async fn update(
headers: HeaderMap,
Form(form): Form<UpdateForm>,
) -> Result<Response> {
let stock = published_product(&ctx, form.product_id)
.await?
.map(|p| p.stock)
.unwrap_or(0);
// Clamp the requested quantity to what's available (no cap for untracked
// variants); a removed variant clamps to 0 and drops out below.
let clamped = match published_variant(&ctx, form.variant_id).await? {
Some((variant, _)) => variant.cap(form.quantity),
None => 0,
};
let mut items = parse_cart(&jar);
let clamped = form.quantity.clamp(0, stock);
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.product_id) {
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.variant_id) {
entry.1 = clamped;
}
items.retain(|(_, qty)| *qty > 0);
@@ -142,7 +154,7 @@ async fn remove(
Form(form): Form<RemoveForm>,
) -> Result<Response> {
let mut items = parse_cart(&jar);
items.retain(|(id, _)| *id != form.product_id);
items.retain(|(id, _)| *id != form.variant_id);
let jar = jar.add(cart_cookie(serialize_cart(&items)));
cart_response(&ctx, &v, jar, &headers).await
@@ -161,12 +173,8 @@ async fn cart_response(
return Ok((jar, Redirect::to("/cart")).into_response());
}
let (lines, valid, total) = resolve_cart(ctx, &jar).await?;
let currency = lines
.first()
.and_then(|line| line["currency"].as_str())
.unwrap_or("EUR")
.to_string();
let cur = currency::resolve(ctx, &jar).await;
let (lines, valid, total) = resolve_cart(ctx, &jar, &cur).await?;
// Persist the re-validated cookie (drops now-invalid lines).
let jar = jar.add(cart_cookie(serialize_cart(&valid)));
let response = format::view(
@@ -174,8 +182,8 @@ async fn cart_response(
"shop/_cart_body.html",
json!({
"items": lines,
"total": format_price(total),
"currency": currency,
"total": cur.format(total),
"currency_symbol": cur.symbol,
"lang": current_lang(&jar),
}),
)?;
@@ -188,32 +196,45 @@ async fn cart_response(
pub(crate) async fn resolve_cart(
ctx: &AppContext,
jar: &CookieJar,
cur: &Currency,
) -> Result<(Vec<serde_json::Value>, Vec<(i32, i32)>, i64)> {
let mut lines = Vec::new();
let mut valid = Vec::new();
let mut total: i64 = 0;
// 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).
let user = guard::current_user(ctx, jar).await;
let mut items: Vec<(product_variants::Model, products::Model, i32)> = Vec::new();
for (id, qty) in parse_cart(jar) {
let Some(product) = published_product(ctx, id).await? else {
let Some((variant, product)) = published_variant(ctx, id).await? else {
continue;
};
let qty = qty.clamp(0, product.stock);
let qty = variant.cap(qty);
if qty == 0 {
continue;
}
let unit_price = product.effective_price_cents();
let line_total = unit_price * i64::from(qty);
items.push((variant, product, qty));
}
let variants_only: Vec<product_variants::Model> =
items.iter().map(|(v, _, _)| v.clone()).collect();
let priced = pricing::price_variants(ctx, &variants_only, user.as_ref()).await?;
let mut lines = Vec::new();
let mut valid = Vec::new();
let mut total: i64 = 0;
for ((variant, product, qty), priced) in items.iter().zip(priced.iter()) {
let unit_price = priced.price_cents;
let line_total = unit_price * i64::from(*qty);
total += line_total;
valid.push((product.id, qty));
valid.push((variant.id, *qty));
lines.push(json!({
"id": product.id,
"id": variant.id,
"name": product.name,
"variant_label": variant.label,
"slug": product.slug,
"price": format_price(unit_price),
"currency": product.currency,
"price": cur.format(unit_price),
"regular_price": cur.format(priced.regular_cents),
"on_sale": priced.is_reduced(),
"quantity": qty,
"stock": product.stock,
"line_total": format_price(line_total),
"stock": variant.stock,
"line_total": cur.format(line_total),
}));
}
@@ -226,12 +247,8 @@ async fn show(
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let (lines, valid, total) = resolve_cart(&ctx, &jar).await?;
let currency = lines
.first()
.and_then(|line| line["currency"].as_str())
.unwrap_or("EUR")
.to_string();
let cur = currency::resolve(&ctx, &jar).await;
let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?;
// Drop any now-invalid lines from the cookie so the badge stays accurate.
let rebuilt = serialize_cart(&valid);
@@ -241,12 +258,13 @@ async fn show(
"shop/cart.html",
json!({
"items": lines,
"total": format_price(total),
"currency": currency,
"total": cur.format(total),
"currency_symbol": cur.symbol,
"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,
"lang": current_lang(&jar),
}),
)?;
@@ -262,20 +280,16 @@ async fn preview(
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let (lines, valid, total) = resolve_cart(&ctx, &jar).await?;
let currency = lines
.first()
.and_then(|line| line["currency"].as_str())
.unwrap_or("EUR")
.to_string();
let cur = currency::resolve(&ctx, &jar).await;
let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?;
let rebuilt = serialize_cart(&valid);
let response = format::view(
&v,
"shop/_cart_preview.html",
json!({
"items": lines,
"total": format_price(total),
"currency": currency,
"total": cur.format(total),
"currency_symbol": cur.symbol,
"lang": current_lang(&jar),
}),
)?;

View File

@@ -18,7 +18,7 @@ use crate::{
users::{self, normalize_account_type},
},
controllers::i18n::current_lang,
shared::{guard, money::format_price, settings},
shared::{currency::Currency, guard, money::format_price, settings},
views::checkout as view,
};
@@ -77,15 +77,12 @@ async fn checkout_page(
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> 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() {
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)
.await?
@@ -127,7 +124,6 @@ async fn checkout_page(
"items": lines,
"subtotal": format_price(subtotal),
"subtotal_cents": subtotal,
"currency": currency,
"shipping_methods": methods,
"packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""),
"logged_in_admin": is_admin,
@@ -136,6 +132,7 @@ async fn checkout_page(
// logged_in_customer is true); None for admins/guests.
"customer_name": user.as_ref().filter(|_| is_customer).map(|u| u.name.clone()),
"customer_account_type": user.as_ref().filter(|_| is_customer).map(|u| u.account_type.clone()),
"customer_avatar": user.as_ref().filter(|_| is_customer).and_then(|u| u.avatar_id.clone()),
"profile_filled": profile_filled,
// A logged-in customer's account type is fixed; only guests pick it
// and may opt to create an account from the order.
@@ -165,7 +162,7 @@ async fn place_order(
State(ctx): State<AppContext>,
Form(form): Form<CheckoutForm>,
) -> 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() {
return format::redirect("/cart");
}
@@ -331,6 +328,7 @@ async fn place_order(
pickup_point_id,
pickup_point_name,
},
logged_in_customer,
)
.await?;
@@ -378,6 +376,7 @@ async fn order_confirmation(
"logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
"customer_account_type": c.customer_account_type,
"customer_avatar": c.customer_avatar,
"account_created": account_created,
"lang": current_lang(&jar),
}),

View File

@@ -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 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]
async fn index(
@@ -12,8 +14,10 @@ async fn index(
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let products = shop::featured_products(&ctx, 8).await?;
let c = guard::chrome(&ctx, &jar).await;
let user = guard::current_user(&ctx, &jar).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());
format::view(
&v,
@@ -24,7 +28,11 @@ async fn index(
"logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
"customer_account_type": c.customer_account_type,
"customer_avatar": c.customer_avatar,
"currency_symbol": cur.symbol,
"lang": current_lang(&jar),
// The header search bar only appears on the landing page.
"on_home": true,
}),
)
}

View File

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

View File

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

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

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

View File

@@ -1,6 +1,10 @@
//! Public storefront: product listings, product detail, category pages and the
//! lazily-loaded category sidebar.
use std::collections::HashMap;
use axum::extract::Query;
use axum::http::HeaderMap;
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set};
@@ -8,17 +12,303 @@ use serde_json::json;
use crate::{
controllers::i18n::current_lang,
shared::guard,
models::{categories, product_images, products},
shared::{
currency::{self, Currency},
guard,
money::parse_price_to_cents,
pricing,
},
models::{categories, product_images, product_variants, products, users},
views::shop as view,
};
/// Shape a list of products into card rows, loading each one's primary image.
async fn product_rows(ctx: &AppContext, list: Vec<products::Model>) -> Result<Vec<serde_json::Value>> {
let mut rows = Vec::with_capacity(list.len());
for product in list {
/// Default results per page in the storefront listing/search.
const PER_PAGE: usize = 24;
/// Allowed per-page choices offered in the toolbar; any other value falls back
/// to [`PER_PAGE`].
const PER_PAGE_OPTIONS: [usize; 3] = [24, 48, 96];
/// Resolve the requested per-page count to one of [`PER_PAGE_OPTIONS`],
/// defaulting to [`PER_PAGE`].
fn resolve_per_page(params: &SearchParams) -> usize {
params
.per_page
.map(|p| p as usize)
.filter(|p| PER_PAGE_OPTIONS.contains(p))
.unwrap_or(PER_PAGE)
}
/// Hard cap on candidates a single text search considers before faceting; well
/// above any realistic page of results for this catalog.
const SEARCH_CAP: u64 = 1000;
/// 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 {
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
/// shows the resolved price of the product's representative (first) variant; the
/// `variant_count` lets the template render "from {price}" for multi-variant
/// products. Products with no variants are skipped (not purchasable).
async fn product_rows(
ctx: &AppContext,
user: Option<&users::Model>,
list: Vec<products::Model>,
cur: &Currency,
) -> Result<Vec<serde_json::Value>> {
let ids: Vec<i32> = list.iter().map(|p| p.id).collect();
let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &ids).await?;
// Representative (first) variant per product, in list order, dropping any
// product that has no variants.
let mut entries: Vec<(&products::Model, product_variants::Model, usize)> = Vec::new();
for product in &list {
if let Some(variants) = grouped.get(&product.id) {
if let Some(first) = variants.first() {
entries.push((product, first.clone(), variants.len()));
}
}
}
let reps: Vec<product_variants::Model> = entries.iter().map(|(_, v, _)| v.clone()).collect();
let priced = pricing::price_variants(ctx, &reps, user).await?;
let mut rows = Vec::with_capacity(entries.len());
for ((product, rep, count), priced) in entries.iter().zip(priced.iter()) {
let image = product_images::first_for(ctx, product.id).await?;
rows.push(view::product_card(&product, image, None));
rows.push(view::product_card(product, rep, priced, *count, image, None, cur));
}
Ok(rows)
}
@@ -27,7 +317,9 @@ async fn product_rows(ctx: &AppContext, list: Vec<products::Model>) -> Result<Ve
/// by the home-page landing grid.
pub(crate) async fn featured_products(
ctx: &AppContext,
user: Option<&users::Model>,
limit: u64,
cur: &Currency,
) -> Result<Vec<serde_json::Value>> {
let list = products::Entity::find()
.filter(products::Column::Published.eq(true))
@@ -35,7 +327,7 @@ pub(crate) async fn featured_products(
.limit(limit)
.all(&ctx.db)
.await?;
product_rows(ctx, list).await
product_rows(ctx, user, list, cur).await
}
/// The site-wide category sidebar, loaded lazily via htmx by the base layout so
@@ -57,31 +349,71 @@ 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]
async fn index(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> 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 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());
add_chrome(&mut context, &c, &current_lang(&jar));
format::view(&v, "shop/index.html", context)
}
let c = guard::chrome(&ctx, &jar).await;
format::view(
&v,
"shop/index.html",
json!({
"products": product_rows(&ctx, 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),
}),
)
/// Storefront search + faceted browse. Combines the hybrid full-text/fuzzy query
/// ([`products::Entity::search`]) with category, price-band, in-stock and sort
/// filters, ranked and paginated by [`run_search`]. A blank query falls back to
/// the full published listing, so the same endpoint powers both "browse" and
/// "search". Targeted htmx requests from the listing toolbar/pagination get just
/// the results fragment (for live updates); direct navigation, no-JS, and boosted
/// navigations (e.g. submitting the header search box, which hx-boost turns into
/// an AJAX nav) render the whole eshop page.
#[debug_handler]
async fn search(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
headers: HeaderMap,
Query(params): Query<SearchParams>,
State(ctx): State<AppContext>,
) -> Result<Response> {
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?;
let lang = current_lang(&jar);
// A boosted request (the header search form, links) replaces the whole body,
// so it needs the full page — only the toolbar's own targeted hx-get requests
// (HX-Request without HX-Boosted) want the bare results fragment.
let fragment = headers.contains_key("HX-Request") && !headers.contains_key("HX-Boosted");
if fragment {
if let Some(map) = context.as_object_mut() {
map.insert("lang".into(), json!(lang));
// Lets _results.html out-of-band swap the toolbar's Sort dropdown
// (which lives outside the swapped region) to match the ordering.
map.insert("is_fragment".into(), json!(true));
}
return format::view(&v, "shop/_results.html", context);
}
let c = guard::chrome_from(&ctx, user.as_ref());
add_chrome(&mut context, &c, &lang);
format::view(&v, "shop/index.html", context)
}
#[debug_handler]
@@ -112,28 +444,70 @@ async fn show(
None => None,
};
let c = guard::chrome(&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 variant_prices = pricing::price_variants(&ctx, &variants, user.as_ref()).await?;
let options: Vec<serde_json::Value> = variants
.iter()
.zip(variant_prices.iter())
.map(|(variant, priced)| view::variant_option(variant, priced, &cur))
.collect();
// The card header uses the representative (first) variant for its headline
// price; the picker below lets the customer switch.
let representative = variants.first();
let priced = variant_prices.first().copied();
let card = match (representative, priced) {
(Some(rep), Some(priced)) => view::product_card(
&product,
rep,
&priced,
variants.len(),
None,
category.as_ref().map(|c| c.name.clone()),
&cur,
),
// A product with no variants isn't purchasable; show it without a price.
_ => serde_json::json!({
"id": product.id,
"name": product.name,
"slug": product.slug,
"description": product.description,
"variant_count": 0,
"has_options": false,
}),
};
let c = guard::chrome_from(&ctx, user.as_ref());
format::view(
&v,
"shop/show.html",
json!({
"product": view::product_card(&product, None, category.as_ref().map(|c| c.name.clone())),
"product": card,
"variants": options,
"images": images.iter().map(|i| i.image_id.clone()).collect::<Vec<_>>(),
"category": category,
"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),
}),
)
}
/// 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]
async fn category(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(slug): Path<String>,
Query(params): Query<SearchParams>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let published = categories::published(&ctx).await?;
@@ -146,40 +520,31 @@ async fn category(
let breadcrumbs = categories::ancestors(&published, category.parent_id);
let children = categories::children_of(&published, category.id);
// Products listed here span this category and all of its descendants, so a
// parent category is never empty just because its products live in leaves.
let mut category_ids: Vec<i32> = categories::descendant_ids(&published, category.id)
.into_iter()
.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?;
// Force the category filter to this page's category, keeping any other params.
let params = SearchParams {
category: Some(category.id.to_string()),
..params
};
let c = guard::chrome(&ctx, &jar).await;
format::view(
&v,
"shop/category.html",
json!({
"category": category,
"breadcrumbs": breadcrumbs,
"children": children,
"products": product_rows(&ctx, 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),
}),
)
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());
add_chrome(&mut context, &c, &current_lang(&jar));
format::view(&v, "shop/category.html", context)
}
pub fn routes() -> Routes {
Routes::new()
.add("/shop", get(index))
// Top-level path (not /shop/search) so it never collides with the
// /shop/{slug} product route.
.add("/search", get(search))
.add("/shop/{slug}", get(show))
.add("/category/{slug}", get(category))
.add("/partials/categories", get(category_sidebar))

View File

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

View File

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

View File

@@ -54,6 +54,12 @@ impl Initializer for ViewEngineInitializer {
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(())
})?;

View File

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

View File

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

View File

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

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_product_prices")]
pub struct Model {
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
#[sea_orm(primary_key)]
pub id: i32,
pub price_cents: i64,
pub user_id: i32,
pub variant_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,62 @@
//! `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_product_resolutions")]
pub struct Model {
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
#[sea_orm(primary_key)]
pub id: i32,
pub user_id: i32,
pub discount_profile_id: i32,
pub variant_id: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::discount_profiles::Entity",
from = "Column::DiscountProfileId",
to = "super::discount_profiles::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
DiscountProfiles,
#[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::discount_profiles::Entity> for Entity {
fn to() -> RelationDef {
Relation::DiscountProfiles.def()
}
}
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,33 @@
//! `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 = "audience_discount_profiles")]
pub struct Model {
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
#[sea_orm(primary_key)]
pub id: i32,
pub audience: String,
pub discount_profile_id: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::discount_profiles::Entity",
from = "Column::DiscountProfileId",
to = "super::discount_profiles::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
DiscountProfiles,
}
impl Related<super::discount_profiles::Entity> for Entity {
fn to() -> RelationDef {
Relation::DiscountProfiles.def()
}
}

View File

@@ -1,4 +1,4 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};

View File

@@ -1,4 +1,4 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
@@ -23,8 +23,6 @@ pub struct Model {
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::products::Entity")]
Products,
#[sea_orm(
belongs_to = "Entity",
from = "Column::ParentId",
@@ -32,7 +30,9 @@ pub enum Relation {
on_update = "Cascade",
on_delete = "SetNull"
)]
Parent,
SelfRef,
#[sea_orm(has_many = "super::products::Entity")]
Products,
}
impl Related<super::products::Entity> for Entity {

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

@@ -1,5 +1,4 @@
//! `SeaORM` Entity for customer shipping/contact profiles. Hand-written to match
//! the `customer_profiles` migration (1:1 with `users` via a unique `user_id`).
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
@@ -11,18 +10,18 @@ pub struct Model {
pub updated_at: DateTimeWithTimeZone,
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub user_id: i32,
pub company_name: Option<String>,
pub company_id: Option<String>,
pub tax_id: Option<String>,
pub vat_id: Option<String>,
pub phone_prefix: Option<String>,
pub phone: Option<String>,
pub address: Option<String>,
pub city: Option<String>,
pub zip: Option<String>,
pub country: Option<String>,
#[sea_orm(unique)]
pub user_id: i32,
pub company_name: Option<String>,
pub company_id: Option<String>,
pub tax_id: Option<String>,
pub vat_id: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -0,0 +1,47 @@
//! `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 = "discount_profile_products")]
pub struct Model {
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
#[sea_orm(primary_key)]
pub id: i32,
pub discount_profile_id: i32,
pub product_id: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::discount_profiles::Entity",
from = "Column::DiscountProfileId",
to = "super::discount_profiles::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
DiscountProfiles,
#[sea_orm(
belongs_to = "super::products::Entity",
from = "Column::ProductId",
to = "super::products::Column::Id",
on_update = "Cascade",
on_delete = "Cascade"
)]
Products,
}
impl Related<super::discount_profiles::Entity> for Entity {
fn to() -> RelationDef {
Relation::DiscountProfiles.def()
}
}
impl Related<super::products::Entity> for Entity {
fn to() -> RelationDef {
Relation::Products.def()
}
}

View File

@@ -0,0 +1,52 @@
//! `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 = "discount_profiles")]
pub struct Model {
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
#[sea_orm(primary_key)]
pub id: i32,
pub name: String,
pub percent_bp: i32,
pub scope_type: String,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(has_many = "super::account_discount_profiles::Entity")]
AccountDiscountProfiles,
#[sea_orm(has_many = "super::account_product_resolutions::Entity")]
AccountProductResolutions,
#[sea_orm(has_many = "super::audience_discount_profiles::Entity")]
AudienceDiscountProfiles,
#[sea_orm(has_many = "super::discount_profile_products::Entity")]
DiscountProfileProducts,
}
impl Related<super::account_discount_profiles::Entity> for Entity {
fn to() -> RelationDef {
Relation::AccountDiscountProfiles.def()
}
}
impl Related<super::account_product_resolutions::Entity> for Entity {
fn to() -> RelationDef {
Relation::AccountProductResolutions.def()
}
}
impl Related<super::audience_discount_profiles::Entity> for Entity {
fn to() -> RelationDef {
Relation::AudienceDiscountProfiles.def()
}
}
impl Related<super::discount_profile_products::Entity> for Entity {
fn to() -> RelationDef {
Relation::DiscountProfileProducts.def()
}
}

View File

@@ -1,16 +1,24 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
pub mod prelude;
pub mod account_discount_profiles;
pub mod account_product_prices;
pub mod account_product_resolutions;
pub mod audience_discount_profiles;
pub mod audit_logs;
pub mod categories;
pub mod currencies;
pub mod customer_profiles;
pub mod discount_profile_products;
pub mod discount_profiles;
pub mod o_auth2_sessions;
pub mod order_items;
pub mod orders;
pub mod product_images;
pub mod product_product_tags;
pub mod product_tags;
pub mod product_variants;
pub mod products;
pub mod shipping_methods;
pub mod users;

View File

@@ -1,6 +1,4 @@
//! `SeaORM` Entity for loco-oauth2 sessions. Hand-written to match the
//! `o_auth2_sessions` migration (the rest of `_entities/` is codegen; this table
//! is owned by the loco-oauth2 integration).
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
@@ -8,12 +6,13 @@ use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "o_auth2_sessions")]
pub struct Model {
pub created_at: DateTimeUtc,
pub updated_at: DateTimeUtc,
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub session_id: String,
pub expires_at: DateTimeUtc,
pub expires_at: DateTimeWithTimeZone,
pub user_id: i32,
}

View File

@@ -1,4 +1,4 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
@@ -15,6 +15,8 @@ pub struct Model {
pub quantity: i32,
pub order_id: i32,
pub product_id: Option<i32>,
pub variant_label: String,
pub variant_id: Option<i32>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
@@ -27,6 +29,14 @@ pub enum Relation {
on_delete = "Cascade"
)]
Orders,
#[sea_orm(
belongs_to = "super::product_variants::Entity",
from = "Column::VariantId",
to = "super::product_variants::Column::Id",
on_update = "NoAction",
on_delete = "SetNull"
)]
ProductVariants,
#[sea_orm(
belongs_to = "super::products::Entity",
from = "Column::ProductId",
@@ -43,6 +53,12 @@ impl Related<super::orders::Entity> for Entity {
}
}
impl Related<super::product_variants::Entity> for Entity {
fn to() -> RelationDef {
Relation::ProductVariants.def()
}
}
impl Related<super::products::Entity> for Entity {
fn to() -> RelationDef {
Relation::Products.def()

View File

@@ -1,4 +1,4 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
@@ -13,17 +13,9 @@ pub struct Model {
#[sea_orm(unique)]
pub order_number: String,
pub email: String,
pub phone: Option<String>,
pub customer_name: Option<String>,
pub status: String,
pub total_cents: i64,
pub currency: String,
pub user_id: Option<i32>,
pub account_type: String,
pub company_name: Option<String>,
pub company_id: Option<String>,
pub tax_id: Option<String>,
pub vat_id: Option<String>,
pub address: Option<String>,
pub city: Option<String>,
pub zip: Option<String>,
@@ -39,6 +31,13 @@ pub struct Model {
pub tracking_number: Option<String>,
pub shipment_id: Option<String>,
pub label_url: Option<String>,
pub phone: Option<String>,
pub account_type: String,
pub company_name: Option<String>,
pub company_id: Option<String>,
pub tax_id: Option<String>,
pub vat_id: Option<String>,
pub user_id: Option<i32>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -1,14 +1,22 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
pub use super::account_discount_profiles::Entity as AccountDiscountProfiles;
pub use super::account_product_prices::Entity as AccountProductPrices;
pub use super::account_product_resolutions::Entity as AccountProductResolutions;
pub use super::audience_discount_profiles::Entity as AudienceDiscountProfiles;
pub use super::audit_logs::Entity as AuditLogs;
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::discount_profile_products::Entity as DiscountProfileProducts;
pub use super::discount_profiles::Entity as DiscountProfiles;
pub use super::o_auth2_sessions::Entity as OAuth2Sessions;
pub use super::order_items::Entity as OrderItems;
pub use super::orders::Entity as Orders;
pub use super::product_images::Entity as ProductImages;
pub use super::product_product_tags::Entity as ProductProductTags;
pub use super::product_tags::Entity as ProductTags;
pub use super::product_variants::Entity as ProductVariants;
pub use super::products::Entity as Products;
pub use super::shipping_methods::Entity as ShippingMethods;
pub use super::users::Entity as Users;

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