Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e5c84e631f | ||
|
|
0f3189ca26 | ||
|
|
f4c66936c0 | ||
|
|
4a5e0404c7 | ||
|
|
80f3e7d48e | ||
|
|
97c4c23af1 | ||
|
|
269bb15e6f | ||
|
|
da2c487dc4 | ||
|
|
c549e2bc03 | ||
|
|
9bdf91e717 | ||
|
|
d1f9838890 | ||
|
|
e8d8aafd97 | ||
|
|
5001e46866 | ||
|
|
5dcc8028b2 | ||
|
|
3df88b4cee | ||
|
|
ba02930454 | ||
|
|
1fc8796389 | ||
|
|
e5ec2a2de6 | ||
|
|
70908cba8b | ||
|
|
6b3739d629 | ||
|
|
f3b920d4b2 | ||
|
|
caec8b4fb3 | ||
|
|
d6d4f19010 | ||
|
|
77066d660c | ||
|
|
3aa5f63264 | ||
|
|
f04691a733 | ||
|
|
6dd1164c65 | ||
|
|
5f7ddce6a7 | ||
|
|
2023b24d92 | ||
|
|
aea4782e68 | ||
|
|
0c0cae2355 | ||
|
|
194e9e2de3 | ||
|
|
848042c304 | ||
|
|
ee8ec5c85b | ||
|
|
a53bd720bd | ||
|
|
2ed069ea63 | ||
|
|
c0f4d0c93c | ||
|
|
d68ed5ce7c | ||
|
|
72babdf74f | ||
|
|
8dd9a53ad8 | ||
|
|
aae8083de1 | ||
|
|
3159c5b30b | ||
|
|
f51875d5f4 | ||
|
|
d3d1c0d157 | ||
|
|
a34fd1725b | ||
|
|
f665eee96e | ||
|
|
ac31cdfbf3 | ||
|
|
c409e85995 | ||
|
|
6b7422806f | ||
|
|
8085052b2b | ||
|
|
1cf330e4e8 | ||
|
|
031f86adb0 | ||
|
|
96c428eadd | ||
|
|
5e6263e853 | ||
|
|
5a474f3474 | ||
|
|
1e66bfd657 | ||
|
|
f512fbbb94 | ||
|
|
1ecfac2ad6 | ||
|
|
3b9c2f7d64 | ||
|
|
e5cac27010 | ||
|
|
a45f9ef030 | ||
|
|
51155f2fd2 | ||
|
|
2d2aa012ec | ||
|
|
125be1798e | ||
|
|
f724e9763f | ||
|
|
681c88f85d |
@@ -37,29 +37,43 @@
|
|||||||
* dark:bg-surface-dark, border-outline, etc.
|
* dark:bg-surface-dark, border-outline, etc.
|
||||||
* ============================================================ */
|
* ============================================================ */
|
||||||
@theme {
|
@theme {
|
||||||
/* light mode */
|
/* light mode — Catppuccin Latte (https://catppuccin.com/palette)
|
||||||
--color-surface: var(--color-white);
|
* Base #eff1f5, Mantle #e6e9ef, Surface1 #bcc0cc, Subtext1 #5c5f77,
|
||||||
--color-surface-alt: var(--color-slate-100);
|
* Subtext0 #6c6f85, Text #4c4f69. Primary is the KOMPRESS logo blue
|
||||||
--color-on-surface: var(--color-slate-700);
|
* (sampled from logo.jpg) rather than the Latte blue. */
|
||||||
--color-on-surface-strong: var(--color-slate-900);
|
--color-surface: #eff1f5; /* Base */
|
||||||
--color-primary: var(--color-indigo-600);
|
--color-surface-alt: #e6e9ef; /* Mantle */
|
||||||
--color-on-primary: var(--color-white);
|
--color-on-surface: #5c5f77; /* Subtext1 */
|
||||||
--color-secondary: var(--color-slate-600);
|
--color-on-surface-strong: #4c4f69; /* Text */
|
||||||
--color-on-secondary: var(--color-white);
|
--color-primary: #1600ff; /* KOMPRESS logo blue */
|
||||||
--color-outline: var(--color-slate-300);
|
--color-on-primary: #eff1f5; /* Base */
|
||||||
--color-outline-strong: var(--color-slate-800);
|
--color-secondary: #6c6f85; /* Subtext0 */
|
||||||
|
--color-on-secondary: #eff1f5; /* Base */
|
||||||
|
--color-outline: #bcc0cc; /* Surface1 */
|
||||||
|
--color-outline-strong: #4c4f69; /* Text */
|
||||||
|
/* CTA: solid fill for large/filled buttons + the contact block. The vivid
|
||||||
|
* logo blue (--color-primary) is reserved for tiny accents (links, hover
|
||||||
|
* tints, badges); the CTA color is the logo blue itself, just with alpha so
|
||||||
|
* big buttons read as a translucent tint rather than the full vivid fill. */
|
||||||
|
--color-cta: rgba(22, 0, 255, 0.85);
|
||||||
|
--color-on-cta: #eff1f5;
|
||||||
|
|
||||||
/* dark mode */
|
/* dark mode — Gruvbox dark palette (https://github.com/morhetz/gruvbox)
|
||||||
--color-surface-dark: var(--color-slate-900);
|
* bg0 #282828, bg1 #3c3836, bg2 #504945, fg0 #fbf1c7, fg1 #ebdbb2,
|
||||||
--color-surface-dark-alt: var(--color-slate-800);
|
* fg2 #d5c4a1, fg3 #bdae93, bright blue #83a598, bg0_h #1d2021. */
|
||||||
--color-on-surface-dark: var(--color-slate-300);
|
--color-surface-dark: #282828; /* bg0 */
|
||||||
--color-on-surface-dark-strong: var(--color-white);
|
--color-surface-dark-alt: #3c3836; /* bg1 */
|
||||||
--color-primary-dark: var(--color-indigo-400);
|
--color-on-surface-dark: #ebdbb2; /* fg1 */
|
||||||
--color-on-primary-dark: var(--color-slate-950);
|
--color-on-surface-dark-strong: #fbf1c7; /* fg0 */
|
||||||
--color-secondary-dark: var(--color-slate-300);
|
--color-primary-dark: #83a598; /* bright blue */
|
||||||
--color-on-secondary-dark: var(--color-slate-950);
|
--color-on-primary-dark: #1d2021; /* bg0_h */
|
||||||
--color-outline-dark: var(--color-slate-700);
|
--color-secondary-dark: #d5c4a1; /* fg2 */
|
||||||
--color-outline-dark-strong: var(--color-slate-300);
|
--color-on-secondary-dark: #1d2021; /* bg0_h */
|
||||||
|
--color-outline-dark: #504945; /* bg2 */
|
||||||
|
--color-outline-dark-strong: #bdae93; /* fg3 */
|
||||||
|
/* CTA in dark mode tracks the existing primary so dark buttons are unchanged. */
|
||||||
|
--color-cta-dark: #83a598; /* = primary-dark */
|
||||||
|
--color-on-cta-dark: #1d2021; /* = on-primary-dark */
|
||||||
|
|
||||||
/* shared status colors (same in both modes) */
|
/* shared status colors (same in both modes) */
|
||||||
--color-info: var(--color-sky-500);
|
--color-info: var(--color-sky-500);
|
||||||
@@ -77,3 +91,110 @@
|
|||||||
|
|
||||||
/* Hide Alpine x-cloak elements until Alpine initializes. */
|
/* Hide Alpine x-cloak elements until Alpine initializes. */
|
||||||
[x-cloak] { display: none !important; }
|
[x-cloak] { display: none !important; }
|
||||||
|
|
||||||
|
/* === Rich text editor (Quill "snow") =======================
|
||||||
|
* Vendored Quill (assets/static/vendor/quill) drives the admin
|
||||||
|
* product short/long description fields. Stock snow already suits
|
||||||
|
* the light theme; the admin panel is locked to data-theme="dark",
|
||||||
|
* so the rules below repaint the toolbar/editor for dark. Editor
|
||||||
|
* height is per-instance via the --rich-min-height custom prop set
|
||||||
|
* by the ui::rich_editor macro.
|
||||||
|
* ============================================================ */
|
||||||
|
.rich-editor .ql-editor {
|
||||||
|
min-height: var(--rich-min-height, 12rem);
|
||||||
|
font-size: 0.95rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
.ql-editor.ql-blank::before { font-style: normal; }
|
||||||
|
|
||||||
|
[data-theme="dark"] .rich-editor { background: var(--color-surface-dark-alt); }
|
||||||
|
[data-theme="dark"] .ql-toolbar.ql-snow,
|
||||||
|
[data-theme="dark"] .ql-container.ql-snow { border-color: var(--color-outline-dark); }
|
||||||
|
[data-theme="dark"] .ql-toolbar.ql-snow { background: var(--color-surface-dark); }
|
||||||
|
[data-theme="dark"] .ql-container.ql-snow { color: var(--color-on-surface-dark); }
|
||||||
|
[data-theme="dark"] .ql-snow .ql-stroke,
|
||||||
|
[data-theme="dark"] .ql-snow .ql-stroke-miter { stroke: var(--color-on-surface-dark); }
|
||||||
|
[data-theme="dark"] .ql-snow .ql-fill,
|
||||||
|
[data-theme="dark"] .ql-snow .ql-stroke.ql-fill { fill: var(--color-on-surface-dark); }
|
||||||
|
[data-theme="dark"] .ql-snow .ql-picker { color: var(--color-on-surface-dark); }
|
||||||
|
[data-theme="dark"] .ql-snow .ql-picker-options {
|
||||||
|
background: var(--color-surface-dark);
|
||||||
|
border-color: var(--color-outline-dark);
|
||||||
|
}
|
||||||
|
/* active / hover toolbar state -> primary accent */
|
||||||
|
[data-theme="dark"] .ql-snow.ql-toolbar button:hover,
|
||||||
|
[data-theme="dark"] .ql-snow.ql-toolbar button.ql-active,
|
||||||
|
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-label:hover,
|
||||||
|
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-label.ql-active,
|
||||||
|
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-item:hover,
|
||||||
|
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-item.ql-selected { color: var(--color-primary-dark); }
|
||||||
|
[data-theme="dark"] .ql-snow.ql-toolbar button:hover .ql-stroke,
|
||||||
|
[data-theme="dark"] .ql-snow.ql-toolbar button.ql-active .ql-stroke,
|
||||||
|
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-label:hover .ql-stroke,
|
||||||
|
[data-theme="dark"] .ql-snow.ql-toolbar .ql-picker-label.ql-active .ql-stroke { stroke: var(--color-primary-dark); }
|
||||||
|
[data-theme="dark"] .ql-snow.ql-toolbar button:hover .ql-fill,
|
||||||
|
[data-theme="dark"] .ql-snow.ql-toolbar button.ql-active .ql-fill { fill: var(--color-primary-dark); }
|
||||||
|
[data-theme="dark"] .ql-snow .ql-tooltip {
|
||||||
|
background-color: var(--color-surface-dark);
|
||||||
|
border-color: var(--color-outline-dark);
|
||||||
|
color: var(--color-on-surface-dark);
|
||||||
|
box-shadow: 0 2px 8px rgb(0 0 0 / 0.45);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .ql-snow .ql-tooltip input[type=text] {
|
||||||
|
background: var(--color-surface-dark-alt);
|
||||||
|
border-color: var(--color-outline-dark);
|
||||||
|
color: var(--color-on-surface-dark);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .ql-snow .ql-editor a,
|
||||||
|
[data-theme="dark"] .ql-snow .ql-tooltip a { color: var(--color-primary-dark); }
|
||||||
|
|
||||||
|
/* Image size controls under the editor. */
|
||||||
|
.rich-image-size-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
|
||||||
|
.rich-image-size-controls.hidden { display: none; }
|
||||||
|
.rich-image-size-controls button {
|
||||||
|
border: 1px solid var(--color-outline);
|
||||||
|
border-radius: var(--radius-radius);
|
||||||
|
padding: 0.3rem 0.65rem;
|
||||||
|
line-height: 1;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
.rich-image-size-controls button:hover { border-color: var(--color-primary); color: var(--color-primary); }
|
||||||
|
[data-theme="dark"] .rich-image-size-controls button { border-color: var(--color-outline-dark); }
|
||||||
|
[data-theme="dark"] .rich-image-size-controls button:hover {
|
||||||
|
border-color: var(--color-primary-dark);
|
||||||
|
color: var(--color-primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image sizing classes shared by the editor and rendered output. */
|
||||||
|
.rich-editor img, .rich-content img {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
margin: 1rem auto;
|
||||||
|
border-radius: var(--radius-radius);
|
||||||
|
}
|
||||||
|
.rich-editor img { cursor: pointer; }
|
||||||
|
.rich-image-small { width: min(100%, 18rem); }
|
||||||
|
.rich-image-medium { width: min(100%, 34rem); }
|
||||||
|
.rich-image-full { width: 100%; }
|
||||||
|
|
||||||
|
/* === Rendered rich content (storefront product description) =
|
||||||
|
* Inherits text color from context so it works in both themes;
|
||||||
|
* only structural spacing + link/heading treatment is set here. */
|
||||||
|
.rich-content { line-height: 1.7; }
|
||||||
|
.rich-content h2 { margin: 1.25rem 0 0.6rem; font-size: 1.3rem; font-weight: 700; }
|
||||||
|
.rich-content h3 { margin: 1rem 0 0.5rem; font-size: 1.1rem; font-weight: 700; }
|
||||||
|
.rich-content p, .rich-content ul, .rich-content ol { margin: 0.6rem 0; }
|
||||||
|
.rich-content ul { list-style: disc; padding-left: 1.4rem; }
|
||||||
|
.rich-content ol { list-style: decimal; padding-left: 1.4rem; }
|
||||||
|
.rich-content a { color: var(--color-primary); text-decoration: underline; }
|
||||||
|
[data-theme="dark"] .rich-content a { color: var(--color-primary-dark); }
|
||||||
|
.rich-content :first-child { margin-top: 0; }
|
||||||
|
.rich-content :last-child { margin-bottom: 0; }
|
||||||
|
.rich-summary :where(p) { display: inline; margin: 0; }
|
||||||
|
.rich-summary .product-more-link { margin-left: 0.25rem; }
|
||||||
|
|
||||||
|
/* Compact rich blurb for product cards: neutralize block spacing so the
|
||||||
|
* line-clamp truncation stays tidy regardless of the authored markup. */
|
||||||
|
.rich-blurb :where(p, ul, ol, h2, h3) { margin: 0; }
|
||||||
|
.rich-blurb :where(ul, ol) { padding-left: 1.1rem; }
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
brand = Kompress eshop
|
brand = WWW.KOMPRESS.SK, s.r.o.
|
||||||
hello-world = Hello world!
|
hello-world = Hello world!
|
||||||
meta-description = Kompress eshop
|
meta-description = Manufacturer and distributor of medical aids and supplies
|
||||||
nav-home = Home
|
nav-home = Home
|
||||||
nav-about = About
|
nav-about = About
|
||||||
nav-blog = Blog
|
nav-blog = Blog
|
||||||
@@ -152,6 +152,7 @@ artist = Artist
|
|||||||
release-date = Release date
|
release-date = Release date
|
||||||
cover-image = Cover image
|
cover-image = Cover image
|
||||||
description = Description
|
description = Description
|
||||||
|
product-more = more
|
||||||
songs-in-album = Songs in this album
|
songs-in-album = Songs in this album
|
||||||
admin-new-album-desc = Fill in the details, then tick the songs to include.
|
admin-new-album-desc = Fill in the details, then tick the songs to include.
|
||||||
cover-help = Optional - png, jpg, webp or gif; shown on the album page.
|
cover-help = Optional - png, jpg, webp or gif; shown on the album page.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
brand = Kompress eshop
|
brand = WWW.KOMPRESS.SK, s.r.o.
|
||||||
hello-world = Hello world!
|
hello-world = Hello world!
|
||||||
meta-description = Kompress eshop
|
meta-description = Manufacturer and distributor of medical aids and supplies
|
||||||
nav-home = Home
|
nav-home = Home
|
||||||
nav-about = About
|
nav-about = About
|
||||||
nav-blog = Blog
|
nav-blog = Blog
|
||||||
@@ -20,6 +20,7 @@ admin-audio-desc = upload songs, then group them into albums.
|
|||||||
logout = Log out
|
logout = Log out
|
||||||
settings = Settings
|
settings = Settings
|
||||||
settings-language = Language
|
settings-language = Language
|
||||||
|
settings-currency = Currency
|
||||||
settings-theme = Theme
|
settings-theme = Theme
|
||||||
language-en = English
|
language-en = English
|
||||||
language-sk = Slovak
|
language-sk = Slovak
|
||||||
@@ -171,6 +172,9 @@ artist = Artist
|
|||||||
release-date = Release date
|
release-date = Release date
|
||||||
cover-image = Cover image
|
cover-image = Cover image
|
||||||
description = Description
|
description = Description
|
||||||
|
product-more = more
|
||||||
|
short-description = Short description
|
||||||
|
short-description-hint = Shown on product cards. Keep it short.
|
||||||
songs-in-album = Songs in this album
|
songs-in-album = Songs in this album
|
||||||
admin-new-album-desc = Fill in the details, then tick the songs to include.
|
admin-new-album-desc = Fill in the details, then tick the songs to include.
|
||||||
cover-help = Optional - png, jpg, webp or gif; shown on the album page.
|
cover-help = Optional - png, jpg, webp or gif; shown on the album page.
|
||||||
@@ -213,7 +217,9 @@ variants-options = Variants / options
|
|||||||
add-option = Add option
|
add-option = Add option
|
||||||
option-label = Option label
|
option-label = Option label
|
||||||
optional = optional
|
optional = optional
|
||||||
choose-option = Choose an option
|
stock-untracked-hint = Leave blank = available without stock tracking
|
||||||
|
available = Available
|
||||||
|
choose-option = Options
|
||||||
from-price = from { $price }
|
from-price = from { $price }
|
||||||
admin-discounts = Discounts
|
admin-discounts = Discounts
|
||||||
admin-discounts-desc = Set discounted product prices. A discount shows up as a sale in the shop.
|
admin-discounts-desc = Set discounted product prices. A discount shows up as a sale in the shop.
|
||||||
@@ -283,6 +289,10 @@ currency = Currency
|
|||||||
category = Category
|
category = Category
|
||||||
no-category = No category
|
no-category = No category
|
||||||
image = Image
|
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 = URL slug
|
||||||
slug-auto = generated automatically
|
slug-auto = generated automatically
|
||||||
position = Position
|
position = Position
|
||||||
@@ -291,7 +301,7 @@ position-hint = Sort order in the menu (lowest first). Leave blank to add it las
|
|||||||
parent-category = Parent category
|
parent-category = Parent category
|
||||||
no-parent = — None (top level) —
|
no-parent = — None (top level) —
|
||||||
quantity = Quantity
|
quantity = Quantity
|
||||||
add-to-cart = Add to cart
|
add-to-cart = To cart
|
||||||
cart-added = Added to cart
|
cart-added = Added to cart
|
||||||
in-stock = In stock
|
in-stock = In stock
|
||||||
out-of-stock = Out of stock
|
out-of-stock = Out of stock
|
||||||
@@ -301,6 +311,33 @@ confirm-delete = Delete this for good?
|
|||||||
shop-title = Shop
|
shop-title = Shop
|
||||||
shop-subtitle = browse our products.
|
shop-subtitle = browse our products.
|
||||||
shop-empty = There are no products here yet.
|
shop-empty = There are no products here yet.
|
||||||
|
search-placeholder = Search products…
|
||||||
|
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: A–Z
|
||||||
|
sort-name_desc = Name: Z–A
|
||||||
|
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
|
categories = Categories
|
||||||
all-products = All products
|
all-products = All products
|
||||||
uncategorized = Uncategorized
|
uncategorized = Uncategorized
|
||||||
@@ -314,7 +351,9 @@ cart-update = Update
|
|||||||
cart-continue = Continue shopping
|
cart-continue = Continue shopping
|
||||||
checkout-title = Checkout
|
checkout-title = Checkout
|
||||||
checkout-contact = Contact details
|
checkout-contact = Contact details
|
||||||
checkout-shipping = Shipping address
|
checkout-shipping = Delivery address
|
||||||
|
checkout-residence-address = Residence address
|
||||||
|
checkout-delivery-same = Delivery address is the same as residence address
|
||||||
checkout-email = Email
|
checkout-email = Email
|
||||||
checkout-name = Full name
|
checkout-name = Full name
|
||||||
checkout-phone = Phone
|
checkout-phone = Phone
|
||||||
@@ -329,7 +368,8 @@ country-de = Germany
|
|||||||
country-pl = Poland
|
country-pl = Poland
|
||||||
country-hu = Hungary
|
country-hu = Hungary
|
||||||
checkout-note = Order note
|
checkout-note = Order note
|
||||||
checkout-save-profile = Save this address to my profile
|
checkout-save-profile = Save residence address to my profile
|
||||||
|
payment-none = No payment method is currently available.
|
||||||
account-type = Account type
|
account-type = Account type
|
||||||
account-personal = Individual
|
account-personal = Individual
|
||||||
account-company = Company
|
account-company = Company
|
||||||
@@ -351,6 +391,11 @@ profile-last-name = Surname
|
|||||||
profile-edit = Edit profile
|
profile-edit = Edit profile
|
||||||
profile-cancel = Cancel
|
profile-cancel = Cancel
|
||||||
profile-not-set = Not set
|
profile-not-set = Not set
|
||||||
|
profile-avatar = Profile picture
|
||||||
|
profile-avatar-hint = PNG, JPG, WEBP or GIF, up to 10 MB.
|
||||||
|
profile-avatar-choose = Choose a picture
|
||||||
|
profile-avatar-upload = Upload
|
||||||
|
profile-avatar-remove = Remove picture
|
||||||
nav-account = My account
|
nav-account = My account
|
||||||
account-orders = My orders
|
account-orders = My orders
|
||||||
account-change-password = Change password
|
account-change-password = Change password
|
||||||
@@ -440,7 +485,21 @@ bank-variable-symbol = Variable symbol
|
|||||||
bank-amount = Amount
|
bank-amount = Amount
|
||||||
admin-shipping = Shipping
|
admin-shipping = Shipping
|
||||||
admin-shipping-desc = set the price and availability of each delivery option.
|
admin-shipping-desc = set the price and availability of each delivery option.
|
||||||
|
shipping-packeta-missing-settings = Packeta can be enabled after PACKETA_API_KEY, PACKETA_API_PASSWORD and PACKETA_SENDER_LABEL are configured.
|
||||||
|
admin-payments = Payments
|
||||||
|
admin-payments-desc = enable or disable payment methods and edit bank-transfer details.
|
||||||
|
payment-methods = Payment methods
|
||||||
|
payment-enabled = Active
|
||||||
|
payment-bank-settings = Bank transfer details
|
||||||
shipping-enabled = Active
|
shipping-enabled = Active
|
||||||
|
admin-currency = Exchange rate
|
||||||
|
admin-currency-desc = set the exchange rate for the currencies customers can switch between. You always enter prices in EUR.
|
||||||
|
currency-rate = Rate
|
||||||
|
exchange-rate = Exchange rate
|
||||||
|
exchange-rate-hint = { $code } prices are the { $base } price recalculated at this rate.
|
||||||
|
currency-enabled = Available to customers
|
||||||
|
currency-base = Base currency
|
||||||
|
currency-base-hint = the currency you enter prices in and settle payment in. Cannot be changed.
|
||||||
shipping-new = Add delivery option
|
shipping-new = Add delivery option
|
||||||
shipping-add = Add
|
shipping-add = Add
|
||||||
shipping-requires-pickup = Requires pickup point
|
shipping-requires-pickup = Requires pickup point
|
||||||
@@ -457,3 +516,43 @@ order-manual-fulfillment = Manual fulfilment — no carrier API for this option.
|
|||||||
order-send-hint = When the goods are ready, send this order to the carrier.
|
order-send-hint = When the goods are ready, send this order to the carrier.
|
||||||
order-send-to-carrier = Send to
|
order-send-to-carrier = Send to
|
||||||
order-send-confirm = Send this order to the carrier now?
|
order-send-confirm = Send this order to the carrier now?
|
||||||
|
|
||||||
|
# --- storefront chrome: top bar, header, footer ---
|
||||||
|
brand-subtitle = medical supplies
|
||||||
|
top-contact = Contact
|
||||||
|
top-sitemap = Sitemap
|
||||||
|
search-button = Search
|
||||||
|
search-scope-in = Searching in:
|
||||||
|
search-scope-all = Search the whole shop
|
||||||
|
welcome = Welcome
|
||||||
|
cart-units = items
|
||||||
|
hotline = +421 903 410 476
|
||||||
|
footer-tagline = Medical supplies for clinics, hospitals and home care. Delivery within 24 hours.
|
||||||
|
footer-info = Information
|
||||||
|
footer-account = Account
|
||||||
|
footer-contact = Contact
|
||||||
|
footer-terms = Terms and conditions
|
||||||
|
footer-about = About our company
|
||||||
|
footer-stores = Where it's made
|
||||||
|
home-stores-photo = Our production facility
|
||||||
|
home-stores-discover = Step inside our workshop ›
|
||||||
|
page-stores-intro = This is our own facility where our medical aids and supplies are produced.
|
||||||
|
page-stores-facility = Production facility
|
||||||
|
page-stores-address-label = Facility address
|
||||||
|
page-stores-address = Nádražná 328/62, 015 01 Rajec nad Rajčankou
|
||||||
|
page-stores-photo-caption = Our production facility in Rajec nad Rajčankou
|
||||||
|
page-stores-map = Where to find us
|
||||||
|
page-stores-map-open = Open in Google Maps ›
|
||||||
|
home-bestsellers = Best sellers
|
||||||
|
home-bestsellers-all = All best sellers ›
|
||||||
|
home-contact-title = Contact us
|
||||||
|
home-contact-text = Our hotline is available 24/7. We're happy to help you choose.
|
||||||
|
home-contact-cta = Contact the hotline
|
||||||
|
footer-shipping = Shipping and payment
|
||||||
|
footer-orders = My orders
|
||||||
|
footer-email = info@kompress.sk
|
||||||
|
footer-hours = Mon–Fri 8:00–16: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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
brand = Kompress eshop
|
brand = WWW.KOMPRESS.SK, s.r.o.
|
||||||
hello-world = Ahoj svet!
|
hello-world = Ahoj svet!
|
||||||
meta-description = Kompress eshop
|
meta-description = Výrobca a distribútor zdravotníckych pomôcok a potrieb
|
||||||
nav-home = Domov
|
nav-home = Domov
|
||||||
nav-about = O mne
|
nav-about = O mne
|
||||||
nav-blog = Blog
|
nav-blog = Blog
|
||||||
@@ -20,6 +20,7 @@ admin-audio-desc = nahrať skladby a potom ich zoskupiť do albumov.
|
|||||||
logout = Odhlásiť sa
|
logout = Odhlásiť sa
|
||||||
settings = Nastavenia
|
settings = Nastavenia
|
||||||
settings-language = Jazyk
|
settings-language = Jazyk
|
||||||
|
settings-currency = Mena
|
||||||
settings-theme = Téma
|
settings-theme = Téma
|
||||||
language-en = Angličtina
|
language-en = Angličtina
|
||||||
language-sk = Slovenčina
|
language-sk = Slovenčina
|
||||||
@@ -171,6 +172,9 @@ artist = Interpret
|
|||||||
release-date = Dátum vydania
|
release-date = Dátum vydania
|
||||||
cover-image = Obrázok obalu
|
cover-image = Obrázok obalu
|
||||||
description = Popis
|
description = Popis
|
||||||
|
product-more = viac
|
||||||
|
short-description = Krátky popis
|
||||||
|
short-description-hint = Zobrazuje sa na kartách produktov. Najlepšie krátke.
|
||||||
songs-in-album = Skladby v albume
|
songs-in-album = Skladby v albume
|
||||||
admin-new-album-desc = Vyplň údaje a potom označ skladby, ktoré chceš zahrnúť.
|
admin-new-album-desc = Vyplň údaje a potom označ skladby, ktoré chceš zahrnúť.
|
||||||
cover-help = Voliteľné - png, jpg, webp alebo gif; zobrazí sa na stránke albumu.
|
cover-help = Voliteľné - png, jpg, webp alebo gif; zobrazí sa na stránke albumu.
|
||||||
@@ -213,7 +217,9 @@ variants-options = Varianty / možnosti
|
|||||||
add-option = Pridať možnosť
|
add-option = Pridať možnosť
|
||||||
option-label = Označenie možnosti
|
option-label = Označenie možnosti
|
||||||
optional = voliteľné
|
optional = voliteľné
|
||||||
choose-option = Vyberte možnosť
|
stock-untracked-hint = Nechajte prázdne = dostupné bez sledovania zásob
|
||||||
|
available = Dostupné
|
||||||
|
choose-option = Options
|
||||||
from-price = od { $price }
|
from-price = od { $price }
|
||||||
admin-discounts = Zľavy
|
admin-discounts = Zľavy
|
||||||
admin-discounts-desc = Nastavte zľavnené ceny produktov. Zľava sa v obchode zobrazí ako akcia.
|
admin-discounts-desc = Nastavte zľavnené ceny produktov. Zľava sa v obchode zobrazí ako akcia.
|
||||||
@@ -283,6 +289,10 @@ currency = Mena
|
|||||||
category = Kategória
|
category = Kategória
|
||||||
no-category = Bez kategórie
|
no-category = Bez kategórie
|
||||||
image = Obrázok
|
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 = URL adresa
|
||||||
slug-auto = vygeneruje sa automaticky
|
slug-auto = vygeneruje sa automaticky
|
||||||
position = Poradie
|
position = Poradie
|
||||||
@@ -291,7 +301,7 @@ position-hint = Poradie v menu (najnižšie ako prvé). Nechajte prázdne a prid
|
|||||||
parent-category = Nadradená kategória
|
parent-category = Nadradená kategória
|
||||||
no-parent = — Žiadna (najvyššia úroveň) —
|
no-parent = — Žiadna (najvyššia úroveň) —
|
||||||
quantity = Množstvo
|
quantity = Množstvo
|
||||||
add-to-cart = Pridať do košíka
|
add-to-cart = Do košíka
|
||||||
cart-added = Pridané do košíka
|
cart-added = Pridané do košíka
|
||||||
in-stock = Na sklade
|
in-stock = Na sklade
|
||||||
out-of-stock = Vypredané
|
out-of-stock = Vypredané
|
||||||
@@ -301,6 +311,33 @@ confirm-delete = Naozaj zmazať?
|
|||||||
shop-title = Obchod
|
shop-title = Obchod
|
||||||
shop-subtitle = prezrite si našu ponuku produktov.
|
shop-subtitle = prezrite si našu ponuku produktov.
|
||||||
shop-empty = Zatiaľ tu nie sú žiadne produkty.
|
shop-empty = Zatiaľ tu nie sú žiadne produkty.
|
||||||
|
search-placeholder = Hľadať produkty…
|
||||||
|
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: A–Z
|
||||||
|
sort-name_desc = Názov: Z–A
|
||||||
|
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
|
categories = Kategórie
|
||||||
all-products = Všetky produkty
|
all-products = Všetky produkty
|
||||||
uncategorized = Bez kategórie
|
uncategorized = Bez kategórie
|
||||||
@@ -315,6 +352,8 @@ cart-continue = Pokračovať v nákupe
|
|||||||
checkout-title = Pokladňa
|
checkout-title = Pokladňa
|
||||||
checkout-contact = Kontaktné údaje
|
checkout-contact = Kontaktné údaje
|
||||||
checkout-shipping = Dodacia adresa
|
checkout-shipping = Dodacia adresa
|
||||||
|
checkout-residence-address = Adresa bydliska
|
||||||
|
checkout-delivery-same = Dodacia adresa je rovnaká ako adresa bydliska
|
||||||
checkout-email = E-mail
|
checkout-email = E-mail
|
||||||
checkout-name = Meno a priezvisko
|
checkout-name = Meno a priezvisko
|
||||||
checkout-phone = Telefón
|
checkout-phone = Telefón
|
||||||
@@ -329,7 +368,8 @@ country-de = Nemecko
|
|||||||
country-pl = Poľsko
|
country-pl = Poľsko
|
||||||
country-hu = Maďarsko
|
country-hu = Maďarsko
|
||||||
checkout-note = Poznámka k objednávke
|
checkout-note = Poznámka k objednávke
|
||||||
checkout-save-profile = Uložiť túto adresu do môjho profilu
|
checkout-save-profile = Uložiť adresu bydliska do môjho profilu
|
||||||
|
payment-none = Momentálne nie je dostupný žiadny spôsob platby.
|
||||||
account-type = Typ účtu
|
account-type = Typ účtu
|
||||||
account-personal = Súkromná osoba
|
account-personal = Súkromná osoba
|
||||||
account-company = Firma
|
account-company = Firma
|
||||||
@@ -351,6 +391,11 @@ profile-last-name = Priezvisko
|
|||||||
profile-edit = Upraviť profil
|
profile-edit = Upraviť profil
|
||||||
profile-cancel = Zrušiť
|
profile-cancel = Zrušiť
|
||||||
profile-not-set = Neuvedené
|
profile-not-set = Neuvedené
|
||||||
|
profile-avatar = Profilová fotka
|
||||||
|
profile-avatar-hint = PNG, JPG, WEBP alebo GIF, max. 10 MB.
|
||||||
|
profile-avatar-choose = Vybrať fotku
|
||||||
|
profile-avatar-upload = Nahrať
|
||||||
|
profile-avatar-remove = Odstrániť fotku
|
||||||
nav-account = Môj účet
|
nav-account = Môj účet
|
||||||
account-orders = Moje objednávky
|
account-orders = Moje objednávky
|
||||||
account-change-password = Zmeniť heslo
|
account-change-password = Zmeniť heslo
|
||||||
@@ -440,7 +485,21 @@ bank-variable-symbol = Variabilný symbol
|
|||||||
bank-amount = Suma
|
bank-amount = Suma
|
||||||
admin-shipping = Doprava
|
admin-shipping = Doprava
|
||||||
admin-shipping-desc = nastaviť cenu a dostupnosť jednotlivých možností dopravy.
|
admin-shipping-desc = nastaviť cenu a dostupnosť jednotlivých možností dopravy.
|
||||||
|
shipping-packeta-missing-settings = Packeta sa dá zapnúť až po nastavení PACKETA_API_KEY, PACKETA_API_PASSWORD a PACKETA_SENDER_LABEL.
|
||||||
|
admin-payments = Platby
|
||||||
|
admin-payments-desc = zapnite alebo vypnite spôsoby platby a upravte údaje pre prevod na účet.
|
||||||
|
payment-methods = Spôsoby platby
|
||||||
|
payment-enabled = Aktívne
|
||||||
|
payment-bank-settings = Údaje pre prevod na účet
|
||||||
shipping-enabled = Aktívne
|
shipping-enabled = Aktívne
|
||||||
|
admin-currency = Kurz
|
||||||
|
admin-currency-desc = nastaviť výmenný kurz pre meny, medzi ktorými môžu zákazníci prepínať. Ceny zadávate vždy v EUR.
|
||||||
|
currency-rate = Kurz
|
||||||
|
exchange-rate = Výmenný kurz
|
||||||
|
exchange-rate-hint = ceny v { $code } sa prepočítajú z ceny v { $base } týmto kurzom.
|
||||||
|
currency-enabled = Dostupná pre zákazníkov
|
||||||
|
currency-base = Základná mena
|
||||||
|
currency-base-hint = mena, v ktorej zadávate ceny a prebieha platba. Nedá sa zmeniť.
|
||||||
shipping-new = Pridať možnosť dopravy
|
shipping-new = Pridať možnosť dopravy
|
||||||
shipping-add = Pridať
|
shipping-add = Pridať
|
||||||
shipping-requires-pickup = Vyžaduje výdajné miesto
|
shipping-requires-pickup = Vyžaduje výdajné miesto
|
||||||
@@ -457,3 +516,43 @@ order-manual-fulfillment = Manuálne spracovanie — táto možnosť nemá API d
|
|||||||
order-send-hint = Keď je tovar pripravený, odošlite objednávku dopravcovi.
|
order-send-hint = Keď je tovar pripravený, odošlite objednávku dopravcovi.
|
||||||
order-send-to-carrier = Odoslať dopravcovi
|
order-send-to-carrier = Odoslať dopravcovi
|
||||||
order-send-confirm = Odoslať túto objednávku dopravcovi teraz?
|
order-send-confirm = Odoslať túto objednávku dopravcovi teraz?
|
||||||
|
|
||||||
|
# --- storefront chrome: top bar, header, footer ---
|
||||||
|
brand-subtitle = zdravotnícke potreby
|
||||||
|
top-contact = Kontakt
|
||||||
|
top-sitemap = Mapa stránky
|
||||||
|
search-button = Hľadať
|
||||||
|
search-scope-in = Hľadáte v kategórii:
|
||||||
|
search-scope-all = Hľadať v celom obchode
|
||||||
|
welcome = Vitajte
|
||||||
|
cart-units = ks
|
||||||
|
hotline = +421 903 410 476
|
||||||
|
footer-tagline = Zdravotnícke potreby pre ambulancie, nemocnice a domácu starostlivosť. Dodanie do 24 hodín.
|
||||||
|
footer-info = Informácie
|
||||||
|
footer-account = Účet
|
||||||
|
footer-contact = Kontakt
|
||||||
|
footer-terms = Obchodné podmienky
|
||||||
|
footer-about = O našej spoločnosti
|
||||||
|
footer-stores = Kde to vzniká
|
||||||
|
home-stores-photo = Naša výrobná prevádzka
|
||||||
|
home-stores-discover = Nahliadnite do výroby ›
|
||||||
|
page-stores-intro = Toto je naša vlastná prevádzka, kde vyrábame naše zdravotnícke pomôcky a potreby.
|
||||||
|
page-stores-facility = Výrobná prevádzka
|
||||||
|
page-stores-address-label = Adresa prevádzky
|
||||||
|
page-stores-address = Nádražná 328/62, 015 01 Rajec nad Rajčankou
|
||||||
|
page-stores-photo-caption = Naša výrobná prevádzka v Rajci nad Rajčankou
|
||||||
|
page-stores-map = Kde nás nájdete
|
||||||
|
page-stores-map-open = Otvoriť v Google Mapách ›
|
||||||
|
home-bestsellers = Najpredávanejšie
|
||||||
|
home-bestsellers-all = Všetko najpredávanejšie ›
|
||||||
|
home-contact-title = Kontaktujte nás
|
||||||
|
home-contact-text = Naša horúca linka je dostupná 24/7. Radi vám poradíme s výberom.
|
||||||
|
home-contact-cta = Kontaktujte hotline
|
||||||
|
footer-shipping = Doprava a platba
|
||||||
|
footer-orders = Moje objednávky
|
||||||
|
footer-email = info@kompress.sk
|
||||||
|
footer-hours = Po–Pia 8:00–16:00
|
||||||
|
footer-rights = © 2026 Kompress · Zdravotnícke potreby
|
||||||
|
page-coming-soon = Túto stránku práve pripravujeme. Medzitým nás môžete kontaktovať telefonicky alebo e-mailom.
|
||||||
|
page-contact-intro = Radi vám poradíme s výberom. Ozvite sa nám:
|
||||||
|
page-sitemap-intro = Prehľad hlavných sekcií obchodu.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
BIN
assets/static/img/logo.jpg
Normal file
BIN
assets/static/img/logo.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
BIN
assets/static/img/store.jpg
Normal file
BIN
assets/static/img/store.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
174
assets/static/js/rich-editor.js
Normal file
174
assets/static/js/rich-editor.js
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
// Quill-based rich text editor, ported from the universal_web blog editor and
|
||||||
|
// adapted to this shop: each editor lives in a `[data-rich-field]` wrapper so a
|
||||||
|
// single form can host several (e.g. short + long description); image uploads go
|
||||||
|
// to this app's /images/upload and carry the CSRF token the middleware expects.
|
||||||
|
(function () {
|
||||||
|
function setImageSize(image, size) {
|
||||||
|
image.classList.remove('rich-image-small', 'rich-image-medium', 'rich-image-full');
|
||||||
|
image.style.removeProperty('width');
|
||||||
|
image.style.removeProperty('height');
|
||||||
|
image.classList.add('rich-image-' + size);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setImageWidth(image, width) {
|
||||||
|
var px = parseInt(width, 10);
|
||||||
|
if (!Number.isFinite(px) || px < 40) return;
|
||||||
|
image.classList.remove('rich-image-small', 'rich-image-medium', 'rich-image-full');
|
||||||
|
image.style.width = Math.min(px, 1200) + 'px';
|
||||||
|
image.style.height = 'auto';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEditorImages(root) {
|
||||||
|
root.querySelectorAll('img').forEach(function (image) {
|
||||||
|
if (
|
||||||
|
!image.classList.contains('rich-image-small')
|
||||||
|
&& !image.classList.contains('rich-image-medium')
|
||||||
|
&& !image.classList.contains('rich-image-full')
|
||||||
|
) {
|
||||||
|
image.classList.add('rich-image-full');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// The CSRF middleware accepts the token as an X-CSRF-Token header; read it from
|
||||||
|
// the form's hidden _csrf field (rendered by ui::csrf_field()).
|
||||||
|
function csrfToken(field) {
|
||||||
|
var form = field.closest('form');
|
||||||
|
var input = form && form.querySelector('input[name="_csrf"]');
|
||||||
|
return input ? input.value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function initField(field) {
|
||||||
|
var editorEl = field.querySelector('[data-rich-editor]');
|
||||||
|
var contentInput = field.querySelector('[data-rich-content]');
|
||||||
|
var status = field.querySelector('[data-rich-status]');
|
||||||
|
var imageControls = field.querySelector('[data-image-size-controls]');
|
||||||
|
var imageWidthInput = field.querySelector('[data-image-width]');
|
||||||
|
if (!editorEl || !contentInput || !window.Quill) return;
|
||||||
|
|
||||||
|
var selectedImage = null;
|
||||||
|
var toolbar = [
|
||||||
|
[{ header: [2, 3, false] }],
|
||||||
|
['bold', 'italic'],
|
||||||
|
[{ list: 'ordered' }, { list: 'bullet' }],
|
||||||
|
['link', 'image'],
|
||||||
|
['clean']
|
||||||
|
];
|
||||||
|
var editor = new Quill(editorEl, {
|
||||||
|
modules: { toolbar: toolbar },
|
||||||
|
placeholder: editorEl.dataset.placeholder || '',
|
||||||
|
theme: 'snow'
|
||||||
|
});
|
||||||
|
|
||||||
|
var initialContent = contentInput.value.trim();
|
||||||
|
if (initialContent) {
|
||||||
|
if (initialContent.indexOf('<') >= 0) editor.clipboard.dangerouslyPasteHTML(initialContent);
|
||||||
|
else editor.setText(initialContent);
|
||||||
|
normalizeEditorImages(editor.root);
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncContent() {
|
||||||
|
normalizeEditorImages(editor.root);
|
||||||
|
// Quill leaves an empty editor as "<p><br></p>"; store empty instead so the
|
||||||
|
// server sees a blank (nullable) value rather than stray markup.
|
||||||
|
var html = editor.root.innerHTML;
|
||||||
|
contentInput.value = editor.getText().trim() === '' && !editor.root.querySelector('img')
|
||||||
|
? ''
|
||||||
|
: html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(message) {
|
||||||
|
if (status) status.textContent = message || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function chooseImageFile() {
|
||||||
|
var input = document.createElement('input');
|
||||||
|
input.type = 'file';
|
||||||
|
input.accept = 'image/jpeg,image/png,image/webp,image/gif';
|
||||||
|
input.addEventListener('change', function () {
|
||||||
|
var file = input.files && input.files[0];
|
||||||
|
if (!file) return;
|
||||||
|
uploadImage(file);
|
||||||
|
});
|
||||||
|
input.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadImage(file) {
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('file', file);
|
||||||
|
setStatus(status ? status.dataset.uploading : '');
|
||||||
|
try {
|
||||||
|
var response = await fetch('/images/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'X-CSRF-Token': csrfToken(field) }
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error('upload failed');
|
||||||
|
var result = await response.json();
|
||||||
|
var range = editor.getSelection(true);
|
||||||
|
editor.insertEmbed(range.index, 'image', result.url, 'user');
|
||||||
|
editor.setSelection(range.index + 1, 0, 'silent');
|
||||||
|
window.setTimeout(function () {
|
||||||
|
var images = editor.root.querySelectorAll('img');
|
||||||
|
var image = images[images.length - 1];
|
||||||
|
if (image) {
|
||||||
|
setImageSize(image, 'full');
|
||||||
|
selectedImage = image;
|
||||||
|
if (imageControls) imageControls.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
syncContent();
|
||||||
|
}, 0);
|
||||||
|
setStatus(status ? status.dataset.uploaded : '');
|
||||||
|
} catch (_error) {
|
||||||
|
setStatus(status ? status.dataset.error : '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.getModule('toolbar').addHandler('image', chooseImageFile);
|
||||||
|
|
||||||
|
editor.root.addEventListener('click', function (event) {
|
||||||
|
if (event.target && event.target.tagName === 'IMG') {
|
||||||
|
selectedImage = event.target;
|
||||||
|
if (imageWidthInput) imageWidthInput.value = parseInt(selectedImage.style.width, 10) || '';
|
||||||
|
if (imageControls) imageControls.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (imageControls) {
|
||||||
|
imageControls.addEventListener('click', function (event) {
|
||||||
|
var button = event.target.closest('[data-image-size]');
|
||||||
|
if (button && selectedImage) {
|
||||||
|
setImageSize(selectedImage, button.dataset.imageSize);
|
||||||
|
if (imageWidthInput) imageWidthInput.value = '';
|
||||||
|
syncContent();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imageWidthInput) {
|
||||||
|
imageWidthInput.addEventListener('change', function () {
|
||||||
|
if (!selectedImage) return;
|
||||||
|
setImageWidth(selectedImage, imageWidthInput.value);
|
||||||
|
syncContent();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
editor.on('text-change', syncContent);
|
||||||
|
var form = field.closest('form');
|
||||||
|
if (form) form.addEventListener('submit', syncContent);
|
||||||
|
syncContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAll(root) {
|
||||||
|
(root || document).querySelectorAll('[data-rich-field]').forEach(function (field) {
|
||||||
|
if (field.dataset.richReady) return;
|
||||||
|
field.dataset.richReady = '1';
|
||||||
|
initField(field);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () { initAll(document); });
|
||||||
|
// Re-init after htmx swaps a fragment containing an editor into the page.
|
||||||
|
document.addEventListener('htmx:afterSwap', function (event) { initAll(event.target); });
|
||||||
|
})();
|
||||||
31
assets/static/vendor/quill/LICENSE
vendored
Normal file
31
assets/static/vendor/quill/LICENSE
vendored
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
Copyright (c) 2017-2024, Slab
|
||||||
|
Copyright (c) 2014, Jason Chen
|
||||||
|
Copyright (c) 2013, salesforce.com
|
||||||
|
All rights reserved.
|
||||||
|
|
||||||
|
Redistribution and use in source and binary forms, with or without
|
||||||
|
modification, are permitted provided that the following conditions
|
||||||
|
are met:
|
||||||
|
|
||||||
|
1. Redistributions of source code must retain the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer.
|
||||||
|
|
||||||
|
2. Redistributions in binary form must reproduce the above copyright
|
||||||
|
notice, this list of conditions and the following disclaimer in the
|
||||||
|
documentation and/or other materials provided with the distribution.
|
||||||
|
|
||||||
|
3. Neither the name of the copyright holder nor the names of its
|
||||||
|
contributors may be used to endorse or promote products derived from
|
||||||
|
this software without specific prior written permission.
|
||||||
|
|
||||||
|
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
|
||||||
|
IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
|
||||||
|
TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
|
||||||
|
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
|
||||||
|
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
|
||||||
|
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
|
||||||
|
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
|
||||||
|
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
||||||
|
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||||
|
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||||
3
assets/static/vendor/quill/quill.js
vendored
Normal file
3
assets/static/vendor/quill/quill.js
vendored
Normal file
File diff suppressed because one or more lines are too long
7
assets/static/vendor/quill/quill.js.LICENSE.txt
vendored
Normal file
7
assets/static/vendor/quill/quill.js.LICENSE.txt
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
/*!
|
||||||
|
* Quill Editor v2.0.3
|
||||||
|
* https://quilljs.com
|
||||||
|
* Copyright (c) 2017-2024, Slab
|
||||||
|
* Copyright (c) 2014, Jason Chen
|
||||||
|
* Copyright (c) 2013, salesforce.com
|
||||||
|
*/
|
||||||
10
assets/static/vendor/quill/quill.snow.css
vendored
Normal file
10
assets/static/vendor/quill/quill.snow.css
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -30,18 +30,18 @@
|
|||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<li class="flex justify-between gap-2">
|
<li class="flex justify-between gap-2">
|
||||||
<span class="text-on-surface/80 dark:text-on-surface-dark/80">{{ item.product_name }}{% if item.variant_label %} · {{ item.variant_label }}{% endif %} × {{ item.quantity }}</span>
|
<span class="text-on-surface/80 dark:text-on-surface-dark/80">{{ item.product_name }}{% if item.variant_label %} · {{ item.variant_label }}{% endif %} × {{ item.quantity }}</span>
|
||||||
<span class="tabular-nums">{{ item.line_total }} {{ order.currency }}</span>
|
<span class="tabular-nums">{{ item.line_total }} €</span>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
<div class="space-y-1 border-t border-outline py-3 text-sm dark:border-outline-dark">
|
<div class="space-y-1 border-t border-outline py-3 text-sm dark:border-outline-dark">
|
||||||
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }}</span><span class="tabular-nums">{{ order.subtotal }} {{ order.currency }}</span></div>
|
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }}</span><span class="tabular-nums">{{ order.subtotal }} €</span></div>
|
||||||
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ order.carrier_name }}</span><span class="tabular-nums">{{ order.shipping }} {{ order.currency }}</span></div>
|
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ order.carrier_name }}</span><span class="tabular-nums">{{ order.shipping }} €</span></div>
|
||||||
{% if order.pickup_point_name %}<div class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ order.pickup_point_name }}</div>{% endif %}
|
{% if order.pickup_point_name %}<div class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ order.pickup_point_name }}</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between border-t border-outline pt-3 font-bold dark:border-outline-dark">
|
<div class="flex justify-between border-t border-outline pt-3 font-bold dark:border-outline-dark">
|
||||||
<span>{{ t(key="order-total", lang=lang | default(value='sk')) }}</span>
|
<span>{{ t(key="order-total", lang=lang | default(value='sk')) }}</span>
|
||||||
<span class="tabular-nums text-primary dark:text-primary-dark">{{ order.total }} {{ order.currency }}</span>
|
<span class="tabular-nums text-primary dark:text-primary-dark">{{ order.total }} €</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -55,10 +55,21 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="rounded-radius border border-outline bg-surface p-6 text-sm dark:border-outline-dark dark:bg-surface-dark-alt">
|
<div class="rounded-radius border border-outline bg-surface p-6 text-sm dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
<h2 class="mb-2 font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</h2>
|
<div class="space-y-4">
|
||||||
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.customer_name }}</p>
|
{% if order.residence_address %}
|
||||||
{% if order.address %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.address }}</p>{% endif %}
|
<div>
|
||||||
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.zip }} {{ order.city }}{% if order.country %}, {{ order.country }}{% endif %}</p>
|
<h2 class="mb-2 font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-residence-address", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.residence_address }}</p>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.residence_zip }} {{ order.residence_city }}{% if order.residence_country %}, {{ order.residence_country }}{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-2 font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.customer_name }}</p>
|
||||||
|
{% if order.address %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.address }}</p>{% endif %}
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.zip }} {{ order.city }}{% if order.country %}, {{ order.country }}{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if order.payment_method == "bank_transfer" and order.status == "pending" %}
|
{% if order.payment_method == "bank_transfer" and order.status == "pending" %}
|
||||||
@@ -68,7 +79,7 @@
|
|||||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-account-name", lang=lang | default(value='sk')) }}</span><span class="font-medium">{{ order.bank_account_name }}</span>
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-account-name", lang=lang | default(value='sk')) }}</span><span class="font-medium">{{ order.bank_account_name }}</span>
|
||||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">IBAN</span><span class="font-mono font-medium">{{ order.bank_iban }}</span>
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">IBAN</span><span class="font-mono font-medium">{{ order.bank_iban }}</span>
|
||||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-variable-symbol", lang=lang | default(value='sk')) }}</span><span class="font-mono font-medium">{{ order.variable_symbol }}</span>
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-variable-symbol", lang=lang | default(value='sk')) }}</span><span class="font-mono font-medium">{{ order.variable_symbol }}</span>
|
||||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-amount", lang=lang | default(value='sk')) }}</span><span class="font-medium tabular-nums">{{ order.total }} {{ order.currency }}</span>
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-amount", lang=lang | default(value='sk')) }}</span><span class="font-medium tabular-nums">{{ order.total }} €</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
{{ self::status_badge(status=order.status) }}
|
{{ self::status_badge(status=order.status) }}
|
||||||
<span class="tabular-nums text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.total }} {{ order.currency }}</span>
|
<span class="tabular-nums text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.total }} €</span>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
{% endmacro order_row %}
|
{% endmacro order_row %}
|
||||||
|
|||||||
@@ -28,6 +28,45 @@
|
|||||||
{{ ui::alert_danger(message=t(key="profile-company-required", lang=lang | default(value='sk')), extra="mt-4") }}
|
{{ ui::alert_danger(message=t(key="profile-company-required", lang=lang | default(value='sk')), extra="mt-4") }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# initials fallback when no avatar is set, e.g. "Filip Priec" -> "FP" #}
|
||||||
|
{% set _name = name | default(value='') | trim %}
|
||||||
|
{% set _parts = _name | split(pat=' ') %}
|
||||||
|
{% set _initials = _parts.0 | truncate(length=1, end='') | upper %}
|
||||||
|
{% if _parts | length > 1 %}{% set _second = _parts | last | truncate(length=1, end='') | upper %}{% set _initials = _initials ~ _second %}{% endif %}
|
||||||
|
|
||||||
|
<!-- avatar: upload / replace / remove. Own multipart form, independent of the
|
||||||
|
profile edit toggle below, so it works in both view and edit modes. -->
|
||||||
|
<fieldset class="mt-6 space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt"
|
||||||
|
x-data="{ name: '' }">
|
||||||
|
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="profile-avatar", lang=lang | default(value='sk')) }}</legend>
|
||||||
|
<div class="flex items-center gap-5">
|
||||||
|
<span class="flex size-20 shrink-0 items-center justify-center overflow-hidden rounded-full border border-primary bg-primary text-2xl font-bold tracking-wider text-on-primary/90 dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark/90">
|
||||||
|
{%- if avatar_id %}<img src="/images/{{ avatar_id }}" alt="{{ _name }}" class="size-full object-cover">{% elif _initials %}{{ _initials }}{% endif -%}
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0 space-y-3">
|
||||||
|
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="profile-avatar-hint", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<form method="post" action="/account/profile/avatar" enctype="multipart/form-data" hx-boost="false" class="flex flex-wrap items-center gap-3">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<label class="inline-flex cursor-pointer items-center gap-2 rounded-radius border border-outline bg-surface-alt px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-primary/5 hover:text-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark dark:hover:text-primary-dark">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4 shrink-0" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" /></svg>
|
||||||
|
<span class="truncate max-w-[12rem]" x-text="name || '{{ t(key='profile-avatar-choose', lang=lang | default(value='sk')) }}'">{{ t(key="profile-avatar-choose", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<input type="file" name="image" accept="image/png,image/jpeg,image/webp,image/gif" class="sr-only"
|
||||||
|
@change="name = $event.target.files.length ? $event.target.files[0].name : ''">
|
||||||
|
</label>
|
||||||
|
{{ ui::button(label=t(key="profile-avatar-upload", lang=lang | default(value='sk')), type="submit", size="px-4 py-2 text-sm", attrs='x-show="name" x-cloak') }}
|
||||||
|
</form>
|
||||||
|
{% if avatar_id %}
|
||||||
|
<form method="post" action="/account/profile/avatar/remove" hx-boost="false">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
{{ ui::button(label=t(key="profile-avatar-remove", lang=lang | default(value='sk')), type="submit", variant="outline-secondary", size="px-4 py-2 text-sm") }}
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<!-- read-only view (default) -->
|
<!-- read-only view (default) -->
|
||||||
<div x-show="!editing" class="mt-6 space-y-6">
|
<div x-show="!editing" class="mt-6 space-y-6">
|
||||||
<fieldset class="space-y-2 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
<fieldset class="space-y-2 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
@@ -68,7 +107,7 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
<fieldset class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</legend>
|
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-residence-address", lang=lang | default(value='sk')) }}</legend>
|
||||||
{{ self::field(label=t(key="checkout-address", lang=lang | default(value='sk')), value=address) }}
|
{{ self::field(label=t(key="checkout-address", lang=lang | default(value='sk')), value=address) }}
|
||||||
<div class="grid gap-4 sm:grid-cols-3">
|
<div class="grid gap-4 sm:grid-cols-3">
|
||||||
{{ self::field(label=t(key="checkout-city", lang=lang | default(value='sk')), value=city) }}
|
{{ self::field(label=t(key="checkout-city", lang=lang | default(value='sk')), value=city) }}
|
||||||
@@ -172,9 +211,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<!-- default shipping address -->
|
<!-- residence address -->
|
||||||
<fieldset class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
<fieldset class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</legend>
|
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-residence-address", lang=lang | default(value='sk')) }}</legend>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="address" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-address", lang=lang | default(value='sk')) }}</label>
|
<label for="address" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-address", lang=lang | default(value='sk')) }}</label>
|
||||||
{{ ui::input(name="address", id="address", value=address | default(value=''), autocomplete="street-address") }}
|
{{ ui::input(name="address", id="address", value=address | default(value=''), autocomplete="street-address") }}
|
||||||
|
|||||||
@@ -105,6 +105,14 @@
|
|||||||
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||||
{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}
|
{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/payments" data-nav="/admin/payments"
|
||||||
|
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||||
|
{{ t(key="admin-payments", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
<a href="/admin/currencies" data-nav="/admin/currencies"
|
||||||
|
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||||
|
{{ t(key="admin-currency", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="border-t border-outline p-4 dark:border-outline-dark">
|
<div class="border-t border-outline p-4 dark:border-outline-dark">
|
||||||
|
|||||||
@@ -15,82 +15,80 @@
|
|||||||
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products?audience=" ~ audience, size="px-3 py-2 text-sm") }}
|
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products?audience=" ~ audience, size="px-3 py-2 text-sm") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{# One discount row per option (variant). Each row picks a fixed sale price or a #}
|
||||||
|
{# percentage off its own regular price; a blank input clears that option's #}
|
||||||
|
{# discount. Both the fixed and percent inputs always submit (the server reads the #}
|
||||||
|
{# active mode); rows are pre-filled from `rows` (DB values, or submitted values #}
|
||||||
|
{# when repainting after a validation error) and indexed as v[<variant id>][...]. #}
|
||||||
|
<script id="discount-data" type="application/json">{{ rows | json_encode() | safe }}</script>
|
||||||
|
|
||||||
<form method="post" action="/admin/catalog/products/{{ product.id }}/discount?audience={{ audience }}"
|
<form method="post" action="/admin/catalog/products/{{ product.id }}/discount?audience={{ audience }}"
|
||||||
x-data="{
|
x-data="discountEditor(JSON.parse(document.getElementById('discount-data').textContent))"
|
||||||
mode: '{{ mode }}',
|
class="mt-6 max-w-2xl space-y-5">
|
||||||
fixed: '{{ fixed }}',
|
|
||||||
percent: '{{ percent }}',
|
|
||||||
regular: {{ product.regular_cents }},
|
|
||||||
num(v) { let n = parseFloat(String(v).replace(',', '.')); return isFinite(n) ? n : null; },
|
|
||||||
get afterCents() {
|
|
||||||
if (this.mode === 'percent') {
|
|
||||||
let p = this.num(this.percent); if (p === null) return null;
|
|
||||||
return this.regular - Math.round(this.regular * p / 100);
|
|
||||||
}
|
|
||||||
let f = this.num(this.fixed); if (f === null) return null;
|
|
||||||
return Math.round(f * 100);
|
|
||||||
},
|
|
||||||
money(c) { return (c / 100).toFixed(2); },
|
|
||||||
get valid() { let a = this.afterCents; return a !== null && a > 0 && a < this.regular; },
|
|
||||||
get percentOff() { let a = this.afterCents; return (a === null || this.regular <= 0) ? null : Math.round((this.regular - a) / this.regular * 100); }
|
|
||||||
}"
|
|
||||||
class="mt-6 max-w-md space-y-5 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
|
||||||
{{ ui::csrf_field() }}
|
{{ ui::csrf_field() }}
|
||||||
|
|
||||||
{% if error %}
|
{% if error %}
|
||||||
{{ ui::alert_danger(message=t(key=error, lang=lang | default(value='sk'))) }}
|
{{ ui::alert_danger(message=t(key=error, lang=lang | default(value='sk'))) }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="flex items-center justify-between gap-3 rounded-radius bg-surface-alt px-4 py-3 dark:bg-surface-dark/40">
|
<template x-for="row in rows" :key="row.id">
|
||||||
<span class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="price", lang=lang | default(value='sk')) }}</span>
|
<div class="space-y-4 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
<span class="font-semibold tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.regular_price }} {{ product.currency }}</span>
|
<div class="flex items-center justify-between gap-3">
|
||||||
</div>
|
<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>
|
||||||
|
|
||||||
<!-- mode toggle -->
|
<input type="hidden" :name="`v[${row.id}][mode]`" :value="row.mode">
|
||||||
<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">
|
|
||||||
{{ 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">
|
|
||||||
{{ t(key="discount-mode-percent", lang=lang | default(value='sk')) }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- fixed price input -->
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
<div class="space-y-1.5" x-show="mode === 'fixed'">
|
<!-- mode toggle -->
|
||||||
<label for="sale_price" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="sale-price", lang=lang | default(value='sk')) }}</label>
|
<div class="grid grid-cols-2 gap-2">
|
||||||
{{ ui::input(name="sale_price", id="sale_price", value=fixed, placeholder="0.00", attrs='inputmode="decimal" x-model="fixed"') }}
|
<label class="flex cursor-pointer items-center justify-center gap-2 rounded-radius border px-3 py-2 text-sm transition"
|
||||||
</div>
|
: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="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>
|
||||||
|
|
||||||
<!-- percentage input -->
|
<!-- value input: both fields stay in the DOM and submit; the server reads
|
||||||
<div class="space-y-1.5" x-show="mode === 'percent'">
|
whichever matches the row's mode -->
|
||||||
<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>
|
<div class="space-y-1.5">
|
||||||
{{ ui::input(name="percent", id="percent", value=percent, placeholder="0", attrs='inputmode="decimal" min="0" max="100" x-model="percent"') }}
|
<div x-show="row.mode === 'fixed'">
|
||||||
</div>
|
<label class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="sale-price", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input :name="`v[${row.id}][fixed]`" x-model="row.fixed" inputmode="decimal" placeholder="0.00"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface-alt px-4 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark">
|
||||||
|
</div>
|
||||||
|
<div x-show="row.mode === 'percent'">
|
||||||
|
<label class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="discount-percent", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input :name="`v[${row.id}][percent]`" x-model="row.percent" inputmode="decimal" min="0" max="100" placeholder="0"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface-alt px-4 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- live preview -->
|
<!-- live preview -->
|
||||||
<div x-show="afterCents !== null" x-cloak
|
<div x-show="afterCents(row) !== null" x-cloak
|
||||||
class="space-y-2 rounded-radius border border-outline bg-surface-alt px-4 py-3 dark:border-outline-dark dark:bg-surface-dark/40">
|
class="flex flex-wrap items-center justify-between gap-3 rounded-radius border border-outline bg-surface-alt px-4 py-2.5 text-sm dark:border-outline-dark dark:bg-surface-dark/40">
|
||||||
<div class="flex items-center justify-between gap-3 text-sm">
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="discount-preview-after", lang=lang | default(value='sk')) }}</span>
|
||||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="discount-preview-before", lang=lang | default(value='sk')) }}</span>
|
<span class="flex items-center gap-2">
|
||||||
<span class="tabular-nums text-on-surface/60 line-through dark:text-on-surface-dark/60"><span x-text="money(regular)"></span> {{ product.currency }}</span>
|
<span class="tabular-nums text-on-surface/50 line-through dark:text-on-surface-dark/50" x-text="money(row.regular_cents) + ' €'"></span>
|
||||||
|
<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>
|
||||||
|
<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>
|
</div>
|
||||||
<div class="flex items-center justify-between gap-3">
|
</template>
|
||||||
<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 }}
|
|
||||||
</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>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-wrap gap-3 pt-2">
|
<div class="flex flex-wrap gap-3 pt-2">
|
||||||
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", attrs=`onclick="return confirm('` ~ t(key="discount-apply-confirm", lang=lang | default(value='sk')) ~ `')"`) }}
|
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", attrs=`onclick="return confirm('` ~ t(key="discount-apply-confirm", lang=lang | default(value='sk')) ~ `')"`) }}
|
||||||
@@ -99,4 +97,35 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function discountEditor(initial) {
|
||||||
|
return {
|
||||||
|
rows: (initial || []).map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
label: r.label || '',
|
||||||
|
regular_cents: r.regular_cents,
|
||||||
|
regular_price: r.regular_price,
|
||||||
|
mode: r.mode || 'fixed',
|
||||||
|
fixed: r.fixed || '',
|
||||||
|
percent: r.percent || '',
|
||||||
|
})),
|
||||||
|
num(v) { let n = parseFloat(String(v).replace(',', '.')); return isFinite(n) ? n : null; },
|
||||||
|
money(c) { return (c / 100).toFixed(2); },
|
||||||
|
afterCents(row) {
|
||||||
|
if (row.mode === 'percent') {
|
||||||
|
let p = this.num(row.percent); if (p === null) return null;
|
||||||
|
return row.regular_cents - Math.round(row.regular_cents * p / 100);
|
||||||
|
}
|
||||||
|
let f = this.num(row.fixed); if (f === null) return null;
|
||||||
|
return Math.round(f * 100);
|
||||||
|
},
|
||||||
|
valid(row) { let a = this.afterCents(row); return a !== null && a > 0 && a < row.regular_cents; },
|
||||||
|
percentOff(row) {
|
||||||
|
let a = this.afterCents(row);
|
||||||
|
return (a === null || row.regular_cents <= 0) ? null : Math.round((row.regular_cents - a) / row.regular_cents * 100);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
|
|
||||||
{% block title %}{% if product %}{{ t(key="edit-product", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-product", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
|
{% block title %}{% if product %}{{ t(key="edit-product", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-product", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
|
||||||
{% block crumb %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
{% block crumb %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
{% block head %}
|
||||||
|
<link href="/static/vendor/quill/quill.snow.css" rel="stylesheet" type="text/css">
|
||||||
|
{% endblock head %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
@@ -18,9 +21,9 @@
|
|||||||
{{ ui::csrf_field() }}
|
{{ ui::csrf_field() }}
|
||||||
|
|
||||||
{% if product %}
|
{% if product %}
|
||||||
{% set v_name = product.name %}{% set v_currency = product.currency %}{% set v_desc = product.description | default(value="") %}{% set v_pub = product.published %}
|
{% set v_name = product.name %}{% set v_desc = product.description | default(value="") %}{% set v_short = product.short_description | default(value="") %}{% set v_pub = product.published %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% set v_name = "" %}{% set v_currency = "EUR" %}{% set v_desc = "" %}{% set v_pub = false %}
|
{% set v_name = "" %}{% set v_desc = "" %}{% set v_short = "" %}{% set v_pub = false %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% set inp = "w-full rounded-radius border border-outline bg-surface-alt px-3 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark" %}
|
{% set inp = "w-full rounded-radius border border-outline bg-surface-alt px-3 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark" %}
|
||||||
{% set sublabel = "text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70" %}
|
{% set sublabel = "text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70" %}
|
||||||
@@ -30,17 +33,13 @@
|
|||||||
{{ ui::input(name="name", id="name", required=true, value=v_name) }}
|
{{ ui::input(name="name", id="name", required=true, value=v_name) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5 sm:max-w-[10rem]">
|
|
||||||
<label for="currency" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="currency", lang=lang | default(value='sk')) }}</label>
|
|
||||||
{{ ui::input(name="currency", id="currency", value=v_currency, attrs='maxlength="3"', extra="uppercase") }}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{# --- Variants / options editor ------------------------------------------- #}
|
{# --- Variants / options editor ------------------------------------------- #}
|
||||||
{# Each product is sold as one or more variants (a free-text label such as #}
|
{# Each product is sold as one or more variants (a free-text label such as #}
|
||||||
{# "10cm x 13cm" or "5ml" plus its own price/stock, optional sku & business #}
|
{# "10cm x 13cm" or "5ml" plus its own price). Price is required. Stock is #}
|
||||||
{# price). Price and stock are required; the browser blocks save if a row is #}
|
{# optional — leave it blank ("∞") to mark the option simply available (not #}
|
||||||
{# incomplete. Rows are managed client-side; names are indexed (variants[i][…]) #}
|
{# inventory-tracked). SKU and business price are optional too. Rows are #}
|
||||||
{# and read back by the controller. #}
|
{# managed client-side; names are indexed (variants[i][…]) and read back by #}
|
||||||
|
{# the controller. #}
|
||||||
{% set opt = " (" ~ t(key="optional", lang=lang | default(value='sk')) ~ ")" %}
|
{% set opt = " (" ~ t(key="optional", lang=lang | default(value='sk')) ~ ")" %}
|
||||||
<script id="variants-data" type="application/json">{{ variants | json_encode() | safe }}</script>
|
<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="space-y-3" x-data="variantEditor(JSON.parse(document.getElementById('variants-data').textContent))">
|
||||||
@@ -59,7 +58,7 @@
|
|||||||
{# items-end bottom-aligns every input regardless of how many lines each
|
{# items-end bottom-aligns every input regardless of how many lines each
|
||||||
label takes, so the row stays aligned even with the "(optional)" notes. #}
|
label takes, so the row stays aligned even with the "(optional)" notes. #}
|
||||||
<div class="grid flex-1 grid-cols-2 gap-3 sm:grid-cols-12 sm:items-end">
|
<div class="grid flex-1 grid-cols-2 gap-3 sm:grid-cols-12 sm:items-end">
|
||||||
<div class="space-y-1 col-span-2 sm:col-span-4">
|
<div class="space-y-1 col-span-2 sm:col-span-6">
|
||||||
<label class="{{ sublabel }} block truncate">{{ t(key="option-label", lang=lang | default(value='sk')) }}{{ opt }}</label>
|
<label class="{{ sublabel }} block truncate">{{ t(key="option-label", lang=lang | default(value='sk')) }}{{ opt }}</label>
|
||||||
<input :name="`variants[${i}][label]`" x-model="row.label" class="{{ inp }}" placeholder="napr. 10cm x 13cm">
|
<input :name="`variants[${i}][label]`" x-model="row.label" class="{{ inp }}" placeholder="napr. 10cm x 13cm">
|
||||||
</div>
|
</div>
|
||||||
@@ -68,17 +67,13 @@
|
|||||||
<input :name="`variants[${i}][sku]`" x-model="row.sku" class="{{ inp }}">
|
<input :name="`variants[${i}][sku]`" x-model="row.sku" class="{{ inp }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1 sm:col-span-2">
|
<div class="space-y-1 sm:col-span-2">
|
||||||
<label class="{{ sublabel }} block truncate">{{ t(key="stock", lang=lang | default(value='sk')) }}</label>
|
<label class="{{ sublabel }} block truncate">{{ t(key="stock", lang=lang | default(value='sk')) }}{{ opt }}</label>
|
||||||
<input type="number" min="0" required :name="`variants[${i}][stock]`" x-model="row.stock" class="{{ inp }}">
|
<input type="number" min="0" :name="`variants[${i}][stock]`" x-model="row.stock" class="{{ inp }}" placeholder="∞" title="{{ t(key='stock-untracked-hint', lang=lang | default(value='sk')) }}">
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1 sm:col-span-2">
|
<div class="space-y-1 sm:col-span-2">
|
||||||
<label class="{{ sublabel }} block truncate">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
|
<label class="{{ sublabel }} block truncate">{{ t(key="price", lang=lang | default(value='sk')) }} (€)</label>
|
||||||
<input :name="`variants[${i}][price]`" x-model="row.price" inputmode="decimal" required class="{{ inp }}" placeholder="0.00">
|
<input :name="`variants[${i}][price]`" x-model="row.price" inputmode="decimal" required class="{{ inp }}" placeholder="0.00">
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1 sm:col-span-2">
|
|
||||||
<label class="{{ sublabel }} block truncate">{{ t(key="business-price", lang=lang | default(value='sk')) }}{{ opt }}</label>
|
|
||||||
<input :name="`variants[${i}][business_sale]`" x-model="row.business_sale" inputmode="decimal" class="{{ inp }}" placeholder="—">
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button" @click="remove(i)"
|
<button type="button" @click="remove(i)"
|
||||||
@@ -89,7 +84,7 @@
|
|||||||
|
|
||||||
<script>
|
<script>
|
||||||
function variantEditor(initial) {
|
function variantEditor(initial) {
|
||||||
const blank = () => ({ id: '', label: '', sku: '', stock: '', price: '', business_sale: '' });
|
const blank = () => ({ id: '', label: '', sku: '', stock: '', price: '' });
|
||||||
return {
|
return {
|
||||||
rows: (initial || []).map(r => ({
|
rows: (initial || []).map(r => ({
|
||||||
id: r.id || '',
|
id: r.id || '',
|
||||||
@@ -97,7 +92,6 @@
|
|||||||
sku: r.sku || '',
|
sku: r.sku || '',
|
||||||
stock: (r.stock === null || r.stock === undefined) ? '' : r.stock,
|
stock: (r.stock === null || r.stock === undefined) ? '' : r.stock,
|
||||||
price: r.price || '',
|
price: r.price || '',
|
||||||
business_sale: r.business_sale || '',
|
|
||||||
})),
|
})),
|
||||||
init() { if (this.rows.length === 0) this.add(); },
|
init() { if (this.rows.length === 0) this.add(); },
|
||||||
add() { this.rows.push(blank()); },
|
add() { this.rows.push(blank()); },
|
||||||
@@ -121,16 +115,96 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="description" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="description", lang=lang | default(value='sk')) }}</label>
|
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="short-description", lang=lang | default(value='sk')) }}</span>
|
||||||
{{ ui::textarea(name="description", id="description", rows="5", value=v_desc) }}
|
<p class="{{ sublabel }}">{{ t(key="short-description-hint", lang=lang | default(value='sk')) }}</p>
|
||||||
|
{{ ui::rich_editor(name="short_description", lang=lang | default(value='sk'), value=v_short, min_height="6rem") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<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>
|
<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>
|
||||||
{% if product and product.image %}
|
{{ ui::rich_editor(name="description", lang=lang | default(value='sk'), value=v_desc, min_height="16rem") }}
|
||||||
<img src="/images/{{ product.image }}" alt="" class="size-24 rounded-radius object-cover">
|
</div>
|
||||||
{% endif %}
|
|
||||||
{{ ui::file_input(name="image", id="image", accept="image/*") }}
|
{# --- 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>
|
</div>
|
||||||
|
|
||||||
{{ ui::checkbox(name="published", id="published", label=t(key="published", lang=lang | default(value='sk')), checked=v_pub) }}
|
{{ ui::checkbox(name="published", id="published", label=t(key="published", lang=lang | default(value='sk')), checked=v_pub) }}
|
||||||
@@ -140,4 +214,6 @@
|
|||||||
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products") }}
|
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products") }}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
<script src="/static/vendor/quill/quill.js"></script>
|
||||||
|
<script src="/static/js/rich-editor.js"></script>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{% set business = audience == "business" %}
|
{% set business = audience == "business" %}
|
||||||
|
{% set L = lang | default(value='sk') %}
|
||||||
|
{% set q_enc = query | default(value='') | urlencode %}
|
||||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-products", lang=lang | default(value='sk')) }}</h1>
|
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-products", lang=lang | default(value='sk')) }}</h1>
|
||||||
@@ -15,20 +17,34 @@
|
|||||||
{{ ui::button(label=t(key="new-product", lang=lang | default(value='sk')), href="/admin/catalog/products/new") }}
|
{{ ui::button(label=t(key="new-product", lang=lang | default(value='sk')), href="/admin/catalog/products/new") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- audience tabs -->
|
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
<div class="mt-4 inline-flex rounded-radius border border-outline p-1 dark:border-outline-dark">
|
<!-- audience tabs -->
|
||||||
<a href="/admin/catalog/products?audience=personal"
|
<div class="inline-flex rounded-radius border border-outline p-1 dark:border-outline-dark">
|
||||||
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 %}">
|
<a href="/admin/catalog/products?audience=personal&q={{ q_enc }}"
|
||||||
{{ t(key="audience-personal", lang=lang | default(value='sk')) }}
|
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 %}">
|
||||||
</a>
|
{{ t(key="audience-personal", lang=L) }}
|
||||||
<a href="/admin/catalog/products?audience=business"
|
</a>
|
||||||
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 %}">
|
<a href="/admin/catalog/products?audience=business&q={{ q_enc }}"
|
||||||
{{ t(key="audience-business", lang=lang | default(value='sk')) }}
|
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 %}">
|
||||||
</a>
|
{{ 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>
|
</div>
|
||||||
|
|
||||||
{% set category_base = "/admin/catalog/products" %}
|
{% set category_base = "/admin/catalog/products" %}
|
||||||
{% set category_suffix = "&audience=" ~ audience %}
|
{% set category_suffix = "&audience=" ~ audience ~ "&q=" ~ q_enc %}
|
||||||
<div class="mt-4 flex flex-col gap-6 md:flex-row md:items-start">
|
<div class="mt-4 flex flex-col gap-6 md:flex-row md:items-start">
|
||||||
{% include "admin/partials/category_filter.html" %}
|
{% include "admin/partials/category_filter.html" %}
|
||||||
|
|
||||||
@@ -106,7 +122,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 tabular-nums">{% if product.has_options %}{{ t(key="from-price", price=product.regular_price, lang=lang | default(value='sk')) }}{% else %}{{ product.regular_price }}{% endif %} {{ product.currency }}</td>
|
<td class="px-4 py-3 tabular-nums">{% if product.has_options %}{{ t(key="from-price", price=product.regular_price, lang=lang | default(value='sk')) }}{% else %}{{ product.regular_price }}{% endif %} €</td>
|
||||||
<td class="px-4 py-3 tabular-nums">{{ product.variant_count }}</td>
|
<td class="px-4 py-3 tabular-nums">{{ product.variant_count }}</td>
|
||||||
<td class="px-4 py-3 tabular-nums">
|
<td class="px-4 py-3 tabular-nums">
|
||||||
<span id="eff-{{ product.id }}">{{ ui::eff_price(p=product) }}</span>
|
<span id="eff-{{ product.id }}">{{ ui::eff_price(p=product) }}</span>
|
||||||
@@ -122,6 +138,14 @@
|
|||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<div class="flex flex-wrap justify-end gap-2">
|
<div class="flex flex-wrap justify-end gap-2">
|
||||||
{{ ui::button(variant="outline-secondary", label=t(key="edit", lang=lang | default(value='sk')), href="/admin/catalog/products/" ~ product.id ~ "/edit", size="px-3 py-1.5 text-xs") }}
|
{{ ui::button(variant="outline-secondary", label=t(key="edit", lang=lang | default(value='sk')), href="/admin/catalog/products/" ~ product.id ~ "/edit", size="px-3 py-1.5 text-xs") }}
|
||||||
|
{{ ui::button(variant="outline-secondary", label=t(key="set-discount", lang=lang | default(value='sk')), href="/admin/catalog/products/" ~ product.id ~ "/discount/edit?audience=" ~ audience, size="px-3 py-1.5 text-xs") }}
|
||||||
|
{% if product.on_sale %}
|
||||||
|
<form method="post" action="/admin/catalog/products/{{ product.id }}/discount/remove?audience={{ audience }}"
|
||||||
|
onsubmit="return confirm('{{ t(key="discount-remove-confirm", lang=lang | default(value='sk')) }}')">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
{{ ui::button(variant="outline-danger", label=t(key="remove-discount", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
{{ ui::button(variant="outline-secondary", label=t(key="view", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, size="px-3 py-1.5 text-xs") }}
|
{{ ui::button(variant="outline-secondary", label=t(key="view", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, size="px-3 py-1.5 text-xs") }}
|
||||||
<form method="post" action="/admin/catalog/products/{{ product.id }}/delete"
|
<form method="post" action="/admin/catalog/products/{{ product.id }}/delete"
|
||||||
onsubmit="return confirm('{{ t(key="confirm-delete", lang=lang | default(value='sk')) }}')">
|
onsubmit="return confirm('{{ t(key="confirm-delete", lang=lang | default(value='sk')) }}')">
|
||||||
|
|||||||
44
assets/views/admin/currencies/index.html
Normal file
44
assets/views/admin/currencies/index.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="admin-currency", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
{% block crumb %}{{ t(key="admin-currency", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<header class="space-y-1">
|
||||||
|
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-currency", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-currency-desc", lang=lang | default(value='sk')) }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="mt-6 space-y-4">
|
||||||
|
<!-- base currency, read-only for context -->
|
||||||
|
<div class="flex flex-wrap items-center gap-4 rounded-radius border border-outline bg-surface-alt/40 p-5 dark:border-outline-dark dark:bg-surface-dark-alt/30">
|
||||||
|
<div class="min-w-40">
|
||||||
|
<p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ base_code }} ({{ base_symbol }})</p>
|
||||||
|
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="currency-base-hint", lang=lang | default(value='sk')) }}</p>
|
||||||
|
</div>
|
||||||
|
{{ ui::badge(label=t(key="currency-base", lang=lang | default(value='sk')), variant="neutral") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% for c in currencies %}
|
||||||
|
<form method="post" action="/admin/currencies/{{ c.id }}"
|
||||||
|
class="flex flex-wrap items-end gap-4 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<div class="min-w-40">
|
||||||
|
<p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ c.code }} ({{ c.symbol }})</p>
|
||||||
|
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="exchange-rate-hint", code=c.code, base=base_code, lang=lang | default(value='sk')) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="rate-{{ c.id }}" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="exchange-rate", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">1 {{ base_code }} =</span>
|
||||||
|
{{ ui::input(name="rate", id="rate-" ~ c.id, value=c.rate, width="w-28", attrs='inputmode="decimal"') }}
|
||||||
|
<span class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ c.code }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="pb-2">{{ ui::checkbox(name="enabled", label=t(key="currency-enabled", lang=lang | default(value='sk')), checked=c.enabled) }}</div>
|
||||||
|
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", extra="ml-auto") }}
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
@@ -38,15 +38,15 @@
|
|||||||
<div class="space-y-2 rounded-radius bg-surface-alt px-4 py-3 dark:bg-surface-dark/40">
|
<div class="space-y-2 rounded-radius bg-surface-alt px-4 py-3 dark:bg-surface-dark/40">
|
||||||
<div class="flex items-center justify-between gap-3 text-sm">
|
<div class="flex items-center justify-between gap-3 text-sm">
|
||||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="price", lang=lang | default(value='sk')) }}</span>
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="price", lang=lang | default(value='sk')) }}</span>
|
||||||
<span class="tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.regular_price }} {{ product.currency }}</span>
|
<span class="tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.regular_price }} €</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between gap-3 text-sm">
|
<div class="flex items-center justify-between gap-3 text-sm">
|
||||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="business-price", lang=lang | default(value='sk')) }}</span>
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="business-price", lang=lang | default(value='sk')) }}</span>
|
||||||
<span class="tabular-nums {% if product.business_reduced %}font-medium text-danger{% else %}text-on-surface-strong dark:text-on-surface-dark-strong{% endif %}">{{ product.business_price }} {{ product.currency }}</span>
|
<span class="tabular-nums {% if product.business_reduced %}font-medium text-danger{% else %}text-on-surface-strong dark:text-on-surface-dark-strong{% endif %}">{{ product.business_price }} €</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center justify-between gap-3 text-sm">
|
<div class="flex items-center justify-between gap-3 text-sm">
|
||||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="effective-price", lang=lang | default(value='sk')) }}</span>
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="effective-price", lang=lang | default(value='sk')) }}</span>
|
||||||
<span class="tabular-nums font-medium {% if product.effective_differs %}text-primary dark:text-primary-dark{% else %}text-on-surface-strong dark:text-on-surface-dark-strong{% endif %}">{{ product.effective_price }} {{ product.currency }}</span>
|
<span class="tabular-nums font-medium {% if product.effective_differs %}text-primary dark:text-primary-dark{% else %}text-on-surface-strong dark:text-on-surface-dark-strong{% endif %}">{{ product.effective_price }} €</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<span class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="negotiated-price", lang=lang | default(value='sk')) }}</span>
|
<span class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="negotiated-price", lang=lang | default(value='sk')) }}</span>
|
||||||
<span class="text-lg font-semibold tabular-nums" :class="valid ? 'text-secondary dark:text-secondary-dark' : 'text-on-surface/40 dark:text-on-surface-dark/40'">
|
<span class="text-lg font-semibold tabular-nums" :class="valid ? 'text-secondary dark:text-secondary-dark' : 'text-on-surface/40 dark:text-on-surface-dark/40'">
|
||||||
<span x-text="money(afterCents)"></span> {{ product.currency }}
|
<span x-text="money(afterCents)"></span> €
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p x-show="!valid" class="text-xs text-danger">{{ t(key="discount-must-be-positive", lang=lang | default(value='sk')) }}</p>
|
<p x-show="!valid" class="text-xs text-danger">{{ t(key="discount-must-be-positive", lang=lang | default(value='sk')) }}</p>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
{% block crumb %}{{ t(key="admin-customers", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
{% block crumb %}{{ t(key="admin-customers", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% set L = lang | default(value='sk') %}
|
||||||
|
{% set q_enc = query | default(value='') | urlencode %}
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-center justify-between gap-3">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ customer.name }}</h1>
|
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ customer.name }}</h1>
|
||||||
@@ -41,10 +43,23 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p class="mt-6 text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="negotiated-prices-hint", lang=lang | default(value='sk')) }}</p>
|
<div class="mt-6 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="negotiated-prices-hint", lang=L) }}</p>
|
||||||
|
|
||||||
|
<!-- product search (drafts included); keeps the active category -->
|
||||||
|
<form method="get" action="/admin/customers/{{ customer.id }}" role="search" class="relative w-full max-w-xs">
|
||||||
|
<input type="hidden" name="category" value="{{ selected_category }}">
|
||||||
|
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-on-surface/50 dark:text-on-surface-dark/50">
|
||||||
|
{{ ui::icon(name="search", size="size-5") }}
|
||||||
|
</span>
|
||||||
|
<input type="search" name="q" value="{{ query | default(value='') }}" autocomplete="off"
|
||||||
|
placeholder="{{ t(key='search-placeholder', lang=L) }}" aria-label="{{ t(key='search-placeholder', lang=L) }}"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface py-2 pl-10 pr-3 text-sm text-on-surface placeholder:text-on-surface/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50 dark:focus-visible:outline-primary-dark">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% set category_base = "/admin/customers/" ~ customer.id %}
|
{% set category_base = "/admin/customers/" ~ customer.id %}
|
||||||
{% set category_suffix = "" %}
|
{% set category_suffix = "&q=" ~ q_enc %}
|
||||||
<div class="mt-3 flex flex-col gap-6 md:flex-row md:items-start">
|
<div class="mt-3 flex flex-col gap-6 md:flex-row md:items-start">
|
||||||
{% include "admin/partials/category_filter.html" %}
|
{% include "admin/partials/category_filter.html" %}
|
||||||
<div class="min-w-0 flex-1 {{ ui::table_wrap_cls() }}">
|
<div class="min-w-0 flex-1 {{ ui::table_wrap_cls() }}">
|
||||||
@@ -67,14 +82,14 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 tabular-nums">
|
<td class="px-4 py-3 tabular-nums">
|
||||||
{% if product.business_reduced %}
|
{% if product.business_reduced %}
|
||||||
<span class="font-medium text-danger">{{ product.business_price }} {{ product.currency }}</span>
|
<span class="font-medium text-danger">{{ product.business_price }} €</span>
|
||||||
<span class="ml-1 text-xs text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }}</span>
|
<span class="ml-1 text-xs text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ product.business_price }} {{ product.currency }}
|
{{ product.business_price }} €
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 tabular-nums">
|
<td class="px-4 py-3 tabular-nums">
|
||||||
<span class="font-medium {% if product.effective_differs %}text-primary dark:text-primary-dark{% else %}text-on-surface-strong dark:text-on-surface-dark-strong{% endif %}">{{ product.effective_price }} {{ product.currency }}</span>
|
<span class="font-medium {% if product.effective_differs %}text-primary dark:text-primary-dark{% else %}text-on-surface-strong dark:text-on-surface-dark-strong{% endif %}">{{ product.effective_price }} €</span>
|
||||||
{% if product.collision %}<span class="ml-1">{{ ui::badge(label=t(key="collision", lang=lang | default(value='sk')), variant="warning") }}</span>{% endif %}
|
{% if product.collision %}<span class="ml-1">{{ ui::badge(label=t(key="collision", lang=lang | default(value='sk')), variant="warning") }}</span>{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
|
|||||||
@@ -5,7 +5,24 @@
|
|||||||
{% block crumb %}{{ t(key="admin-orders", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
{% block crumb %}{{ t(key="admin-orders", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-orders", lang=lang | default(value='sk')) }}</h1>
|
{% set L = lang | default(value='sk') %}
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-orders", lang=L) }}</h1>
|
||||||
|
|
||||||
|
<!-- order search: order number, customer, email, company, phone, tracking -->
|
||||||
|
<form method="get" action="/admin/orders" role="search" class="relative w-full max-w-xs">
|
||||||
|
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-on-surface/50 dark:text-on-surface-dark/50">
|
||||||
|
{{ ui::icon(name="search", size="size-5") }}
|
||||||
|
</span>
|
||||||
|
<input type="search" name="q" value="{{ query | default(value='') }}" autocomplete="off"
|
||||||
|
placeholder="{{ t(key='order-search-placeholder', lang=L) }}" aria-label="{{ t(key='order-search-placeholder', lang=L) }}"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface py-2 pl-10 pr-3 text-sm text-on-surface placeholder:text-on-surface/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50 dark:focus-visible:outline-primary-dark">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if query and query != "" %}
|
||||||
|
<p class="mt-2 text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="results-count", lang=L, count=total) }} · “{{ query }}”</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="mt-6 {{ ui::table_wrap_cls() }}">
|
<div class="mt-6 {{ ui::table_wrap_cls() }}">
|
||||||
{% if orders | length > 0 %}
|
{% if orders | length > 0 %}
|
||||||
@@ -27,7 +44,7 @@
|
|||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
{{ ui::badge(label=t(key="order-status-" ~ order.status, lang=lang | default(value='sk')), variant="neutral") }}
|
{{ ui::badge(label=t(key="order-status-" ~ order.status, lang=lang | default(value='sk')), variant="neutral") }}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-right tabular-nums">{{ order.total }} {{ order.currency }}</td>
|
<td class="px-4 py-3 text-right tabular-nums">{{ order.total }} €</td>
|
||||||
<td class="px-4 py-3 text-right">
|
<td class="px-4 py-3 text-right">
|
||||||
{{ ui::button(variant="outline-secondary", label=t(key="view", lang=lang | default(value='sk')), href="/admin/orders/" ~ order.id, size="px-3 py-1.5 text-xs") }}
|
{{ ui::button(variant="outline-secondary", label=t(key="view", lang=lang | default(value='sk')), href="/admin/orders/" ~ order.id, size="px-3 py-1.5 text-xs") }}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -38,14 +38,14 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="px-4 py-3">{{ item.product_name }}{% if item.variant_label %} <span class="text-on-surface/60 dark:text-on-surface-dark/60">· {{ item.variant_label }}</span>{% endif %}</td>
|
<td class="px-4 py-3">{{ item.product_name }}{% if item.variant_label %} <span class="text-on-surface/60 dark:text-on-surface-dark/60">· {{ item.variant_label }}</span>{% endif %}</td>
|
||||||
<td class="px-4 py-3 tabular-nums">{{ item.quantity }}</td>
|
<td class="px-4 py-3 tabular-nums">{{ item.quantity }}</td>
|
||||||
<td class="px-4 py-3 text-right tabular-nums">{{ item.line_total }} {{ order.currency }}</td>
|
<td class="px-4 py-3 text-right tabular-nums">{{ item.line_total }} €</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot class="{{ ui::tfoot_cls() }}">
|
<tfoot class="{{ ui::tfoot_cls() }}">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="2" class="px-4 py-3 text-right font-semibold">{{ t(key="order-total", lang=lang | default(value='sk')) }}</td>
|
<td colspan="2" class="px-4 py-3 text-right font-semibold">{{ t(key="order-total", lang=lang | default(value='sk')) }}</td>
|
||||||
<td class="px-4 py-3 text-right font-bold tabular-nums text-primary dark:text-primary-dark">{{ order.total }} {{ order.currency }}</td>
|
<td class="px-4 py-3 text-right font-bold tabular-nums text-primary dark:text-primary-dark">{{ order.total }} €</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
@@ -69,13 +69,17 @@
|
|||||||
{% if order.vat_id %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ t(key="company-icdph", lang=lang | default(value='sk')) }}: {{ order.vat_id }}</p>{% endif %}
|
{% if order.vat_id %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ t(key="company-icdph", lang=lang | default(value='sk')) }}: {{ order.vat_id }}</p>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-residence-address", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{% if order.residence_address %}{{ order.residence_address }}<br>{{ order.residence_zip }} {{ order.residence_city }}<br>{{ order.residence_country }}{% else %}{{ t(key="profile-not-set", lang=lang | default(value='sk')) }}{% endif %}</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</p>
|
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</p>
|
||||||
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.address }}<br>{{ order.zip }} {{ order.city }}<br>{{ order.country }}</p>
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.address }}<br>{{ order.zip }} {{ order.city }}<br>{{ order.country }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-carrier", lang=lang | default(value='sk')) }}</p>
|
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-carrier", lang=lang | default(value='sk')) }}</p>
|
||||||
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.carrier_name }} — {{ order.shipping }} {{ order.currency }}</p>
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.carrier_name }} — {{ order.shipping }} €</p>
|
||||||
{% if order.pickup_point_name %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.pickup_point_name }}</p>{% endif %}
|
{% if order.pickup_point_name %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.pickup_point_name }}</p>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
47
assets/views/admin/payments/index.html
Normal file
47
assets/views/admin/payments/index.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="admin-payments", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
{% block crumb %}{{ t(key="admin-payments", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<header class="space-y-1">
|
||||||
|
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-payments", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-payments-desc", lang=lang | default(value='sk')) }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="mt-6 space-y-4">
|
||||||
|
<h2 class="text-lg font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-methods", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
{% for method in methods %}
|
||||||
|
<form method="post" action="/admin/payments/methods/{{ method.id }}"
|
||||||
|
class="flex flex-wrap items-center gap-4 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<div class="min-w-40">
|
||||||
|
<p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key=method.label_key, lang=lang | default(value='sk')) }}</p>
|
||||||
|
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ method.code }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="pb-1">{{ ui::checkbox(name="enabled", label=t(key="payment-enabled", lang=lang | default(value='sk')), checked=method.enabled) }}</div>
|
||||||
|
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", extra="ml-auto") }}
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-8 space-y-4">
|
||||||
|
<h2 class="text-lg font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-bank-settings", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
<form method="post" action="/admin/payments/bank"
|
||||||
|
class="space-y-4 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="bank_account_name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="bank-account-name", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="bank_account_name", id="bank_account_name", value=bank_account_name) }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="bank_iban" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">IBAN</label>
|
||||||
|
{{ ui::input(name="bank_iban", id="bank_iban", value=bank_iban) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit") }}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock content %}
|
||||||
@@ -18,12 +18,21 @@
|
|||||||
<div class="min-w-40">
|
<div class="min-w-40">
|
||||||
<p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ method.name }}</p>
|
<p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ method.name }}</p>
|
||||||
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ method.carrier | upper }}{% if method.requires_pickup_point %} · {{ t(key="checkout-pickup-point", lang=lang | default(value='sk')) }}{% endif %}</p>
|
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ method.carrier | upper }}{% if method.requires_pickup_point %} · {{ t(key="checkout-pickup-point", lang=lang | default(value='sk')) }}{% endif %}</p>
|
||||||
|
{% if method.packeta_not_ready %}
|
||||||
|
<p class="mt-1 text-xs text-warning">{{ t(key=method.lock_reason, lang=lang | default(value='sk')) }}</p>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="price-{{ method.id }}" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
|
<label for="price-{{ method.id }}" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
|
||||||
{{ ui::input(name="price", id="price-" ~ method.id, value=method.price, width="w-28", attrs='inputmode="decimal"') }}
|
{{ ui::input(name="price", id="price-" ~ method.id, value=method.price, width="w-28", attrs='inputmode="decimal"') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="pb-2">{{ ui::checkbox(name="enabled", label=t(key="shipping-enabled", lang=lang | default(value='sk')), checked=method.enabled) }}</div>
|
<div class="pb-2">
|
||||||
|
{% if method.locked %}
|
||||||
|
{{ ui::checkbox(name="enabled", label=t(key="shipping-enabled", lang=lang | default(value='sk')), checked=method.enabled, attrs='disabled') }}
|
||||||
|
{% else %}
|
||||||
|
{{ ui::checkbox(name="enabled", label=t(key="shipping-enabled", lang=lang | default(value='sk')), checked=method.enabled) }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", extra="ml-auto") }}
|
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", extra="ml-auto") }}
|
||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -73,42 +73,85 @@
|
|||||||
x-data="{ cats: false, lg: window.matchMedia('(min-width: 1024px)').matches }"
|
x-data="{ cats: false, lg: window.matchMedia('(min-width: 1024px)').matches }"
|
||||||
x-init="window.matchMedia('(min-width: 1024px)').addEventListener('change', e => lg = e.matches)"
|
x-init="window.matchMedia('(min-width: 1024px)').addEventListener('change', e => lg = e.matches)"
|
||||||
class="min-h-screen bg-surface text-on-surface antialiased dark:bg-surface-dark dark:text-on-surface-dark">
|
class="min-h-screen bg-surface text-on-surface antialiased dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
<!-- top utility bar (Kompress design): primary nav on the left, contact /
|
||||||
|
sitemap links on the right. Non-sticky — it scrolls away above the
|
||||||
|
sticky header. -->
|
||||||
|
<div class="hidden border-b border-outline bg-surface text-xs sm:block dark:border-outline-dark dark:bg-surface-dark">
|
||||||
|
<div class="mx-auto flex max-w-7xl items-center justify-between gap-4 px-4 py-2 text-on-surface/70 dark:text-on-surface-dark/70">
|
||||||
|
<div class="flex items-center gap-5">
|
||||||
|
<a href="/" data-nav="/" class="transition hover:text-primary aria-[current=page]:font-semibold aria-[current=page]:text-primary dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-home", lang=lang | default(value='sk')) }}</a>
|
||||||
|
<a href="/shop" data-nav="/shop" class="transition hover:text-primary aria-[current=page]:font-semibold aria-[current=page]:text-primary dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
<a href="/kontakt" class="transition hover:text-primary dark:hover:text-primary-dark">{{ t(key="top-contact", lang=lang | default(value='sk')) }}</a>
|
||||||
|
<span class="h-3 w-px bg-outline dark:bg-outline-dark"></span>
|
||||||
|
<a href="/mapa-stranky" class="transition hover:text-primary dark:hover:text-primary-dark">{{ t(key="top-sitemap", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<header
|
<header
|
||||||
class="sticky top-0 z-30 border-b border-outline bg-surface/95 backdrop-blur dark:border-outline-dark dark:bg-surface-dark/95">
|
class="sticky top-0 z-30 border-b border-outline bg-surface/95 backdrop-blur dark:border-outline-dark dark:bg-surface-dark/95">
|
||||||
<nav x-data="{ mobile: false }" class="mx-auto flex max-w-7xl items-center gap-4 px-4 py-3">
|
<nav class="mx-auto flex max-w-7xl items-center gap-3 px-4 py-3 sm:gap-4">
|
||||||
<!-- category sidebar toggle (mobile only) -->
|
<!-- category sidebar toggle (mobile only) -->
|
||||||
{% set hamburger_icon = ui::icon(name="hamburger", size="size-6") %}
|
{% set hamburger_icon = ui::icon(name="hamburger", size="size-6") %}
|
||||||
{{ ui::icon_button(aria_label=t(key='categories', lang=lang | default(value='sk')), attrs='@click="cats = !cats" :aria-expanded="cats"', extra="lg:hidden", icon=hamburger_icon) }}
|
{{ ui::icon_button(aria_label=t(key='categories', lang=lang | default(value='sk')), attrs='@click="cats = !cats" :aria-expanded="cats"', extra="lg:hidden", icon=hamburger_icon) }}
|
||||||
<a href="/"
|
|
||||||
class="text-lg font-bold tracking-tight text-on-surface-strong dark:text-on-surface-dark-strong">
|
<!-- real KOMPRESS logo from www.e-shop.kompress.sk (hidden on mobile;
|
||||||
{{ t(key="brand", lang=lang | default(value='sk')) }}
|
the category drawer carries navigation there) -->
|
||||||
|
<a href="/" class="hidden shrink-0 items-center sm:flex">
|
||||||
|
<img src="/static/img/logo.jpg" alt="{{ t(key='brand', lang=lang | default(value='sk')) }}" width="260" height="52" class="h-8 w-auto dark:rounded-radius dark:bg-white dark:px-1.5 dark:py-0.5" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- desktop links — Penguin navbar link treatment via ui::nav_link -->
|
<!-- in-header search → existing GET /search (q param). Hidden on small
|
||||||
<ul class="ml-2 hidden items-center gap-6 md:flex">
|
screens; the shop page keeps its compact mobile search row there. -->
|
||||||
<li>{{ ui::nav_link(label=t(key="nav-home", lang=lang | default(value='sk')), href="/", data_nav="/") }}</li>
|
<form action="/search" method="get" role="search" class="hidden min-w-0 flex-1 md:flex md:max-w-sm lg:max-w-md">
|
||||||
<li>{{ ui::nav_link(label=t(key="nav-shop", lang=lang | default(value='sk')), href="/shop", data_nav="/shop") }}</li>
|
{% if selected_category and selected_category != "all" %}
|
||||||
|
<input type="hidden" name="category" value="{{ selected_category }}" />
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex min-w-0 flex-1 overflow-hidden rounded-radius border border-outline transition focus-within:border-primary dark:border-outline-dark dark:focus-within:border-primary-dark">
|
||||||
|
<span class="pointer-events-none flex items-center bg-surface-alt pl-3.5 text-on-surface/40 dark:bg-surface-dark-alt dark:text-on-surface-dark/40">{{ ui::icon(name="search", size="size-[18px]") }}</span>
|
||||||
|
<input type="search" name="q" value="{{ query | default(value='') }}" autocomplete="off"
|
||||||
|
placeholder="{{ t(key='search-placeholder', lang=lang | default(value='sk')) }}"
|
||||||
|
aria-label="{{ t(key='search-placeholder', lang=lang | default(value='sk')) }}"
|
||||||
|
class="min-w-0 flex-1 border-0 bg-surface-alt px-2.5 py-2.5 text-sm text-on-surface placeholder:text-on-surface/50 focus:outline-none dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50" />
|
||||||
|
<button type="submit" class="shrink-0 bg-cta px-5 text-sm font-bold text-on-cta transition hover:opacity-90 dark:bg-cta-dark dark:text-on-cta-dark">{{ t(key="search-button", lang=lang | default(value='sk')) }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- right side: kurz + account + cart + settings + mobile toggle -->
|
||||||
|
<div class="ml-auto flex items-center gap-2 sm:gap-3">
|
||||||
|
<!-- exchange-rate ("kurz") display: the admin-set EUR→alt rate(s).
|
||||||
|
Hidden when the store is EUR-only (no enabled alternatives). -->
|
||||||
|
{% set nav_cc = currencies() %}
|
||||||
|
{% if nav_cc.alts | length > 0 %}
|
||||||
|
<div class="hidden items-center gap-2 text-xs text-on-surface/70 dark:text-on-surface-dark/70 sm:flex">
|
||||||
|
<span class="font-semibold uppercase tracking-wide">{{ t(key="currency-rate", lang=lang | default(value='sk')) }}</span>
|
||||||
|
{% for a in nav_cc.alts %}
|
||||||
|
<span class="tabular-nums">1 {{ nav_cc.base.symbol }} = {{ a.rate }} {{ a.symbol }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<!-- account area: admin quick links / customer profile dropdown /
|
||||||
|
guest two-line "Vitajte · Prihláste sa" button (Kompress design) -->
|
||||||
{% if logged_in_admin %}
|
{% if logged_in_admin %}
|
||||||
<li>{{ ui::nav_link(label=t(key="admin-title", lang=lang | default(value='sk')), href="/admin/dashboard", data_nav="/admin", variant="warning", attrs='hx-boost="false"') }}</li>
|
<div class="flex items-center gap-3">
|
||||||
<li>
|
{{ ui::nav_link(label=t(key="admin-title", lang=lang | default(value='sk')), href="/admin/dashboard", data_nav="/admin", variant="warning", attrs='hx-boost="false"') }}
|
||||||
<form method="post" action="/logout" hx-boost="false">
|
<form method="post" action="/logout" hx-boost="false">
|
||||||
{{ ui::csrf_field() }}
|
{{ ui::csrf_field() }}
|
||||||
<button type="submit" class="text-sm font-medium text-danger underline-offset-2 transition hover:opacity-75 focus:outline-hidden focus-visible:underline">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
|
<button type="submit" class="text-sm font-medium text-danger underline-offset-2 transition hover:opacity-75 focus:outline-hidden focus-visible:underline">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
|
||||||
</form>
|
</form>
|
||||||
</li>
|
</div>
|
||||||
{% elif logged_in_customer %}
|
{% elif logged_in_customer %}
|
||||||
{# customer account links live in the profile dropdown next to the cart #}
|
|
||||||
{% else %}
|
|
||||||
<li>{{ ui::nav_link(label=t(key="nav-login", lang=lang | default(value='sk')), href="/login", data_nav="/login") }}</li>
|
|
||||||
<li>{{ ui::nav_link(label=t(key="nav-register", lang=lang | default(value='sk')), href="/register", data_nav="/register") }}</li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<!-- right side: cart + settings + mobile toggle -->
|
|
||||||
<div class="ml-auto flex items-center gap-3">
|
|
||||||
<!-- customer profile dropdown (avatar + name + account type) -->
|
|
||||||
{% if logged_in_customer %}
|
|
||||||
{% include "partials/profile_menu.html" %}
|
{% include "partials/profile_menu.html" %}
|
||||||
|
{% else %}
|
||||||
|
<a href="/login" data-nav="/login" class="inline-flex items-center gap-2.5 rounded-radius px-2.5 py-1.5 text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round" class="size-5 shrink-0" aria-hidden="true"><circle cx="12" cy="8" r="4"></circle><path d="M5 20a7 7 0 0 1 14 0"></path></svg>
|
||||||
|
<span class="hidden flex-col items-start leading-tight sm:flex">
|
||||||
|
<span class="text-[11px] text-on-surface/50 dark:text-on-surface-dark/50">{{ t(key="welcome", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span class="text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="nav-login", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<!-- cart: hover opens an Alza-style mini-cart preview (Penguin
|
<!-- cart: hover opens an Alza-style mini-cart preview (Penguin
|
||||||
dropdown-with-hover), lazy-loaded from /partials/cart on each hover
|
dropdown-with-hover), lazy-loaded from /partials/cart on each hover
|
||||||
@@ -127,10 +170,16 @@
|
|||||||
hx-get="/partials/cart" hx-trigger="mouseenter delay:150ms" hx-target="#cart-preview-body" hx-swap="innerHTML"
|
hx-get="/partials/cart" hx-trigger="mouseenter delay:150ms" hx-target="#cart-preview-body" hx-swap="innerHTML"
|
||||||
aria-label="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
|
aria-label="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
|
||||||
title="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
|
title="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
|
||||||
class="relative inline-flex size-9 shrink-0 items-center justify-center rounded-radius bg-transparent text-secondary transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-secondary active:opacity-100 active:outline-offset-0 dark:text-secondary-dark dark:focus-visible:outline-secondary-dark">
|
class="flex shrink-0 items-center gap-2.5 rounded-radius border border-outline bg-surface-alt px-2.5 py-1.5 text-on-surface transition hover:border-outline-strong focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:border-outline-dark-strong dark:focus-visible:outline-primary-dark">
|
||||||
{{ ui::icon(name="cart") }}
|
<span class="relative inline-flex text-primary dark:text-primary-dark">
|
||||||
<span x-show="count > 0" x-cloak x-text="count"
|
{{ ui::icon(name="cart", size="size-6") }}
|
||||||
class="absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-4 text-on-primary dark:bg-primary-dark dark:text-on-primary-dark"></span>
|
<span x-show="count > 0" x-cloak x-text="count"
|
||||||
|
class="absolute -right-2 -top-2 inline-flex min-w-[18px] items-center justify-center rounded-full bg-danger px-1 text-[10px] font-bold leading-[18px] text-on-danger ring-2 ring-surface-alt dark:ring-surface-dark-alt"></span>
|
||||||
|
</span>
|
||||||
|
<span class="hidden flex-col items-start leading-tight sm:flex">
|
||||||
|
<span class="text-[11px] text-on-surface/50 dark:text-on-surface-dark/50">{{ t(key="cart-title", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span class="text-sm font-bold text-on-surface-strong dark:text-on-surface-dark-strong"><span x-text="count">0</span> {{ t(key="cart-units", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</span>
|
||||||
</a>
|
</a>
|
||||||
<!-- hover preview panel (no id on the panel → not htmx-settled on boosted nav) -->
|
<!-- hover preview panel (no id on the panel → not htmx-settled on boosted nav) -->
|
||||||
<div x-cloak x-show="isOpen" x-transition
|
<div x-cloak x-show="isOpen" x-transition
|
||||||
@@ -145,56 +194,39 @@
|
|||||||
|
|
||||||
<!-- settings (language + theme) dropdown (self-contained Alpine state) -->
|
<!-- settings (language + theme) dropdown (self-contained Alpine state) -->
|
||||||
{% include "partials/settings_dropdown.html" %}
|
{% include "partials/settings_dropdown.html" %}
|
||||||
|
|
||||||
<!-- mobile hamburger — Penguin animated icon swap (bars ↔ X), kept in
|
|
||||||
our ghost-square icon-button shell for consistency with cart/gear -->
|
|
||||||
<button type="button" @click="mobile = !mobile" :aria-expanded="mobile" aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}"
|
|
||||||
class="inline-flex size-9 shrink-0 items-center justify-center rounded-radius bg-transparent text-secondary transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-secondary active:opacity-100 active:outline-offset-0 md:hidden dark:text-secondary-dark dark:focus-visible:outline-secondary-dark">
|
|
||||||
{{ ui::icon(name="hamburger", size="size-6", attrs='x-show="!mobile"') }}
|
|
||||||
{{ ui::icon(name="close", size="size-6", attrs='x-cloak x-show="mobile"') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- mobile menu panel — Penguin sidebar-style menu rows (hover:bg-primary/5,
|
|
||||||
underline focus), active state via data-nav + markActiveNav() -->
|
|
||||||
<ul x-show="mobile" x-cloak @click.outside="mobile = false" x-transition
|
|
||||||
class="absolute inset-x-0 top-full mx-4 mt-2 flex flex-col gap-1 rounded-radius border border-outline bg-surface p-2 shadow-lg md:hidden dark:border-outline-dark dark:bg-surface-dark-alt">
|
|
||||||
<li><a href="/" data-nav="/" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-home", lang=lang | default(value='sk')) }}</a></li>
|
|
||||||
<li><a href="/shop" data-nav="/shop" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</a></li>
|
|
||||||
{% if logged_in_admin %}
|
|
||||||
<li><a href="/admin/dashboard" hx-boost="false" data-nav="/admin" class="block rounded-radius px-3 py-2 text-sm font-medium text-warning underline-offset-2 transition hover:bg-primary/5 focus:outline-hidden focus-visible:underline">{{ t(key="admin-title", lang=lang | default(value='sk')) }}</a></li>
|
|
||||||
<li>
|
|
||||||
<form method="post" action="/logout" hx-boost="false">
|
|
||||||
{{ ui::csrf_field() }}
|
|
||||||
<button type="submit" class="block w-full rounded-radius px-3 py-2 text-left text-sm font-medium text-danger underline-offset-2 transition hover:bg-primary/5 focus:outline-hidden focus-visible:underline">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
{% elif logged_in_customer %}
|
|
||||||
<li><a href="/account/profile" data-nav="/account" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-profile", lang=lang | default(value='sk')) }}</a></li>
|
|
||||||
<li>
|
|
||||||
<form method="post" action="/logout" hx-boost="false">
|
|
||||||
{{ ui::csrf_field() }}
|
|
||||||
<button type="submit" class="block w-full rounded-radius px-3 py-2 text-left text-sm font-medium text-danger underline-offset-2 transition hover:bg-primary/5 focus:outline-hidden focus-visible:underline">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
|
|
||||||
</form>
|
|
||||||
</li>
|
|
||||||
{% else %}
|
|
||||||
<li><a href="/login" data-nav="/login" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-login", lang=lang | default(value='sk')) }}</a></li>
|
|
||||||
<li><a href="/register" data-nav="/register" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="nav-register", lang=lang | default(value='sk')) }}</a></li>
|
|
||||||
{% endif %}
|
|
||||||
</ul>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
{% set mobile_search_category = selected_category | default(value="") %}
|
||||||
|
{% if on_home | default(value=false) or mobile_search_category %}
|
||||||
|
<form action="/search" method="get" role="search" class="px-4 pb-3 md:hidden">
|
||||||
|
{% if mobile_search_category and mobile_search_category != "all" %}
|
||||||
|
<input type="hidden" name="category" value="{{ mobile_search_category }}" />
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex min-w-0 overflow-hidden rounded-radius border border-outline transition focus-within:border-primary dark:border-outline-dark dark:focus-within:border-primary-dark">
|
||||||
|
<span class="pointer-events-none flex items-center bg-surface-alt pl-3.5 text-on-surface/40 dark:bg-surface-dark-alt dark:text-on-surface-dark/40">{{ ui::icon(name="search", size="size-[18px]") }}</span>
|
||||||
|
<input type="search" name="q" value="{{ query | default(value='') }}" autocomplete="off"
|
||||||
|
placeholder="{{ t(key='search-placeholder', lang=lang | default(value='sk')) }}"
|
||||||
|
aria-label="{{ t(key='search-placeholder', lang=lang | default(value='sk')) }}"
|
||||||
|
class="min-w-0 flex-1 border-0 bg-surface-alt px-2.5 py-2.5 text-sm text-on-surface placeholder:text-on-surface/50 focus:outline-none dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50" />
|
||||||
|
<button type="submit" class="shrink-0 bg-cta px-4 text-sm font-bold text-on-cta transition hover:opacity-90 dark:bg-cta-dark dark:text-on-cta-dark">{{ t(key="search-button", lang=lang | default(value='sk')) }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- dark overlay behind the category drawer on small screens -->
|
<!-- dark overlay behind the category drawer on small screens -->
|
||||||
<div x-cloak x-show="cats" x-transition.opacity @click="cats = false" aria-hidden="true"
|
<div x-cloak x-show="cats" x-transition.opacity @click="cats = false" aria-hidden="true"
|
||||||
class="fixed inset-0 z-30 bg-black/50 lg:hidden"></div>
|
class="fixed inset-0 z-30 bg-black/50 lg:hidden"></div>
|
||||||
|
|
||||||
<div class="mx-auto flex w-full max-w-7xl gap-8 px-4 py-8">
|
<div class="mx-auto w-full max-w-7xl px-4 py-8">
|
||||||
|
<!-- page breadcrumbs: full width, above the sidebar + content row -->
|
||||||
|
{% block breadcrumbs %}{% endblock breadcrumbs %}
|
||||||
|
<div class="flex w-full gap-8">
|
||||||
{% if account_nav %}
|
{% if account_nav %}
|
||||||
<!-- account-area sidebar: replaces the storefront categories while the
|
<!-- account-area sidebar: replaces the storefront categories while the
|
||||||
customer is inside /account/*. -->
|
customer is inside /account/*. -->
|
||||||
<aside x-cloak x-show="cats || lg" aria-label="{{ t(key='nav-account', lang=lang | default(value='sk')) }}"
|
<aside x-cloak x-show="cats || lg" aria-label="{{ t(key='nav-account', lang=lang | default(value='sk')) }}"
|
||||||
class="fixed inset-y-0 left-0 z-40 w-64 overflow-y-auto border-r border-outline bg-surface-alt p-4 lg:static lg:z-auto lg:w-64 lg:shrink-0 lg:self-start lg:overflow-visible lg:rounded-radius lg:border lg:p-3 dark:border-outline-dark dark:bg-surface-dark-alt">
|
class="fixed inset-y-0 left-0 z-40 w-64 overflow-y-auto border-r border-outline bg-surface-alt p-4 lg:sticky lg:top-24 lg:z-auto lg:w-64 lg:shrink-0 lg:self-start lg:overflow-visible lg:rounded-radius lg:border lg:p-3 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
<h2 class="px-3 pb-2 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="nav-account", lang=lang | default(value='sk')) }}</h2>
|
<h2 class="px-3 pb-2 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="nav-account", lang=lang | default(value='sk')) }}</h2>
|
||||||
<ul class="space-y-1">
|
<ul class="space-y-1">
|
||||||
<li><a href="/account/orders" data-nav="/account/orders" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="account-orders", lang=lang | default(value='sk')) }}</a></li>
|
<li><a href="/account/orders" data-nav="/account/orders" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="account-orders", lang=lang | default(value='sk')) }}</a></li>
|
||||||
@@ -214,15 +246,50 @@
|
|||||||
<aside id="category-sidebar" hx-preserve="true"
|
<aside id="category-sidebar" hx-preserve="true"
|
||||||
x-cloak x-show="cats || lg" aria-label="{{ t(key='categories', lang=lang | default(value='sk')) }}"
|
x-cloak x-show="cats || lg" aria-label="{{ t(key='categories', lang=lang | default(value='sk')) }}"
|
||||||
hx-get="/partials/categories" hx-trigger="load"
|
hx-get="/partials/categories" hx-trigger="load"
|
||||||
class="fixed inset-y-0 left-0 z-40 w-64 overflow-y-auto border-r border-outline bg-surface-alt p-4 lg:static lg:z-auto lg:w-64 lg:shrink-0 lg:self-start lg:overflow-visible lg:rounded-radius lg:border lg:p-3 dark:border-outline-dark dark:bg-surface-dark-alt">
|
class="fixed inset-y-0 left-0 z-40 w-64 overflow-y-auto border-r border-outline bg-surface-alt p-4 lg:sticky lg:top-24 lg:z-auto lg:w-64 lg:shrink-0 lg:self-start lg:overflow-visible lg:rounded-radius lg:border lg:p-3 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
</aside>
|
</aside>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<main class="min-w-0 flex-1">
|
<main class="min-w-0 flex-1">
|
||||||
{% block content %}{% endblock content %}
|
{% block content %}{% endblock content %}
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- site footer (Kompress design): Informácie / Účet / Kontakt link columns
|
||||||
|
+ copyright bar. Static links; reuses the nav i18n keys. -->
|
||||||
|
<footer class="border-t border-outline bg-surface dark:border-outline-dark dark:bg-surface-dark">
|
||||||
|
<div class="mx-auto grid max-w-7xl grid-cols-1 gap-8 px-4 py-10 sm:w-fit sm:grid-cols-3 sm:gap-x-32 md:gap-x-36 md:px-8 lg:gap-x-40">
|
||||||
|
<div class="flex flex-col gap-2.5">
|
||||||
|
<div class="text-xs font-bold uppercase tracking-wider text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="footer-info", lang=lang | default(value='sk')) }}</div>
|
||||||
|
<a href="/obchodne-podmienky" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="footer-terms", lang=lang | default(value='sk')) }}</a>
|
||||||
|
<a href="/o-nas" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="footer-about", lang=lang | default(value='sk')) }}</a>
|
||||||
|
<a href="/predajne" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="footer-stores", lang=lang | default(value='sk')) }}</a>
|
||||||
|
<a href="/doprava-a-platba" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="footer-shipping", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2.5">
|
||||||
|
<div class="text-xs font-bold uppercase tracking-wider text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="footer-account", lang=lang | default(value='sk')) }}</div>
|
||||||
|
{% if logged_in_customer %}
|
||||||
|
<a href="/account/orders" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="footer-orders", lang=lang | default(value='sk')) }}</a>
|
||||||
|
<a href="/account/profile" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="nav-profile", lang=lang | default(value='sk')) }}</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="/login" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="nav-login", lang=lang | default(value='sk')) }}</a>
|
||||||
|
<a href="/register" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="nav-register", lang=lang | default(value='sk')) }}</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="/cart" hx-boost="false" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="cart-title", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2.5">
|
||||||
|
<div class="text-xs font-bold uppercase tracking-wider text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="footer-contact", lang=lang | default(value='sk')) }}</div>
|
||||||
|
<a href="tel:+421903410476" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="hotline", lang=lang | default(value='sk')) }}</a>
|
||||||
|
<a href="mailto:info@kompress.sk" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="footer-email", lang=lang | default(value='sk')) }}</a>
|
||||||
|
<span class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="footer-hours", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-outline dark:border-outline-dark">
|
||||||
|
<div class="mx-auto max-w-7xl px-4 py-4 text-xs text-on-surface/50 md:px-8 dark:text-on-surface-dark/50">{{ t(key="footer-rights", lang=lang | default(value='sk')) }}</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
<!-- toast notifications: fire from anywhere with toast('message').
|
<!-- toast notifications: fire from anywhere with toast('message').
|
||||||
Adapted from the vendored Penguin UI component
|
Adapted from the vendored Penguin UI component
|
||||||
(penguinui-components/toast-notification/stacking-toast-notification.html):
|
(penguinui-components/toast-notification/stacking-toast-notification.html):
|
||||||
|
|||||||
@@ -3,28 +3,99 @@
|
|||||||
|
|
||||||
{% block title %}{{ t(key="brand", lang=lang | default(value='sk')) }}{% endblock title %}
|
{% block title %}{{ t(key="brand", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
|
||||||
{% block content %}
|
{% block breadcrumbs %}
|
||||||
<div class="space-y-12">
|
<nav aria-label="breadcrumb" class="mb-5 text-sm">
|
||||||
<!-- hero -->
|
<ol class="flex flex-wrap items-center gap-1.5 text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
<section class="rounded-radius border border-outline bg-surface-alt px-6 py-12 text-center dark:border-outline-dark dark:bg-surface-dark-alt">
|
{{ ui::crumb_current(label=t(key="nav-home", lang=lang | default(value='sk'))) }}
|
||||||
<h1 class="text-4xl font-bold tracking-tight text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shop-title", lang=lang | default(value='sk')) }}</h1>
|
</ol>
|
||||||
<p class="mx-auto mt-3 max-w-xl text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-subtitle", lang=lang | default(value='sk')) }}</p>
|
</nav>
|
||||||
<a href="/shop" class="mt-6 inline-flex items-center justify-center rounded-radius bg-primary px-6 py-2.5 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</a>
|
{% endblock breadcrumbs %}
|
||||||
</section>
|
|
||||||
|
|
||||||
<!-- featured products -->
|
{% block content %}
|
||||||
|
{% set L = lang | default(value='sk') %}
|
||||||
|
{# Home layout adapted from the Kompress design mockup: the left "Kategórie"
|
||||||
|
column is already supplied by base.html's #category-sidebar, so the main
|
||||||
|
area is split into a featured product grid + a right rail (bestsellers /
|
||||||
|
our stores / contact). All colors use the design tokens so light + dark
|
||||||
|
both work; the brand accent is the medical blue set in app.css. #}
|
||||||
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-[minmax(0,1fr)_19rem] lg:grid-rows-[auto_1fr] lg:items-start">
|
||||||
|
|
||||||
|
<!-- bestsellers (reuses the featured products). DOM-first so it stacks above
|
||||||
|
the product grid on mobile; placed in the right rail's top cell on lg. -->
|
||||||
{% if products | length > 0 %}
|
{% if products | length > 0 %}
|
||||||
<section class="space-y-5">
|
<section class="overflow-hidden rounded-radius border border-outline bg-surface-alt lg:col-start-2 lg:row-start-1 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
<div class="flex items-end justify-between">
|
<h2 class="border-b border-outline px-4 py-3.5 text-xs font-bold uppercase tracking-wider text-on-surface-strong dark:border-outline-dark dark:text-on-surface-dark-strong">{{ t(key="home-bestsellers", lang=L) }}</h2>
|
||||||
<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>
|
<ol class="p-2">
|
||||||
<a href="/shop" class="text-sm font-medium text-primary dark:text-primary-dark">{{ t(key="cart-continue", lang=lang | default(value='sk')) }} →</a>
|
{% for product in products | slice(end=5) %}
|
||||||
</div>
|
<li>
|
||||||
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 lg:grid-cols-4">
|
<a href="/shop/{{ product.slug }}" class="flex items-center gap-3 rounded-radius px-2 py-2 transition hover:bg-primary/5">
|
||||||
{% for product in products %}
|
<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>
|
||||||
{% include "shop/_card.html" %}
|
<span class="flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-md border border-outline bg-surface dark:border-outline-dark dark:bg-surface-dark">
|
||||||
|
{% if product.image %}
|
||||||
|
<img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover">
|
||||||
|
{% else %}
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round" class="text-on-surface/30 dark:text-on-surface-dark/30"><rect x="3" y="4" width="18" height="16" rx="2"></rect><circle cx="8.5" cy="9" r="1.6"></circle><path d="M21 16l-5-5L5 20"></path></svg>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
<span class="flex min-w-0 flex-col gap-0.5">
|
||||||
|
<span class="line-clamp-2 text-[13px] font-semibold leading-tight text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</span>
|
||||||
|
<span class="text-sm font-extrabold text-on-surface-strong dark:text-on-surface-dark-strong">{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ currency_symbol }}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</ol>
|
||||||
|
<a href="/shop" class="block border-t border-outline px-4 py-3 text-center text-[13px] font-semibold text-primary transition hover:bg-primary/5 dark:border-outline-dark dark:text-primary-dark">{{ t(key="home-bestsellers-all", lang=L) }}</a>
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- center column -->
|
||||||
|
<div class="flex min-w-0 flex-col gap-6 lg:col-start-1 lg:row-span-2 lg:row-start-1">
|
||||||
|
<!-- hero / heading -->
|
||||||
|
<section>
|
||||||
|
<h1 class="text-3xl font-extrabold tracking-tight text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shop-title", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
<p class="mt-2 max-w-2xl text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-subtitle", lang=lang | default(value='sk')) }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- featured products -->
|
||||||
|
{% if products | length > 0 %}
|
||||||
|
<section class="space-y-4">
|
||||||
|
<div class="flex items-end justify-between">
|
||||||
|
<h2 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
<a href="/shop" class="text-sm font-semibold text-primary dark:text-primary-dark">{{ t(key="cart-continue", lang=lang | default(value='sk')) }} →</a>
|
||||||
|
</div>
|
||||||
|
<div x-data="{ view: localStorage.getItem('shopView') === 'grid' ? 'grid' : 'list' }"
|
||||||
|
x-init="$watch('view', v => localStorage.setItem('shopView', v))"
|
||||||
|
{# Fixed-width cards (14rem), identical to the shop. Cards never stretch;
|
||||||
|
the column just fits as many as it can (home fewer, shop more), so a
|
||||||
|
card is the exact same width on both pages regardless of column count. #}
|
||||||
|
:class="view === 'list' ? 'flex flex-col gap-5' : 'grid grid-cols-2 gap-5 sm:grid-cols-[repeat(auto-fill,14rem)] sm:justify-center'">
|
||||||
|
{% for product in products %}
|
||||||
|
{% include "shop/_card.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{% else %}
|
||||||
|
<section class="rounded-radius border border-outline bg-surface-alt px-6 py-16 text-center dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-empty", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<a href="/shop" class="mt-4 inline-flex items-center justify-center rounded-radius bg-primary px-6 py-2.5 text-sm font-semibold text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- right rail -->
|
||||||
|
<aside class="flex flex-col gap-5 lg:col-start-2 lg:row-start-2">
|
||||||
|
|
||||||
|
<!-- our stores (static) -->
|
||||||
|
<section class="overflow-hidden rounded-radius border border-outline bg-surface-alt dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<h2 class="border-b border-outline px-4 py-3.5 text-xs font-bold uppercase tracking-wider text-on-surface-strong dark:border-outline-dark dark:text-on-surface-dark-strong">{{ t(key="footer-stores", lang=L) }}</h2>
|
||||||
|
<div class="p-3.5">
|
||||||
|
<img src="/static/img/store.jpg" alt="{{ t(key='home-stores-photo', lang=L) }}" width="142" height="115" loading="lazy"
|
||||||
|
class="h-28 w-full rounded-radius border border-outline object-cover dark:border-outline-dark" />
|
||||||
|
<a href="/predajne" class="mt-3 inline-block text-sm font-bold text-primary transition hover:underline dark:text-primary-dark">{{ t(key="home-stores-discover", lang=L) }}</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -36,7 +36,7 @@
|
|||||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||||
{%- endmacro %}
|
{%- endmacro %}
|
||||||
|
|
||||||
{% macro button(label, variant="primary", type="button", href="", attrs="", extra="", icon="", size="px-4 py-2 text-sm") -%}
|
{% macro button(label, variant="primary", type="button", href="", attrs="", extra="", icon="", size="px-4 py-2 text-sm", nowrap=true) -%}
|
||||||
{%- if variant == "secondary" -%}{% set cls = "border border-secondary bg-secondary text-on-secondary focus-visible:outline-secondary dark:border-secondary-dark dark:bg-secondary-dark dark:text-on-secondary-dark dark:focus-visible:outline-secondary-dark" -%}
|
{%- if variant == "secondary" -%}{% set cls = "border border-secondary bg-secondary text-on-secondary focus-visible:outline-secondary dark:border-secondary-dark dark:bg-secondary-dark dark:text-on-secondary-dark dark:focus-visible:outline-secondary-dark" -%}
|
||||||
{%- elif variant == "danger" -%}{% set cls = "border border-danger bg-danger text-on-danger focus-visible:outline-danger dark:bg-danger dark:border-danger dark:text-on-danger dark:focus-visible:outline-danger" -%}
|
{%- elif variant == "danger" -%}{% set cls = "border border-danger bg-danger text-on-danger focus-visible:outline-danger dark:bg-danger dark:border-danger dark:text-on-danger dark:focus-visible:outline-danger" -%}
|
||||||
{%- elif variant == "success" -%}{% set cls = "border border-success bg-success text-on-success focus-visible:outline-success dark:bg-success dark:border-success dark:text-on-success dark:focus-visible:outline-success" -%}
|
{%- elif variant == "success" -%}{% set cls = "border border-success bg-success text-on-success focus-visible:outline-success dark:bg-success dark:border-success dark:text-on-success dark:focus-visible:outline-success" -%}
|
||||||
@@ -49,9 +49,9 @@
|
|||||||
{%- elif variant == "ghost-primary" -%}{% set cls = "bg-transparent text-primary focus-visible:outline-primary dark:text-primary-dark dark:focus-visible:outline-primary-dark" -%}
|
{%- elif variant == "ghost-primary" -%}{% set cls = "bg-transparent text-primary focus-visible:outline-primary dark:text-primary-dark dark:focus-visible:outline-primary-dark" -%}
|
||||||
{%- elif variant == "ghost-secondary" -%}{% set cls = "bg-transparent text-secondary focus-visible:outline-secondary dark:text-secondary-dark dark:focus-visible:outline-secondary-dark" -%}
|
{%- elif variant == "ghost-secondary" -%}{% set cls = "bg-transparent text-secondary focus-visible:outline-secondary dark:text-secondary-dark dark:focus-visible:outline-secondary-dark" -%}
|
||||||
{%- elif variant == "ghost-danger" -%}{% set cls = "bg-transparent text-danger focus-visible:outline-danger dark:text-danger dark:focus-visible:outline-danger" -%}
|
{%- elif variant == "ghost-danger" -%}{% set cls = "bg-transparent text-danger focus-visible:outline-danger dark:text-danger dark:focus-visible:outline-danger" -%}
|
||||||
{%- else -%}{% set cls = "border border-primary bg-primary text-on-primary focus-visible:outline-primary dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark dark:focus-visible:outline-primary-dark" -%}
|
{%- else -%}{% set cls = "border border-cta bg-cta text-on-cta focus-visible:outline-cta dark:border-cta-dark dark:bg-cta-dark dark:text-on-cta-dark dark:focus-visible:outline-cta-dark" -%}
|
||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
{% if href %}<a href="{{ href }}"{% else %}<button type="{{ type }}"{% endif %} class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-radius {{ size }} text-center font-medium tracking-wide transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75 {{ cls }} {{ extra }}" {{ attrs | safe }}>{{ icon | safe }}{{ label }}</{% if href %}a{% else %}button{% endif %}>
|
{% if nowrap %}{% set wrap = "whitespace-nowrap" %}{% else %}{% set wrap = "text-balance" %}{% endif %}{% if href %}<a href="{{ href }}"{% else %}<button type="{{ type }}"{% endif %} class="inline-flex items-center justify-center gap-2 {{ wrap }} rounded-radius {{ size }} text-center font-medium tracking-wide transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75 {{ cls }} {{ extra }}" {{ attrs | safe }}>{{ icon | safe }}{{ label }}</{% if href %}a{% else %}button{% endif %}>
|
||||||
{%- endmacro button %}
|
{%- endmacro button %}
|
||||||
|
|
||||||
{# Icon-only button (square). Penguin ghost treatment (bg-transparent,
|
{# Icon-only button (square). Penguin ghost treatment (bg-transparent,
|
||||||
@@ -83,6 +83,8 @@
|
|||||||
<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>
|
<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" -%}
|
{%- 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>
|
<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 -%}
|
{%- 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>
|
<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 -%}
|
{%- endif -%}
|
||||||
@@ -130,10 +132,10 @@
|
|||||||
{% macro eff_price(p, preview=false) -%}
|
{% macro eff_price(p, preview=false) -%}
|
||||||
{%- if preview -%}{% set strong = "text-info" %}{%- else -%}{% set strong = "text-primary dark:text-primary-dark" %}{%- endif -%}
|
{%- if preview -%}{% set strong = "text-info" %}{%- else -%}{% set strong = "text-primary dark:text-primary-dark" %}{%- endif -%}
|
||||||
{% if p.effective_reduced %}
|
{% if p.effective_reduced %}
|
||||||
<span class="font-medium {{ strong }}">{{ p.effective_price }} {{ p.currency }}</span>
|
<span class="font-medium {{ strong }}">{{ p.effective_price }} €</span>
|
||||||
<span class="ml-1 text-xs text-on-surface/60 dark:text-on-surface-dark/60">(−{{ p.effective_percent_off }}%)</span>
|
<span class="ml-1 text-xs text-on-surface/60 dark:text-on-surface-dark/60">(−{{ p.effective_percent_off }}%)</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ p.effective_price }} {{ p.currency }}
|
{{ p.effective_price }} €
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{%- endmacro eff_price %}
|
{%- endmacro eff_price %}
|
||||||
|
|
||||||
@@ -151,6 +153,34 @@
|
|||||||
<textarea {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" rows="{{ rows }}"{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if required %} required{% endif %} class="w-full rounded-radius border border-outline bg-surface-alt px-2.5 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}>{{ value }}</textarea>
|
<textarea {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" rows="{{ rows }}"{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if required %} required{% endif %} class="w-full rounded-radius border border-outline bg-surface-alt px-2.5 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}>{{ value }}</textarea>
|
||||||
{%- endmacro textarea %}
|
{%- endmacro textarea %}
|
||||||
|
|
||||||
|
{# Quill rich-text editor (see /static/js/rich-editor.js + /static/vendor/quill).
|
||||||
|
The real value rides in a hidden <textarea> the editor keeps in sync, so it
|
||||||
|
submits like any other field. `value` pre-fills (HTML or plain text). Several
|
||||||
|
editors may share one form — each is scoped to its own [data-rich-field].
|
||||||
|
Requires the page to load quill.js + quill.snow.css + rich-editor.js (the
|
||||||
|
product form does so) and a _csrf field in the form for image uploads. #}
|
||||||
|
{% macro rich_editor(name, lang, value="", placeholder="", min_height="12rem") -%}
|
||||||
|
<div data-rich-field>
|
||||||
|
<textarea name="{{ name }}" data-rich-content class="hidden">{{ value }}</textarea>
|
||||||
|
<div data-rich-editor class="rich-editor" style="--rich-min-height: {{ min_height }};"{% if placeholder %} data-placeholder="{{ placeholder }}"{% endif %}></div>
|
||||||
|
<div data-image-size-controls class="rich-image-size-controls mt-2 hidden">
|
||||||
|
<span class="text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="image-size", lang=lang) }}</span>
|
||||||
|
<button type="button" data-image-size="small">{{ t(key="image-size-small", lang=lang) }}</button>
|
||||||
|
<button type="button" data-image-size="medium">{{ t(key="image-size-medium", lang=lang) }}</button>
|
||||||
|
<button type="button" data-image-size="full">{{ t(key="image-size-full", lang=lang) }}</button>
|
||||||
|
<label class="inline-flex items-center gap-1.5">
|
||||||
|
<span class="text-xs text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="image-width-px", lang=lang) }}</span>
|
||||||
|
<input type="number" min="40" max="1200" step="10" data-image-width
|
||||||
|
class="w-20 rounded-radius border border-outline bg-surface-alt px-2 py-1 text-sm text-on-surface dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark">
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-xs text-on-surface/60 dark:text-on-surface-dark/60" data-rich-status
|
||||||
|
data-uploading="{{ t(key='image-uploading', lang=lang) }}"
|
||||||
|
data-uploaded="{{ t(key='image-uploaded', lang=lang) }}"
|
||||||
|
data-error="{{ t(key='image-upload-error', lang=lang) }}"></p>
|
||||||
|
</div>
|
||||||
|
{%- endmacro rich_editor %}
|
||||||
|
|
||||||
{# File input. #}
|
{# File input. #}
|
||||||
{% macro file_input(name, id="", accept="", attrs="", extra="") -%}
|
{% macro file_input(name, id="", accept="", attrs="", extra="") -%}
|
||||||
<input {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" type="file"{% if accept %} accept="{{ accept }}"{% endif %} class="w-full overflow-clip rounded-radius border border-outline bg-surface-alt/50 text-sm text-on-surface file:mr-4 file:border-none file:bg-surface-alt file:px-4 file:py-2 file:font-medium file:text-on-surface-strong focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:file:bg-surface-dark-alt dark:file:text-on-surface-dark-strong dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}/>
|
<input {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" type="file"{% if accept %} accept="{{ accept }}"{% endif %} class="w-full overflow-clip rounded-radius border border-outline bg-surface-alt/50 text-sm text-on-surface file:mr-4 file:border-none file:bg-surface-alt file:px-4 file:py-2 file:font-medium file:text-on-surface-strong focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:file:bg-surface-dark-alt dark:file:text-on-surface-dark-strong dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}/>
|
||||||
@@ -237,3 +267,42 @@ border-t border-outline dark:border-outline-dark
|
|||||||
{%- endif -%}
|
{%- endif -%}
|
||||||
<a href="{{ href }}"{% if data_nav %} data-nav="{{ data_nav }}"{% endif %} class="text-sm font-medium underline-offset-2 transition focus:outline-hidden focus-visible:underline {{ c }}" {{ attrs | safe }}>{{ label }}</a>
|
<a href="{{ href }}"{% if data_nav %} data-nav="{{ data_nav }}"{% endif %} class="text-sm font-medium underline-offset-2 transition focus:outline-hidden focus-visible:underline {{ c }}" {{ attrs | safe }}>{{ label }}</a>
|
||||||
{%- endmacro nav_link %}
|
{%- endmacro nav_link %}
|
||||||
|
|
||||||
|
{# Breadcrumbs (Kompress design: chevron separators). Build a trail by emitting
|
||||||
|
one ui::crumb(label, href) per ancestor and a final ui::crumb_current(label)
|
||||||
|
for the active page, all inside <nav><ol>…</ol></nav>:
|
||||||
|
|
||||||
|
<nav aria-label="breadcrumb" class="text-sm">
|
||||||
|
<ol class="flex flex-wrap items-center gap-1.5 text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
|
{{ ui::crumb(label="Domov", href="/") }}
|
||||||
|
{{ ui::crumb(label="Obchod", href="/shop") }}
|
||||||
|
{{ ui::crumb_current(label=category.name) }}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
Adapted from penguinui/breadcrumbs/breadcrumb-with-chevron.html. #}
|
||||||
|
{% macro crumb(label, href) -%}
|
||||||
|
<li class="flex items-center gap-1.5">
|
||||||
|
<a href="{{ href }}" class="transition hover:text-primary dark:hover:text-primary-dark">{{ label }}</a>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-3.5 shrink-0 text-on-surface/30 dark:text-on-surface-dark/30" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /></svg>
|
||||||
|
</li>
|
||||||
|
{%- endmacro crumb %}
|
||||||
|
|
||||||
|
{% macro crumb_current(label) -%}
|
||||||
|
<li class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong" aria-current="page">{{ label }}</li>
|
||||||
|
{%- endmacro crumb_current %}
|
||||||
|
|
||||||
|
{# Title for the static info pages (controllers/pages.rs → pages/info.html),
|
||||||
|
resolved from the `page` slug. Lives in a macro because a child template's
|
||||||
|
top-level {% set %} isn't visible inside its {% block %}s under `extends`;
|
||||||
|
the macro can be called from both the title and content blocks. #}
|
||||||
|
{% macro page_title(page, lang) -%}
|
||||||
|
{%- if page == "contact" -%}{{ t(key="top-contact", lang=lang) }}
|
||||||
|
{%- elif page == "sitemap" -%}{{ t(key="top-sitemap", lang=lang) }}
|
||||||
|
{%- elif page == "terms" -%}{{ t(key="footer-terms", lang=lang) }}
|
||||||
|
{%- elif page == "about" -%}{{ t(key="footer-about", lang=lang) }}
|
||||||
|
{%- elif page == "stores" -%}{{ t(key="footer-stores", lang=lang) }}
|
||||||
|
{%- elif page == "shipping" -%}{{ t(key="footer-shipping", lang=lang) }}
|
||||||
|
{%- else -%}{{ t(key="brand", lang=lang) }}
|
||||||
|
{%- endif -%}
|
||||||
|
{%- endmacro page_title %}
|
||||||
|
|||||||
102
assets/views/pages/info.html
Normal file
102
assets/views/pages/info.html
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{# Static info pages (contact / sitemap / terms / about / stores / shipping).
|
||||||
|
One template switches title + body on the `page` slug passed by
|
||||||
|
controllers/pages.rs. Titles reuse the existing top-/footer- i18n keys. #}
|
||||||
|
{% block title %}{{ ui::page_title(page=page, lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
{% set L = lang | default(value='sk') %}
|
||||||
|
<nav aria-label="breadcrumb" class="mb-5 text-sm">
|
||||||
|
<ol class="flex flex-wrap items-center gap-1.5 text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
|
{{ ui::crumb(label=t(key="nav-home", lang=L), href="/") }}
|
||||||
|
{{ ui::crumb_current(label=ui::page_title(page=page, lang=L)) }}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
{% endblock breadcrumbs %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% set L = lang | default(value='sk') %}
|
||||||
|
{% set title = ui::page_title(page=page, lang=L) %}
|
||||||
|
<div class="mx-auto max-w-3xl space-y-6">
|
||||||
|
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ title }}</h1>
|
||||||
|
|
||||||
|
{% if page == "contact" %}
|
||||||
|
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="page-contact-intro", lang=L) }}</p>
|
||||||
|
<div class="grid gap-3 sm:grid-cols-3">
|
||||||
|
<div class="rounded-radius border border-outline bg-surface-alt p-4 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-on-surface/50 dark:text-on-surface-dark/50">{{ t(key="top-contact", lang=L) }}</div>
|
||||||
|
<a href="tel:+421903410476" class="mt-1 block text-lg font-bold text-primary dark:text-primary-dark">{{ t(key="hotline", lang=L) }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-radius border border-outline bg-surface-alt p-4 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-on-surface/50 dark:text-on-surface-dark/50">E-mail</div>
|
||||||
|
<a href="mailto:info@kompress.sk" class="mt-1 block font-semibold text-primary dark:text-primary-dark">{{ t(key="footer-email", lang=L) }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="rounded-radius border border-outline bg-surface-alt p-4 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-on-surface/50 dark:text-on-surface-dark/50">{{ t(key="footer-hours", lang=L) }}</div>
|
||||||
|
<div class="mt-1 font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="footer-hours", lang=L) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% elif page == "sitemap" %}
|
||||||
|
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="page-sitemap-intro", lang=L) }}</p>
|
||||||
|
<ul class="grid gap-2 sm:grid-cols-2">
|
||||||
|
<li><a href="/" class="text-primary transition hover:underline dark:text-primary-dark">{{ t(key="nav-home", lang=L) }}</a></li>
|
||||||
|
<li><a href="/shop" class="text-primary transition hover:underline dark:text-primary-dark">{{ t(key="nav-shop", lang=L) }}</a></li>
|
||||||
|
<li><a href="/cart" hx-boost="false" class="text-primary transition hover:underline dark:text-primary-dark">{{ t(key="cart-title", lang=L) }}</a></li>
|
||||||
|
<li><a href="/kontakt" class="text-primary transition hover:underline dark:text-primary-dark">{{ t(key="top-contact", lang=L) }}</a></li>
|
||||||
|
<li><a href="/o-nas" class="text-primary transition hover:underline dark:text-primary-dark">{{ t(key="footer-about", lang=L) }}</a></li>
|
||||||
|
<li><a href="/predajne" class="text-primary transition hover:underline dark:text-primary-dark">{{ t(key="footer-stores", lang=L) }}</a></li>
|
||||||
|
<li><a href="/doprava-a-platba" class="text-primary transition hover:underline dark:text-primary-dark">{{ t(key="footer-shipping", lang=L) }}</a></li>
|
||||||
|
<li><a href="/obchodne-podmienky" class="text-primary transition hover:underline dark:text-primary-dark">{{ t(key="footer-terms", lang=L) }}</a></li>
|
||||||
|
{% if logged_in_customer %}
|
||||||
|
<li><a href="/account/orders" class="text-primary transition hover:underline dark:text-primary-dark">{{ t(key="footer-orders", lang=L) }}</a></li>
|
||||||
|
{% else %}
|
||||||
|
<li><a href="/login" class="text-primary transition hover:underline dark:text-primary-dark">{{ t(key="nav-login", lang=L) }}</a></li>
|
||||||
|
{% endif %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% elif page == "stores" %}
|
||||||
|
{# Production facility (not a retail store): intro, Google map on top, then a
|
||||||
|
small facility photo next to the address card. #}
|
||||||
|
<p class="text-lg text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="page-stores-intro", lang=L) }}</p>
|
||||||
|
|
||||||
|
{# Google Map of the production facility. Embedded via the keyless Maps embed
|
||||||
|
(centered on the geocoded coords); a static PNG would need a Maps Static
|
||||||
|
API key. The header links out to the full map. #}
|
||||||
|
<section class="overflow-hidden rounded-radius border border-outline bg-surface-alt dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<div class="flex items-center justify-between gap-3 border-b border-outline px-4 py-3 dark:border-outline-dark">
|
||||||
|
<h2 class="text-sm font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="page-stores-map", lang=L) }}</h2>
|
||||||
|
<a href="https://www.google.com/maps/search/?api=1&query=49.092412,18.643697" target="_blank" rel="noopener"
|
||||||
|
class="text-sm font-semibold text-primary transition hover:underline dark:text-primary-dark">{{ t(key="page-stores-map-open", lang=L) }}</a>
|
||||||
|
</div>
|
||||||
|
<iframe title="{{ t(key='page-stores-map', lang=L) }}" loading="lazy" class="block h-72 w-full border-0 sm:h-96"
|
||||||
|
referrerpolicy="no-referrer-when-downgrade"
|
||||||
|
src="https://maps.google.com/maps?q=49.092412,18.643697&z=15&hl={{ L }}&output=embed"></iframe>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{# Small facility photo next to the address. #}
|
||||||
|
<div class="flex flex-col gap-4 sm:flex-row sm:items-stretch">
|
||||||
|
<figure class="shrink-0 overflow-hidden rounded-radius border border-outline bg-surface-alt dark:border-outline-dark dark:bg-surface-dark-alt sm:w-48">
|
||||||
|
<img src="/static/img/store.jpg" alt="{{ t(key='home-stores-photo', lang=L) }}" width="142" height="115" loading="lazy"
|
||||||
|
class="h-32 w-full object-cover sm:h-full" />
|
||||||
|
</figure>
|
||||||
|
<div class="flex-1 rounded-radius border border-outline bg-surface-alt p-4 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<div class="text-xs font-semibold uppercase tracking-wide text-on-surface/50 dark:text-on-surface-dark/50">{{ t(key="page-stores-address-label", lang=L) }}</div>
|
||||||
|
<div class="mt-1 font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="page-stores-facility", lang=L) }}</div>
|
||||||
|
<address class="mt-1 not-italic text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="page-stores-address", lang=L) }}</address>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
<div class="rounded-radius border border-outline bg-surface-alt p-6 text-on-surface/70 dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark/70">
|
||||||
|
<p>{{ t(key="page-coming-soon", lang=L) }}</p>
|
||||||
|
<div class="mt-4 flex flex-wrap gap-4 text-sm">
|
||||||
|
<a href="tel:+421903410476" class="font-semibold text-primary dark:text-primary-dark">{{ t(key="hotline", lang=L) }}</a>
|
||||||
|
<a href="mailto:info@kompress.sk" class="font-semibold text-primary dark:text-primary-dark">{{ t(key="footer-email", lang=L) }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
x-bind:aria-expanded="isOpen || openedWithKeyboard" aria-haspopup="true"
|
x-bind:aria-expanded="isOpen || openedWithKeyboard" aria-haspopup="true"
|
||||||
aria-label="{{ t(key='nav-account', lang=lang | default(value='sk')) }}"
|
aria-label="{{ t(key='nav-account', lang=lang | default(value='sk')) }}"
|
||||||
class="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full border border-primary bg-primary text-sm font-bold tracking-wider text-on-primary/90 transition hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark/90 dark:focus-visible:outline-primary-dark">
|
class="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full border border-primary bg-primary text-sm font-bold tracking-wider text-on-primary/90 transition hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark/90 dark:focus-visible:outline-primary-dark">
|
||||||
{%- if _initials %}{{ _initials }}{% else %}{{ _person_icon | safe }}{% endif -%}
|
{%- if customer_avatar %}<img src="/images/{{ customer_avatar }}" alt="{{ _name }}" class="size-full object-cover">{% elif _initials %}{{ _initials }}{% else %}{{ _person_icon | safe }}{% endif -%}
|
||||||
</button>
|
</button>
|
||||||
<!-- Dropdown Menu (positioned like the settings cog: right-0 mt-2) -->
|
<!-- Dropdown Menu (positioned like the settings cog: right-0 mt-2) -->
|
||||||
<div x-cloak x-show="isOpen || openedWithKeyboard" x-transition x-trap="openedWithKeyboard"
|
<div x-cloak x-show="isOpen || openedWithKeyboard" x-transition x-trap="openedWithKeyboard"
|
||||||
@@ -40,7 +40,7 @@
|
|||||||
<!-- header: avatar + name + account type -->
|
<!-- header: avatar + name + account type -->
|
||||||
<div class="flex items-center gap-3 px-4 py-2.5">
|
<div class="flex items-center gap-3 px-4 py-2.5">
|
||||||
<span class="flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-full border border-primary bg-primary text-base font-bold tracking-wider text-on-primary/90 dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark/90">
|
<span class="flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-full border border-primary bg-primary text-base font-bold tracking-wider text-on-primary/90 dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark/90">
|
||||||
{%- if _initials %}{{ _initials }}{% else %}{{ _person_icon | safe }}{% endif -%}
|
{%- if customer_avatar %}<img src="/images/{{ customer_avatar }}" alt="{{ _name }}" class="size-full object-cover">{% elif _initials %}{{ _initials }}{% else %}{{ _person_icon | safe }}{% endif -%}
|
||||||
</span>
|
</span>
|
||||||
<div class="flex min-w-0 flex-col">
|
<div class="flex min-w-0 flex-col">
|
||||||
<span class="truncate text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ _name }}</span>
|
<span class="truncate text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ _name }}</span>
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
for why — htmx hx-boost settles by id). #}
|
for why — htmx hx-boost settles by id). #}
|
||||||
<div x-data="{ isOpen: false, openedWithKeyboard: false }"
|
<div x-data="{ isOpen: false, openedWithKeyboard: false }"
|
||||||
x-on:keydown.esc.window="isOpen = false, openedWithKeyboard = false"
|
x-on:keydown.esc.window="isOpen = false, openedWithKeyboard = false"
|
||||||
class="relative">
|
class="relative self-stretch">
|
||||||
{{ ui::icon_button(aria_label=t(key='settings', lang=lang | default(value='sk')), attrs='x-on:click="isOpen = ! isOpen" x-on:keydown.space.prevent="openedWithKeyboard = true" x-on:keydown.enter.prevent="openedWithKeyboard = true" x-on:keydown.down.prevent="openedWithKeyboard = true" x-bind:aria-expanded="isOpen || openedWithKeyboard" aria-haspopup="true"', icon='<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>') }}
|
{{ ui::icon_button(size="h-full w-9", aria_label=t(key='settings', lang=lang | default(value='sk')), attrs='x-on:click="isOpen = ! isOpen" x-on:keydown.space.prevent="openedWithKeyboard = true" x-on:keydown.enter.prevent="openedWithKeyboard = true" x-on:keydown.down.prevent="openedWithKeyboard = true" x-bind:aria-expanded="isOpen || openedWithKeyboard" aria-haspopup="true"', icon='<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>') }}
|
||||||
<div x-cloak x-show="isOpen || openedWithKeyboard" x-transition x-trap="openedWithKeyboard"
|
<div x-cloak x-show="isOpen || openedWithKeyboard" x-transition x-trap="openedWithKeyboard"
|
||||||
x-on:click.outside="isOpen = false, openedWithKeyboard = false"
|
x-on:click.outside="isOpen = false, openedWithKeyboard = false"
|
||||||
x-on:keydown.down.prevent="$focus.wrap().next()" x-on:keydown.up.prevent="$focus.wrap().previous()"
|
x-on:keydown.down.prevent="$focus.wrap().next()" x-on:keydown.up.prevent="$focus.wrap().previous()"
|
||||||
@@ -35,6 +35,32 @@
|
|||||||
{% if lang | default(value='sk') == "sk" %}<span class="text-primary dark:text-primary-dark">✓</span>{% endif %}
|
{% if lang | default(value='sk') == "sk" %}<span class="text-primary dark:text-primary-dark">✓</span>{% endif %}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
{# Currency switcher. Only enabled (buyer-available) currencies are listed,
|
||||||
|
from the `currencies()` snapshot; the whole section is hidden when the store
|
||||||
|
is EUR-only (no enabled alternatives). The active code is read from the
|
||||||
|
`currency` cookie client-side (Alpine); posting to /currency sets it. #}
|
||||||
|
{% set cc = currencies() %}
|
||||||
|
{% if cc.alts | length > 0 %}
|
||||||
|
<p class="mt-1 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
|
{{ t(key="settings-currency", lang=lang | default(value='sk')) }}
|
||||||
|
</p>
|
||||||
|
<form method="post" action="/currency" hx-boost="false"
|
||||||
|
x-data="{ cur: ((document.cookie.split('; ').find(function (c) { return c.indexOf('currency=') === 0 }) || 'currency={{ cc.base.code }}').split('=')[1]) }">
|
||||||
|
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||||
|
<button type="submit" name="currency" value="{{ cc.base.code }}" role="menuitem"
|
||||||
|
class="flex w-full items-center justify-between px-4 py-2 text-sm text-on-surface transition hover:bg-primary/5 hover:text-on-surface-strong focus-visible:bg-primary/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
|
||||||
|
<span>{{ cc.base.code }} ({{ cc.base.symbol }})</span>
|
||||||
|
<span x-cloak x-show="cur === '{{ cc.base.code }}'" class="text-primary dark:text-primary-dark">✓</span>
|
||||||
|
</button>
|
||||||
|
{% for a in cc.alts %}
|
||||||
|
<button type="submit" name="currency" value="{{ a.code }}" role="menuitem"
|
||||||
|
class="flex w-full items-center justify-between px-4 py-2 text-sm text-on-surface transition hover:bg-primary/5 hover:text-on-surface-strong focus-visible:bg-primary/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
|
||||||
|
<span>{{ a.code }} ({{ a.symbol }})</span>
|
||||||
|
<span x-cloak x-show="cur === '{{ a.code }}'" class="text-primary dark:text-primary-dark">✓</span>
|
||||||
|
</button>
|
||||||
|
{% endfor %}
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
<p class="mt-1 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
<p class="mt-1 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
{{ t(key="settings-theme", lang=lang | default(value='sk')) }}
|
{{ t(key="settings-theme", lang=lang | default(value='sk')) }}
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,45 +1,79 @@
|
|||||||
|
{# 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
|
{# Adapted from the vendored Penguin UI component
|
||||||
(penguinui-components/card/ecommerce-product-card.html):
|
(penguinui-components/card/ecommerce-product-card.html):
|
||||||
wired to our product data + i18n + htmx add-to-cart + toast. The demo rating
|
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
|
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. #}
|
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
|
<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">
|
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"
|
||||||
<a href="/shop/{{ product.slug }}" class="flex flex-1 flex-col">
|
:class="view === 'list' ? 'flex-col sm:flex-row' : 'flex-col'">
|
||||||
|
<a href="/shop/{{ product.slug }}" class="flex min-w-0 flex-1"
|
||||||
|
:class="view === 'list' ? 'flex-row' : 'flex-col'">
|
||||||
<!-- Image -->
|
<!-- 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' ? 'w-28 shrink-0 self-stretch min-h-36 sm:w-48' : 'aspect-[5/4]'">
|
||||||
|
{% if product.on_sale and product.percent_off > 0 %}
|
||||||
|
<span class="absolute left-2 top-2 z-10 rounded-full bg-danger px-2 py-0.5 text-[11px] font-bold text-on-danger shadow-sm">−{{ product.percent_off }} %</span>
|
||||||
|
{% endif %}
|
||||||
{% if product.image %}
|
{% if product.image %}
|
||||||
<img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover transition duration-700 ease-out group-hover:scale-105">
|
<img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover transition duration-700 ease-out group-hover:scale-105">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="flex flex-1 flex-col gap-1 p-6 pb-2">
|
<div class="flex min-w-0 flex-1 flex-col gap-1"
|
||||||
<!-- Header: Title & Price -->
|
:class="view === 'list' ? 'justify-center p-4 sm:p-5' : 'px-4 pt-3 pb-1'">
|
||||||
<div class="flex justify-between gap-4">
|
<!-- Header: Title & Price (stacked so neither overflows the narrow card) -->
|
||||||
<h3 class="text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
|
<h3 class="break-words text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
|
||||||
{% if product.on_sale %}
|
{# Short blurb for the card; falls back to the full description (clamped)
|
||||||
<span class="flex flex-col items-end whitespace-nowrap leading-tight">
|
for products without a dedicated short one. Both are authored as rich
|
||||||
<span class="text-sm text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }} {{ product.currency }}</span>
|
text (Quill), so render the stored HTML — `.rich-blurb` strips block
|
||||||
<span class="text-xl font-semibold text-danger"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ product.currency }}</span>
|
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 %}
|
||||||
|
<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>
|
</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
<span class="whitespace-nowrap text-xl"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ product.currency }}</span>
|
<span class="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 %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="flex flex-col gap-2 p-6 pt-0">
|
<div class="flex flex-col gap-2"
|
||||||
|
:class="view === 'list' ? 'w-full justify-center border-t border-outline p-4 sm:w-48 sm:self-stretch sm:border-l sm:border-t-0 sm:p-5 dark:border-outline-dark' : 'px-4 pb-4 pt-0'">
|
||||||
{% if product.has_options %}
|
{% if product.has_options %}
|
||||||
{# Multiple variants: customer must pick on the product page. #}
|
{# Multiple variants: customer must pick on the product page. #}
|
||||||
{{ ui::button(label=t(key="choose-option", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, extra="w-full") }}
|
{{ ui::button(label=t(key="choose-option", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, extra="w-full", nowrap=false) }}
|
||||||
{% elif product.stock > 0 %}
|
{% elif product.in_stock %}
|
||||||
<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>
|
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{% if product.tracked %}{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}{% else %}{{ t(key="available", lang=lang | default(value='sk')) }}{% endif %}</p>
|
||||||
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none"
|
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none"
|
||||||
hx-on::after-request="if (event.detail.successful) toast('{{ t(key='cart-added', lang=lang | default(value='sk')) }}')">
|
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="_csrf" value="{{ csrf_token() }}">
|
||||||
<input type="hidden" name="variant_id" value="{{ product.variant_id }}">
|
<input type="hidden" name="variant_id" value="{{ product.variant_id }}">
|
||||||
<input type="hidden" name="quantity" value="1">
|
<input type="hidden" name="quantity" value="1">
|
||||||
{{ ui::button(label=t(key="add-to-cart", lang=lang | default(value='sk')), type="submit", extra="w-full", icon='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" class="size-3.5"><path fill-rule="evenodd" d="M5 4a3 3 0 0 1 6 0v1h.643a1.5 1.5 0 0 1 1.492 1.35l.7 7A1.5 1.5 0 0 1 12.342 15H3.657a1.5 1.5 0 0 1-1.492-1.65l.7-7A1.5 1.5 0 0 1 4.357 5H5V4Zm4.5 0v1h-3V4a1.5 1.5 0 0 1 3 0Zm-3 3.75a.75.75 0 0 0-1.5 0v1a3 3 0 1 0 6 0v-1a.75.75 0 0 0-1.5 0v1a1.5 1.5 0 1 1-3 0v-1Z" clip-rule="evenodd" /></svg>') }}
|
{{ ui::button(label=t(key="add-to-cart", lang=lang | default(value='sk')), type="submit", extra="w-full", nowrap=false, icon='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" class="size-3.5 shrink-0"><path fill-rule="evenodd" d="M5 4a3 3 0 0 1 6 0v1h.643a1.5 1.5 0 0 1 1.492 1.35l.7 7A1.5 1.5 0 0 1 12.342 15H3.657a1.5 1.5 0 0 1-1.492-1.65l.7-7A1.5 1.5 0 0 1 4.357 5H5V4Zm4.5 0v1h-3V4a1.5 1.5 0 0 1 3 0Zm-3 3.75a.75.75 0 0 0-1.5 0v1a3 3 0 1 0 6 0v-1a.75.75 0 0 0-1.5 0v1a1.5 1.5 0 1 1-3 0v-1Z" clip-rule="evenodd" /></svg>') }}
|
||||||
</form>
|
</form>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="inline-flex justify-center rounded-radius bg-danger/10 px-3 py-2 text-xs font-medium text-danger">{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}</p>
|
<p class="inline-flex justify-center rounded-radius bg-danger/10 px-3 py-2 text-xs font-medium text-danger">{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}</p>
|
||||||
|
|||||||
@@ -23,10 +23,10 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 tabular-nums">
|
<td class="px-4 py-3 tabular-nums">
|
||||||
{% if item.on_sale %}
|
{% if item.on_sale %}
|
||||||
<span class="font-medium text-danger">{{ item.price }} {{ item.currency }}</span>
|
<span class="font-medium text-danger">{{ item.price }} {{ currency_symbol }}</span>
|
||||||
<span class="ml-1 text-xs text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ item.regular_price }}</span>
|
<span class="ml-1 text-xs text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ item.regular_price }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ item.price }} {{ item.currency }}
|
{{ item.price }} {{ currency_symbol }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
hx-post="/cart/update" hx-trigger="cartchange" hx-target="#cart-body" hx-swap="innerHTML">
|
hx-post="/cart/update" hx-trigger="cartchange" hx-target="#cart-body" hx-swap="innerHTML">
|
||||||
{{ ui::csrf_field() }}
|
{{ ui::csrf_field() }}
|
||||||
<input type="hidden" name="variant_id" value="{{ item.id }}">
|
<input type="hidden" name="variant_id" value="{{ item.id }}">
|
||||||
<input type="number" name="quantity" min="0" max="{{ item.stock }}" value="{{ item.quantity }}"
|
<input type="number" name="quantity" min="0" {% if item.stock %}max="{{ item.stock }}"{% endif %} value="{{ item.quantity }}"
|
||||||
@change="
|
@change="
|
||||||
if (parseInt($el.value || '0') <= 0 && !window.confirm('{{ t(key='cart-remove-confirm', lang=lang | default(value='sk')) }}')) {
|
if (parseInt($el.value || '0') <= 0 && !window.confirm('{{ t(key='cart-remove-confirm', lang=lang | default(value='sk')) }}')) {
|
||||||
$el.value = '{{ item.quantity }}';
|
$el.value = '{{ item.quantity }}';
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
class="w-20 rounded-radius border border-outline bg-surface-alt px-2 py-1 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark">
|
class="w-20 rounded-radius border border-outline bg-surface-alt px-2 py-1 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark">
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 text-right font-medium tabular-nums">{{ item.line_total }} {{ item.currency }}</td>
|
<td class="px-4 py-3 text-right font-medium tabular-nums">{{ item.line_total }} {{ currency_symbol }}</td>
|
||||||
<td class="px-4 py-3 text-right">
|
<td class="px-4 py-3 text-right">
|
||||||
<form method="post" action="/cart/remove"
|
<form method="post" action="/cart/remove"
|
||||||
hx-post="/cart/remove" hx-target="#cart-body" hx-swap="innerHTML">
|
hx-post="/cart/remove" hx-target="#cart-body" hx-swap="innerHTML">
|
||||||
@@ -63,7 +63,7 @@
|
|||||||
<tfoot class="{{ ui::tfoot_cls() }}">
|
<tfoot class="{{ ui::tfoot_cls() }}">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="3" class="px-4 py-3 text-right font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="cart-total", lang=lang | default(value='sk')) }}</td>
|
<td colspan="3" class="px-4 py-3 text-right font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="cart-total", lang=lang | default(value='sk')) }}</td>
|
||||||
<td class="px-4 py-3 text-right text-lg font-bold tabular-nums text-primary dark:text-primary-dark">{{ total }} {{ currency }}</td>
|
<td class="px-4 py-3 text-right text-lg font-bold tabular-nums text-primary dark:text-primary-dark">{{ total }} {{ currency_symbol }}</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{# Mini-cart preview shown on hover over the navbar cart (Alza-style).
|
{# Mini-cart preview shown on hover over the navbar cart (Alza-style).
|
||||||
Lazy-loaded via htmx from /partials/cart into the hover dropdown panel in
|
Lazy-loaded via htmx from /partials/cart into the hover dropdown panel in
|
||||||
base.html. Receives: items[], total, currency, lang. #}
|
base.html. Receives: items[], total, lang. #}
|
||||||
{% import "macros/ui.html" as ui %}
|
{% import "macros/ui.html" as ui %}
|
||||||
{% if items | length > 0 %}
|
{% if items | length > 0 %}
|
||||||
<div class="max-h-80 divide-y divide-outline overflow-y-auto dark:divide-outline-dark">
|
<div class="max-h-80 divide-y divide-outline overflow-y-auto dark:divide-outline-dark">
|
||||||
@@ -9,16 +9,16 @@
|
|||||||
<div class="min-w-0 flex-1">
|
<div class="min-w-0 flex-1">
|
||||||
<a href="/shop/{{ item.slug }}" class="block truncate text-sm font-medium text-on-surface-strong hover:text-primary dark:text-on-surface-dark-strong dark:hover:text-primary-dark">{{ item.name }}</a>
|
<a href="/shop/{{ item.slug }}" class="block truncate text-sm font-medium text-on-surface-strong hover:text-primary dark:text-on-surface-dark-strong dark:hover:text-primary-dark">{{ item.name }}</a>
|
||||||
{% if item.variant_label %}<span class="block truncate text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ item.variant_label }}</span>{% endif %}
|
{% if item.variant_label %}<span class="block truncate text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ item.variant_label }}</span>{% endif %}
|
||||||
<p class="mt-0.5 text-xs tabular-nums text-on-surface dark:text-on-surface-dark">{{ item.quantity }} × {{ item.price }} {{ item.currency }}</p>
|
<p class="mt-0.5 text-xs tabular-nums text-on-surface dark:text-on-surface-dark">{{ item.quantity }} × {{ item.price }} {{ currency_symbol }}</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="shrink-0 text-sm font-semibold tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{{ item.line_total }} {{ item.currency }}</span>
|
<span class="shrink-0 text-sm font-semibold tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{{ item.line_total }} {{ currency_symbol }}</span>
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
<div class="border-t border-outline px-4 py-3 dark:border-outline-dark">
|
<div class="border-t border-outline px-4 py-3 dark:border-outline-dark">
|
||||||
<div class="mb-3 flex items-center justify-between">
|
<div class="mb-3 flex items-center justify-between">
|
||||||
<span class="text-sm text-on-surface dark:text-on-surface-dark">{{ t(key="cart-total", lang=lang | default(value='sk')) }}</span>
|
<span class="text-sm text-on-surface dark:text-on-surface-dark">{{ t(key="cart-total", lang=lang | default(value='sk')) }}</span>
|
||||||
<span class="text-base font-bold tabular-nums text-primary dark:text-primary-dark">{{ total }} {{ currency }}</span>
|
<span class="text-base font-bold tabular-nums text-primary dark:text-primary-dark">{{ total }} {{ currency_symbol }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
{{ ui::button(href="/cart", variant="outline-primary", label=t(key="cart-title", lang=lang | default(value='sk')), extra="flex-1", attrs='hx-boost="false"') }}
|
{{ ui::button(href="/cart", variant="outline-primary", label=t(key="cart-title", lang=lang | default(value='sk')), extra="flex-1", attrs='hx-boost="false"') }}
|
||||||
|
|||||||
12
assets/views/shop/_product_grid.html
Normal file
12
assets/views/shop/_product_grid.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
{# Product collection. The grid / list `view` state is provided by the Alpine
|
||||||
|
wrapper in _search.html (it persists across htmx swaps and is shared with the
|
||||||
|
sort + view-toggle row); `_card.html` reads the same `view` to switch its own
|
||||||
|
layout between a vertical card and a horizontal row. #}
|
||||||
|
{# Fixed-width cards (14rem) — same as the home page. Cards never stretch; the row
|
||||||
|
just fits as many as the width allows. This keeps a card the exact same width on
|
||||||
|
the shop and the home page regardless of how many columns fit. #}
|
||||||
|
<div :class="view === 'list' ? 'flex flex-col gap-5' : 'grid grid-cols-2 gap-5 sm:grid-cols-[repeat(auto-fill,14rem)] sm:justify-center'">
|
||||||
|
{% for product in products %}
|
||||||
|
{% include "shop/_card.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
58
assets/views/shop/_results.html
Normal file
58
assets/views/shop/_results.html
Normal 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>
|
||||||
132
assets/views/shop/_search.html
Normal file
132
assets/views/shop/_search.html
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
{# Shared storefront search box + results region, used by the shop index and
|
||||||
|
every category page. One form drives the listing: htmx re-runs /search and
|
||||||
|
swaps only #shop-results; the toolbar keeps its own DOM state. Triggers: live
|
||||||
|
(debounced) typing in the search box, immediate on a sort change, and submit
|
||||||
|
(Enter). Degrades to a plain GET form without JS.
|
||||||
|
Category is chosen from the sidebar (carried here as a hidden field so it
|
||||||
|
survives a search / re-sort). The grid/list view toggle lives next to sort;
|
||||||
|
its `view` state is held in Alpine on this wrapper so both the toggle and the
|
||||||
|
swapped-in product grid (and `_card.html`) share it.
|
||||||
|
Expects: query, selected_category, sort, plus the result vars consumed by
|
||||||
|
_results.html. #}
|
||||||
|
{% set L = lang | default(value='sk') %}
|
||||||
|
<div x-data="{ view: localStorage.getItem('shopView') === 'grid' ? 'grid' : 'list' }"
|
||||||
|
x-init="$watch('view', v => localStorage.setItem('shopView', v))"
|
||||||
|
class="space-y-6">
|
||||||
|
<form action="/search" method="get" role="search"
|
||||||
|
hx-get="/search" hx-target="#shop-results" hx-swap="innerHTML"
|
||||||
|
hx-push-url="true" hx-indicator="#search-spinner"
|
||||||
|
{# The text query runs only on submit (Enter / the Search button); the
|
||||||
|
sort / per-page / in-stock controls still apply immediately on change. #}
|
||||||
|
hx-trigger="submit, change from:select, change from:input[type='checkbox']"
|
||||||
|
class="space-y-3">
|
||||||
|
|
||||||
|
{# Category comes from the sidebar; keep it on the query so searching /
|
||||||
|
re-sorting stays within the active category. #}
|
||||||
|
<input type="hidden" name="category" value="{{ selected_category | default(value='all') }}" />
|
||||||
|
|
||||||
|
<!-- search box -->
|
||||||
|
<div class="hidden max-w-xl gap-2">
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-on-surface/50 dark:text-on-surface-dark/50">
|
||||||
|
{{ ui::icon(name="search", size="size-5") }}
|
||||||
|
</span>
|
||||||
|
<input type="search" name="q" value="{{ query | default(value='') }}" autocomplete="off"
|
||||||
|
placeholder="{{ t(key='search-placeholder', lang=L) }}"
|
||||||
|
aria-label="{{ t(key='search-placeholder', lang=L) }}"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface py-2 pl-10 pr-10 text-sm text-on-surface placeholder:text-on-surface/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50 dark:focus-visible:outline-primary-dark" />
|
||||||
|
<span id="search-spinner" class="htmx-indicator pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-on-surface/50 dark:text-on-surface-dark/50">
|
||||||
|
<svg class="size-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.4 0 0 5.4 0 12h4Z"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="shrink-0 rounded-radius bg-cta px-5 text-sm font-bold text-on-cta transition hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-cta dark:bg-cta-dark dark:text-on-cta-dark dark:focus-visible:outline-cta-dark">
|
||||||
|
{{ t(key="search-button", lang=L) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Scope indicator: when a category is active, make clear the search is
|
||||||
|
limited to it (not the whole shop), with a one-click escape to search
|
||||||
|
everything. Category only changes via full navigation (the sidebar), so
|
||||||
|
this stays accurate across the toolbar's results-only htmx swaps. #}
|
||||||
|
{% if selected_category and selected_category != "all" %}
|
||||||
|
{# set_global so the value survives the nested if (a plain `set` inside a
|
||||||
|
block is scoped to that block in Tera and wouldn't be visible below). #}
|
||||||
|
{% set_global _scope = selected_category_name | default(value="") %}
|
||||||
|
{% if selected_category == "none" %}{% set_global _scope = t(key="uncategorized", lang=L) %}{% endif %}
|
||||||
|
{% if _scope %}
|
||||||
|
<div class="flex max-w-xl flex-wrap items-center gap-2 text-xs">
|
||||||
|
<span class="inline-flex items-center gap-1.5 rounded-full bg-primary/10 px-3 py-1 font-medium text-primary dark:bg-primary-dark/15 dark:text-primary-dark">
|
||||||
|
{{ ui::icon(name="search", size="size-3.5", extra="shrink-0") }}
|
||||||
|
{{ t(key="search-scope-in", lang=L) }} <span class="font-semibold">{{ _scope }}</span>
|
||||||
|
</span>
|
||||||
|
{# This link descends from the search form, so it inherits its
|
||||||
|
hx-target="#shop-results" / hx-swap="innerHTML". Switching scope is a
|
||||||
|
real navigation (new breadcrumb, sidebar state, full-page response),
|
||||||
|
so override the inherited target back to the body — otherwise the
|
||||||
|
boosted full page gets nested inside the results region. #}
|
||||||
|
<a href="/search{% if query %}?q={{ query | urlencode }}{% endif %}"
|
||||||
|
hx-target="body" hx-swap="innerHTML"
|
||||||
|
class="font-medium text-on-surface/60 underline-offset-2 hover:text-primary hover:underline dark:text-on-surface-dark/60 dark:hover:text-primary-dark">
|
||||||
|
{{ t(key="search-scope-all", lang=L) }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- sort + product card style switch -->
|
||||||
|
<div class="flex flex-wrap items-center justify-end gap-3">
|
||||||
|
<label class="flex items-center gap-2 text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70">
|
||||||
|
{{ t(key="sort-label", lang=L) }}
|
||||||
|
{% include "shop/_sort_select.html" %}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- per-page count -->
|
||||||
|
<label class="flex items-center gap-2 text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70">
|
||||||
|
{{ t(key="per-page-label", lang=L) }}
|
||||||
|
<select name="per_page"
|
||||||
|
class="rounded-radius border border-outline bg-surface px-2 py-1.5 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
{% for opt in per_page_options %}
|
||||||
|
<option value="{{ opt }}"{% if per_page == opt %} selected{% endif %}>{{ opt }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- in stock only -->
|
||||||
|
<label class="flex items-center gap-2 text-sm text-on-surface dark:text-on-surface-dark">
|
||||||
|
<input type="checkbox" name="in_stock" value="1"{% if in_stock %} checked{% endif %}
|
||||||
|
class="size-4 rounded border-outline text-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:text-primary-dark" />
|
||||||
|
{{ t(key="filter-in-stock", lang=L) }}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- grid / list view toggle -->
|
||||||
|
<div class="inline-flex gap-0.5 rounded-radius border border-outline p-0.5 dark:border-outline-dark" role="group"
|
||||||
|
aria-label="{{ t(key='view-grid', lang=L) }} / {{ t(key='view-list', lang=L) }}">
|
||||||
|
<button type="button" @click="view = 'grid'" :aria-pressed="view === 'grid'"
|
||||||
|
class="inline-flex size-8 items-center justify-center rounded-radius transition"
|
||||||
|
:class="view === 'grid' ? 'bg-primary text-on-primary dark:bg-primary-dark dark:text-on-primary-dark' : 'text-on-surface hover:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark'"
|
||||||
|
aria-label="{{ t(key='view-grid', lang=L) }}"
|
||||||
|
title="{{ t(key='view-grid', lang=L) }}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-4">
|
||||||
|
<path d="M3 3h6v6H3V3Zm8 0h6v6h-6V3ZM3 11h6v6H3v-6Zm8 0h6v6h-6v-6Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="view = 'list'" :aria-pressed="view === 'list'"
|
||||||
|
class="inline-flex size-8 items-center justify-center rounded-radius transition"
|
||||||
|
:class="view === 'list' ? 'bg-primary text-on-primary dark:bg-primary-dark dark:text-on-primary-dark' : 'text-on-surface hover:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark'"
|
||||||
|
aria-label="{{ t(key='view-list', lang=L) }}"
|
||||||
|
title="{{ t(key='view-list', lang=L) }}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-4">
|
||||||
|
<path d="M3 4h14v2.5H3V4Zm0 4.75h14v2.5H3v-2.5ZM3 13.5h14V16H3v-2.5Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="shop-results">
|
||||||
|
{% include "shop/_results.html" %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -16,8 +16,13 @@
|
|||||||
{{ t(key="categories", lang=lang | default(value='sk')) }}
|
{{ t(key="categories", lang=lang | default(value='sk')) }}
|
||||||
</p>
|
</p>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
|
{# mobile-only Home link: the navbar logo (the Home affordance) is hidden on
|
||||||
|
small screens, so navigation home lives here in the drawer instead. #}
|
||||||
|
<a href="/" data-nav="/" class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm lg:hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark">
|
||||||
|
{{ t(key="nav-home", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
<a href="/shop" data-nav="/shop"
|
<a href="/shop" data-nav="/shop"
|
||||||
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark">
|
||||||
{{ t(key="all-products", lang=lang | default(value='sk')) }}
|
{{ t(key="all-products", lang=lang | default(value='sk')) }}
|
||||||
</a>
|
</a>
|
||||||
{% for group in category_groups %}
|
{% for group in category_groups %}
|
||||||
@@ -26,7 +31,7 @@
|
|||||||
x-init="open = ['{{ group.slug }}'{% for child in group.children %}, '{{ child.slug }}'{% endfor %}].some(s => location.pathname === '/category/' + s)">
|
x-init="open = ['{{ group.slug }}'{% for child in group.children %}, '{{ child.slug }}'{% endfor %}].some(s => location.pathname === '/category/' + s)">
|
||||||
<div class="flex items-stretch">
|
<div class="flex items-stretch">
|
||||||
<a href="/category/{{ group.slug }}" data-nav="/category/{{ group.slug }}"
|
<a href="/category/{{ group.slug }}" data-nav="/category/{{ group.slug }}"
|
||||||
class="flex flex-1 items-center gap-2 truncate rounded-l-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
class="flex flex-1 items-center gap-2 truncate rounded-l-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark">
|
||||||
{{ group.name }}
|
{{ group.name }}
|
||||||
</a>
|
</a>
|
||||||
<button type="button" x-on:click="open = ! open" x-bind:aria-expanded="open ? 'true' : 'false'"
|
<button type="button" x-on:click="open = ! open" x-bind:aria-expanded="open ? 'true' : 'false'"
|
||||||
@@ -42,7 +47,7 @@
|
|||||||
{% for child in group.children %}
|
{% for child in group.children %}
|
||||||
<li>
|
<li>
|
||||||
<a href="/category/{{ child.slug }}" data-nav="/category/{{ child.slug }}"
|
<a href="/category/{{ child.slug }}" data-nav="/category/{{ child.slug }}"
|
||||||
class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark">
|
||||||
{{ child.name }}
|
{{ child.name }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -51,7 +56,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/category/{{ group.slug }}" data-nav="/category/{{ group.slug }}"
|
<a href="/category/{{ group.slug }}" data-nav="/category/{{ group.slug }}"
|
||||||
class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark">
|
||||||
{{ group.name }}
|
{{ group.name }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -60,3 +65,18 @@
|
|||||||
{% if category_groups | length == 0 %}
|
{% if category_groups | length == 0 %}
|
||||||
<p class="px-2 py-2 text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="shop-empty", lang=lang | default(value='sk')) }}</p>
|
<p class="px-2 py-2 text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="shop-empty", lang=lang | default(value='sk')) }}</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
{# "Informácie" card (Kompress design): static info links below the category
|
||||||
|
tree, separated by a divider. Targets are placeholders (#) until real pages
|
||||||
|
exist; labels reuse the footer-* i18n keys. #}
|
||||||
|
<div class="mt-4 border-t border-outline pt-3 dark:border-outline-dark">
|
||||||
|
<p class="px-2 pb-2 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
|
{{ t(key="footer-info", lang=lang | default(value='sk')) }}
|
||||||
|
</p>
|
||||||
|
{% set L = lang | default(value='sk') %}
|
||||||
|
<div class="flex flex-col gap-0.5">
|
||||||
|
<a href="/obchodne-podmienky" data-nav="/obchodne-podmienky" class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark">{{ t(key="footer-terms", lang=L) }}</a>
|
||||||
|
<a href="/predajne" data-nav="/predajne" class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark">{{ t(key="footer-stores", lang=L) }}</a>
|
||||||
|
<a href="/doprava-a-platba" data-nav="/doprava-a-platba" class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark">{{ t(key="footer-shipping", lang=L) }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
12
assets/views/shop/_sort_select.html
Normal file
12
assets/views/shop/_sort_select.html
Normal 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>
|
||||||
@@ -3,18 +3,24 @@
|
|||||||
|
|
||||||
{% block title %}{{ category.name }}{% endblock title %}
|
{% block title %}{{ category.name }}{% endblock title %}
|
||||||
|
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
{% set L = lang | default(value='sk') %}
|
||||||
|
<nav aria-label="breadcrumb" class="mb-5 text-sm">
|
||||||
|
<ol class="flex flex-wrap items-center gap-1.5 text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
|
{{ ui::crumb(label=t(key="nav-home", lang=L), href="/") }}
|
||||||
|
{{ ui::crumb(label=t(key="nav-shop", lang=L), href="/shop") }}
|
||||||
|
{% for crumb in breadcrumbs %}
|
||||||
|
{{ ui::crumb(label=crumb.name, href="/category/" ~ crumb.slug) }}
|
||||||
|
{% endfor %}
|
||||||
|
{{ ui::crumb_current(label=category.name) }}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
{% endblock breadcrumbs %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="space-y-8">
|
{% set L = lang | default(value='sk') %}
|
||||||
|
<div class="space-y-6">
|
||||||
<header class="space-y-2">
|
<header class="space-y-2">
|
||||||
<nav class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">
|
|
||||||
<a href="/shop" class="hover:text-primary dark:hover:text-primary-dark">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</a>
|
|
||||||
{% for crumb in breadcrumbs %}
|
|
||||||
<span class="px-1">/</span>
|
|
||||||
<a href="/category/{{ crumb.slug }}" class="hover:text-primary dark:hover:text-primary-dark">{{ crumb.name }}</a>
|
|
||||||
{% endfor %}
|
|
||||||
<span class="px-1">/</span>
|
|
||||||
<span>{{ category.name }}</span>
|
|
||||||
</nav>
|
|
||||||
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ category.name }}</h1>
|
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ category.name }}</h1>
|
||||||
{% if category.description %}<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ category.description }}</p>{% endif %}
|
{% if category.description %}<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ category.description }}</p>{% endif %}
|
||||||
|
|
||||||
@@ -28,16 +34,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{% if products | length > 0 %}
|
{# Same search + filters as the shop, with this category preselected. #}
|
||||||
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4">
|
{% include "shop/_search.html" %}
|
||||||
{% 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 %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
x-data="{
|
x-data="{
|
||||||
paymentMethod: '',
|
paymentMethod: '',
|
||||||
accountType: '{{ prefill_account_type | default(value='personal') }}',
|
accountType: '{{ prefill_account_type | default(value='personal') }}',
|
||||||
|
deliverySame: false,
|
||||||
carrier: '',
|
carrier: '',
|
||||||
carrierPrice: 0,
|
carrierPrice: 0,
|
||||||
requiresPoint: false,
|
requiresPoint: false,
|
||||||
@@ -128,26 +129,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<!-- shipping address -->
|
<!-- residence address -->
|
||||||
<fieldset class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
<fieldset class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</legend>
|
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-residence-address", lang=lang | default(value='sk')) }}</legend>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="address" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-address", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
<label for="residence_address" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-address", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
||||||
{{ ui::input(name="address", id="address", value=prefill_address | default(value=''), required=true, autocomplete="street-address") }}
|
{{ ui::input(name="residence_address", id="residence_address", value=prefill_residence_address | default(value=''), required=true, autocomplete="billing street-address") }}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-4 sm:grid-cols-3">
|
<div class="grid gap-4 sm:grid-cols-3">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="city" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-city", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
<label for="residence_city" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-city", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
||||||
{{ ui::input(name="city", id="city", value=prefill_city | default(value=''), required=true, autocomplete="address-level2") }}
|
{{ ui::input(name="residence_city", id="residence_city", value=prefill_residence_city | default(value=''), required=true, autocomplete="billing address-level2") }}
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="zip" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-zip", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
<label for="residence_zip" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-zip", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
||||||
{{ ui::input(name="zip", id="zip", value=prefill_zip | default(value=''), required=true, autocomplete="postal-code") }}
|
{{ ui::input(name="residence_zip", id="residence_zip", value=prefill_residence_zip | default(value=''), required=true, autocomplete="billing postal-code") }}
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="country" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-country", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
<label for="residence_country" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-country", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
||||||
<div class="relative" @click.outside="countryOpen = false"
|
<div class="relative" @click.outside="countryOpen = false"
|
||||||
x-data="{ countryOpen: false, country: '{{ prefill_country | default(value=t(key='country-sk', lang=lang | default(value='sk'))) }}', opts: [
|
x-data="{ countryOpen: false, country: '{{ prefill_residence_country | default(value=t(key='country-sk', lang=lang | default(value='sk'))) }}', opts: [
|
||||||
{ v: '{{ t(key='country-sk', lang=lang | default(value='sk')) }}', l: '🇸🇰 {{ t(key='country-sk', lang=lang | default(value='sk')) }}' },
|
{ v: '{{ t(key='country-sk', lang=lang | default(value='sk')) }}', l: '🇸🇰 {{ t(key='country-sk', lang=lang | default(value='sk')) }}' },
|
||||||
{ v: '{{ t(key='country-cz', lang=lang | default(value='sk')) }}', l: '🇨🇿 {{ t(key='country-cz', lang=lang | default(value='sk')) }}' },
|
{ v: '{{ t(key='country-cz', lang=lang | default(value='sk')) }}', l: '🇨🇿 {{ t(key='country-cz', lang=lang | default(value='sk')) }}' },
|
||||||
{ v: '{{ t(key='country-at', lang=lang | default(value='sk')) }}', l: '🇦🇹 {{ t(key='country-at', lang=lang | default(value='sk')) }}' },
|
{ v: '{{ t(key='country-at', lang=lang | default(value='sk')) }}', l: '🇦🇹 {{ t(key='country-at', lang=lang | default(value='sk')) }}' },
|
||||||
@@ -155,7 +156,57 @@
|
|||||||
{ v: '{{ t(key='country-pl', lang=lang | default(value='sk')) }}', l: '🇵🇱 {{ t(key='country-pl', lang=lang | default(value='sk')) }}' },
|
{ v: '{{ t(key='country-pl', lang=lang | default(value='sk')) }}', l: '🇵🇱 {{ t(key='country-pl', lang=lang | default(value='sk')) }}' },
|
||||||
{ v: '{{ t(key='country-hu', lang=lang | default(value='sk')) }}', l: '🇭🇺 {{ t(key='country-hu', lang=lang | default(value='sk')) }}' }
|
{ v: '{{ t(key='country-hu', lang=lang | default(value='sk')) }}', l: '🇭🇺 {{ t(key='country-hu', lang=lang | default(value='sk')) }}' }
|
||||||
], get filtered() { return this.opts.filter(o => !this.country || o.v.toLowerCase().includes(this.country.toLowerCase())) } }">
|
], get filtered() { return this.opts.filter(o => !this.country || o.v.toLowerCase().includes(this.country.toLowerCase())) } }">
|
||||||
<input id="country" name="country" type="text" x-model="country" required @focus="countryOpen = true" @input="countryOpen = true"
|
<input id="residence_country" name="residence_country" type="text" x-model="country" required @focus="countryOpen = true" @input="countryOpen = true"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface py-2 pl-3 pr-8 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
<button type="button" tabindex="-1" @click="countryOpen = !countryOpen"
|
||||||
|
class="absolute inset-y-0 right-0 flex w-8 items-center justify-center text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"
|
||||||
|
class="size-4 transition-transform" :class="countryOpen && 'rotate-180'">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<ul x-show="countryOpen" x-cloak x-transition
|
||||||
|
class="absolute z-20 mt-1 max-h-56 w-full overflow-auto rounded-radius border border-outline bg-surface p-1 shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<template x-for="o in filtered" :key="o.v">
|
||||||
|
<li><button type="button" @click="country = o.v; countryOpen = false" x-text="o.l"
|
||||||
|
class="block w-full rounded-radius px-3 py-1.5 text-left text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark"></button></li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{{ ui::checkbox(name="delivery_same_as_residence", id="delivery_same_as_residence", label=t(key="checkout-delivery-same", lang=lang | default(value='sk')), attrs='x-model="deliverySame"') }}
|
||||||
|
|
||||||
|
<!-- delivery address -->
|
||||||
|
<fieldset x-show="!deliverySame" x-cloak class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</legend>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="address" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-address", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
||||||
|
{{ ui::input(name="address", id="address", autocomplete="shipping street-address", attrs=':required="!deliverySame"') }}
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="city" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-city", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
||||||
|
{{ ui::input(name="city", id="city", autocomplete="shipping address-level2", attrs=':required="!deliverySame"') }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="zip" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-zip", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
||||||
|
{{ ui::input(name="zip", id="zip", autocomplete="shipping postal-code", attrs=':required="!deliverySame"') }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="country" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-country", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
||||||
|
<div class="relative" @click.outside="countryOpen = false"
|
||||||
|
x-data="{ countryOpen: false, country: '{{ t(key='country-sk', lang=lang | default(value='sk')) }}', opts: [
|
||||||
|
{ v: '{{ t(key='country-sk', lang=lang | default(value='sk')) }}', l: '🇸🇰 {{ t(key='country-sk', lang=lang | default(value='sk')) }}' },
|
||||||
|
{ v: '{{ t(key='country-cz', lang=lang | default(value='sk')) }}', l: '🇨🇿 {{ t(key='country-cz', lang=lang | default(value='sk')) }}' },
|
||||||
|
{ v: '{{ t(key='country-at', lang=lang | default(value='sk')) }}', l: '🇦🇹 {{ t(key='country-at', lang=lang | default(value='sk')) }}' },
|
||||||
|
{ v: '{{ t(key='country-de', lang=lang | default(value='sk')) }}', l: '🇩🇪 {{ t(key='country-de', lang=lang | default(value='sk')) }}' },
|
||||||
|
{ v: '{{ t(key='country-pl', lang=lang | default(value='sk')) }}', l: '🇵🇱 {{ t(key='country-pl', lang=lang | default(value='sk')) }}' },
|
||||||
|
{ v: '{{ t(key='country-hu', lang=lang | default(value='sk')) }}', l: '🇭🇺 {{ t(key='country-hu', lang=lang | default(value='sk')) }}' }
|
||||||
|
], get filtered() { return this.opts.filter(o => !this.country || o.v.toLowerCase().includes(this.country.toLowerCase())) } }">
|
||||||
|
<input id="country" name="country" type="text" x-model="country" :required="!deliverySame" @focus="countryOpen = true" @input="countryOpen = true"
|
||||||
class="w-full rounded-radius border border-outline bg-surface py-2 pl-3 pr-8 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
class="w-full rounded-radius border border-outline bg-surface py-2 pl-3 pr-8 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
<button type="button" tabindex="-1" @click="countryOpen = !countryOpen"
|
<button type="button" tabindex="-1" @click="countryOpen = !countryOpen"
|
||||||
class="absolute inset-y-0 right-0 flex w-8 items-center justify-center text-on-surface/60 dark:text-on-surface-dark/60">
|
class="absolute inset-y-0 right-0 flex w-8 items-center justify-center text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
@@ -188,7 +239,7 @@
|
|||||||
class="before:content[''] relative h-4 w-4 appearance-none rounded-full border border-outline bg-surface before:invisible before:absolute before:left-1/2 before:top-1/2 before:h-1.5 before:w-1.5 before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:bg-on-primary checked:border-primary checked:bg-primary checked:before:visible focus:outline-2 focus:outline-offset-2 focus:outline-outline-strong checked:focus:outline-primary disabled:cursor-not-allowed dark:border-outline-dark dark:bg-surface-dark dark:before:bg-on-primary-dark dark:checked:border-primary-dark dark:checked:bg-primary-dark dark:focus:outline-outline-dark-strong dark:checked:focus:outline-primary-dark">
|
class="before:content[''] relative h-4 w-4 appearance-none rounded-full border border-outline bg-surface before:invisible before:absolute before:left-1/2 before:top-1/2 before:h-1.5 before:w-1.5 before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:bg-on-primary checked:border-primary checked:bg-primary checked:before:visible focus:outline-2 focus:outline-offset-2 focus:outline-outline-strong checked:focus:outline-primary disabled:cursor-not-allowed dark:border-outline-dark dark:bg-surface-dark dark:before:bg-on-primary-dark dark:checked:border-primary-dark dark:checked:bg-primary-dark dark:focus:outline-outline-dark-strong dark:checked:focus:outline-primary-dark">
|
||||||
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ m.name }}</span>
|
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ m.name }}</span>
|
||||||
</span>
|
</span>
|
||||||
<span class="tabular-nums text-on-surface/80 dark:text-on-surface-dark/80">{{ m.price }} {{ currency }}</span>
|
<span class="tabular-nums text-on-surface/80 dark:text-on-surface-dark/80">{{ m.price }} €</span>
|
||||||
</label>
|
</label>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
@@ -215,14 +266,16 @@
|
|||||||
<!-- payment -->
|
<!-- payment -->
|
||||||
<fieldset class="space-y-3 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
<fieldset class="space-y-3 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-payment", lang=lang | default(value='sk')) }}{{ ui::req() }}</legend>
|
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-payment", lang=lang | default(value='sk')) }}{{ ui::req() }}</legend>
|
||||||
|
{% if payment_methods | length > 0 %}
|
||||||
|
{% for method in payment_methods %}
|
||||||
<label class="flex cursor-pointer items-center gap-3 rounded-radius border border-outline px-4 py-3 transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark">
|
<label class="flex cursor-pointer items-center gap-3 rounded-radius border border-outline px-4 py-3 transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark">
|
||||||
{{ ui::radio(name="payment_method", value="cod", attrs='required x-model="paymentMethod"') }}
|
{{ ui::radio(name="payment_method", value=method.code, attrs='required x-model="paymentMethod"') }}
|
||||||
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-cod", lang=lang | default(value='sk')) }}</span>
|
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key=method.label_key, lang=lang | default(value='sk')) }}</span>
|
||||||
</label>
|
|
||||||
<label class="flex cursor-pointer items-center gap-3 rounded-radius border border-outline px-4 py-3 transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark">
|
|
||||||
{{ ui::radio(name="payment_method", value="bank_transfer", attrs='required x-model="paymentMethod"') }}
|
|
||||||
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-bank", lang=lang | default(value='sk')) }}</span>
|
|
||||||
</label>
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="payment-none", lang=lang | default(value='sk')) }}</p>
|
||||||
|
{% endif %}
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
@@ -252,23 +305,23 @@
|
|||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<li class="flex justify-between gap-2">
|
<li class="flex justify-between gap-2">
|
||||||
<span class="text-on-surface/80 dark:text-on-surface-dark/80">{{ item.name }} × {{ item.quantity }}</span>
|
<span class="text-on-surface/80 dark:text-on-surface-dark/80">{{ item.name }} × {{ item.quantity }}</span>
|
||||||
<span class="tabular-nums">{{ item.line_total }} {{ item.currency }}</span>
|
<span class="tabular-nums">{{ item.line_total }} €</span>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
<div class="space-y-1 border-t border-outline pt-3 text-sm dark:border-outline-dark">
|
<div class="space-y-1 border-t border-outline pt-3 text-sm dark:border-outline-dark">
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }}</span>
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }}</span>
|
||||||
<span class="tabular-nums">{{ subtotal }} {{ currency }}</span>
|
<span class="tabular-nums">{{ subtotal }} €</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between">
|
<div class="flex justify-between">
|
||||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-shipping-cost", lang=lang | default(value='sk')) }}</span>
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-shipping-cost", lang=lang | default(value='sk')) }}</span>
|
||||||
<span class="tabular-nums" x-text="fmt(carrierPrice) + ' {{ currency }}'"></span>
|
<span class="tabular-nums" x-text="fmt(carrierPrice) + ' €'"></span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between border-t border-outline pt-3 text-base font-bold dark:border-outline-dark">
|
<div class="flex justify-between border-t border-outline pt-3 text-base font-bold dark:border-outline-dark">
|
||||||
<span>{{ t(key="cart-total", lang=lang | default(value='sk')) }}</span>
|
<span>{{ t(key="cart-total", lang=lang | default(value='sk')) }}</span>
|
||||||
<span class="tabular-nums text-primary dark:text-primary-dark" x-text="fmt(subtotal + carrierPrice) + ' {{ currency }}'"></span>
|
<span class="tabular-nums text-primary dark:text-primary-dark" x-text="fmt(subtotal + carrierPrice) + ' €'"></span>
|
||||||
</div>
|
</div>
|
||||||
{{ ui::button(label=t(key="checkout-place-order", lang=lang | default(value='sk')), type="submit", attrs=':disabled="!canSubmit"', extra="w-full", size="px-6 py-2.5 text-sm") }}
|
{{ ui::button(label=t(key="checkout-place-order", lang=lang | default(value='sk')), type="submit", attrs=':disabled="!canSubmit"', extra="w-full", size="px-6 py-2.5 text-sm") }}
|
||||||
</aside>
|
</aside>
|
||||||
|
|||||||
@@ -3,23 +3,24 @@
|
|||||||
|
|
||||||
{% block title %}{{ t(key="nav-shop", lang=lang | default(value='sk')) }}{% endblock title %}
|
{% block title %}{{ t(key="nav-shop", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
{% set L = lang | default(value='sk') %}
|
||||||
|
<nav aria-label="breadcrumb" class="mb-5 text-sm">
|
||||||
|
<ol class="flex flex-wrap items-center gap-1.5 text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
|
{{ ui::crumb(label=t(key="nav-home", lang=L), href="/") }}
|
||||||
|
{{ ui::crumb_current(label=t(key="nav-shop", lang=L)) }}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
{% endblock breadcrumbs %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="space-y-8">
|
{% set L = lang | default(value='sk') %}
|
||||||
<header class="space-y-2">
|
<div class="space-y-6">
|
||||||
<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>
|
<header class="space-y-1">
|
||||||
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-subtitle", lang=lang | default(value='sk')) }}</p>
|
<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>
|
</header>
|
||||||
|
|
||||||
{% if products | length > 0 %}
|
{% include "shop/_search.html" %}
|
||||||
<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 %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -30,18 +30,33 @@
|
|||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<li class="flex justify-between gap-2">
|
<li class="flex justify-between gap-2">
|
||||||
<span class="text-on-surface/80 dark:text-on-surface-dark/80">{{ item.product_name }}{% if item.variant_label %} · {{ item.variant_label }}{% endif %} × {{ item.quantity }}</span>
|
<span class="text-on-surface/80 dark:text-on-surface-dark/80">{{ item.product_name }}{% if item.variant_label %} · {{ item.variant_label }}{% endif %} × {{ item.quantity }}</span>
|
||||||
<span class="tabular-nums">{{ item.line_total }} {{ order.currency }}</span>
|
<span class="tabular-nums">{{ item.line_total }} €</span>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
<div class="space-y-1 border-t border-outline py-3 text-sm dark:border-outline-dark">
|
<div class="space-y-1 border-t border-outline py-3 text-sm dark:border-outline-dark">
|
||||||
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }}</span><span class="tabular-nums">{{ order.subtotal }} {{ order.currency }}</span></div>
|
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }}</span><span class="tabular-nums">{{ order.subtotal }} €</span></div>
|
||||||
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ order.carrier_name }}</span><span class="tabular-nums">{{ order.shipping }} {{ order.currency }}</span></div>
|
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ order.carrier_name }}</span><span class="tabular-nums">{{ order.shipping }} €</span></div>
|
||||||
{% if order.pickup_point_name %}<div class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ order.pickup_point_name }}</div>{% endif %}
|
{% if order.pickup_point_name %}<div class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ order.pickup_point_name }}</div>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-between border-t border-outline pt-3 font-bold dark:border-outline-dark">
|
<div class="flex justify-between border-t border-outline pt-3 font-bold dark:border-outline-dark">
|
||||||
<span>{{ t(key="order-total", lang=lang | default(value='sk')) }}</span>
|
<span>{{ t(key="order-total", lang=lang | default(value='sk')) }}</span>
|
||||||
<span class="tabular-nums text-primary dark:text-primary-dark">{{ order.total }} {{ order.currency }}</span>
|
<span class="tabular-nums text-primary dark:text-primary-dark">{{ order.total }} €</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 text-sm sm:grid-cols-2">
|
||||||
|
{% if order.residence_address %}
|
||||||
|
<div class="rounded-radius border border-outline bg-surface p-4 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<h2 class="mb-2 font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-residence-address", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.residence_address }}</p>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.residence_zip }} {{ order.residence_city }}{% if order.residence_country %}, {{ order.residence_country }}{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="rounded-radius border border-outline bg-surface p-4 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<h2 class="mb-2 font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
{% if order.address %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.address }}</p>{% endif %}
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.zip }} {{ order.city }}{% if order.country %}, {{ order.country }}{% endif %}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -52,7 +67,7 @@
|
|||||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-account-name", lang=lang | default(value='sk')) }}</span><span class="font-medium">{{ order.bank_account_name }}</span>
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-account-name", lang=lang | default(value='sk')) }}</span><span class="font-medium">{{ order.bank_account_name }}</span>
|
||||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">IBAN</span><span class="font-mono font-medium">{{ order.bank_iban }}</span>
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">IBAN</span><span class="font-mono font-medium">{{ order.bank_iban }}</span>
|
||||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-variable-symbol", lang=lang | default(value='sk')) }}</span><span class="font-mono font-medium">{{ order.variable_symbol }}</span>
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-variable-symbol", lang=lang | default(value='sk')) }}</span><span class="font-mono font-medium">{{ order.variable_symbol }}</span>
|
||||||
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-amount", lang=lang | default(value='sk')) }}</span><span class="font-medium tabular-nums">{{ order.total }} {{ order.currency }}</span>
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-amount", lang=lang | default(value='sk')) }}</span><span class="font-medium tabular-nums">{{ order.total }} €</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
{% block title %}{{ product.name }}{% endblock title %}
|
{% block title %}{{ product.name }}{% endblock title %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div class="space-y-12">
|
||||||
<div class="grid gap-10 lg:grid-cols-2">
|
<div class="grid gap-10 lg:grid-cols-2">
|
||||||
<!-- gallery — prev/next arrows + opacity transitions adapted from
|
<!-- gallery — prev/next arrows + opacity transitions adapted from
|
||||||
penguinui/carousel/default-carousel.html; kept our product thumbnail strip
|
penguinui/carousel/default-carousel.html; kept our product thumbnail strip
|
||||||
@@ -50,14 +51,34 @@
|
|||||||
|
|
||||||
<!-- details -->
|
<!-- details -->
|
||||||
{% set fld = "w-full rounded-radius border border-outline bg-surface-alt px-3 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark" %}
|
{% set fld = "w-full rounded-radius border border-outline bg-surface-alt px-3 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark" %}
|
||||||
{% set btn = "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-radius px-5 py-2 text-sm text-center font-medium tracking-wide transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75 border border-primary bg-primary text-on-primary focus-visible:outline-primary dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark dark:focus-visible:outline-primary-dark" %}
|
{% set btn = "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-radius px-5 py-2 text-sm text-center font-medium tracking-wide transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75 border border-cta bg-cta text-on-cta focus-visible:outline-cta dark:border-cta-dark dark:bg-cta-dark dark:text-on-cta-dark dark:focus-visible:outline-cta-dark" %}
|
||||||
<script id="variant-data" type="application/json">{{ variants | json_encode() | safe }}</script>
|
<script id="variant-data" type="application/json">{{ variants | json_encode() | safe }}</script>
|
||||||
<div class="space-y-6" x-data="productBuy(JSON.parse(document.getElementById('variant-data').textContent))">
|
<div class="space-y-6"
|
||||||
|
x-data="{
|
||||||
|
variants: JSON.parse(document.getElementById('variant-data').textContent) || [],
|
||||||
|
sel: 0,
|
||||||
|
get current() { return this.variants[this.sel] || null },
|
||||||
|
init() {
|
||||||
|
const firstInStock = this.variants.findIndex(v => v.in_stock);
|
||||||
|
this.sel = Math.max(0, firstInStock);
|
||||||
|
},
|
||||||
|
}">
|
||||||
{% if category %}
|
{% if category %}
|
||||||
<a href="/category/{{ category.slug }}" class="text-sm font-medium text-primary dark:text-primary-dark">{{ category.name }}</a>
|
<a href="/category/{{ category.slug }}" class="text-sm font-medium text-primary dark:text-primary-dark">{{ category.name }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h1>
|
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h1>
|
||||||
|
|
||||||
|
{% if product.short_description %}
|
||||||
|
<div class="rich-content rich-summary text-on-surface/80 dark:text-on-surface-dark/80">
|
||||||
|
{{ product.short_description | safe }}
|
||||||
|
{% if product.description %}
|
||||||
|
<a href="#product-description" class="product-more-link inline font-medium text-primary underline underline-offset-4 hover:opacity-75 dark:text-primary-dark">{{ t(key="product-more", lang=lang | default(value='sk')) }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% elif product.description %}
|
||||||
|
<a href="#product-description" class="inline-flex text-sm font-medium text-primary underline underline-offset-4 hover:opacity-75 dark:text-primary-dark">{{ t(key="product-more", lang=lang | default(value='sk')) }}</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<template x-if="current">
|
<template x-if="current">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- option picker (only when there's a real choice); first option is
|
<!-- option picker (only when there's a real choice); first option is
|
||||||
@@ -67,7 +88,7 @@
|
|||||||
<label for="variant-select" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="choose-option", lang=lang | default(value='sk')) }}</label>
|
<label for="variant-select" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="choose-option", lang=lang | default(value='sk')) }}</label>
|
||||||
<select id="variant-select" x-model.number="sel" class="{{ fld }}">
|
<select id="variant-select" x-model.number="sel" class="{{ fld }}">
|
||||||
<template x-for="(v, i) in variants" :key="v.id">
|
<template x-for="(v, i) in variants" :key="v.id">
|
||||||
<option :value="i" x-text="(v.label || '—') + ' · ' + v.price + ' {{ product.currency }}' + (v.in_stock ? '' : ' ({{ t(key='out-of-stock', lang=lang | default(value='sk')) }})')"></option>
|
<option :value="i" x-text="(v.label || '—') + ' · ' + v.price + ' {{ currency_symbol }}' + (v.in_stock ? '' : ' ({{ t(key='out-of-stock', lang=lang | default(value='sk')) }})')"></option>
|
||||||
</template>
|
</template>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -75,17 +96,13 @@
|
|||||||
|
|
||||||
<div class="flex items-baseline gap-3">
|
<div class="flex items-baseline gap-3">
|
||||||
<p class="text-2xl font-semibold" :class="current.on_sale ? 'text-danger' : 'text-primary dark:text-primary-dark'">
|
<p class="text-2xl font-semibold" :class="current.on_sale ? 'text-danger' : 'text-primary dark:text-primary-dark'">
|
||||||
<span x-text="current.price"></span> {{ product.currency }}
|
<span x-text="current.price"></span> {{ currency_symbol }}
|
||||||
</p>
|
</p>
|
||||||
<template x-if="current.on_sale">
|
<template x-if="current.on_sale">
|
||||||
<p class="text-lg text-on-surface/50 line-through dark:text-on-surface-dark/50"><span x-text="current.regular_price"></span> {{ product.currency }}</p>
|
<p class="text-lg text-on-surface/50 line-through dark:text-on-surface-dark/50"><span x-text="current.regular_price"></span> {{ currency_symbol }}</p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if product.description %}
|
|
||||||
<div class="whitespace-pre-line leading-relaxed text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description }}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<template x-if="current.in_stock">
|
<template x-if="current.in_stock">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none" class="flex flex-wrap items-end gap-3"
|
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none" class="flex flex-wrap items-end gap-3"
|
||||||
@@ -98,7 +115,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<button type="submit" class="{{ btn }}">{{ t(key="add-to-cart", lang=lang | default(value='sk')) }}</button>
|
<button type="submit" class="{{ btn }}">{{ t(key="add-to-cart", lang=lang | default(value='sk')) }}</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="in-stock", lang=lang | default(value='sk')) }}: <span x-text="current.stock"></span></p>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!current.in_stock">
|
<template x-if="!current.in_stock">
|
||||||
@@ -111,16 +135,13 @@
|
|||||||
<p class="inline-flex rounded-radius bg-danger/10 px-3 py-2 text-sm font-medium text-danger">{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}</p>
|
<p class="inline-flex rounded-radius bg-danger/10 px-3 py-2 text-sm font-medium text-danger">{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}</p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<script>
|
{% if product.description %}
|
||||||
function productBuy(variants) {
|
<section id="product-description" class="scroll-mt-28 border-t border-outline pt-8 dark:border-outline-dark">
|
||||||
return {
|
<h2 class="mb-4 text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="description", lang=lang | default(value='sk')) }}</h2>
|
||||||
variants: variants || [],
|
{# Authored as rich text (Quill) in the admin; render the stored HTML. #}
|
||||||
// Default to the first in-stock variant, else the first.
|
<div class="rich-content text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description | safe }}</div>
|
||||||
sel: Math.max(0, (variants || []).findIndex(v => v.in_stock)),
|
</section>
|
||||||
get current() { return this.variants[this.sel] || null; },
|
{% endif %}
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
123
docs/real-site-data-to-port.md
Normal file
123
docs/real-site-data-to-port.md
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
# Real data to port from www.e-shop.kompress.sk
|
||||||
|
|
||||||
|
Source: <http://e-shop.kompress.sk/> (live PrestaShop site). This file lists the
|
||||||
|
real business content that exists on the production site but is **not** yet in
|
||||||
|
this app, so it can be ported into our catalog / CMS / config.
|
||||||
|
|
||||||
|
> Status note: the header branding has already been switched from the
|
||||||
|
> placeholder "Kompress eshop" to the real logo (`assets/static/img/logo.jpg`,
|
||||||
|
> the blue **KOMPRESS** wordmark pulled from `/img/logo.jpg` on the live site)
|
||||||
|
> and the `brand` / `meta-description` i18n keys now carry the real company
|
||||||
|
> name and tagline.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Company / branding
|
||||||
|
|
||||||
|
| Field | Real value |
|
||||||
|
|---|---|
|
||||||
|
| Legal name | **WWW.KOMPRESS.SK, s.r.o.** |
|
||||||
|
| Display name | www.e-shop.kompress.sk |
|
||||||
|
| Tagline (meta) | *Výrobca a distribútor zdravotníckych pomôcok a potrieb* (Manufacturer and distributor of medical aids and supplies) |
|
||||||
|
| Logo | blue **KOMPRESS** wordmark — `/img/logo.jpg` (260×52), saved to `assets/static/img/logo.jpg` |
|
||||||
|
| Keywords | obchod, výroba, distribúcia, striekačky, ihly, krytie, sušenie, jednorázový, materiál, štvorce |
|
||||||
|
|
||||||
|
## 2. Contact / legal (for footer + invoicing config)
|
||||||
|
|
||||||
|
- **Sídlo (registered seat):** Gunduličova 4, 811 05 Bratislava
|
||||||
|
*(the homepage footer block also shows "Moyzesova 3, 811 05 Bratislava" — confirm which is current)*
|
||||||
|
- **Prevádzka (operations / warehouse):** Nádražná 328/62, 015 01 Rajec nad Rajčankou
|
||||||
|
- **Registrácia:** Obchodný register Okresného súdu v Bratislave, Odd. Sro, Vložka číslo: 102522/B
|
||||||
|
- **Telefón / hotline:** +421 903 410476
|
||||||
|
- **E-mail:** kompress@kompress.sk
|
||||||
|
|
||||||
|
## 3. "O našej spoločnosti" (About) — CMS page
|
||||||
|
|
||||||
|
> WWW.KOMPRESS.SK, s.r.o., Sídlo: Gunduličova 4, 811 05 Bratislava, Prevádzka:
|
||||||
|
> Nádražná 328/62, 015 01 Rajec nad Rajčankou, Registrácia: Obchodný register
|
||||||
|
> Okresného súdu v Bratislave Odd. Sro, Vložka číslo: 102522/B. Spoločnosť
|
||||||
|
> dodáva zdravotnícke potreby a svojich zákazníkov tradične oslovuje vysokou
|
||||||
|
> kvalitou a nízkou cenou. Samozrejmosťou je doprava tovaru až k rukám
|
||||||
|
> odberateľa a kvalitný zákaznícky servis.
|
||||||
|
|
||||||
|
Other CMS pages present on the live site:
|
||||||
|
- `O našej spoločnosti` — `/content/4-kompress`
|
||||||
|
- `Podmienky používania obchodu` (terms) — `/content/3-podmienky-pouzivania-obchodu`
|
||||||
|
|
||||||
|
## 4. Product categories (PrestaShop IDs → name / description)
|
||||||
|
|
||||||
|
These are the real catalog categories. Top-level is **Zdravotnícke pomôcky**
|
||||||
|
(id 7); the rest are its subtree. The text below is the real category
|
||||||
|
description copy to port.
|
||||||
|
|
||||||
|
| ID | Category / description copy |
|
||||||
|
|---|---|
|
||||||
|
| 7 | **Zdravotnícke pomôcky** (root) |
|
||||||
|
| 8 | **Gáza, role, štvorce, prírezy** — Gáza a výrobky z gázy, prírezy, role, zložky, kompresy resp. štvorce, pásy resp. longety, sterilné či nesterilné. Čistá biela bavlna. |
|
||||||
|
| 9 | **Vata** (buničitá, obväzová, stomatologická) — bielená, v rezoch, v návine, delená na tampóny, skladaná ako harmonika, savá a mäkká. |
|
||||||
|
| 10 | **Netkané textílie** — Pervin, SMS obaly na sterilizáciu, návleky na obuv. |
|
||||||
|
| 11 | **Textil, Sanavel** |
|
||||||
|
| 12 | **Plasty, injekčná technika** — vrecká, tŕne, hadičky, ihly, striekačky, infúzne súpravy, urínové vrecká, odberové vaky, cievky, katétre Nelaton, odsávačky. |
|
||||||
|
| 13 | **Papier na lôžko** — krepovaný biely/farebný, tissue embosovaný, laminovaný tissue s plastovou spodnou vrstvou. |
|
||||||
|
| 14 | **Somatické / psycho-somatické prístroje** — pomôcky na elimináciu geopatogénnych zón, elektrosmogu. |
|
||||||
|
| 15 | **Somavedic** (rad prístrojov) — eliminácia vplyvu geopatogénnych zón, elektrosmogu; dosah 30 m. |
|
||||||
|
| 16 | **Úprava vody** — voda ako nosič energie. |
|
||||||
|
| 17 | **Gely a lubrikanty** — gély na sonografiu/ECG, lubrikanty na sondy; balenia 260/500/1000 ml, kanister 5 l. |
|
||||||
|
| 18 | **Somavedic AURUM** — najúčinnejší z kolekcie. |
|
||||||
|
| 19 | **Tecasorb** — moderné absorpčné krytie (vlhké aj suché). |
|
||||||
|
| 20 | (Somavedic — biofyzikálne sledovanie IEGF) |
|
||||||
|
| 21 | **Obväzy** — fixačné, elastické ovínadlá, sádrové obväzy, gumové škrtidlá MARTIN, ESMARCH. |
|
||||||
|
| 22 | **Nástroje** — čepelky, peany, nožničky, pinzety. |
|
||||||
|
| 23 | **Pre invalidov** — vozíky, postele, matrace, návleky, poťahy, stoličky, nádstavce na WC, antidekubitné matrace. |
|
||||||
|
| 24 | **Proti preležaninám** — antidekubitné matrace, poťahy, podložky, sedáky, motorové pumpy. |
|
||||||
|
| 25 | **Barle, palice, chodúliky** — chodítka, statické/pohyblivé chodúliky s kolieskami. |
|
||||||
|
| 26 | **Vozíky pre invalidov - mechanické** — bez pohonu, polohovateľné, skladacie. |
|
||||||
|
| 27 | **Držadlá, nástavce na WC, operadlá do kúpeľne** — namontovateľné na stenu/umývadlo/WC. |
|
||||||
|
| 28 | **Ochrana matracov** — umývateľné poťahy, návleky, obliečky. |
|
||||||
|
| 29 | **Ortopedické pomôcky** — vložky do topánok, silikónové, medziprstové, gelové. |
|
||||||
|
| 30 | **Polohovacie pomôcky** — podložky s výrezom, sedáky, krúžky, valce. |
|
||||||
|
| 31 | **Do kúpeľne** — stolička/sedačka/opierka/držadlo do vane a sprchy, protišmykové, nastaviteľné. |
|
||||||
|
| 32 | **Relaxácia a rehabilitácia** — lopty, balóny, gumové pásy, pedále, masážne loptičky. |
|
||||||
|
| 33 | **Sebaobsluha a obsluha pacienta** — obúvanie, poháre, pásy na dvíhanie, sklápacie stolíky. |
|
||||||
|
| 34 | **Toaletné kreslá a toaletné vozíky** — kreslá/vozíky s otvorom v sedadle, kreslo do sprchy. |
|
||||||
|
| 35 | **Somavedic MEDIC** — certifikát IGEF na rušenie elektrosmogu. |
|
||||||
|
| 36 | **Rukavice** — vyšetrovacie, chirurgické bezpúdrové/púdrované, latexové, nitrilové, vinylové, neoprénové. |
|
||||||
|
|
||||||
|
## 5. Featured products (homepage) with prices
|
||||||
|
|
||||||
|
Prices are EUR (display on site uses `X,XX €`). Image paths are on the live
|
||||||
|
site under `/<id>-home_default/<slug>.jpg`.
|
||||||
|
|
||||||
|
| Product | Price (€) | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| Somavedic URAN | 600,00 | id 581 |
|
||||||
|
| Somavedic MEDIC (elektrosmog eliminátor) | 360,00 | id 213 |
|
||||||
|
| Somavedic ATLANTIK | 300,00 | id 572 |
|
||||||
|
| Sedačka do sprchy nastaviteľná, s opierkou chrbta a výrezom | 65,00 | id 497 |
|
||||||
|
| Opierka pod chrbát polohovateľná | 37,20 | id 378 |
|
||||||
|
| Krepovaný papier na lôžko (papier na operačné stoly) | 4,80 | id 248 |
|
||||||
|
| GAMMEX chirurgické rukavice – pár | 1,23 | id 582 |
|
||||||
|
| Gáza v páse s buničitou vatou (Mullro) | 1,14 / 1,23 | id 567 |
|
||||||
|
| Návleky na topánky NT | 0,12 | id 224 |
|
||||||
|
| Návlek na obuv (plastový s gumičkou) | 0,04 | id 279 |
|
||||||
|
| Gázové kompresy, štvorce nesterilné | — | id 100 |
|
||||||
|
|
||||||
|
Other Somavedic models referenced on site: **Somavedic AURUM** (id 18 cat).
|
||||||
|
|
||||||
|
## 6. Storefront blocks present on live site (UX reference)
|
||||||
|
|
||||||
|
- Home slider (homeslider) — "pomôcky pre pacientov", "prístroj Somavedic aurum".
|
||||||
|
- "Naše obchody" (Our stores / blockstore) block.
|
||||||
|
- Reinsurance block (5 trust badges) — `blockreinsurance`.
|
||||||
|
- Price-comparison badges: Pricemania, Heureka.sk, Tovar.sk, NajNakup.sk.
|
||||||
|
|
||||||
|
## 7. Suggested port order
|
||||||
|
|
||||||
|
1. **Config/contact** — drop real legal name, seat, ops address, IČO/registration,
|
||||||
|
phone, email into footer + invoicing config (see `account-type-rules` memory).
|
||||||
|
2. **CMS pages** — seed `O našej spoločnosti` and `Obchodné podmienky` content.
|
||||||
|
3. **Categories** — seed categories 7–36 (names + descriptions above) under root
|
||||||
|
"Zdravotnícke pomôcky"; map to our category model.
|
||||||
|
4. **Products** — import the featured products with prices/variants; pull images
|
||||||
|
from `/<id>-home_default/` on the live site.
|
||||||
|
5. **Currency** — site prices are EUR (matches our EUR base; CZK display optional).
|
||||||
@@ -41,6 +41,18 @@ mod m20260621_000003_discount_profiles;
|
|||||||
mod m20260621_000004_add_business_sale_price_to_products;
|
mod m20260621_000004_add_business_sale_price_to_products;
|
||||||
mod m20260622_000001_audience_discount_profiles;
|
mod m20260622_000001_audience_discount_profiles;
|
||||||
mod m20260622_000002_product_variants;
|
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;
|
||||||
|
mod m20260627_000001_order_residence_address;
|
||||||
|
mod m20260627_000002_payment_settings;
|
||||||
|
mod m20260627_000003_account_cart_items;
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -86,7 +98,19 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260621_000004_add_business_sale_price_to_products::Migration),
|
Box::new(m20260621_000004_add_business_sale_price_to_products::Migration),
|
||||||
Box::new(m20260622_000001_audience_discount_profiles::Migration),
|
Box::new(m20260622_000001_audience_discount_profiles::Migration),
|
||||||
Box::new(m20260622_000002_product_variants::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),
|
||||||
|
Box::new(m20260627_000001_order_residence_address::Migration),
|
||||||
|
Box::new(m20260627_000002_payment_settings::Migration),
|
||||||
|
Box::new(m20260627_000003_account_cart_items::Migration),
|
||||||
// inject-above (do not remove this comment)
|
// inject-above (do not remove this comment)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
36
migration/src/m20260622_000003_variant_stock_nullable.rs
Normal file
36
migration/src/m20260622_000003_variant_stock_nullable.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
92
migration/src/m20260622_000004_product_search.rs
Normal file
92
migration/src/m20260622_000004_product_search.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
232
migration/src/m20260622_000005_product_search_aggregate.rs
Normal file
232
migration/src/m20260622_000005_product_search_aggregate.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
48
migration/src/m20260622_000006_order_search_indexes.rs
Normal file
48
migration/src/m20260622_000006_order_search_indexes.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// A short blurb shown on product cards (grid/list), distinct from the full
|
||||||
|
// `description` rendered on the product detail page.
|
||||||
|
add_column(m, "products", "short_description", ColType::TextNull).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
remove_column(m, "products", "short_description").await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
//! Product descriptions are now authored as rich text (Quill) and stored as
|
||||||
|
//! HTML. The product search vector (see m20260622_000005) tokenizes the raw
|
||||||
|
//! description, so without this the markup itself (`p`, `strong`, `li`, `href`,
|
||||||
|
//! `class`, `ql`, …) would land in the index and pollute matches.
|
||||||
|
//!
|
||||||
|
//! Redefine `kompress_build_product_search` so the description is run through a
|
||||||
|
//! tag-stripping `regexp_replace` before `to_tsvector`, then backfill every
|
||||||
|
//! product's stored vector. Everything else about the function is unchanged.
|
||||||
|
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let db = m.get_connection();
|
||||||
|
db.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
CREATE OR REPLACE FUNCTION kompress_build_product_search(
|
||||||
|
p_name text, p_description text, p_id integer
|
||||||
|
) RETURNS tsvector
|
||||||
|
LANGUAGE sql STABLE AS $func$
|
||||||
|
SELECT
|
||||||
|
setweight(to_tsvector('sk_unaccent', COALESCE(p_name, '')), 'A')
|
||||||
|
|| setweight(to_tsvector('sk_unaccent', COALESCE((
|
||||||
|
SELECT string_agg(t.name, ' ')
|
||||||
|
FROM product_product_tags ppt
|
||||||
|
JOIN product_tags t ON t.id = ppt.product_tag_id
|
||||||
|
WHERE ppt.product_id = p_id
|
||||||
|
), '')), 'B')
|
||||||
|
|| setweight(to_tsvector('sk_unaccent', COALESCE((
|
||||||
|
SELECT string_agg(v.label, ' ')
|
||||||
|
FROM product_variants v
|
||||||
|
WHERE v.product_id = p_id
|
||||||
|
), '')), 'B')
|
||||||
|
|| setweight(to_tsvector('sk_unaccent',
|
||||||
|
regexp_replace(COALESCE(p_description, ''), '<[^>]+>', ' ', 'g')
|
||||||
|
), 'C')
|
||||||
|
|| setweight(to_tsvector('sk_unaccent', COALESCE((
|
||||||
|
SELECT string_agg(v.sku, ' ')
|
||||||
|
FROM product_variants v
|
||||||
|
WHERE v.product_id = p_id AND v.sku IS NOT NULL
|
||||||
|
), '')), 'C');
|
||||||
|
$func$;
|
||||||
|
|
||||||
|
-- Backfill: recompute every product's vector with the new definition.
|
||||||
|
UPDATE products
|
||||||
|
SET search_vector = kompress_build_product_search(name, description, id);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// Restore the prior definition (raw, un-stripped description).
|
||||||
|
let db = m.get_connection();
|
||||||
|
db.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
CREATE OR REPLACE FUNCTION kompress_build_product_search(
|
||||||
|
p_name text, p_description text, p_id integer
|
||||||
|
) RETURNS tsvector
|
||||||
|
LANGUAGE sql STABLE AS $func$
|
||||||
|
SELECT
|
||||||
|
setweight(to_tsvector('sk_unaccent', COALESCE(p_name, '')), 'A')
|
||||||
|
|| setweight(to_tsvector('sk_unaccent', COALESCE((
|
||||||
|
SELECT string_agg(t.name, ' ')
|
||||||
|
FROM product_product_tags ppt
|
||||||
|
JOIN product_tags t ON t.id = ppt.product_tag_id
|
||||||
|
WHERE ppt.product_id = p_id
|
||||||
|
), '')), 'B')
|
||||||
|
|| setweight(to_tsvector('sk_unaccent', COALESCE((
|
||||||
|
SELECT string_agg(v.label, ' ')
|
||||||
|
FROM product_variants v
|
||||||
|
WHERE v.product_id = p_id
|
||||||
|
), '')), 'B')
|
||||||
|
|| setweight(to_tsvector('sk_unaccent', COALESCE(p_description, '')), 'C')
|
||||||
|
|| setweight(to_tsvector('sk_unaccent', COALESCE((
|
||||||
|
SELECT string_agg(v.sku, ' ')
|
||||||
|
FROM product_variants v
|
||||||
|
WHERE v.product_id = p_id AND v.sku IS NOT NULL
|
||||||
|
), '')), 'C');
|
||||||
|
$func$;
|
||||||
|
|
||||||
|
UPDATE products
|
||||||
|
SET search_vector = kompress_build_product_search(name, description, id);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
20
migration/src/m20260623_000003_drop_currency.rs
Normal file
20
migration/src/m20260623_000003_drop_currency.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// The store is EUR-only. Currency is no longer stored per product/order;
|
||||||
|
// the euro symbol is rendered everywhere in the UI.
|
||||||
|
remove_column(m, "products", "currency").await?;
|
||||||
|
remove_column(m, "orders", "currency").await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
add_column(m, "products", "currency", ColType::StringWithDefault("EUR".to_string())).await?;
|
||||||
|
add_column(m, "orders", "currency", ColType::StringWithDefault("EUR".to_string())).await
|
||||||
|
}
|
||||||
|
}
|
||||||
31
migration/src/m20260623_000004_currencies.rs
Normal file
31
migration/src/m20260623_000004_currencies.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// Buyer-selectable display currencies. EUR is the base/transaction
|
||||||
|
// currency and is NOT stored here; each row is an alternative the buyer
|
||||||
|
// can switch to, whose prices are the EUR price recalculated at
|
||||||
|
// `rate_e4` (units of this currency per 1 EUR, scaled ×10000). For now
|
||||||
|
// the only row is CZK, seeded by `initializers::currency_seeder`.
|
||||||
|
create_table(m, "currencies",
|
||||||
|
&[
|
||||||
|
("id", ColType::PkAuto),
|
||||||
|
("code", ColType::StringUniq),
|
||||||
|
("symbol", ColType::String),
|
||||||
|
("rate_e4", ColType::BigIntegerWithDefault(10_000)),
|
||||||
|
("enabled", ColType::BooleanWithDefault(true)),
|
||||||
|
],
|
||||||
|
&[
|
||||||
|
]
|
||||||
|
).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
drop_table(m, "currencies").await
|
||||||
|
}
|
||||||
|
}
|
||||||
20
migration/src/m20260625_000001_add_avatar_to_users.rs
Normal file
20
migration/src/m20260625_000001_add_avatar_to_users.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
22
migration/src/m20260627_000001_order_residence_address.rs
Normal file
22
migration/src/m20260627_000001_order_residence_address.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
add_column(m, "orders", "residence_address", ColType::StringNull).await?;
|
||||||
|
add_column(m, "orders", "residence_city", ColType::StringNull).await?;
|
||||||
|
add_column(m, "orders", "residence_zip", ColType::StringNull).await?;
|
||||||
|
add_column(m, "orders", "residence_country", ColType::StringNull).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
remove_column(m, "orders", "residence_country").await?;
|
||||||
|
remove_column(m, "orders", "residence_zip").await?;
|
||||||
|
remove_column(m, "orders", "residence_city").await?;
|
||||||
|
remove_column(m, "orders", "residence_address").await
|
||||||
|
}
|
||||||
|
}
|
||||||
41
migration/src/m20260627_000002_payment_settings.rs
Normal file
41
migration/src/m20260627_000002_payment_settings.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
create_table(
|
||||||
|
m,
|
||||||
|
"payment_methods",
|
||||||
|
&[
|
||||||
|
("id", ColType::PkAuto),
|
||||||
|
("code", ColType::StringUniq),
|
||||||
|
("name", ColType::String),
|
||||||
|
("enabled", ColType::BooleanWithDefault(true)),
|
||||||
|
("position", ColType::IntegerWithDefault(0)),
|
||||||
|
],
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
create_table(
|
||||||
|
m,
|
||||||
|
"shop_settings",
|
||||||
|
&[
|
||||||
|
("id", ColType::PkAuto),
|
||||||
|
("key", ColType::StringUniq),
|
||||||
|
("value", ColType::TextNull),
|
||||||
|
],
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
drop_table(m, "shop_settings").await?;
|
||||||
|
drop_table(m, "payment_methods").await
|
||||||
|
}
|
||||||
|
}
|
||||||
48
migration/src/m20260627_000003_account_cart_items.rs
Normal file
48
migration/src/m20260627_000003_account_cart_items.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
create_table(
|
||||||
|
m,
|
||||||
|
"account_cart_items",
|
||||||
|
&[
|
||||||
|
("id", ColType::PkAuto),
|
||||||
|
("variant_id", ColType::Integer),
|
||||||
|
("quantity", ColType::Integer),
|
||||||
|
],
|
||||||
|
&[("user", "")],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
m.create_foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk-account_cart_items-variant_id-to-product_variants")
|
||||||
|
.from(Alias::new("account_cart_items"), Alias::new("variant_id"))
|
||||||
|
.to(Alias::new("product_variants"), Alias::new("id"))
|
||||||
|
.on_delete(ForeignKeyAction::Cascade)
|
||||||
|
.on_update(ForeignKeyAction::NoAction)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
m.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_account_cart_items_user_variant_unique")
|
||||||
|
.table(Alias::new("account_cart_items"))
|
||||||
|
.col(Alias::new("user_id"))
|
||||||
|
.col(Alias::new("variant_id"))
|
||||||
|
.unique()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
drop_table(m, "account_cart_items").await
|
||||||
|
}
|
||||||
|
}
|
||||||
14
src/app.rs
14
src/app.rs
@@ -17,10 +17,10 @@ use std::{path::Path, sync::Arc};
|
|||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use crate::{
|
use crate::{
|
||||||
controllers::{
|
controllers::{
|
||||||
account, admin_categories, admin_customers, admin_dashboard, admin_discount_profiles,
|
account, admin_categories, admin_currencies, admin_customers, admin_dashboard,
|
||||||
admin_form, admin_orders, admin_products, admin_shipping, auth, auth_pages,
|
admin_discount_profiles, admin_form, admin_orders, admin_payments, admin_products, admin_shipping,
|
||||||
cart, checkout, home, i18n, media, oauth2,
|
auth, auth_pages, cart, checkout, currency, home, i18n, media, oauth2,
|
||||||
shop,
|
pages, shop,
|
||||||
},
|
},
|
||||||
initializers,
|
initializers,
|
||||||
models::_entities::users,
|
models::_entities::users,
|
||||||
@@ -83,6 +83,8 @@ impl Hooks for App {
|
|||||||
Box::new(initializers::view_engine::ViewEngineInitializer),
|
Box::new(initializers::view_engine::ViewEngineInitializer),
|
||||||
Box::new(initializers::admin_seeder::AdminSeeder),
|
Box::new(initializers::admin_seeder::AdminSeeder),
|
||||||
Box::new(initializers::shipping_seeder::ShippingSeeder),
|
Box::new(initializers::shipping_seeder::ShippingSeeder),
|
||||||
|
Box::new(initializers::payment_seeder::PaymentSeeder),
|
||||||
|
Box::new(initializers::currency_seeder::CurrencySeeder),
|
||||||
Box::new(initializers::oauth2::OAuth2StoreInitializer),
|
Box::new(initializers::oauth2::OAuth2StoreInitializer),
|
||||||
Box::new(initializers::oauth2_session::OAuth2SessionInitializer),
|
Box::new(initializers::oauth2_session::OAuth2SessionInitializer),
|
||||||
])
|
])
|
||||||
@@ -95,6 +97,8 @@ impl Hooks for App {
|
|||||||
.add_route(shop::routes())
|
.add_route(shop::routes())
|
||||||
.add_route(cart::routes())
|
.add_route(cart::routes())
|
||||||
.add_route(checkout::routes())
|
.add_route(checkout::routes())
|
||||||
|
.add_route(currency::routes())
|
||||||
|
.add_route(pages::routes())
|
||||||
// cross-cutting
|
// cross-cutting
|
||||||
.add_route(auth::routes())
|
.add_route(auth::routes())
|
||||||
.add_route(auth_pages::routes())
|
.add_route(auth_pages::routes())
|
||||||
@@ -108,8 +112,10 @@ impl Hooks for App {
|
|||||||
.add_route(admin_discount_profiles::routes())
|
.add_route(admin_discount_profiles::routes())
|
||||||
.add_route(admin_categories::routes())
|
.add_route(admin_categories::routes())
|
||||||
.add_route(admin_orders::routes())
|
.add_route(admin_orders::routes())
|
||||||
|
.add_route(admin_payments::routes())
|
||||||
.add_route(admin_customers::routes())
|
.add_route(admin_customers::routes())
|
||||||
.add_route(admin_shipping::routes())
|
.add_route(admin_shipping::routes())
|
||||||
|
.add_route(admin_currencies::routes())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn after_context(ctx: AppContext) -> Result<AppContext> {
|
async fn after_context(ctx: AppContext) -> Result<AppContext> {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
//! on the user — it is shown here read-only and can never be changed. The
|
//! on the user — it is shown here read-only and can never be changed. The
|
||||||
//! profile only edits the type-specific details (company identity + address).
|
//! profile only edits the type-specific details (company identity + address).
|
||||||
|
|
||||||
|
use axum::extract::{DefaultBodyLimit, Multipart};
|
||||||
use axum_extra::extract::cookie::CookieJar;
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use sea_orm::QueryOrder;
|
use sea_orm::QueryOrder;
|
||||||
@@ -14,7 +15,11 @@ use serde::Deserialize;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
controllers::i18n::current_lang,
|
controllers::{
|
||||||
|
admin_form::{read_multipart_form, store_image},
|
||||||
|
i18n::current_lang,
|
||||||
|
media::IMAGE_MAX_BYTES,
|
||||||
|
},
|
||||||
models::{
|
models::{
|
||||||
customer_profiles::{self, ProfileFields},
|
customer_profiles::{self, ProfileFields},
|
||||||
order_items, orders, users,
|
order_items, orders, users,
|
||||||
@@ -128,6 +133,8 @@ fn profile_view(
|
|||||||
"account_nav": true,
|
"account_nav": true,
|
||||||
"customer_name": user.name,
|
"customer_name": user.name,
|
||||||
"customer_account_type": user.account_type,
|
"customer_account_type": user.account_type,
|
||||||
|
"customer_avatar": user.avatar_id,
|
||||||
|
"avatar_id": user.avatar_id,
|
||||||
"saved": saved,
|
"saved": saved,
|
||||||
"error": error,
|
"error": error,
|
||||||
"name": user.name,
|
"name": user.name,
|
||||||
@@ -202,6 +209,64 @@ async fn save_profile(
|
|||||||
profile_view(&v, &jar, &user, &fields, true, false)
|
profile_view(&v, &jar, &user, &fields, true, false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persist `avatar_id` (a stored image filename, or `None` to clear) on the
|
||||||
|
/// signed-in customer and re-render the profile page with the success banner.
|
||||||
|
async fn set_avatar(
|
||||||
|
v: &TeraView,
|
||||||
|
jar: &CookieJar,
|
||||||
|
ctx: &AppContext,
|
||||||
|
user: users::Model,
|
||||||
|
avatar_id: Option<String>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let mut active = user.clone().into_active_model();
|
||||||
|
active.avatar_id = ActiveValue::set(avatar_id.clone());
|
||||||
|
let user = active.update(&ctx.db).await?;
|
||||||
|
let profile = customer_profiles::Model::find_for_user(&ctx.db, user.id).await?;
|
||||||
|
profile_view(v, jar, &user, &fields_of(profile.as_ref()), true, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload (or replace) the signed-in customer's avatar picture. The single
|
||||||
|
/// `image` file part is validated and stored through the shared image storage,
|
||||||
|
/// then its generated filename is saved as the user's `avatar_id`.
|
||||||
|
#[debug_handler]
|
||||||
|
async fn upload_avatar(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
multipart: Multipart,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let Some(user) = guard::current_user(&ctx, &jar).await else {
|
||||||
|
return format::redirect("/login");
|
||||||
|
};
|
||||||
|
if guard::is_admin(&ctx, &user) {
|
||||||
|
return format::redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
|
let form = read_multipart_form(multipart).await?;
|
||||||
|
let Some(image) = form.single_image() else {
|
||||||
|
// No file chosen — nothing to do, just re-show the profile.
|
||||||
|
let profile = customer_profiles::Model::find_for_user(&ctx.db, user.id).await?;
|
||||||
|
return profile_view(&v, &jar, &user, &fields_of(profile.as_ref()), false, false);
|
||||||
|
};
|
||||||
|
let filename = store_image(&ctx, image).await?;
|
||||||
|
set_avatar(&v, &jar, &ctx, user, Some(filename)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the signed-in customer's avatar, reverting to the initials fallback.
|
||||||
|
#[debug_handler]
|
||||||
|
async fn remove_avatar(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let Some(user) = guard::current_user(&ctx, &jar).await else {
|
||||||
|
return format::redirect("/login");
|
||||||
|
};
|
||||||
|
if guard::is_admin(&ctx, &user) {
|
||||||
|
return format::redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
|
set_avatar(&v, &jar, &ctx, user, None).await
|
||||||
|
}
|
||||||
|
|
||||||
/// Lists the signed-in customer's orders, split into still-active and past.
|
/// Lists the signed-in customer's orders, split into still-active and past.
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn orders_page(
|
async fn orders_page(
|
||||||
@@ -236,6 +301,7 @@ async fn orders_page(
|
|||||||
"account_nav": true,
|
"account_nav": true,
|
||||||
"customer_name": user.name,
|
"customer_name": user.name,
|
||||||
"customer_account_type": user.account_type,
|
"customer_account_type": user.account_type,
|
||||||
|
"customer_avatar": user.avatar_id,
|
||||||
"active_orders": shape(active),
|
"active_orders": shape(active),
|
||||||
"past_orders": shape(past),
|
"past_orders": shape(past),
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
@@ -269,6 +335,7 @@ async fn order_detail_page(
|
|||||||
.all(&ctx.db)
|
.all(&ctx.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let (bank_iban, bank_account_name) = settings::bank_details(&ctx).await?;
|
||||||
format::view(
|
format::view(
|
||||||
&v,
|
&v,
|
||||||
"account/order_detail.html",
|
"account/order_detail.html",
|
||||||
@@ -278,10 +345,11 @@ async fn order_detail_page(
|
|||||||
"account_nav": true,
|
"account_nav": true,
|
||||||
"customer_name": user.name,
|
"customer_name": user.name,
|
||||||
"customer_account_type": user.account_type,
|
"customer_account_type": user.account_type,
|
||||||
|
"customer_avatar": user.avatar_id,
|
||||||
"order": order_view::detail(
|
"order": order_view::detail(
|
||||||
&order,
|
&order,
|
||||||
settings::get(&ctx, "bank_iban").unwrap_or(""),
|
&bank_iban,
|
||||||
settings::get(&ctx, "bank_account_name").unwrap_or(""),
|
&bank_account_name,
|
||||||
),
|
),
|
||||||
"items": order_view::items(&items),
|
"items": order_view::items(&items),
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
@@ -312,6 +380,7 @@ fn password_view(
|
|||||||
"account_nav": true,
|
"account_nav": true,
|
||||||
"customer_name": user.name,
|
"customer_name": user.name,
|
||||||
"customer_account_type": user.account_type,
|
"customer_account_type": user.account_type,
|
||||||
|
"customer_avatar": user.avatar_id,
|
||||||
"changed": changed,
|
"changed": changed,
|
||||||
"error": error,
|
"error": error,
|
||||||
"lang": current_lang(jar),
|
"lang": current_lang(jar),
|
||||||
@@ -406,6 +475,7 @@ fn security_view(
|
|||||||
"account_nav": true,
|
"account_nav": true,
|
||||||
"customer_name": user.name,
|
"customer_name": user.name,
|
||||||
"customer_account_type": user.account_type,
|
"customer_account_type": user.account_type,
|
||||||
|
"customer_avatar": user.avatar_id,
|
||||||
"totp_enabled": user.totp_enabled(),
|
"totp_enabled": user.totp_enabled(),
|
||||||
"enrolling": enrolling,
|
"enrolling": enrolling,
|
||||||
"qr": qr,
|
"qr": qr,
|
||||||
@@ -538,6 +608,11 @@ pub fn routes() -> Routes {
|
|||||||
Routes::new()
|
Routes::new()
|
||||||
.add("/account/profile", get(profile_page))
|
.add("/account/profile", get(profile_page))
|
||||||
.add("/account/profile", post(save_profile))
|
.add("/account/profile", post(save_profile))
|
||||||
|
.add(
|
||||||
|
"/account/profile/avatar",
|
||||||
|
post(upload_avatar).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024)),
|
||||||
|
)
|
||||||
|
.add("/account/profile/avatar/remove", post(remove_avatar))
|
||||||
.add("/account/orders", get(orders_page))
|
.add("/account/orders", get(orders_page))
|
||||||
.add("/account/orders/{order_number}", get(order_detail_page))
|
.add("/account/orders/{order_number}", get(order_detail_page))
|
||||||
.add("/account/password", get(change_password_page))
|
.add("/account/password", get(change_password_page))
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ async fn create(
|
|||||||
guard::current_admin(auth, &ctx).await?;
|
guard::current_admin(auth, &ctx).await?;
|
||||||
let form = read_multipart_form(multipart).await?;
|
let form = read_multipart_form(multipart).await?;
|
||||||
let fields = parse_category_fields(&ctx, &form, None).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?),
|
Some(data) => Some(store_image(&ctx, data).await?),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
@@ -252,7 +252,7 @@ async fn update(
|
|||||||
category.position = Set(fields.position);
|
category.position = Set(fields.position);
|
||||||
category.published = Set(fields.published);
|
category.published = Set(fields.published);
|
||||||
category.parent_id = Set(fields.parent_id);
|
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.image_id = Set(Some(store_image(&ctx, data).await?));
|
||||||
}
|
}
|
||||||
category.update(&ctx.db).await?;
|
category.update(&ctx.db).await?;
|
||||||
|
|||||||
94
src/controllers/admin_currencies.rs
Normal file
94
src/controllers/admin_currencies.rs
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
//! Admin management of the alternative display currencies.
|
||||||
|
//!
|
||||||
|
//! EUR is the base/transaction currency and is shown read-only for context. The
|
||||||
|
//! admin sets each alternative currency's exchange rate (units per 1 EUR) and
|
||||||
|
//! toggles whether buyers may switch to it. The currencies themselves are fixed
|
||||||
|
//! and seeded by `initializers::currency_seeder`.
|
||||||
|
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{ActiveModelTrait, EntityTrait, QueryOrder, Set};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
controllers::i18n::current_lang,
|
||||||
|
models::currencies,
|
||||||
|
shared::{
|
||||||
|
currency::{self, BASE_CODE, BASE_SYMBOL},
|
||||||
|
guard,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct CurrencyForm {
|
||||||
|
rate: String,
|
||||||
|
enabled: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_checked(value: &Option<String>) -> bool {
|
||||||
|
matches!(value.as_deref(), Some("on" | "true" | "1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn index(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let rows = currencies::Entity::find()
|
||||||
|
.order_by_asc(currencies::Column::Code)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let currencies_json: Vec<serde_json::Value> = rows
|
||||||
|
.iter()
|
||||||
|
.map(|c| {
|
||||||
|
json!({
|
||||||
|
"id": c.id,
|
||||||
|
"code": c.code,
|
||||||
|
"symbol": c.symbol,
|
||||||
|
"rate": currency::format_rate(c.rate_e4),
|
||||||
|
"enabled": c.enabled,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"admin/currencies/index.html",
|
||||||
|
json!({
|
||||||
|
"base_code": BASE_CODE,
|
||||||
|
"base_symbol": BASE_SYMBOL,
|
||||||
|
"currencies": currencies_json,
|
||||||
|
"lang": current_lang(&jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn update(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(form): Form<CurrencyForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let row = currencies::Entity::find_by_id(id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
let mut active = row.into_active_model();
|
||||||
|
active.rate_e4 = Set(currency::parse_rate(&form.rate)?);
|
||||||
|
active.enabled = Set(is_checked(&form.enabled));
|
||||||
|
active.update(&ctx.db).await?;
|
||||||
|
// Keep the navbar/settings chrome snapshot in sync with the new rate/state.
|
||||||
|
currency::refresh_snapshot(&ctx.db).await?;
|
||||||
|
format::redirect("/admin/currencies")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.add("/admin/currencies", get(index))
|
||||||
|
.add("/admin/currencies/{id}", post(update))
|
||||||
|
}
|
||||||
@@ -138,10 +138,17 @@ async fn show(
|
|||||||
.all(&ctx.db)
|
.all(&ctx.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let list = products::Entity::find()
|
// Optional text search (drafts included), otherwise the whole catalog by
|
||||||
.order_by_asc(products::Column::Name)
|
// name. Reuses the storefront's hybrid full-text + fuzzy product search.
|
||||||
.all(&ctx.db)
|
let query = params.get("q").map(String::as_str).unwrap_or("").trim().to_string();
|
||||||
.await?;
|
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
|
// Category sidebar tree (counts over the full, unfiltered product list) plus
|
||||||
// the active `?category=` filter applied to the rows.
|
// the active `?category=` filter applied to the rows.
|
||||||
@@ -191,7 +198,6 @@ async fn show(
|
|||||||
"variant_id": variant.id,
|
"variant_id": variant.id,
|
||||||
"name": product.name,
|
"name": product.name,
|
||||||
"variant_label": variant.label,
|
"variant_label": variant.label,
|
||||||
"currency": product.currency,
|
|
||||||
"regular_price": format_price(d.regular_cents),
|
"regular_price": format_price(d.regular_cents),
|
||||||
"business_price": format_price(b.price_cents),
|
"business_price": format_price(b.price_cents),
|
||||||
"business_reduced": b.price_cents < d.regular_cents,
|
"business_reduced": b.price_cents < d.regular_cents,
|
||||||
@@ -212,6 +218,7 @@ async fn show(
|
|||||||
"products": rows,
|
"products": rows,
|
||||||
"category_groups": category_groups,
|
"category_groups": category_groups,
|
||||||
"selected_category": selected_category,
|
"selected_category": selected_category,
|
||||||
|
"query": query,
|
||||||
"total_count": list.len(),
|
"total_count": list.len(),
|
||||||
"uncategorized_count": category_ids.iter().filter(|c| c.is_none()).count(),
|
"uncategorized_count": category_ids.iter().filter(|c| c.is_none()).count(),
|
||||||
"error": params.get("error"),
|
"error": params.get("error"),
|
||||||
@@ -277,7 +284,6 @@ async fn price_edit(
|
|||||||
"variant_id": variant.id,
|
"variant_id": variant.id,
|
||||||
"name": product.name,
|
"name": product.name,
|
||||||
"variant_label": variant.label,
|
"variant_label": variant.label,
|
||||||
"currency": product.currency,
|
|
||||||
"regular_price": format_price(d.regular_cents),
|
"regular_price": format_price(d.regular_cents),
|
||||||
"regular_cents": d.regular_cents,
|
"regular_cents": d.regular_cents,
|
||||||
"business_price": format_price(business_cents),
|
"business_price": format_price(business_cents),
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
//! Multipart form handling shared by the product and category admin forms.
|
//! 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;
|
//! Both forms submit a mix of text fields and `image` file part(s); this
|
||||||
//! this collects them into an easy-to-query [`MultipartForm`] and stores any
|
//! collects them into an easy-to-query [`MultipartForm`] and stores any
|
||||||
//! uploaded image through the configured storage driver.
|
//! 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;
|
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
|
/// One slot in the unified gallery order submitted by the product form.
|
||||||
/// an `image` file part if one was uploaded (an empty file input is ignored).
|
#[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 {
|
pub(crate) struct MultipartForm {
|
||||||
fields: HashMap<String, String>,
|
fields: HashMap<String, String>,
|
||||||
pub(crate) image: Option<Vec<u8>>,
|
pub(crate) images: Vec<Vec<u8>>,
|
||||||
|
pub(crate) image_order: Vec<ImageSlot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MultipartForm {
|
impl MultipartForm {
|
||||||
@@ -31,6 +47,12 @@ impl MultipartForm {
|
|||||||
normalize_empty(self.fields.get(key).cloned())
|
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.
|
/// Whether a checkbox-style field is checked.
|
||||||
pub(crate) fn checked(&self, key: &str) -> bool {
|
pub(crate) fn checked(&self, key: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
@@ -59,7 +81,8 @@ impl MultipartForm {
|
|||||||
|
|
||||||
pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<MultipartForm> {
|
pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<MultipartForm> {
|
||||||
let mut fields = HashMap::new();
|
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
|
while let Some(mut field) = multipart
|
||||||
.next_field()
|
.next_field()
|
||||||
@@ -82,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() {
|
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 {
|
} else {
|
||||||
let value = field
|
let value = field
|
||||||
@@ -94,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.
|
/// Store an uploaded image's bytes and return its generated filename.
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
//! Admin order list, detail, status updates, and manual carrier dispatch.
|
//! Admin order list, detail, status updates, and manual carrier dispatch.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use axum::extract::Query;
|
||||||
use axum_extra::extract::cookie::CookieJar;
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
|
||||||
@@ -30,18 +33,31 @@ async fn index(
|
|||||||
auth: auth::JWT,
|
auth: auth::JWT,
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
ViewEngine(v): ViewEngine<TeraView>,
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
guard::current_admin(auth, &ctx).await?;
|
guard::current_admin(auth, &ctx).await?;
|
||||||
let list = orders::Entity::find()
|
// Optional search over order number / customer / email / etc., otherwise the
|
||||||
.order_by_desc(orders::Column::CreatedAt)
|
// full list newest first.
|
||||||
.all(&ctx.db)
|
let query = params.get("q").map(String::as_str).unwrap_or("").trim().to_string();
|
||||||
.await?;
|
let list = if query.is_empty() {
|
||||||
|
orders::Entity::find()
|
||||||
|
.order_by_desc(orders::Column::CreatedAt)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
orders::Entity::search(&ctx.db, &query, 500).await?
|
||||||
|
};
|
||||||
let rows: Vec<serde_json::Value> = list.iter().map(view::summary).collect();
|
let rows: Vec<serde_json::Value> = list.iter().map(view::summary).collect();
|
||||||
format::view(
|
format::view(
|
||||||
&v,
|
&v,
|
||||||
"admin/orders/index.html",
|
"admin/orders/index.html",
|
||||||
json!({ "orders": rows, "lang": current_lang(&jar) }),
|
json!({
|
||||||
|
"orders": rows,
|
||||||
|
"query": query,
|
||||||
|
"total": list.len(),
|
||||||
|
"lang": current_lang(&jar),
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,6 +93,7 @@ async fn render_show(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let carrier = order_carrier(ctx, &order).await?;
|
let carrier = order_carrier(ctx, &order).await?;
|
||||||
|
let (bank_iban, bank_account_name) = settings::bank_details(ctx).await?;
|
||||||
// The order can be sent only if it maps to a real carrier and hasn't been
|
// The order can be sent only if it maps to a real carrier and hasn't been
|
||||||
// dispatched yet.
|
// dispatched yet.
|
||||||
let can_ship = carrier != "none" && order.tracking_number.is_none();
|
let can_ship = carrier != "none" && order.tracking_number.is_none();
|
||||||
@@ -87,8 +104,8 @@ async fn render_show(
|
|||||||
json!({
|
json!({
|
||||||
"order": view::detail(
|
"order": view::detail(
|
||||||
&order,
|
&order,
|
||||||
settings::get(ctx, "bank_iban").unwrap_or(""),
|
&bank_iban,
|
||||||
settings::get(ctx, "bank_account_name").unwrap_or(""),
|
&bank_account_name,
|
||||||
),
|
),
|
||||||
"items": view::items(&items),
|
"items": view::items(&items),
|
||||||
"statuses": ORDER_STATUSES,
|
"statuses": ORDER_STATUSES,
|
||||||
@@ -186,7 +203,6 @@ async fn ship(
|
|||||||
country: order.country.as_deref(),
|
country: order.country.as_deref(),
|
||||||
pickup_point_id: order.pickup_point_id.as_deref(),
|
pickup_point_id: order.pickup_point_id.as_deref(),
|
||||||
cod_cents,
|
cod_cents,
|
||||||
currency: &order.currency,
|
|
||||||
value_cents: goods_value,
|
value_cents: goods_value,
|
||||||
weight_grams: DEFAULT_PARCEL_WEIGHT_GRAMS,
|
weight_grams: DEFAULT_PARCEL_WEIGHT_GRAMS,
|
||||||
};
|
};
|
||||||
|
|||||||
112
src/controllers/admin_payments.rs
Normal file
112
src/controllers/admin_payments.rs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
//! Admin management for checkout payment methods and bank-transfer details.
|
||||||
|
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{ActiveModelTrait, EntityTrait, QueryOrder, Set};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
controllers::i18n::current_lang,
|
||||||
|
models::{payment_methods, shop_settings},
|
||||||
|
shared::guard,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct PaymentMethodForm {
|
||||||
|
enabled: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct BankSettingsForm {
|
||||||
|
bank_account_name: String,
|
||||||
|
bank_iban: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_checked(value: &Option<String>) -> bool {
|
||||||
|
matches!(value.as_deref(), Some("on" | "true" | "1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trimmed(value: &str) -> Option<String> {
|
||||||
|
let value = value.trim();
|
||||||
|
(!value.is_empty()).then(|| value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn index(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let methods = payment_methods::Entity::find()
|
||||||
|
.order_by_asc(payment_methods::Column::Position)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let rows: Vec<serde_json::Value> = methods
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
json!({
|
||||||
|
"id": m.id,
|
||||||
|
"code": m.code,
|
||||||
|
"label_key": m.label_key(),
|
||||||
|
"enabled": m.enabled,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let bank_account_name = shop_settings::Entity::get(&ctx.db, "bank_account_name")
|
||||||
|
.await?
|
||||||
|
.unwrap_or_default();
|
||||||
|
let bank_iban = shop_settings::Entity::get(&ctx.db, "bank_iban")
|
||||||
|
.await?
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"admin/payments/index.html",
|
||||||
|
json!({
|
||||||
|
"methods": rows,
|
||||||
|
"bank_account_name": bank_account_name,
|
||||||
|
"bank_iban": bank_iban,
|
||||||
|
"lang": current_lang(&jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn update_method(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(form): Form<PaymentMethodForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let method = payment_methods::Entity::find_by_id(id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
let mut active = method.into_active_model();
|
||||||
|
active.enabled = Set(is_checked(&form.enabled));
|
||||||
|
active.update(&ctx.db).await?;
|
||||||
|
format::redirect("/admin/payments")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn update_bank(
|
||||||
|
auth: auth::JWT,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(form): Form<BankSettingsForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
shop_settings::Entity::set(&ctx.db, "bank_account_name", trimmed(&form.bank_account_name)).await?;
|
||||||
|
shop_settings::Entity::set(&ctx.db, "bank_iban", trimmed(&form.bank_iban)).await?;
|
||||||
|
format::redirect("/admin/payments")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.add("/admin/payments", get(index))
|
||||||
|
.add("/admin/payments/methods/{id}", post(update_method))
|
||||||
|
.add("/admin/payments/bank", post(update_bank))
|
||||||
|
}
|
||||||
@@ -20,13 +20,13 @@ use serde_json::json;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
controllers::{
|
controllers::{
|
||||||
admin_form::{read_multipart_form, store_image, MultipartForm},
|
admin_form::{read_multipart_form, store_image, ImageSlot, MultipartForm},
|
||||||
i18n::current_lang,
|
i18n::current_lang,
|
||||||
media::IMAGE_MAX_BYTES,
|
media::IMAGE_MAX_BYTES,
|
||||||
},
|
},
|
||||||
shared::{
|
shared::{
|
||||||
guard,
|
guard,
|
||||||
money::{format_bp, format_price, parse_price_to_cents},
|
money::{format_bp, format_price, parse_percent, parse_price_to_cents},
|
||||||
pricing,
|
pricing,
|
||||||
slug::{slugify, unique_slug},
|
slug::{slugify, unique_slug},
|
||||||
},
|
},
|
||||||
@@ -52,7 +52,7 @@ struct ProductFields {
|
|||||||
name: String,
|
name: String,
|
||||||
slug: String,
|
slug: String,
|
||||||
description: Option<String>,
|
description: Option<String>,
|
||||||
currency: String,
|
short_description: Option<String>,
|
||||||
category_id: Option<i32>,
|
category_id: Option<i32>,
|
||||||
published: bool,
|
published: bool,
|
||||||
}
|
}
|
||||||
@@ -65,8 +65,8 @@ async fn parse_product_fields(
|
|||||||
let name = form
|
let name = form
|
||||||
.text("name")
|
.text("name")
|
||||||
.ok_or_else(|| Error::BadRequest("product name is required".to_string()))?;
|
.ok_or_else(|| Error::BadRequest("product name is required".to_string()))?;
|
||||||
let currency = form.text("currency").unwrap_or_else(|| "EUR".to_string());
|
|
||||||
let description = form.text("description");
|
let description = form.text("description");
|
||||||
|
let short_description = form.text("short_description");
|
||||||
let category_id = form.text("category_id").and_then(|s| s.parse::<i32>().ok());
|
let category_id = form.text("category_id").and_then(|s| s.parse::<i32>().ok());
|
||||||
let published = form.checked("published");
|
let published = form.checked("published");
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ async fn parse_product_fields(
|
|||||||
name,
|
name,
|
||||||
slug,
|
slug,
|
||||||
description,
|
description,
|
||||||
currency,
|
short_description,
|
||||||
category_id,
|
category_id,
|
||||||
published,
|
published,
|
||||||
})
|
})
|
||||||
@@ -102,32 +102,12 @@ struct VariantInput {
|
|||||||
id: Option<i32>,
|
id: Option<i32>,
|
||||||
label: String,
|
label: String,
|
||||||
sku: Option<String>,
|
sku: Option<String>,
|
||||||
stock: i32,
|
/// `None` = available but not inventory-tracked.
|
||||||
|
stock: Option<i32>,
|
||||||
price_cents: i64,
|
price_cents: i64,
|
||||||
business_sale_cents: Option<i64>,
|
|
||||||
position: i32,
|
position: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The optional business-sale price field on a variant row: blank means "no
|
|
||||||
/// business quick-sale", a value must parse and be below the regular price.
|
|
||||||
fn parse_optional_sale(
|
|
||||||
form: &MultipartForm,
|
|
||||||
i: usize,
|
|
||||||
key: &str,
|
|
||||||
price_cents: i64,
|
|
||||||
) -> Result<Option<i64>> {
|
|
||||||
let Some(raw) = form.text(&format!("variants[{i}][{key}]")) else {
|
|
||||||
return Ok(None);
|
|
||||||
};
|
|
||||||
let cents = parse_price_to_cents(&raw)?;
|
|
||||||
if cents <= 0 || cents >= price_cents {
|
|
||||||
return Err(Error::BadRequest(
|
|
||||||
"a sale price must be positive and below the regular price".to_string(),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
Ok(Some(cents))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse the repeated variant rows from the form, in submission order. Blank
|
/// Parse the repeated variant rows from the form, in submission order. Blank
|
||||||
/// rows (no price and no label) are skipped; at least one valid row is required.
|
/// rows (no price and no label) are skipped; at least one valid row is required.
|
||||||
fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
|
fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
|
||||||
@@ -156,12 +136,17 @@ fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let sku = form.text(&format!("variants[{i}][sku]"));
|
let sku = form.text(&format!("variants[{i}][sku]"));
|
||||||
let stock = form
|
// Stock is optional: blank means "available, not tracked". A value must
|
||||||
.text(&format!("variants[{i}][stock]"))
|
// be a non-negative integer.
|
||||||
.and_then(|s| s.parse::<i32>().ok())
|
let stock = match form.text(&format!("variants[{i}][stock]")) {
|
||||||
.filter(|n| *n >= 0)
|
None => None,
|
||||||
.ok_or_else(|| Error::BadRequest("each option needs a stock quantity".to_string()))?;
|
Some(raw) => Some(
|
||||||
let business_sale_cents = parse_optional_sale(form, i, "business_sale", price_cents)?;
|
raw.parse::<i32>()
|
||||||
|
.ok()
|
||||||
|
.filter(|n| *n >= 0)
|
||||||
|
.ok_or_else(|| Error::BadRequest("stock must be 0 or more".to_string()))?,
|
||||||
|
),
|
||||||
|
};
|
||||||
let id = form
|
let id = form
|
||||||
.text(&format!("variants[{i}][id]"))
|
.text(&format!("variants[{i}][id]"))
|
||||||
.and_then(|s| s.parse::<i32>().ok());
|
.and_then(|s| s.parse::<i32>().ok());
|
||||||
@@ -172,7 +157,6 @@ fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
|
|||||||
sku,
|
sku,
|
||||||
stock,
|
stock,
|
||||||
price_cents,
|
price_cents,
|
||||||
business_sale_cents,
|
|
||||||
position: out.len() as i32,
|
position: out.len() as i32,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -190,9 +174,9 @@ fn apply_variant(active: &mut product_variants::ActiveModel, input: &VariantInpu
|
|||||||
active.sku = Set(input.sku.clone());
|
active.sku = Set(input.sku.clone());
|
||||||
active.stock = Set(input.stock);
|
active.stock = Set(input.stock);
|
||||||
active.price_cents = Set(input.price_cents);
|
active.price_cents = Set(input.price_cents);
|
||||||
// The per-variant public sale price was removed from the UI; keep it cleared.
|
// Discounts (public + business sale) are owned by the discount page and keyed
|
||||||
active.sale_price_cents = Set(None);
|
// per option/audience; the product form must leave those columns untouched so
|
||||||
active.business_sale_price_cents = Set(input.business_sale_cents);
|
// it never clobbers a discount. New variants default them to NULL.
|
||||||
active.position = Set(input.position);
|
active.position = Set(input.position);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,7 +234,6 @@ fn variant_form_json(variant: &product_variants::Model) -> serde_json::Value {
|
|||||||
"sku": variant.sku,
|
"sku": variant.sku,
|
||||||
"stock": variant.stock,
|
"stock": variant.stock,
|
||||||
"price": format_price(variant.price_cents),
|
"price": format_price(variant.price_cents),
|
||||||
"business_sale": variant.business_sale_price_cents.map(format_price),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -274,10 +257,17 @@ async fn index(
|
|||||||
.map(|c| (c.id, c.name.clone()))
|
.map(|c| (c.id, c.name.clone()))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let list = products::Entity::find()
|
// Optional text search (drafts included), otherwise the full catalog newest
|
||||||
.order_by_desc(products::Column::CreatedAt)
|
// first. Reuses the storefront's hybrid full-text + fuzzy product search.
|
||||||
.all(&ctx.db)
|
let query = params.get("q").map(String::as_str).unwrap_or("").trim().to_string();
|
||||||
.await?;
|
let list = if query.is_empty() {
|
||||||
|
products::Entity::find()
|
||||||
|
.order_by_desc(products::Column::CreatedAt)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
products::Entity::search(&ctx.db, &query, 1000, false).await?
|
||||||
|
};
|
||||||
let ids: Vec<i32> = list.iter().map(|p| p.id).collect();
|
let ids: Vec<i32> = list.iter().map(|p| p.id).collect();
|
||||||
let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &ids).await?;
|
let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &ids).await?;
|
||||||
|
|
||||||
@@ -315,12 +305,26 @@ async fn index(
|
|||||||
let category_name = product
|
let category_name = product
|
||||||
.category_id
|
.category_id
|
||||||
.and_then(|id| category_name.get(&id).cloned());
|
.and_then(|id| category_name.get(&id).cloned());
|
||||||
let total_stock: i32 = variants.iter().map(|v| v.stock).sum();
|
// Stock column: total across tracked variants, or "∞" when any option is
|
||||||
|
// untracked (always available).
|
||||||
|
let stock_display = if variants.iter().any(|v| !v.tracked()) {
|
||||||
|
"∞".to_string()
|
||||||
|
} else {
|
||||||
|
variants
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.stock)
|
||||||
|
.sum::<i32>()
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
|
// The product is "on sale" for this audience if any option carries a
|
||||||
|
// discount; the per-option amounts live on the discount page.
|
||||||
|
let on_sale = variants.iter().any(|v| current_value(v, audience).is_some());
|
||||||
rows.push(product_row(
|
rows.push(product_row(
|
||||||
product,
|
product,
|
||||||
priced,
|
priced,
|
||||||
|
on_sale,
|
||||||
variants.len(),
|
variants.len(),
|
||||||
total_stock,
|
stock_display,
|
||||||
image,
|
image,
|
||||||
category_name,
|
category_name,
|
||||||
));
|
));
|
||||||
@@ -335,6 +339,7 @@ async fn index(
|
|||||||
"audience": audience,
|
"audience": audience,
|
||||||
"category_groups": category_groups,
|
"category_groups": category_groups,
|
||||||
"selected_category": selected_category,
|
"selected_category": selected_category,
|
||||||
|
"query": query,
|
||||||
"total_count": list.len(),
|
"total_count": list.len(),
|
||||||
"uncategorized_count": category_ids.iter().filter(|c| c.is_none()).count(),
|
"uncategorized_count": category_ids.iter().filter(|c| c.is_none()).count(),
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
@@ -348,8 +353,9 @@ async fn index(
|
|||||||
fn product_row(
|
fn product_row(
|
||||||
product: &products::Model,
|
product: &products::Model,
|
||||||
effective: &pricing::PricedProduct,
|
effective: &pricing::PricedProduct,
|
||||||
|
on_sale: bool,
|
||||||
variant_count: usize,
|
variant_count: usize,
|
||||||
total_stock: i32,
|
stock_display: String,
|
||||||
image: Option<String>,
|
image: Option<String>,
|
||||||
category_name: Option<String>,
|
category_name: Option<String>,
|
||||||
) -> serde_json::Value {
|
) -> serde_json::Value {
|
||||||
@@ -357,14 +363,14 @@ fn product_row(
|
|||||||
"id": product.id,
|
"id": product.id,
|
||||||
"name": product.name,
|
"name": product.name,
|
||||||
"slug": product.slug,
|
"slug": product.slug,
|
||||||
"currency": product.currency,
|
"stock": stock_display,
|
||||||
"stock": total_stock,
|
|
||||||
"variant_count": variant_count,
|
"variant_count": variant_count,
|
||||||
"has_options": variant_count > 1,
|
"has_options": variant_count > 1,
|
||||||
"published": product.published,
|
"published": product.published,
|
||||||
"image": image,
|
"image": image,
|
||||||
"category_name": category_name,
|
"category_name": category_name,
|
||||||
"regular_price": format_price(effective.regular_cents),
|
"regular_price": format_price(effective.regular_cents),
|
||||||
|
"on_sale": on_sale,
|
||||||
"effective_price": format_price(effective.price_cents),
|
"effective_price": format_price(effective.price_cents),
|
||||||
"effective_reduced": effective.is_reduced(),
|
"effective_reduced": effective.is_reduced(),
|
||||||
"effective_percent_off": percent_off(effective.regular_cents, effective.price_cents),
|
"effective_percent_off": percent_off(effective.regular_cents, effective.price_cents),
|
||||||
@@ -431,7 +437,7 @@ async fn create(
|
|||||||
name: Set(fields.name),
|
name: Set(fields.name),
|
||||||
slug: Set(fields.slug),
|
slug: Set(fields.slug),
|
||||||
description: Set(fields.description),
|
description: Set(fields.description),
|
||||||
currency: Set(fields.currency),
|
short_description: Set(fields.short_description),
|
||||||
view_count: Set(0),
|
view_count: Set(0),
|
||||||
published: Set(fields.published),
|
published: Set(fields.published),
|
||||||
published_at: Set(fields.published.then(|| chrono::Utc::now().into())),
|
published_at: Set(fields.published.then(|| chrono::Utc::now().into())),
|
||||||
@@ -441,24 +447,73 @@ async fn create(
|
|||||||
.insert(&txn)
|
.insert(&txn)
|
||||||
.await?;
|
.await?;
|
||||||
sync_variants(&txn, product.id, &variants).await?;
|
sync_variants(&txn, product.id, &variants).await?;
|
||||||
|
sync_images(&ctx, &txn, product.id, &form.image_order, &form.images).await?;
|
||||||
if let Some(data) = form.image {
|
|
||||||
let filename = store_image(&ctx, data).await?;
|
|
||||||
product_images::ActiveModel {
|
|
||||||
product_id: Set(product.id),
|
|
||||||
image_id: Set(filename),
|
|
||||||
position: Set(0),
|
|
||||||
alt: Set(None),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
.insert(&txn)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
txn.commit().await?;
|
txn.commit().await?;
|
||||||
|
|
||||||
format::redirect("/admin/catalog/products")
|
format::redirect("/admin/catalog/products")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reconcile a product's images inside `txn` with the submitted unified
|
||||||
|
/// `image_order`: for each [`ImageSlot::Existing`] entry re-number the
|
||||||
|
/// corresponding image to its slot position; for each [`ImageSlot::New`]
|
||||||
|
/// consume the next freshly uploaded file from `new_images`, storing and
|
||||||
|
/// inserting it at that position. Any existing image not referenced in
|
||||||
|
/// `image_order` is deleted.
|
||||||
|
async fn sync_images<C: ConnectionTrait>(
|
||||||
|
ctx: &AppContext,
|
||||||
|
txn: &C,
|
||||||
|
product_id: i32,
|
||||||
|
image_order: &[ImageSlot],
|
||||||
|
new_images: &[Vec<u8>],
|
||||||
|
) -> Result<()> {
|
||||||
|
let existing = product_images::for_product(txn, product_id).await?;
|
||||||
|
let by_id: HashMap<i32, product_images::Model> =
|
||||||
|
existing.iter().map(|m| (m.id, m.clone())).collect();
|
||||||
|
let keep: HashSet<i32> = image_order
|
||||||
|
.iter()
|
||||||
|
.filter_map(|slot| match slot {
|
||||||
|
ImageSlot::Existing(id) => Some(*id),
|
||||||
|
ImageSlot::New => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for image in &existing {
|
||||||
|
if !keep.contains(&image.id) {
|
||||||
|
image.clone().delete(txn).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut new_iter = new_images.iter();
|
||||||
|
let mut position = 0i32;
|
||||||
|
for slot in image_order {
|
||||||
|
match slot {
|
||||||
|
ImageSlot::Existing(id) => {
|
||||||
|
if let Some(model) = by_id.get(id) {
|
||||||
|
let mut active = model.clone().into_active_model();
|
||||||
|
active.position = Set(position);
|
||||||
|
active.update(txn).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImageSlot::New => {
|
||||||
|
if let Some(data) = new_iter.next() {
|
||||||
|
let filename = store_image(ctx, data.clone()).await?;
|
||||||
|
product_images::ActiveModel {
|
||||||
|
product_id: Set(product_id),
|
||||||
|
image_id: Set(filename),
|
||||||
|
position: Set(position),
|
||||||
|
alt: Set(None),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(txn)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
position += 1;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn edit(
|
async fn edit(
|
||||||
auth: auth::JWT,
|
auth: auth::JWT,
|
||||||
@@ -469,10 +524,10 @@ async fn edit(
|
|||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
guard::current_admin(auth, &ctx).await?;
|
guard::current_admin(auth, &ctx).await?;
|
||||||
let product = product_by_id(&ctx, id).await?;
|
let product = product_by_id(&ctx, id).await?;
|
||||||
let image = product_images::first_for(&ctx, id).await?;
|
let images = product_images::for_product(&ctx.db, id).await?;
|
||||||
let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
|
let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
|
||||||
let mut context = form_context(&ctx, &jar).await?;
|
let mut context = form_context(&ctx, &jar).await?;
|
||||||
context["product"] = view::product_form(&product, image);
|
context["product"] = view::product_form(&product, &images);
|
||||||
context["variants"] = json!(variants.iter().map(variant_form_json).collect::<Vec<_>>());
|
context["variants"] = json!(variants.iter().map(variant_form_json).collect::<Vec<_>>());
|
||||||
format::view(&v, "admin/catalog/product_form.html", context)
|
format::view(&v, "admin/catalog/product_form.html", context)
|
||||||
}
|
}
|
||||||
@@ -496,7 +551,7 @@ async fn update(
|
|||||||
product.name = Set(fields.name);
|
product.name = Set(fields.name);
|
||||||
product.slug = Set(fields.slug);
|
product.slug = Set(fields.slug);
|
||||||
product.description = Set(fields.description);
|
product.description = Set(fields.description);
|
||||||
product.currency = Set(fields.currency);
|
product.short_description = Set(fields.short_description);
|
||||||
product.category_id = Set(fields.category_id);
|
product.category_id = Set(fields.category_id);
|
||||||
product.published = Set(fields.published);
|
product.published = Set(fields.published);
|
||||||
if fields.published && !was_published {
|
if fields.published && !was_published {
|
||||||
@@ -506,20 +561,7 @@ async fn update(
|
|||||||
}
|
}
|
||||||
product.update(&txn).await?;
|
product.update(&txn).await?;
|
||||||
sync_variants(&txn, id, &variants).await?;
|
sync_variants(&txn, id, &variants).await?;
|
||||||
|
sync_images(&ctx, &txn, id, &form.image_order, &form.images).await?;
|
||||||
if let Some(data) = form.image {
|
|
||||||
let filename = store_image(&ctx, data).await?;
|
|
||||||
let next_position = product_images::count_for(&ctx, id).await?;
|
|
||||||
product_images::ActiveModel {
|
|
||||||
product_id: Set(id),
|
|
||||||
image_id: Set(filename),
|
|
||||||
position: Set(next_position),
|
|
||||||
alt: Set(None),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
.insert(&txn)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
txn.commit().await?;
|
txn.commit().await?;
|
||||||
|
|
||||||
format::redirect("/admin/catalog/products")
|
format::redirect("/admin/catalog/products")
|
||||||
@@ -558,6 +600,30 @@ fn list_redirect(audience: &str) -> Result<Response> {
|
|||||||
format::redirect(&format!("/admin/catalog/products?audience={audience}"))
|
format::redirect(&format!("/admin/catalog/products?audience={audience}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a percentage off the regular price into a fixed sale price in cents.
|
||||||
|
fn percent_to_sale_cents(regular_cents: i64, percent: f64) -> i64 {
|
||||||
|
let off = (regular_cents as f64 * percent / 100.0).round() as i64;
|
||||||
|
regular_cents - off
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which discount value an audience tab sees on a variant.
|
||||||
|
fn current_value(variant: &product_variants::Model, audience: &str) -> Option<i64> {
|
||||||
|
if audience == BUSINESS {
|
||||||
|
variant.business_sale_price_cents
|
||||||
|
} else {
|
||||||
|
variant.sale_price_cents
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the discount column on a variant for a given audience.
|
||||||
|
fn set_value(active: &mut product_variants::ActiveModel, audience: &str, value: Option<i64>) {
|
||||||
|
if audience == BUSINESS {
|
||||||
|
active.business_sale_price_cents = Set(value);
|
||||||
|
} else {
|
||||||
|
active.sale_price_cents = Set(value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Percent off the regular price, rounded to a whole number.
|
/// Percent off the regular price, rounded to a whole number.
|
||||||
fn percent_off(regular_cents: i64, sale_cents: i64) -> i64 {
|
fn percent_off(regular_cents: i64, sale_cents: i64) -> i64 {
|
||||||
if regular_cents <= 0 {
|
if regular_cents <= 0 {
|
||||||
@@ -632,7 +698,6 @@ async fn profiles_preview(
|
|||||||
}
|
}
|
||||||
rows.push(json!({
|
rows.push(json!({
|
||||||
"id": product.id,
|
"id": product.id,
|
||||||
"currency": product.currency,
|
|
||||||
"effective_price": format_price(priced.price_cents),
|
"effective_price": format_price(priced.price_cents),
|
||||||
"effective_reduced": priced.is_reduced(),
|
"effective_reduced": priced.is_reduced(),
|
||||||
"effective_percent_off": percent_off(priced.regular_cents, priced.price_cents),
|
"effective_percent_off": percent_off(priced.regular_cents, priced.price_cents),
|
||||||
@@ -681,8 +746,233 @@ async fn sync_profiles(
|
|||||||
list_redirect(audience)
|
list_redirect(audience)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Per-variant discounts ---------------------------------------------------
|
||||||
|
//
|
||||||
|
// Each product is sold as one or more options (variants). A discount can be set
|
||||||
|
// on every option individually, for the active audience: personal writes the
|
||||||
|
// public `sale_price_cents`, business writes `business_sale_price_cents`. Per
|
||||||
|
// option the admin picks a fixed sale price or a percentage off the regular
|
||||||
|
// price; an empty value clears that option's discount.
|
||||||
|
|
||||||
|
/// One option row in the discount form. Carries enough to pre-fill the editor
|
||||||
|
/// and to survive a validation-error round-trip.
|
||||||
|
struct DiscountRow {
|
||||||
|
id: i32,
|
||||||
|
label: String,
|
||||||
|
regular_cents: i64,
|
||||||
|
mode: String,
|
||||||
|
fixed: String,
|
||||||
|
percent: String,
|
||||||
|
has_discount: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DiscountRow {
|
||||||
|
/// Pre-fill from the discount stored for this audience.
|
||||||
|
fn from_db(v: &product_variants::Model, audience: &str) -> Self {
|
||||||
|
let sale = current_value(v, audience);
|
||||||
|
DiscountRow {
|
||||||
|
id: v.id,
|
||||||
|
label: v.label.clone(),
|
||||||
|
regular_cents: v.price_cents,
|
||||||
|
mode: "fixed".to_string(),
|
||||||
|
fixed: sale.map(format_price).unwrap_or_default(),
|
||||||
|
percent: String::new(),
|
||||||
|
has_discount: sale.is_some(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pre-fill from the submitted values, to repaint the form after an error.
|
||||||
|
fn from_submitted(
|
||||||
|
v: &product_variants::Model,
|
||||||
|
audience: &str,
|
||||||
|
pairs: &HashMap<String, String>,
|
||||||
|
) -> Self {
|
||||||
|
let get = |key: &str| {
|
||||||
|
pairs
|
||||||
|
.get(&format!("v[{}][{key}]", v.id))
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.unwrap_or_default()
|
||||||
|
};
|
||||||
|
let mode = get("mode");
|
||||||
|
DiscountRow {
|
||||||
|
id: v.id,
|
||||||
|
label: v.label.clone(),
|
||||||
|
regular_cents: v.price_cents,
|
||||||
|
mode: if mode == "percent" {
|
||||||
|
mode
|
||||||
|
} else {
|
||||||
|
"fixed".to_string()
|
||||||
|
},
|
||||||
|
fixed: get("fixed"),
|
||||||
|
percent: get("percent"),
|
||||||
|
has_discount: current_value(v, audience).is_some(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn to_json(&self) -> serde_json::Value {
|
||||||
|
json!({
|
||||||
|
"id": self.id,
|
||||||
|
"label": self.label,
|
||||||
|
"regular_cents": self.regular_cents,
|
||||||
|
"regular_price": format_price(self.regular_cents),
|
||||||
|
"mode": self.mode,
|
||||||
|
"fixed": self.fixed,
|
||||||
|
"percent": self.percent,
|
||||||
|
"has_discount": self.has_discount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve one submitted option into the sale price to store. `Ok(None)` clears
|
||||||
|
/// the discount; `Err` is an i18n key for the validation message.
|
||||||
|
fn resolve_row(
|
||||||
|
regular_cents: i64,
|
||||||
|
mode: &str,
|
||||||
|
fixed: &str,
|
||||||
|
percent: &str,
|
||||||
|
) -> std::result::Result<Option<i64>, &'static str> {
|
||||||
|
let sale_cents = if mode == "percent" {
|
||||||
|
if percent.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let pct = parse_percent(percent).ok_or("discount-invalid")?;
|
||||||
|
if pct <= 0.0 || pct >= 100.0 {
|
||||||
|
return Err("discount-percent-range");
|
||||||
|
}
|
||||||
|
percent_to_sale_cents(regular_cents, pct)
|
||||||
|
} else {
|
||||||
|
if fixed.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
parse_price_to_cents(fixed).map_err(|_| "discount-invalid")?
|
||||||
|
};
|
||||||
|
if sale_cents <= 0 {
|
||||||
|
return Err("discount-must-be-positive");
|
||||||
|
}
|
||||||
|
if sale_cents >= regular_cents {
|
||||||
|
return Err("discount-below-regular");
|
||||||
|
}
|
||||||
|
Ok(Some(sale_cents))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn discount_view(
|
||||||
|
v: &TeraView,
|
||||||
|
jar: &CookieJar,
|
||||||
|
product: &products::Model,
|
||||||
|
rows: &[DiscountRow],
|
||||||
|
audience: &str,
|
||||||
|
error: Option<&str>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let rows_json: Vec<_> = rows.iter().map(DiscountRow::to_json).collect();
|
||||||
|
let has_discount = rows.iter().any(|r| r.has_discount);
|
||||||
|
format::view(
|
||||||
|
v,
|
||||||
|
"admin/catalog/discount_form.html",
|
||||||
|
json!({
|
||||||
|
"product": {
|
||||||
|
"id": product.id,
|
||||||
|
"name": product.name,
|
||||||
|
},
|
||||||
|
"rows": rows_json,
|
||||||
|
"audience": audience,
|
||||||
|
"has_discount": has_discount,
|
||||||
|
"error": error.map(|e| e.to_string()),
|
||||||
|
"lang": current_lang(jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn discount_show(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let audience = read_audience(¶ms);
|
||||||
|
let product = product_by_id(&ctx, id).await?;
|
||||||
|
let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
|
||||||
|
let rows: Vec<DiscountRow> = variants
|
||||||
|
.iter()
|
||||||
|
.map(|variant| DiscountRow::from_db(variant, audience))
|
||||||
|
.collect();
|
||||||
|
discount_view(&v, &jar, &product, &rows, audience, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn discount_update(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
body: String,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let audience = read_audience(¶ms);
|
||||||
|
let product = product_by_id(&ctx, id).await?;
|
||||||
|
let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
|
||||||
|
|
||||||
|
let pairs: HashMap<String, String> = form_urlencoded::parse(body.as_bytes())
|
||||||
|
.into_owned()
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Resolve every option before persisting anything, so one bad row can't leave
|
||||||
|
// the product half-discounted. On the first error, repaint with the inputs.
|
||||||
|
let mut resolved: Vec<(product_variants::Model, Option<i64>)> = Vec::new();
|
||||||
|
for variant in &variants {
|
||||||
|
let row = DiscountRow::from_submitted(variant, audience, &pairs);
|
||||||
|
match resolve_row(variant.price_cents, &row.mode, &row.fixed, &row.percent) {
|
||||||
|
Ok(value) => resolved.push((variant.clone(), value)),
|
||||||
|
Err(key) => {
|
||||||
|
let rows: Vec<DiscountRow> = variants
|
||||||
|
.iter()
|
||||||
|
.map(|v| DiscountRow::from_submitted(v, audience, &pairs))
|
||||||
|
.collect();
|
||||||
|
return discount_view(&v, &jar, &product, &rows, audience, Some(key)).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let txn = ctx.db.begin().await?;
|
||||||
|
for (variant, value) in &resolved {
|
||||||
|
let mut active = variant.clone().into_active_model();
|
||||||
|
set_value(&mut active, audience, *value);
|
||||||
|
active.update(&txn).await?;
|
||||||
|
}
|
||||||
|
txn.commit().await?;
|
||||||
|
list_redirect(audience)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn discount_remove(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let audience = read_audience(¶ms);
|
||||||
|
let _product = product_by_id(&ctx, id).await?;
|
||||||
|
let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
|
||||||
|
let txn = ctx.db.begin().await?;
|
||||||
|
for variant in &variants {
|
||||||
|
let mut active = variant.clone().into_active_model();
|
||||||
|
set_value(&mut active, audience, None);
|
||||||
|
active.update(&txn).await?;
|
||||||
|
}
|
||||||
|
txn.commit().await?;
|
||||||
|
list_redirect(audience)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn routes() -> Routes {
|
pub fn routes() -> Routes {
|
||||||
let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024);
|
// Several images may be uploaded in one submission; allow a generous total
|
||||||
|
// (per-file size is still capped at IMAGE_MAX_BYTES while reading).
|
||||||
|
let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES * 12 + 1024 * 1024);
|
||||||
Routes::new()
|
Routes::new()
|
||||||
.add("/admin/catalog/products", get(index))
|
.add("/admin/catalog/products", get(index))
|
||||||
.add("/admin/catalog/products/new", get(new))
|
.add("/admin/catalog/products/new", get(new))
|
||||||
@@ -701,4 +991,16 @@ pub fn routes() -> Routes {
|
|||||||
post(update).layer(image_limit),
|
post(update).layer(image_limit),
|
||||||
)
|
)
|
||||||
.add("/admin/catalog/products/{id}/delete", post(delete))
|
.add("/admin/catalog/products/{id}/delete", post(delete))
|
||||||
|
.add(
|
||||||
|
"/admin/catalog/products/{id}/discount/edit",
|
||||||
|
get(discount_show),
|
||||||
|
)
|
||||||
|
.add(
|
||||||
|
"/admin/catalog/products/{id}/discount",
|
||||||
|
post(discount_update),
|
||||||
|
)
|
||||||
|
.add(
|
||||||
|
"/admin/catalog/products/{id}/discount/remove",
|
||||||
|
post(discount_remove),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ use crate::{
|
|||||||
shared::{
|
shared::{
|
||||||
guard,
|
guard,
|
||||||
money::{format_price, parse_price_to_cents},
|
money::{format_price, parse_price_to_cents},
|
||||||
|
shipping as shipping_rules,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,13 +38,17 @@ async fn index(
|
|||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
guard::current_admin(auth, &ctx).await?;
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
shipping_rules::disable_packeta_if_unconfigured(&ctx).await?;
|
||||||
let methods = shipping_methods::Entity::find()
|
let methods = shipping_methods::Entity::find()
|
||||||
.order_by_asc(shipping_methods::Column::Position)
|
.order_by_asc(shipping_methods::Column::Position)
|
||||||
.all(&ctx.db)
|
.all(&ctx.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
let packeta_ready = shipping_rules::packeta_ready(&ctx);
|
||||||
let rows: Vec<serde_json::Value> = methods
|
let rows: Vec<serde_json::Value> = methods
|
||||||
.iter()
|
.iter()
|
||||||
.map(|m| {
|
.map(|m| {
|
||||||
|
let packeta_not_ready = m.carrier == "packeta" && !packeta_ready;
|
||||||
|
let locked = packeta_not_ready && !m.enabled;
|
||||||
json!({
|
json!({
|
||||||
"id": m.id,
|
"id": m.id,
|
||||||
"code": m.code,
|
"code": m.code,
|
||||||
@@ -52,6 +57,9 @@ async fn index(
|
|||||||
"carrier": m.carrier,
|
"carrier": m.carrier,
|
||||||
"requires_pickup_point": m.requires_pickup_point,
|
"requires_pickup_point": m.requires_pickup_point,
|
||||||
"enabled": m.enabled,
|
"enabled": m.enabled,
|
||||||
|
"packeta_not_ready": packeta_not_ready,
|
||||||
|
"locked": locked,
|
||||||
|
"lock_reason": if packeta_not_ready { Some("shipping-packeta-missing-settings") } else { None::<&str> },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -74,9 +82,15 @@ async fn update(
|
|||||||
.one(&ctx.db)
|
.one(&ctx.db)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| Error::NotFound)?;
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
let requested_enabled = is_checked(&form.enabled);
|
||||||
|
if requested_enabled && method.carrier == "packeta" && !shipping_rules::packeta_ready(&ctx) {
|
||||||
|
return Err(Error::BadRequest(
|
||||||
|
"Packeta cannot be enabled until PACKETA_API_KEY, PACKETA_API_PASSWORD and PACKETA_SENDER_LABEL are configured.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
let mut active = method.into_active_model();
|
let mut active = method.into_active_model();
|
||||||
active.price_cents = Set(parse_price_to_cents(&form.price)?);
|
active.price_cents = Set(parse_price_to_cents(&form.price)?);
|
||||||
active.enabled = Set(is_checked(&form.enabled));
|
active.enabled = Set(requested_enabled);
|
||||||
active.update(&ctx.db).await?;
|
active.update(&ctx.db).await?;
|
||||||
format::redirect("/admin/shipping")
|
format::redirect("/admin/shipping")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
|
controllers::cart,
|
||||||
models::users::{self, LoginParams, RegisterParams},
|
models::users::{self, LoginParams, RegisterParams},
|
||||||
views::auth::{CurrentResponse, LoginResponse},
|
views::auth::{CurrentResponse, LoginResponse},
|
||||||
mailers::auth::AuthMailer,
|
mailers::auth::AuthMailer,
|
||||||
shared::guard::is_admin,
|
shared::guard::is_admin,
|
||||||
};
|
};
|
||||||
use axum_extra::extract::cookie::{Cookie, SameSite};
|
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -88,6 +89,7 @@ pub struct ResendVerificationParams {
|
|||||||
/// welcome email to the user
|
/// welcome email to the user
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn register(
|
async fn register(
|
||||||
|
jar: CookieJar,
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
Json(params): Json<RegisterParams>,
|
Json(params): Json<RegisterParams>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
@@ -109,6 +111,7 @@ async fn register(
|
|||||||
.into_active_model()
|
.into_active_model()
|
||||||
.set_email_verification_sent(&ctx.db)
|
.set_email_verification_sent(&ctx.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
cart::claim_guest_cart(&ctx, &jar, user.id).await?;
|
||||||
|
|
||||||
AuthMailer::send_welcome(&ctx, &user).await?;
|
AuthMailer::send_welcome(&ctx, &user).await?;
|
||||||
|
|
||||||
@@ -199,8 +202,9 @@ async fn login(State(ctx): State<AppContext>, Json(params): Json<LoginParams>) -
|
|||||||
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
||||||
.or_else(|_| unauthorized("unauthorized!"))?;
|
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||||
|
|
||||||
|
let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?;
|
||||||
format::render()
|
format::render()
|
||||||
.cookies(&[auth_cookie(&token, jwt_secret.expiration)])?
|
.cookies(&[auth_cookie(&token, jwt_secret.expiration), cart_cookie])?
|
||||||
.json(LoginResponse::new(&user, &token, is_admin(&ctx, &user)))
|
.json(LoginResponse::new(&user, &token, is_admin(&ctx, &user)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +216,9 @@ async fn current(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Respo
|
|||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn logout() -> Result<Response> {
|
async fn logout() -> Result<Response> {
|
||||||
format::render().cookies(&[clear_auth_cookie()])?.json(())
|
format::render()
|
||||||
|
.cookies(&[clear_auth_cookie(), cart::cleared_cart_cookie()])?
|
||||||
|
.json(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Magic link authentication provides a secure and passwordless way to log in to the application.
|
/// Magic link authentication provides a secure and passwordless way to log in to the application.
|
||||||
@@ -274,8 +280,9 @@ async fn magic_link_verify(
|
|||||||
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
||||||
.or_else(|_| unauthorized("unauthorized!"))?;
|
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||||
|
|
||||||
|
let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?;
|
||||||
format::render()
|
format::render()
|
||||||
.cookies(&[auth_cookie(&token, jwt_secret.expiration)])?
|
.cookies(&[auth_cookie(&token, jwt_secret.expiration), cart_cookie])?
|
||||||
.json(LoginResponse::new(&user, &token, is_admin(&ctx, &user)))
|
.json(LoginResponse::new(&user, &token, is_admin(&ctx, &user)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use serde_json::json;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
controllers::auth as auth_controller,
|
controllers::auth as auth_controller,
|
||||||
|
controllers::cart,
|
||||||
controllers::i18n::current_lang,
|
controllers::i18n::current_lang,
|
||||||
mailers::auth::AuthMailer,
|
mailers::auth::AuthMailer,
|
||||||
models::users::{self, LoginParams, RegisterParams},
|
models::users::{self, LoginParams, RegisterParams},
|
||||||
@@ -105,9 +106,13 @@ async fn login(
|
|||||||
let token = user
|
let token = user
|
||||||
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
||||||
.or_else(|_| unauthorized("unauthorized!"))?;
|
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||||
|
let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?;
|
||||||
|
|
||||||
format::render()
|
format::render()
|
||||||
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])?
|
.cookies(&[
|
||||||
|
auth_controller::auth_cookie(&token, jwt_secret.expiration),
|
||||||
|
cart_cookie,
|
||||||
|
])?
|
||||||
.redirect(home_for(&ctx, &user))
|
.redirect(home_for(&ctx, &user))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,11 +190,13 @@ async fn login_totp(
|
|||||||
let token = user
|
let token = user
|
||||||
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
||||||
.or_else(|_| unauthorized("unauthorized!"))?;
|
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||||
|
let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?;
|
||||||
|
|
||||||
format::render()
|
format::render()
|
||||||
.cookies(&[
|
.cookies(&[
|
||||||
auth_controller::auth_cookie(&token, jwt_secret.expiration),
|
auth_controller::auth_cookie(&token, jwt_secret.expiration),
|
||||||
auth_controller::clear_totp_pending_cookie(),
|
auth_controller::clear_totp_pending_cookie(),
|
||||||
|
cart_cookie,
|
||||||
])?
|
])?
|
||||||
.redirect(home_for(&ctx, &user))
|
.redirect(home_for(&ctx, &user))
|
||||||
}
|
}
|
||||||
@@ -270,6 +277,7 @@ async fn register(
|
|||||||
.into_active_model()
|
.into_active_model()
|
||||||
.set_email_verification_sent(&ctx.db)
|
.set_email_verification_sent(&ctx.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
cart::claim_guest_cart(&ctx, &jar, user.id).await?;
|
||||||
|
|
||||||
// The account already exists; a failed email send shouldn't 500 the page —
|
// The account already exists; a failed email send shouldn't 500 the page —
|
||||||
// log it and let the user fall back to resend-verification.
|
// log it and let the user fall back to resend-verification.
|
||||||
@@ -304,7 +312,9 @@ async fn verify(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if user.email_verified_at.is_none() {
|
if user.email_verified_at.is_none() {
|
||||||
|
let user_id = user.id;
|
||||||
user.into_active_model().verified(&ctx.db).await?;
|
user.into_active_model().verified(&ctx.db).await?;
|
||||||
|
cart::claim_guest_cart(&ctx, &jar, user_id).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
verified_view(&v, &jar, true)
|
verified_view(&v, &jar, true)
|
||||||
@@ -446,7 +456,10 @@ async fn set_password(
|
|||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn logout() -> Result<Response> {
|
async fn logout() -> Result<Response> {
|
||||||
format::render()
|
format::render()
|
||||||
.cookies(&[auth_controller::clear_auth_cookie()])?
|
.cookies(&[
|
||||||
|
auth_controller::clear_auth_cookie(),
|
||||||
|
cart::cleared_cart_cookie(),
|
||||||
|
])?
|
||||||
.redirect("/login")
|
.redirect("/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
use crate::{controllers::i18n::current_lang, shared::{guard, money::format_price, pricing}, models::{product_variants, products}};
|
use crate::{
|
||||||
|
controllers::i18n::current_lang,
|
||||||
|
models::{account_cart_items, product_variants, products, users},
|
||||||
|
shared::{
|
||||||
|
currency::{self, Currency},
|
||||||
|
guard, pricing,
|
||||||
|
},
|
||||||
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode},
|
||||||
response::Redirect,
|
response::Redirect,
|
||||||
@@ -64,8 +71,77 @@ fn cart_cookie(value: String) -> Cookie<'static> {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn cleared_cart_cookie() -> Cookie<'static> {
|
||||||
|
Cookie::build((CART_COOKIE, ""))
|
||||||
|
.path("/")
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
|
.max_age(TimeDuration::seconds(0))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_items(items: Vec<(i32, i32)>) -> Vec<(i32, i32)> {
|
||||||
|
let mut normalized: Vec<(i32, i32)> = Vec::new();
|
||||||
|
for (id, qty) in items.into_iter().filter(|(_, qty)| *qty > 0) {
|
||||||
|
if let Some(existing) = normalized.iter_mut().find(|(existing_id, _)| *existing_id == id) {
|
||||||
|
existing.1 += qty;
|
||||||
|
} else {
|
||||||
|
normalized.push((id, qty));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stored_cart(
|
||||||
|
ctx: &AppContext,
|
||||||
|
user: Option<&users::Model>,
|
||||||
|
jar: &CookieJar,
|
||||||
|
) -> Result<Vec<(i32, i32)>> {
|
||||||
|
match user {
|
||||||
|
Some(user) => Ok(account_cart_items::Model::find_for_user(&ctx.db, user.id).await?),
|
||||||
|
None => Ok(normalize_items(parse_cart(jar))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_cart(
|
||||||
|
ctx: &AppContext,
|
||||||
|
jar: CookieJar,
|
||||||
|
user: Option<&users::Model>,
|
||||||
|
items: &[(i32, i32)],
|
||||||
|
) -> Result<CookieJar> {
|
||||||
|
let items = normalize_items(items.to_vec());
|
||||||
|
if let Some(user) = user {
|
||||||
|
account_cart_items::Model::replace_for_user(&ctx.db, user.id, &items).await?;
|
||||||
|
}
|
||||||
|
Ok(jar.add(cart_cookie(serialize_cart(&items))))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn claim_guest_cart(
|
||||||
|
ctx: &AppContext,
|
||||||
|
jar: &CookieJar,
|
||||||
|
user_id: i32,
|
||||||
|
) -> Result<()> {
|
||||||
|
let items = normalize_items(parse_cart(jar));
|
||||||
|
if !items.is_empty() {
|
||||||
|
account_cart_items::Model::replace_for_user(&ctx.db, user_id, &items).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn cart_cookie_for_user(
|
||||||
|
ctx: &AppContext,
|
||||||
|
user_id: i32,
|
||||||
|
) -> Result<Cookie<'static>> {
|
||||||
|
let items = account_cart_items::Model::find_for_user(&ctx.db, user_id).await?;
|
||||||
|
Ok(cart_cookie(serialize_cart(&items)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn clear_account_cart(ctx: &AppContext, user_id: i32) -> Result<()> {
|
||||||
|
account_cart_items::Model::replace_for_user(&ctx.db, user_id, &[]).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Look up a variant whose product is published, returning the variant together
|
/// Look up a variant whose product is published, returning the variant together
|
||||||
/// with its parent product (for name/slug/currency).
|
/// with its parent product (for name/slug).
|
||||||
async fn published_variant(
|
async fn published_variant(
|
||||||
ctx: &AppContext,
|
ctx: &AppContext,
|
||||||
variant_id: i32,
|
variant_id: i32,
|
||||||
@@ -94,16 +170,17 @@ async fn add(
|
|||||||
return Err(Error::NotFound);
|
return Err(Error::NotFound);
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut items = parse_cart(&jar);
|
let user = guard::current_user(&ctx, &jar).await;
|
||||||
|
let mut items = stored_cart(&ctx, user.as_ref(), &jar).await?;
|
||||||
let add_qty = form.quantity.unwrap_or(1).max(1);
|
let add_qty = form.quantity.unwrap_or(1).max(1);
|
||||||
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == variant.id) {
|
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == variant.id) {
|
||||||
entry.1 = (entry.1 + add_qty).min(variant.stock);
|
entry.1 = variant.cap(entry.1 + add_qty);
|
||||||
} else {
|
} else {
|
||||||
items.push((variant.id, add_qty.min(variant.stock)));
|
items.push((variant.id, variant.cap(add_qty)));
|
||||||
}
|
}
|
||||||
items.retain(|(_, qty)| *qty > 0);
|
items.retain(|(_, qty)| *qty > 0);
|
||||||
|
|
||||||
let jar = jar.add(cart_cookie(serialize_cart(&items)));
|
let jar = persist_cart(&ctx, jar, user.as_ref(), &items).await?;
|
||||||
|
|
||||||
// Adding to the cart should never navigate away: htmx requests get an empty
|
// Adding to the cart should never navigate away: htmx requests get an empty
|
||||||
// 204 (the header cart badge updates client-side), and a no-JS submit goes
|
// 204 (the header cart badge updates client-side), and a no-JS submit goes
|
||||||
@@ -128,19 +205,21 @@ async fn update(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Form(form): Form<UpdateForm>,
|
Form(form): Form<UpdateForm>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let stock = published_variant(&ctx, form.variant_id)
|
// Clamp the requested quantity to what's available (no cap for untracked
|
||||||
.await?
|
// variants); a removed variant clamps to 0 and drops out below.
|
||||||
.map(|(v, _)| v.stock)
|
let clamped = match published_variant(&ctx, form.variant_id).await? {
|
||||||
.unwrap_or(0);
|
Some((variant, _)) => variant.cap(form.quantity),
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
|
||||||
let mut items = parse_cart(&jar);
|
let user = guard::current_user(&ctx, &jar).await;
|
||||||
let clamped = form.quantity.clamp(0, stock);
|
let mut items = stored_cart(&ctx, user.as_ref(), &jar).await?;
|
||||||
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.variant_id) {
|
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.variant_id) {
|
||||||
entry.1 = clamped;
|
entry.1 = clamped;
|
||||||
}
|
}
|
||||||
items.retain(|(_, qty)| *qty > 0);
|
items.retain(|(_, qty)| *qty > 0);
|
||||||
|
|
||||||
let jar = jar.add(cart_cookie(serialize_cart(&items)));
|
let jar = persist_cart(&ctx, jar, user.as_ref(), &items).await?;
|
||||||
cart_response(&ctx, &v, jar, &headers).await
|
cart_response(&ctx, &v, jar, &headers).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,10 +231,11 @@ async fn remove(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Form(form): Form<RemoveForm>,
|
Form(form): Form<RemoveForm>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let mut items = parse_cart(&jar);
|
let user = guard::current_user(&ctx, &jar).await;
|
||||||
|
let mut items = stored_cart(&ctx, user.as_ref(), &jar).await?;
|
||||||
items.retain(|(id, _)| *id != form.variant_id);
|
items.retain(|(id, _)| *id != form.variant_id);
|
||||||
|
|
||||||
let jar = jar.add(cart_cookie(serialize_cart(&items)));
|
let jar = persist_cart(&ctx, jar, user.as_ref(), &items).await?;
|
||||||
cart_response(&ctx, &v, jar, &headers).await
|
cart_response(&ctx, &v, jar, &headers).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -172,43 +252,41 @@ async fn cart_response(
|
|||||||
return Ok((jar, Redirect::to("/cart")).into_response());
|
return Ok((jar, Redirect::to("/cart")).into_response());
|
||||||
}
|
}
|
||||||
|
|
||||||
let (lines, valid, total) = resolve_cart(ctx, &jar).await?;
|
let cur = currency::resolve(ctx, &jar).await;
|
||||||
let currency = lines
|
let (lines, valid, total) = resolve_cart(ctx, &jar, &cur).await?;
|
||||||
.first()
|
|
||||||
.and_then(|line| line["currency"].as_str())
|
|
||||||
.unwrap_or("EUR")
|
|
||||||
.to_string();
|
|
||||||
// Persist the re-validated cookie (drops now-invalid lines).
|
// Persist the re-validated cookie (drops now-invalid lines).
|
||||||
let jar = jar.add(cart_cookie(serialize_cart(&valid)));
|
let user = guard::current_user(ctx, &jar).await;
|
||||||
|
let jar = persist_cart(ctx, jar, user.as_ref(), &valid).await?;
|
||||||
let response = format::view(
|
let response = format::view(
|
||||||
v,
|
v,
|
||||||
"shop/_cart_body.html",
|
"shop/_cart_body.html",
|
||||||
json!({
|
json!({
|
||||||
"items": lines,
|
"items": lines,
|
||||||
"total": format_price(total),
|
"total": cur.format(total),
|
||||||
"currency": currency,
|
"currency_symbol": cur.symbol,
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
}),
|
}),
|
||||||
)?;
|
)?;
|
||||||
Ok((jar, response).into_response())
|
Ok((jar, response).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the cart cookie into priced line items, dropping anything that is no
|
/// Resolve the active cart into priced line items, dropping anything that is no
|
||||||
/// longer purchasable and clamping quantities to current stock. Returns the
|
/// longer purchasable and clamping quantities to current stock. Guests resolve
|
||||||
/// (re-validated) lines, the rebuilt cookie value, and the total in cents.
|
/// from the cookie; authenticated users resolve from their account cart.
|
||||||
pub(crate) async fn resolve_cart(
|
pub(crate) async fn resolve_cart(
|
||||||
ctx: &AppContext,
|
ctx: &AppContext,
|
||||||
jar: &CookieJar,
|
jar: &CookieJar,
|
||||||
|
cur: &Currency,
|
||||||
) -> Result<(Vec<serde_json::Value>, Vec<(i32, i32)>, i64)> {
|
) -> Result<(Vec<serde_json::Value>, Vec<(i32, i32)>, i64)> {
|
||||||
// Resolve the cart entries to in-stock products first, then price them all
|
// Resolve the cart entries to in-stock products first, then price them all
|
||||||
// for the current viewer in one batch (the price depends on who's logged in).
|
// for the current viewer in one batch (the price depends on who's logged in).
|
||||||
let user = guard::current_user(ctx, jar).await;
|
let user = guard::current_user(ctx, jar).await;
|
||||||
let mut items: Vec<(product_variants::Model, products::Model, i32)> = Vec::new();
|
let mut items: Vec<(product_variants::Model, products::Model, i32)> = Vec::new();
|
||||||
for (id, qty) in parse_cart(jar) {
|
for (id, qty) in stored_cart(ctx, user.as_ref(), jar).await? {
|
||||||
let Some((variant, product)) = published_variant(ctx, id).await? else {
|
let Some((variant, product)) = published_variant(ctx, id).await? else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let qty = qty.clamp(0, variant.stock);
|
let qty = variant.cap(qty);
|
||||||
if qty == 0 {
|
if qty == 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -231,16 +309,19 @@ pub(crate) async fn resolve_cart(
|
|||||||
"name": product.name,
|
"name": product.name,
|
||||||
"variant_label": variant.label,
|
"variant_label": variant.label,
|
||||||
"slug": product.slug,
|
"slug": product.slug,
|
||||||
"price": format_price(unit_price),
|
"price": cur.format(unit_price),
|
||||||
"regular_price": format_price(priced.regular_cents),
|
"regular_price": cur.format(priced.regular_cents),
|
||||||
"on_sale": priced.is_reduced(),
|
"on_sale": priced.is_reduced(),
|
||||||
"currency": product.currency,
|
|
||||||
"quantity": qty,
|
"quantity": qty,
|
||||||
"stock": variant.stock,
|
"stock": variant.stock,
|
||||||
"line_total": format_price(line_total),
|
"line_total": cur.format(line_total),
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(user) = user.as_ref() {
|
||||||
|
account_cart_items::Model::replace_for_user(&ctx.db, user.id, &valid).await?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok((lines, valid, total))
|
Ok((lines, valid, total))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,32 +331,29 @@ async fn show(
|
|||||||
ViewEngine(v): ViewEngine<TeraView>,
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let (lines, valid, total) = resolve_cart(&ctx, &jar).await?;
|
let cur = currency::resolve(&ctx, &jar).await;
|
||||||
let currency = lines
|
let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?;
|
||||||
.first()
|
|
||||||
.and_then(|line| line["currency"].as_str())
|
|
||||||
.unwrap_or("EUR")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
// Drop any now-invalid lines from the cookie so the badge stays accurate.
|
|
||||||
let rebuilt = serialize_cart(&valid);
|
|
||||||
let c = guard::chrome(&ctx, &jar).await;
|
let c = guard::chrome(&ctx, &jar).await;
|
||||||
let response = format::view(
|
let response = format::view(
|
||||||
&v,
|
&v,
|
||||||
"shop/cart.html",
|
"shop/cart.html",
|
||||||
json!({
|
json!({
|
||||||
"items": lines,
|
"items": lines,
|
||||||
"total": format_price(total),
|
"total": cur.format(total),
|
||||||
"currency": currency,
|
"currency_symbol": cur.symbol,
|
||||||
"logged_in_admin": c.logged_in_admin,
|
"logged_in_admin": c.logged_in_admin,
|
||||||
"logged_in_customer": c.logged_in_customer,
|
"logged_in_customer": c.logged_in_customer,
|
||||||
"customer_name": c.customer_name,
|
"customer_name": c.customer_name,
|
||||||
"customer_account_type": c.customer_account_type,
|
"customer_account_type": c.customer_account_type,
|
||||||
|
"customer_avatar": c.customer_avatar,
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
}),
|
}),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok((jar.add(cart_cookie(rebuilt)), response).into_response())
|
let user = guard::current_user(&ctx, &jar).await;
|
||||||
|
let jar = persist_cart(&ctx, jar, user.as_ref(), &valid).await?;
|
||||||
|
Ok((jar, response).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mini-cart preview for the navbar hover dropdown. Lazy-loaded via htmx from
|
/// Mini-cart preview for the navbar hover dropdown. Lazy-loaded via htmx from
|
||||||
@@ -286,24 +364,21 @@ async fn preview(
|
|||||||
ViewEngine(v): ViewEngine<TeraView>,
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let (lines, valid, total) = resolve_cart(&ctx, &jar).await?;
|
let cur = currency::resolve(&ctx, &jar).await;
|
||||||
let currency = lines
|
let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?;
|
||||||
.first()
|
|
||||||
.and_then(|line| line["currency"].as_str())
|
|
||||||
.unwrap_or("EUR")
|
|
||||||
.to_string();
|
|
||||||
let rebuilt = serialize_cart(&valid);
|
|
||||||
let response = format::view(
|
let response = format::view(
|
||||||
&v,
|
&v,
|
||||||
"shop/_cart_preview.html",
|
"shop/_cart_preview.html",
|
||||||
json!({
|
json!({
|
||||||
"items": lines,
|
"items": lines,
|
||||||
"total": format_price(total),
|
"total": cur.format(total),
|
||||||
"currency": currency,
|
"currency_symbol": cur.symbol,
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
}),
|
}),
|
||||||
)?;
|
)?;
|
||||||
Ok((jar.add(cart_cookie(rebuilt)), response).into_response())
|
let user = guard::current_user(&ctx, &jar).await;
|
||||||
|
let jar = persist_cart(&ctx, jar, user.as_ref(), &valid).await?;
|
||||||
|
Ok((jar, response).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn routes() -> Routes {
|
pub fn routes() -> Routes {
|
||||||
|
|||||||
@@ -2,28 +2,24 @@
|
|||||||
//! confirmation page.
|
//! confirmation page.
|
||||||
|
|
||||||
use axum::extract::Query;
|
use axum::extract::Query;
|
||||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use time::Duration as TimeDuration;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
controllers::cart::{resolve_cart, CART_COOKIE},
|
controllers::cart::{self, resolve_cart},
|
||||||
mailers::auth::AuthMailer,
|
mailers::auth::AuthMailer,
|
||||||
models::{
|
models::{
|
||||||
customer_profiles::{self, ProfileFields},
|
customer_profiles::{self, ProfileFields},
|
||||||
order_items, orders, shipping_methods,
|
order_items, orders, payment_methods, shipping_methods,
|
||||||
users::{self, normalize_account_type},
|
users::{self, normalize_account_type},
|
||||||
},
|
},
|
||||||
controllers::i18n::current_lang,
|
controllers::i18n::current_lang,
|
||||||
shared::{guard, money::format_price, settings},
|
shared::{currency::Currency, guard, money::format_price, settings, shipping as shipping_rules},
|
||||||
views::checkout as view,
|
views::checkout as view,
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAYMENT_METHODS: [&str; 2] = ["cod", "bank_transfer"];
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct CheckoutForm {
|
struct CheckoutForm {
|
||||||
email: String,
|
email: String,
|
||||||
@@ -35,10 +31,15 @@ struct CheckoutForm {
|
|||||||
company_id: Option<String>,
|
company_id: Option<String>,
|
||||||
tax_id: Option<String>,
|
tax_id: Option<String>,
|
||||||
vat_id: Option<String>,
|
vat_id: Option<String>,
|
||||||
address: String,
|
residence_address: String,
|
||||||
city: String,
|
residence_city: String,
|
||||||
zip: String,
|
residence_zip: String,
|
||||||
country: String,
|
residence_country: String,
|
||||||
|
delivery_same_as_residence: Option<String>,
|
||||||
|
address: Option<String>,
|
||||||
|
city: Option<String>,
|
||||||
|
zip: Option<String>,
|
||||||
|
country: Option<String>,
|
||||||
note: Option<String>,
|
note: Option<String>,
|
||||||
payment_method: String,
|
payment_method: String,
|
||||||
carrier_code: String,
|
carrier_code: String,
|
||||||
@@ -55,20 +56,21 @@ fn trimmed(value: &str) -> Option<String> {
|
|||||||
(!value.is_empty()).then(|| value.to_string())
|
(!value.is_empty()).then(|| value.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cleared_cart_cookie() -> Cookie<'static> {
|
|
||||||
Cookie::build((CART_COOKIE, ""))
|
|
||||||
.path("/")
|
|
||||||
.same_site(SameSite::Lax)
|
|
||||||
.max_age(TimeDuration::seconds(0))
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn enabled_shipping_methods(ctx: &AppContext) -> Result<Vec<shipping_methods::Model>> {
|
async fn enabled_shipping_methods(ctx: &AppContext) -> Result<Vec<shipping_methods::Model>> {
|
||||||
|
shipping_rules::disable_packeta_if_unconfigured(ctx).await?;
|
||||||
|
let packeta_ready = shipping_rules::packeta_ready(ctx);
|
||||||
Ok(shipping_methods::Entity::find()
|
Ok(shipping_methods::Entity::find()
|
||||||
.filter(shipping_methods::Column::Enabled.eq(true))
|
.filter(shipping_methods::Column::Enabled.eq(true))
|
||||||
.order_by_asc(shipping_methods::Column::Position)
|
.order_by_asc(shipping_methods::Column::Position)
|
||||||
.all(&ctx.db)
|
.all(&ctx.db)
|
||||||
.await?)
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|method| method.carrier != "packeta" || packeta_ready)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn enabled_payment_methods(ctx: &AppContext) -> Result<Vec<payment_methods::Model>> {
|
||||||
|
Ok(payment_methods::Entity::enabled(&ctx.db).await?)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
@@ -77,15 +79,12 @@ async fn checkout_page(
|
|||||||
ViewEngine(v): ViewEngine<TeraView>,
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar).await?;
|
// Checkout and everything past it (orders, confirmation) stay in the EUR
|
||||||
|
// base — the settlement currency — even when the buyer browsed in another.
|
||||||
|
let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
|
||||||
if lines.is_empty() {
|
if lines.is_empty() {
|
||||||
return format::redirect("/cart");
|
return format::redirect("/cart");
|
||||||
}
|
}
|
||||||
let currency = lines
|
|
||||||
.first()
|
|
||||||
.and_then(|line| line["currency"].as_str())
|
|
||||||
.unwrap_or("EUR")
|
|
||||||
.to_string();
|
|
||||||
|
|
||||||
let methods: Vec<serde_json::Value> = enabled_shipping_methods(&ctx)
|
let methods: Vec<serde_json::Value> = enabled_shipping_methods(&ctx)
|
||||||
.await?
|
.await?
|
||||||
@@ -100,6 +99,16 @@ async fn checkout_page(
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
let payments: Vec<serde_json::Value> = enabled_payment_methods(&ctx)
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
json!({
|
||||||
|
"code": m.code,
|
||||||
|
"label_key": m.label_key(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
// Prefill the form for a logged-in customer: contact name/email come from
|
// Prefill the form for a logged-in customer: contact name/email come from
|
||||||
// the user account, the address/phone from their saved profile (if any).
|
// the user account, the address/phone from their saved profile (if any).
|
||||||
@@ -113,7 +122,7 @@ async fn checkout_page(
|
|||||||
let p = |get: fn(&customer_profiles::Model) -> Option<String>| {
|
let p = |get: fn(&customer_profiles::Model) -> Option<String>| {
|
||||||
profile.as_ref().and_then(get)
|
profile.as_ref().and_then(get)
|
||||||
};
|
};
|
||||||
// Whether the customer already has a shipping address on file. When they do,
|
// Whether the customer already has a residence address on file. When they do,
|
||||||
// the "save this address to my profile" opt-in is pointless (the profile was
|
// the "save this address to my profile" opt-in is pointless (the profile was
|
||||||
// filled in advance), so it's hidden and the existing profile is left alone.
|
// filled in advance), so it's hidden and the existing profile is left alone.
|
||||||
let profile_filled = profile
|
let profile_filled = profile
|
||||||
@@ -127,8 +136,8 @@ async fn checkout_page(
|
|||||||
"items": lines,
|
"items": lines,
|
||||||
"subtotal": format_price(subtotal),
|
"subtotal": format_price(subtotal),
|
||||||
"subtotal_cents": subtotal,
|
"subtotal_cents": subtotal,
|
||||||
"currency": currency,
|
|
||||||
"shipping_methods": methods,
|
"shipping_methods": methods,
|
||||||
|
"payment_methods": payments,
|
||||||
"packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""),
|
"packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""),
|
||||||
"logged_in_admin": is_admin,
|
"logged_in_admin": is_admin,
|
||||||
"logged_in_customer": is_customer,
|
"logged_in_customer": is_customer,
|
||||||
@@ -136,6 +145,7 @@ async fn checkout_page(
|
|||||||
// logged_in_customer is true); None for admins/guests.
|
// logged_in_customer is true); None for admins/guests.
|
||||||
"customer_name": user.as_ref().filter(|_| is_customer).map(|u| u.name.clone()),
|
"customer_name": user.as_ref().filter(|_| is_customer).map(|u| u.name.clone()),
|
||||||
"customer_account_type": user.as_ref().filter(|_| is_customer).map(|u| u.account_type.clone()),
|
"customer_account_type": user.as_ref().filter(|_| is_customer).map(|u| u.account_type.clone()),
|
||||||
|
"customer_avatar": user.as_ref().filter(|_| is_customer).and_then(|u| u.avatar_id.clone()),
|
||||||
"profile_filled": profile_filled,
|
"profile_filled": profile_filled,
|
||||||
// A logged-in customer's account type is fixed; only guests pick it
|
// A logged-in customer's account type is fixed; only guests pick it
|
||||||
// and may opt to create an account from the order.
|
// and may opt to create an account from the order.
|
||||||
@@ -150,10 +160,10 @@ async fn checkout_page(
|
|||||||
"prefill_vat_id": p(|x| x.vat_id.clone()),
|
"prefill_vat_id": p(|x| x.vat_id.clone()),
|
||||||
"prefill_phone_prefix": p(|x| x.phone_prefix.clone()),
|
"prefill_phone_prefix": p(|x| x.phone_prefix.clone()),
|
||||||
"prefill_phone": p(|x| x.phone.clone()),
|
"prefill_phone": p(|x| x.phone.clone()),
|
||||||
"prefill_address": p(|x| x.address.clone()),
|
"prefill_residence_address": p(|x| x.address.clone()),
|
||||||
"prefill_city": p(|x| x.city.clone()),
|
"prefill_residence_city": p(|x| x.city.clone()),
|
||||||
"prefill_zip": p(|x| x.zip.clone()),
|
"prefill_residence_zip": p(|x| x.zip.clone()),
|
||||||
"prefill_country": p(|x| x.country.clone()),
|
"prefill_residence_country": p(|x| x.country.clone()),
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -165,7 +175,7 @@ async fn place_order(
|
|||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
Form(form): Form<CheckoutForm>,
|
Form(form): Form<CheckoutForm>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let (_lines, valid, _total) = resolve_cart(&ctx, &jar).await?;
|
let (_lines, valid, _total) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
|
||||||
if valid.is_empty() {
|
if valid.is_empty() {
|
||||||
return format::redirect("/cart");
|
return format::redirect("/cart");
|
||||||
}
|
}
|
||||||
@@ -180,16 +190,37 @@ async fn place_order(
|
|||||||
None => number.clone(),
|
None => number.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Contact and shipping-address fields are mandatory (also enforced in the
|
// Contact and residence-address fields are mandatory (also enforced in the
|
||||||
// browser via `required`).
|
// browser via `required`).
|
||||||
let require = |value: &str, field: &str| -> Result<String> {
|
let require = |value: &str, field: &str| -> Result<String> {
|
||||||
trimmed(value).ok_or_else(|| Error::BadRequest(format!("{field} is required")))
|
trimmed(value).ok_or_else(|| Error::BadRequest(format!("{field} is required")))
|
||||||
};
|
};
|
||||||
|
let require_opt = |value: Option<&str>, field: &str| -> Result<String> {
|
||||||
|
value
|
||||||
|
.and_then(trimmed)
|
||||||
|
.ok_or_else(|| Error::BadRequest(format!("{field} is required")))
|
||||||
|
};
|
||||||
let customer_name = require(&form.customer_name, "name")?;
|
let customer_name = require(&form.customer_name, "name")?;
|
||||||
let address = require(&form.address, "address")?;
|
let residence_address = require(&form.residence_address, "residence address")?;
|
||||||
let city = require(&form.city, "city")?;
|
let residence_city = require(&form.residence_city, "residence city")?;
|
||||||
let zip = require(&form.zip, "zip")?;
|
let residence_zip = require(&form.residence_zip, "residence zip")?;
|
||||||
let country = require(&form.country, "country")?;
|
let residence_country = require(&form.residence_country, "residence country")?;
|
||||||
|
let same_address = form.delivery_same_as_residence.is_some();
|
||||||
|
let (address, city, zip, country) = if same_address {
|
||||||
|
(
|
||||||
|
residence_address.clone(),
|
||||||
|
residence_city.clone(),
|
||||||
|
residence_zip.clone(),
|
||||||
|
residence_country.clone(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
require_opt(form.address.as_deref(), "delivery address")?,
|
||||||
|
require_opt(form.city.as_deref(), "delivery city")?,
|
||||||
|
require_opt(form.zip.as_deref(), "delivery zip")?,
|
||||||
|
require_opt(form.country.as_deref(), "delivery country")?,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
// The account type is fixed for a logged-in customer (taken from their
|
// The account type is fixed for a logged-in customer (taken from their
|
||||||
// account, never the form); a guest picks it on the form. Admins are treated
|
// account, never the form); a guest picks it on the form. Admins are treated
|
||||||
@@ -216,7 +247,7 @@ async fn place_order(
|
|||||||
(None, None, None, None)
|
(None, None, None, None)
|
||||||
};
|
};
|
||||||
|
|
||||||
if !PAYMENT_METHODS.contains(&form.payment_method.as_str()) {
|
if payment_methods::Entity::find_enabled(&ctx.db, &form.payment_method).await?.is_none() {
|
||||||
return Err(Error::BadRequest("invalid payment method".to_string()));
|
return Err(Error::BadRequest("invalid payment method".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,6 +259,9 @@ async fn place_order(
|
|||||||
.one(&ctx.db)
|
.one(&ctx.db)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| Error::BadRequest("invalid shipping method".to_string()))?;
|
.ok_or_else(|| Error::BadRequest("invalid shipping method".to_string()))?;
|
||||||
|
if method.carrier == "packeta" && !shipping_rules::packeta_ready(&ctx) {
|
||||||
|
return Err(Error::BadRequest("invalid shipping method".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
let (pickup_point_id, pickup_point_name) = if method.requires_pickup_point {
|
let (pickup_point_id, pickup_point_name) = if method.requires_pickup_point {
|
||||||
let id = form
|
let id = form
|
||||||
@@ -249,10 +283,10 @@ async fn place_order(
|
|||||||
vat_id: vat_id.clone(),
|
vat_id: vat_id.clone(),
|
||||||
phone_prefix: trimmed(&form.phone_prefix),
|
phone_prefix: trimmed(&form.phone_prefix),
|
||||||
phone: Some(number.clone()),
|
phone: Some(number.clone()),
|
||||||
address: Some(address.clone()),
|
address: Some(residence_address.clone()),
|
||||||
city: Some(city.clone()),
|
city: Some(residence_city.clone()),
|
||||||
zip: Some(zip.clone()),
|
zip: Some(residence_zip.clone()),
|
||||||
country: Some(country.clone()),
|
country: Some(residence_country.clone()),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resolve the account that will own this order. A logged-in customer always
|
// Resolve the account that will own this order. A logged-in customer always
|
||||||
@@ -321,6 +355,10 @@ async fn place_order(
|
|||||||
company_id,
|
company_id,
|
||||||
tax_id,
|
tax_id,
|
||||||
vat_id,
|
vat_id,
|
||||||
|
residence_address: Some(residence_address),
|
||||||
|
residence_city: Some(residence_city),
|
||||||
|
residence_zip: Some(residence_zip),
|
||||||
|
residence_country: Some(residence_country),
|
||||||
address: Some(address),
|
address: Some(address),
|
||||||
city: Some(city),
|
city: Some(city),
|
||||||
zip: Some(zip),
|
zip: Some(zip),
|
||||||
@@ -340,8 +378,11 @@ async fn place_order(
|
|||||||
} else {
|
} else {
|
||||||
format!("/orders/{}", order.order_number)
|
format!("/orders/{}", order.order_number)
|
||||||
};
|
};
|
||||||
|
if let Some(user) = logged_in_customer {
|
||||||
|
cart::clear_account_cart(&ctx, user.id).await?;
|
||||||
|
}
|
||||||
format::render()
|
format::render()
|
||||||
.cookies(&[cleared_cart_cookie()])?
|
.cookies(&[cart::cleared_cart_cookie()])?
|
||||||
.redirect(&target)
|
.redirect(&target)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -365,20 +406,22 @@ async fn order_confirmation(
|
|||||||
let c = guard::chrome(&ctx, &jar).await;
|
let c = guard::chrome(&ctx, &jar).await;
|
||||||
let account_created = params.contains_key("account_created");
|
let account_created = params.contains_key("account_created");
|
||||||
|
|
||||||
|
let (bank_iban, bank_account_name) = settings::bank_details(&ctx).await?;
|
||||||
format::view(
|
format::view(
|
||||||
&v,
|
&v,
|
||||||
"shop/order_confirmed.html",
|
"shop/order_confirmed.html",
|
||||||
json!({
|
json!({
|
||||||
"order": view::detail(
|
"order": view::detail(
|
||||||
&order,
|
&order,
|
||||||
settings::get(&ctx, "bank_iban").unwrap_or(""),
|
&bank_iban,
|
||||||
settings::get(&ctx, "bank_account_name").unwrap_or(""),
|
&bank_account_name,
|
||||||
),
|
),
|
||||||
"items": view::items(&items),
|
"items": view::items(&items),
|
||||||
"logged_in_admin": c.logged_in_admin,
|
"logged_in_admin": c.logged_in_admin,
|
||||||
"logged_in_customer": c.logged_in_customer,
|
"logged_in_customer": c.logged_in_customer,
|
||||||
"customer_name": c.customer_name,
|
"customer_name": c.customer_name,
|
||||||
"customer_account_type": c.customer_account_type,
|
"customer_account_type": c.customer_account_type,
|
||||||
|
"customer_avatar": c.customer_avatar,
|
||||||
"account_created": account_created,
|
"account_created": account_created,
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
}),
|
}),
|
||||||
|
|||||||
39
src/controllers/currency.rs
Normal file
39
src/controllers/currency.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
//! Storefront display-currency switcher.
|
||||||
|
//!
|
||||||
|
//! Sets the `currency` cookie to the buyer's chosen display currency, then sends
|
||||||
|
//! them back where they were. EUR is the base; any other code must name an
|
||||||
|
//! enabled row in `currencies` or it falls back to EUR on the next render.
|
||||||
|
|
||||||
|
use axum::{
|
||||||
|
http::{header, HeaderMap},
|
||||||
|
response::Redirect,
|
||||||
|
};
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::controllers::i18n::back_path;
|
||||||
|
use crate::shared::currency::{BASE_CODE, COOKIE};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct CurrencyForm {
|
||||||
|
pub currency: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn set_currency(headers: HeaderMap, Form(form): Form<CurrencyForm>) -> Result<Response> {
|
||||||
|
// Store the code uppercased; validation against the enabled set happens at
|
||||||
|
// render time (shared::currency::resolve), which falls back to EUR.
|
||||||
|
let code = form.currency.trim().to_uppercase();
|
||||||
|
let code = if code.is_empty() { BASE_CODE.to_string() } else { code };
|
||||||
|
let cookie = format!("{COOKIE}={code}; Path=/; Max-Age=31536000; SameSite=Lax");
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
[(header::SET_COOKIE, cookie)],
|
||||||
|
Redirect::to(&back_path(&headers)),
|
||||||
|
)
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new().add("/currency", post(set_currency))
|
||||||
|
}
|
||||||
@@ -4,7 +4,9 @@ use axum_extra::extract::cookie::CookieJar;
|
|||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{controllers::i18n::current_lang, shared::guard, controllers::shop};
|
use crate::{
|
||||||
|
controllers::i18n::current_lang, controllers::shop, shared::currency, shared::guard,
|
||||||
|
};
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn index(
|
async fn index(
|
||||||
@@ -13,7 +15,8 @@ async fn index(
|
|||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let user = guard::current_user(&ctx, &jar).await;
|
let user = guard::current_user(&ctx, &jar).await;
|
||||||
let products = shop::featured_products(&ctx, user.as_ref(), 8).await?;
|
let cur = currency::resolve(&ctx, &jar).await;
|
||||||
|
let products = shop::featured_products(&ctx, user.as_ref(), 8, &cur).await?;
|
||||||
let c = guard::chrome_from(&ctx, user.as_ref());
|
let c = guard::chrome_from(&ctx, user.as_ref());
|
||||||
|
|
||||||
format::view(
|
format::view(
|
||||||
@@ -25,7 +28,11 @@ async fn index(
|
|||||||
"logged_in_customer": c.logged_in_customer,
|
"logged_in_customer": c.logged_in_customer,
|
||||||
"customer_name": c.customer_name,
|
"customer_name": c.customer_name,
|
||||||
"customer_account_type": c.customer_account_type,
|
"customer_account_type": c.customer_account_type,
|
||||||
|
"customer_avatar": c.customer_avatar,
|
||||||
|
"currency_symbol": cur.symbol,
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
|
// The header search bar only appears on the landing page.
|
||||||
|
"on_home": true,
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ async fn set_lang(headers: HeaderMap, Form(form): Form<LangForm>) -> Result<Resp
|
|||||||
.into_response())
|
.into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn back_path(headers: &HeaderMap) -> String {
|
pub(crate) fn back_path(headers: &HeaderMap) -> String {
|
||||||
let raw = headers
|
let raw = headers
|
||||||
.get(header::REFERER)
|
.get(header::REFERER)
|
||||||
.and_then(|value| value.to_str().ok())
|
.and_then(|value| value.to_str().ok())
|
||||||
|
|||||||
@@ -3,16 +3,20 @@ pub mod auth;
|
|||||||
pub mod auth_pages;
|
pub mod auth_pages;
|
||||||
pub mod oauth2;
|
pub mod oauth2;
|
||||||
pub mod admin_categories;
|
pub mod admin_categories;
|
||||||
|
pub mod admin_currencies;
|
||||||
pub mod admin_customers;
|
pub mod admin_customers;
|
||||||
pub mod admin_dashboard;
|
pub mod admin_dashboard;
|
||||||
pub mod admin_discount_profiles;
|
pub mod admin_discount_profiles;
|
||||||
pub mod admin_form;
|
pub mod admin_form;
|
||||||
pub mod admin_orders;
|
pub mod admin_orders;
|
||||||
|
pub mod admin_payments;
|
||||||
pub mod admin_products;
|
pub mod admin_products;
|
||||||
pub mod admin_shipping;
|
pub mod admin_shipping;
|
||||||
pub mod cart;
|
pub mod cart;
|
||||||
pub mod checkout;
|
pub mod checkout;
|
||||||
|
pub mod currency;
|
||||||
pub mod home;
|
pub mod home;
|
||||||
pub mod i18n;
|
pub mod i18n;
|
||||||
pub mod media;
|
pub mod media;
|
||||||
|
pub mod pages;
|
||||||
pub mod shop;
|
pub mod shop;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use loco_rs::prelude::*;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
controllers::auth as auth_controller,
|
controllers::auth as auth_controller,
|
||||||
|
controllers::cart,
|
||||||
models::{o_auth2_sessions, users, users::OAuth2UserProfile},
|
models::{o_auth2_sessions, users, users::OAuth2UserProfile},
|
||||||
shared::guard,
|
shared::guard,
|
||||||
};
|
};
|
||||||
@@ -36,8 +37,9 @@ async fn complete(State(ctx): State<AppContext>, user: GoogleCookieUser) -> Resu
|
|||||||
} else {
|
} else {
|
||||||
"/"
|
"/"
|
||||||
};
|
};
|
||||||
|
let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?;
|
||||||
format::render()
|
format::render()
|
||||||
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])?
|
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration), cart_cookie])?
|
||||||
.redirect(dest)
|
.redirect(dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
97
src/controllers/pages.rs
Normal file
97
src/controllers/pages.rs
Normal 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))
|
||||||
|
}
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
//! Public storefront: product listings, product detail, category pages and the
|
//! Public storefront: product listings, product detail, category pages and the
|
||||||
//! lazily-loaded category sidebar.
|
//! lazily-loaded category sidebar.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
use axum::extract::Query;
|
||||||
|
use axum::http::HeaderMap;
|
||||||
use axum_extra::extract::cookie::CookieJar;
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set};
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set};
|
||||||
@@ -8,11 +12,272 @@ use serde_json::json;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
controllers::i18n::current_lang,
|
controllers::i18n::current_lang,
|
||||||
shared::{guard, pricing},
|
shared::{
|
||||||
|
currency::{self, Currency},
|
||||||
|
guard,
|
||||||
|
money::parse_price_to_cents,
|
||||||
|
pricing,
|
||||||
|
},
|
||||||
models::{categories, product_images, product_variants, products, users},
|
models::{categories, product_images, product_variants, products, users},
|
||||||
views::shop as view,
|
views::shop as view,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// 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(¶ms.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(¶ms.in_stock);
|
||||||
|
items.retain(|i| {
|
||||||
|
min_c.is_none_or(|m| i.priced.price_cents >= m)
|
||||||
|
&& max_c.is_none_or(|m| i.priced.price_cents <= m)
|
||||||
|
&& (!in_stock_only || i.in_stock)
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. Category facets: counts computed over the price/stock-filtered set
|
||||||
|
// (i.e. before applying the category choice itself).
|
||||||
|
let all_categories = categories::published(ctx).await?;
|
||||||
|
let cat_ids: Vec<Option<i32>> = items.iter().map(|i| i.product.category_id).collect();
|
||||||
|
let category_groups = view::admin_category_groups(&all_categories, &cat_ids);
|
||||||
|
let uncategorized_count = cat_ids.iter().filter(|c| c.is_none()).count();
|
||||||
|
let category_name: HashMap<i32, String> =
|
||||||
|
all_categories.iter().map(|c| (c.id, c.name.clone())).collect();
|
||||||
|
|
||||||
|
// 5. Apply the category filter.
|
||||||
|
let selected_category = params
|
||||||
|
.category
|
||||||
|
.clone()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| "all".to_string());
|
||||||
|
let filter = view::category_filter_ids(&all_categories, &selected_category);
|
||||||
|
items.retain(|i| view::category_filter_keep(&filter, i.product.category_id));
|
||||||
|
|
||||||
|
// 6. Sort. Newest-first is the default; relevance (the ranked search order)
|
||||||
|
// is available explicitly via the sort control. When a search runs, the
|
||||||
|
// default "newest" becomes "relevance" (a query implies relevance matters
|
||||||
|
// most); any explicitly chosen non-newest sort is left untouched.
|
||||||
|
let mut sort = params
|
||||||
|
.sort
|
||||||
|
.clone()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or_else(|| "newest".to_string());
|
||||||
|
if !q_trim.is_empty() && sort == "newest" {
|
||||||
|
sort = "relevance".to_string();
|
||||||
|
}
|
||||||
|
match sort.as_str() {
|
||||||
|
"price_asc" => items.sort_by(|a, b| a.priced.price_cents.cmp(&b.priced.price_cents)),
|
||||||
|
"price_desc" => items.sort_by(|a, b| b.priced.price_cents.cmp(&a.priced.price_cents)),
|
||||||
|
"name_asc" => items.sort_by(|a, b| {
|
||||||
|
a.product.name.to_lowercase().cmp(&b.product.name.to_lowercase())
|
||||||
|
}),
|
||||||
|
"name_desc" => items.sort_by(|a, b| {
|
||||||
|
b.product.name.to_lowercase().cmp(&a.product.name.to_lowercase())
|
||||||
|
}),
|
||||||
|
"newest" => items.sort_by(|a, b| b.product.published_at.cmp(&a.product.published_at)),
|
||||||
|
// "relevance" and anything unknown: original search rank.
|
||||||
|
_ => items.sort_by_key(|i| i.rank),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Paginate.
|
||||||
|
let per_page = resolve_per_page(params);
|
||||||
|
let total = items.len();
|
||||||
|
let pages = total.div_ceil(per_page).max(1);
|
||||||
|
let page = params.page.unwrap_or(1).clamp(1, pages as u32);
|
||||||
|
let start = (page as usize - 1) * per_page;
|
||||||
|
|
||||||
|
// 8. Render only the current page's cards (images fetched per row).
|
||||||
|
let mut rows = Vec::new();
|
||||||
|
for item in items.iter().skip(start).take(per_page) {
|
||||||
|
let image = product_images::first_for(ctx, item.product.id).await?;
|
||||||
|
let cat_name = item.product.category_id.and_then(|id| category_name.get(&id).cloned());
|
||||||
|
rows.push(view::product_card(
|
||||||
|
&item.product,
|
||||||
|
&item.rep,
|
||||||
|
&item.priced,
|
||||||
|
item.count,
|
||||||
|
image,
|
||||||
|
cat_name,
|
||||||
|
cur,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(json!({
|
||||||
|
"products": rows,
|
||||||
|
"query": q,
|
||||||
|
"category_groups": category_groups,
|
||||||
|
"selected_category": selected_category,
|
||||||
|
// Numeric form so the <select> can mark the active option (Tera can't
|
||||||
|
// compare a string param against a numeric category id).
|
||||||
|
"selected_category_id": selected_category.parse::<i32>().unwrap_or(-1),
|
||||||
|
// Display name of the active category, so the search bar can show that
|
||||||
|
// the query is scoped to it. `None` for "all"/"none" (the template maps
|
||||||
|
// "none" to the localized "uncategorized" label itself).
|
||||||
|
"selected_category_name": selected_category
|
||||||
|
.parse::<i32>()
|
||||||
|
.ok()
|
||||||
|
.and_then(|id| category_name.get(&id).cloned()),
|
||||||
|
"uncategorized_count": uncategorized_count,
|
||||||
|
"sort": sort,
|
||||||
|
"per_page": per_page,
|
||||||
|
"per_page_options": PER_PAGE_OPTIONS,
|
||||||
|
"in_stock": in_stock_only,
|
||||||
|
"min_price": params.min_price.clone().unwrap_or_default(),
|
||||||
|
"max_price": params.max_price.clone().unwrap_or_default(),
|
||||||
|
"price_floor": cur.format(price_floor),
|
||||||
|
"price_ceil": cur.format(price_ceil),
|
||||||
|
"currency_symbol": cur.symbol,
|
||||||
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"pages": pages,
|
||||||
|
"has_prev": page > 1,
|
||||||
|
"has_next": (page as usize) < pages,
|
||||||
|
"prev_page": page.saturating_sub(1).max(1),
|
||||||
|
"next_page": page + 1,
|
||||||
|
"query_base": query_base(params),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// Shape a list of products into card rows for `user` (None = public). Each card
|
/// Shape a list of products into card rows for `user` (None = public). Each card
|
||||||
/// shows the resolved price of the product's representative (first) variant; the
|
/// shows the resolved price of the product's representative (first) variant; the
|
||||||
/// `variant_count` lets the template render "from {price}" for multi-variant
|
/// `variant_count` lets the template render "from {price}" for multi-variant
|
||||||
@@ -21,6 +286,7 @@ async fn product_rows(
|
|||||||
ctx: &AppContext,
|
ctx: &AppContext,
|
||||||
user: Option<&users::Model>,
|
user: Option<&users::Model>,
|
||||||
list: Vec<products::Model>,
|
list: Vec<products::Model>,
|
||||||
|
cur: &Currency,
|
||||||
) -> Result<Vec<serde_json::Value>> {
|
) -> Result<Vec<serde_json::Value>> {
|
||||||
let ids: Vec<i32> = list.iter().map(|p| p.id).collect();
|
let ids: Vec<i32> = list.iter().map(|p| p.id).collect();
|
||||||
let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &ids).await?;
|
let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &ids).await?;
|
||||||
@@ -42,7 +308,7 @@ async fn product_rows(
|
|||||||
let mut rows = Vec::with_capacity(entries.len());
|
let mut rows = Vec::with_capacity(entries.len());
|
||||||
for ((product, rep, count), priced) in entries.iter().zip(priced.iter()) {
|
for ((product, rep, count), priced) in entries.iter().zip(priced.iter()) {
|
||||||
let image = product_images::first_for(ctx, product.id).await?;
|
let image = product_images::first_for(ctx, product.id).await?;
|
||||||
rows.push(view::product_card(product, rep, priced, *count, image, None));
|
rows.push(view::product_card(product, rep, priced, *count, image, None, cur));
|
||||||
}
|
}
|
||||||
Ok(rows)
|
Ok(rows)
|
||||||
}
|
}
|
||||||
@@ -53,6 +319,7 @@ pub(crate) async fn featured_products(
|
|||||||
ctx: &AppContext,
|
ctx: &AppContext,
|
||||||
user: Option<&users::Model>,
|
user: Option<&users::Model>,
|
||||||
limit: u64,
|
limit: u64,
|
||||||
|
cur: &Currency,
|
||||||
) -> Result<Vec<serde_json::Value>> {
|
) -> Result<Vec<serde_json::Value>> {
|
||||||
let list = products::Entity::find()
|
let list = products::Entity::find()
|
||||||
.filter(products::Column::Published.eq(true))
|
.filter(products::Column::Published.eq(true))
|
||||||
@@ -60,7 +327,7 @@ pub(crate) async fn featured_products(
|
|||||||
.limit(limit)
|
.limit(limit)
|
||||||
.all(&ctx.db)
|
.all(&ctx.db)
|
||||||
.await?;
|
.await?;
|
||||||
product_rows(ctx, user, list).await
|
product_rows(ctx, user, list, cur).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The site-wide category sidebar, loaded lazily via htmx by the base layout so
|
/// The site-wide category sidebar, loaded lazily via htmx by the base layout so
|
||||||
@@ -82,32 +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]
|
#[debug_handler]
|
||||||
async fn index(
|
async fn index(
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
ViewEngine(v): ViewEngine<TeraView>,
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let list = products::Entity::find()
|
|
||||||
.filter(products::Column::Published.eq(true))
|
|
||||||
.order_by_desc(products::Column::PublishedAt)
|
|
||||||
.all(&ctx.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let user = guard::current_user(&ctx, &jar).await;
|
let user = guard::current_user(&ctx, &jar).await;
|
||||||
|
let cur = currency::resolve(&ctx, &jar).await;
|
||||||
|
let mut context = run_search(&ctx, user.as_ref(), &SearchParams::default(), &cur).await?;
|
||||||
let c = guard::chrome_from(&ctx, user.as_ref());
|
let c = guard::chrome_from(&ctx, user.as_ref());
|
||||||
format::view(
|
add_chrome(&mut context, &c, ¤t_lang(&jar));
|
||||||
&v,
|
format::view(&v, "shop/index.html", context)
|
||||||
"shop/index.html",
|
}
|
||||||
json!({
|
|
||||||
"products": product_rows(&ctx, user.as_ref(), list).await?,
|
/// Storefront search + faceted browse. Combines the hybrid full-text/fuzzy query
|
||||||
"logged_in_admin": c.logged_in_admin,
|
/// ([`products::Entity::search`]) with category, price-band, in-stock and sort
|
||||||
"logged_in_customer": c.logged_in_customer,
|
/// filters, ranked and paginated by [`run_search`]. A blank query falls back to
|
||||||
"customer_name": c.customer_name,
|
/// the full published listing, so the same endpoint powers both "browse" and
|
||||||
"customer_account_type": c.customer_account_type,
|
/// "search". Targeted htmx requests from the listing toolbar/pagination get just
|
||||||
"lang": current_lang(&jar),
|
/// 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(), ¶ms, &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]
|
#[debug_handler]
|
||||||
@@ -139,12 +445,13 @@ async fn show(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let user = guard::current_user(&ctx, &jar).await;
|
let user = guard::current_user(&ctx, &jar).await;
|
||||||
|
let cur = currency::resolve(&ctx, &jar).await;
|
||||||
let variants = product_variants::Entity::for_product(&ctx.db, product.id).await?;
|
let variants = product_variants::Entity::for_product(&ctx.db, product.id).await?;
|
||||||
let variant_prices = pricing::price_variants(&ctx, &variants, user.as_ref()).await?;
|
let variant_prices = pricing::price_variants(&ctx, &variants, user.as_ref()).await?;
|
||||||
let options: Vec<serde_json::Value> = variants
|
let options: Vec<serde_json::Value> = variants
|
||||||
.iter()
|
.iter()
|
||||||
.zip(variant_prices.iter())
|
.zip(variant_prices.iter())
|
||||||
.map(|(variant, priced)| view::variant_option(variant, priced))
|
.map(|(variant, priced)| view::variant_option(variant, priced, &cur))
|
||||||
.collect();
|
.collect();
|
||||||
// The card header uses the representative (first) variant for its headline
|
// The card header uses the representative (first) variant for its headline
|
||||||
// price; the picker below lets the customer switch.
|
// price; the picker below lets the customer switch.
|
||||||
@@ -158,6 +465,7 @@ async fn show(
|
|||||||
variants.len(),
|
variants.len(),
|
||||||
None,
|
None,
|
||||||
category.as_ref().map(|c| c.name.clone()),
|
category.as_ref().map(|c| c.name.clone()),
|
||||||
|
&cur,
|
||||||
),
|
),
|
||||||
// A product with no variants isn't purchasable; show it without a price.
|
// A product with no variants isn't purchasable; show it without a price.
|
||||||
_ => serde_json::json!({
|
_ => serde_json::json!({
|
||||||
@@ -165,7 +473,7 @@ async fn show(
|
|||||||
"name": product.name,
|
"name": product.name,
|
||||||
"slug": product.slug,
|
"slug": product.slug,
|
||||||
"description": product.description,
|
"description": product.description,
|
||||||
"currency": product.currency,
|
"short_description": product.short_description,
|
||||||
"variant_count": 0,
|
"variant_count": 0,
|
||||||
"has_options": false,
|
"has_options": false,
|
||||||
}),
|
}),
|
||||||
@@ -183,16 +491,24 @@ async fn show(
|
|||||||
"logged_in_customer": c.logged_in_customer,
|
"logged_in_customer": c.logged_in_customer,
|
||||||
"customer_name": c.customer_name,
|
"customer_name": c.customer_name,
|
||||||
"customer_account_type": c.customer_account_type,
|
"customer_account_type": c.customer_account_type,
|
||||||
|
"customer_avatar": c.customer_avatar,
|
||||||
|
"currency_symbol": cur.symbol,
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Category page: the same faceted search as the shop, but with this category
|
||||||
|
/// preselected as the default filter (plus breadcrumbs and subcategory chips).
|
||||||
|
/// Any other filters/sort/query on the URL are honoured; the category itself is
|
||||||
|
/// always forced to this page's category. Interacting with the toolbar navigates
|
||||||
|
/// to `/search` (the category stays selected there too).
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn category(
|
async fn category(
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
ViewEngine(v): ViewEngine<TeraView>,
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
Path(slug): Path<String>,
|
Path(slug): Path<String>,
|
||||||
|
Query(params): Query<SearchParams>,
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let published = categories::published(&ctx).await?;
|
let published = categories::published(&ctx).await?;
|
||||||
@@ -205,41 +521,31 @@ async fn category(
|
|||||||
let breadcrumbs = categories::ancestors(&published, category.parent_id);
|
let breadcrumbs = categories::ancestors(&published, category.parent_id);
|
||||||
let children = categories::children_of(&published, category.id);
|
let children = categories::children_of(&published, category.id);
|
||||||
|
|
||||||
// Products listed here span this category and all of its descendants, so a
|
// Force the category filter to this page's category, keeping any other params.
|
||||||
// parent category is never empty just because its products live in leaves.
|
let params = SearchParams {
|
||||||
let mut category_ids: Vec<i32> = categories::descendant_ids(&published, category.id)
|
category: Some(category.id.to_string()),
|
||||||
.into_iter()
|
..params
|
||||||
.collect();
|
};
|
||||||
category_ids.push(category.id);
|
|
||||||
let list = products::Entity::find()
|
|
||||||
.filter(products::Column::CategoryId.is_in(category_ids))
|
|
||||||
.filter(products::Column::Published.eq(true))
|
|
||||||
.order_by_desc(products::Column::PublishedAt)
|
|
||||||
.all(&ctx.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
let user = guard::current_user(&ctx, &jar).await;
|
let user = guard::current_user(&ctx, &jar).await;
|
||||||
|
let cur = currency::resolve(&ctx, &jar).await;
|
||||||
|
let mut context = run_search(&ctx, user.as_ref(), ¶ms, &cur).await?;
|
||||||
|
if let Some(map) = context.as_object_mut() {
|
||||||
|
map.insert("category".into(), serde_json::to_value(&category)?);
|
||||||
|
map.insert("breadcrumbs".into(), serde_json::to_value(&breadcrumbs)?);
|
||||||
|
map.insert("children".into(), serde_json::to_value(&children)?);
|
||||||
|
}
|
||||||
let c = guard::chrome_from(&ctx, user.as_ref());
|
let c = guard::chrome_from(&ctx, user.as_ref());
|
||||||
format::view(
|
add_chrome(&mut context, &c, ¤t_lang(&jar));
|
||||||
&v,
|
format::view(&v, "shop/category.html", context)
|
||||||
"shop/category.html",
|
|
||||||
json!({
|
|
||||||
"category": category,
|
|
||||||
"breadcrumbs": breadcrumbs,
|
|
||||||
"children": children,
|
|
||||||
"products": product_rows(&ctx, user.as_ref(), list).await?,
|
|
||||||
"logged_in_admin": c.logged_in_admin,
|
|
||||||
"logged_in_customer": c.logged_in_customer,
|
|
||||||
"customer_name": c.customer_name,
|
|
||||||
"customer_account_type": c.customer_account_type,
|
|
||||||
"lang": current_lang(&jar),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn routes() -> Routes {
|
pub fn routes() -> Routes {
|
||||||
Routes::new()
|
Routes::new()
|
||||||
.add("/shop", get(index))
|
.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("/shop/{slug}", get(show))
|
||||||
.add("/category/{slug}", get(category))
|
.add("/category/{slug}", get(category))
|
||||||
.add("/partials/categories", get(category_sidebar))
|
.add("/partials/categories", get(category_sidebar))
|
||||||
|
|||||||
52
src/initializers/currency_seeder.rs
Normal file
52
src/initializers/currency_seeder.rs
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
//! Ensures the built-in alternative display currencies always exist.
|
||||||
|
//!
|
||||||
|
//! EUR is the base currency and is never stored. For now the only alternative is
|
||||||
|
//! the Czech koruna (CZK); the admin sets its exchange rate and can disable it.
|
||||||
|
//! We insert each one only when its `code` is missing, so an admin's rate/enabled
|
||||||
|
//! changes are never overwritten on the next boot.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||||
|
|
||||||
|
use crate::models::currencies;
|
||||||
|
use crate::shared::currency::{self, SCALE};
|
||||||
|
|
||||||
|
/// `(code, symbol, default_rate_e4)` — default rate is a placeholder the admin
|
||||||
|
/// is expected to update from the live FX rate.
|
||||||
|
const BUILTINS: [(&str, &str, i64); 1] = [("CZK", "Kč", 25 * SCALE)];
|
||||||
|
|
||||||
|
pub struct CurrencySeeder;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Initializer for CurrencySeeder {
|
||||||
|
fn name(&self) -> String {
|
||||||
|
"currency-seeder".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn before_run(&self, ctx: &AppContext) -> Result<()> {
|
||||||
|
for (code, symbol, rate_e4) in BUILTINS {
|
||||||
|
let exists = currencies::Entity::find()
|
||||||
|
.filter(currencies::Column::Code.eq(code))
|
||||||
|
.count(&ctx.db)
|
||||||
|
.await?
|
||||||
|
> 0;
|
||||||
|
if exists {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
currencies::ActiveModel {
|
||||||
|
code: Set(code.to_string()),
|
||||||
|
symbol: Set(symbol.to_string()),
|
||||||
|
rate_e4: Set(rate_e4),
|
||||||
|
enabled: Set(true),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
tracing::info!(currency = code, "seeded display currency");
|
||||||
|
}
|
||||||
|
// Prime the process-wide snapshot used by the navbar/settings chrome.
|
||||||
|
currency::refresh_snapshot(&ctx.db).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
pub mod admin_seeder;
|
pub mod admin_seeder;
|
||||||
|
pub mod currency_seeder;
|
||||||
pub mod oauth2;
|
pub mod oauth2;
|
||||||
pub mod oauth2_session;
|
pub mod oauth2_session;
|
||||||
|
pub mod payment_seeder;
|
||||||
pub mod shipping_seeder;
|
pub mod shipping_seeder;
|
||||||
pub mod view_engine;
|
pub mod view_engine;
|
||||||
|
|||||||
73
src/initializers/payment_seeder.rs
Normal file
73
src/initializers/payment_seeder.rs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
//! Ensures built-in payment methods and editable bank-transfer settings exist.
|
||||||
|
//!
|
||||||
|
//! Payment method enabled flags and bank account details are admin-managed in the
|
||||||
|
//! database. We seed missing rows only, so admin changes persist across restarts.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
models::{payment_methods, shop_settings},
|
||||||
|
shared::settings,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `(code, name, enabled, position)`
|
||||||
|
const METHODS: [(&str, &str, bool, i32); 2] = [
|
||||||
|
(payment_methods::COD, "Cash on delivery", true, 0),
|
||||||
|
(payment_methods::BANK_TRANSFER, "Bank transfer", true, 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
pub struct PaymentSeeder;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Initializer for PaymentSeeder {
|
||||||
|
fn name(&self) -> String {
|
||||||
|
"payment-seeder".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn before_run(&self, ctx: &AppContext) -> Result<()> {
|
||||||
|
for (code, name, enabled, position) in METHODS {
|
||||||
|
let exists = payment_methods::Entity::find()
|
||||||
|
.filter(payment_methods::Column::Code.eq(code))
|
||||||
|
.count(&ctx.db)
|
||||||
|
.await?
|
||||||
|
> 0;
|
||||||
|
if exists {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
payment_methods::ActiveModel {
|
||||||
|
code: Set(code.to_string()),
|
||||||
|
name: Set(name.to_string()),
|
||||||
|
enabled: Set(enabled),
|
||||||
|
position: Set(position),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
tracing::info!(payment = code, "seeded built-in payment method");
|
||||||
|
}
|
||||||
|
|
||||||
|
seed_setting(ctx, "bank_iban").await?;
|
||||||
|
seed_setting(ctx, "bank_account_name").await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed_setting(ctx: &AppContext, key: &str) -> Result<()> {
|
||||||
|
let exists = shop_settings::Entity::find()
|
||||||
|
.filter(shop_settings::Column::Key.eq(key))
|
||||||
|
.count(&ctx.db)
|
||||||
|
.await?
|
||||||
|
> 0;
|
||||||
|
if exists {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
shop_settings::ActiveModel {
|
||||||
|
key: Set(key.to_string()),
|
||||||
|
value: Set(settings::get(ctx, key).map(str::to_string)),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ use async_trait::async_trait;
|
|||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||||
|
|
||||||
use crate::models::shipping_methods;
|
use crate::{models::shipping_methods, shared::shipping as shipping_rules};
|
||||||
|
|
||||||
/// `(code, name, carrier, requires_pickup_point, default_price_cents, position)`
|
/// `(code, name, carrier, requires_pickup_point, default_price_cents, position)`
|
||||||
const BUILTINS: [(&str, &str, &str, bool, i64, i32); 2] = [
|
const BUILTINS: [(&str, &str, &str, bool, i64, i32); 2] = [
|
||||||
@@ -49,6 +49,6 @@ impl Initializer for ShippingSeeder {
|
|||||||
.await?;
|
.await?;
|
||||||
tracing::info!(carrier = code, "seeded built-in delivery option");
|
tracing::info!(carrier = code, "seeded built-in delivery option");
|
||||||
}
|
}
|
||||||
Ok(())
|
shipping_rules::disable_packeta_if_unconfigured(ctx).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,6 +54,12 @@ impl Initializer for ViewEngineInitializer {
|
|||||||
crate::shared::csrf::current_token().unwrap_or_default(),
|
crate::shared::csrf::current_token().unwrap_or_default(),
|
||||||
))
|
))
|
||||||
});
|
});
|
||||||
|
// `currencies()`: the EUR base plus enabled alternative currencies
|
||||||
|
// (from the process-wide snapshot), used by the global chrome — the
|
||||||
|
// settings-menu switcher and the navbar exchange-rate display.
|
||||||
|
tera.register_function("currencies", |_args: &HashMap<String, serde_json::Value>| {
|
||||||
|
Ok(crate::shared::currency::selectable_json())
|
||||||
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ pub struct ShipmentRequest<'a> {
|
|||||||
pub pickup_point_id: Option<&'a str>,
|
pub pickup_point_id: Option<&'a str>,
|
||||||
/// Cash-on-delivery amount in cents; `0` when payment is not COD.
|
/// Cash-on-delivery amount in cents; `0` when payment is not COD.
|
||||||
pub cod_cents: i64,
|
pub cod_cents: i64,
|
||||||
pub currency: &'a str,
|
|
||||||
/// Total order value in cents (for insurance / customs declarations).
|
/// Total order value in cents (for insurance / customs declarations).
|
||||||
pub value_cents: i64,
|
pub value_cents: i64,
|
||||||
pub weight_grams: i32,
|
pub weight_grams: i32,
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ pub async fn create_shipment(ctx: &AppContext, req: ShipmentRequest<'_>) -> Resu
|
|||||||
xml_escape(address_id),
|
xml_escape(address_id),
|
||||||
value,
|
value,
|
||||||
cod,
|
cod,
|
||||||
xml_escape(req.currency),
|
"EUR",
|
||||||
weight_kg,
|
weight_kg,
|
||||||
xml_escape(sender_label),
|
xml_escape(sender_label),
|
||||||
);
|
);
|
||||||
|
|||||||
48
src/models/_entities/account_cart_items.rs
Normal file
48
src/models/_entities/account_cart_items.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "account_cart_items")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub variant_id: i32,
|
||||||
|
pub quantity: i32,
|
||||||
|
pub user_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::product_variants::Entity",
|
||||||
|
from = "Column::VariantId",
|
||||||
|
to = "super::product_variants::Column::Id",
|
||||||
|
on_update = "NoAction",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
ProductVariants,
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::users::Entity",
|
||||||
|
from = "Column::UserId",
|
||||||
|
to = "super::users::Column::Id",
|
||||||
|
on_update = "Cascade",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
Users,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::product_variants::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::ProductVariants.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::users::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Users.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/models/_entities/currencies.rs
Normal file
22
src/models/_entities/currencies.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "currencies")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
#[sea_orm(unique)]
|
||||||
|
pub code: String,
|
||||||
|
pub symbol: String,
|
||||||
|
/// Units of this currency per 1 EUR, scaled ×10000 (e.g. 25.30 → 253000).
|
||||||
|
pub rate_e4: i64,
|
||||||
|
pub enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
@@ -2,22 +2,26 @@
|
|||||||
|
|
||||||
pub mod prelude;
|
pub mod prelude;
|
||||||
|
|
||||||
|
pub mod account_cart_items;
|
||||||
pub mod account_discount_profiles;
|
pub mod account_discount_profiles;
|
||||||
pub mod account_product_prices;
|
pub mod account_product_prices;
|
||||||
pub mod account_product_resolutions;
|
pub mod account_product_resolutions;
|
||||||
pub mod audience_discount_profiles;
|
pub mod audience_discount_profiles;
|
||||||
pub mod audit_logs;
|
pub mod audit_logs;
|
||||||
pub mod categories;
|
pub mod categories;
|
||||||
|
pub mod currencies;
|
||||||
pub mod customer_profiles;
|
pub mod customer_profiles;
|
||||||
pub mod discount_profile_products;
|
pub mod discount_profile_products;
|
||||||
pub mod discount_profiles;
|
pub mod discount_profiles;
|
||||||
pub mod o_auth2_sessions;
|
pub mod o_auth2_sessions;
|
||||||
pub mod order_items;
|
pub mod order_items;
|
||||||
pub mod orders;
|
pub mod orders;
|
||||||
|
pub mod payment_methods;
|
||||||
pub mod product_images;
|
pub mod product_images;
|
||||||
pub mod product_product_tags;
|
pub mod product_product_tags;
|
||||||
pub mod product_tags;
|
pub mod product_tags;
|
||||||
pub mod product_variants;
|
pub mod product_variants;
|
||||||
pub mod products;
|
pub mod products;
|
||||||
pub mod shipping_methods;
|
pub mod shipping_methods;
|
||||||
|
pub mod shop_settings;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ pub struct Model {
|
|||||||
pub customer_name: Option<String>,
|
pub customer_name: Option<String>,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub total_cents: i64,
|
pub total_cents: i64,
|
||||||
pub currency: String,
|
|
||||||
pub address: Option<String>,
|
pub address: Option<String>,
|
||||||
pub city: Option<String>,
|
pub city: Option<String>,
|
||||||
pub zip: Option<String>,
|
pub zip: Option<String>,
|
||||||
@@ -39,6 +38,10 @@ pub struct Model {
|
|||||||
pub tax_id: Option<String>,
|
pub tax_id: Option<String>,
|
||||||
pub vat_id: Option<String>,
|
pub vat_id: Option<String>,
|
||||||
pub user_id: Option<i32>,
|
pub user_id: Option<i32>,
|
||||||
|
pub residence_address: Option<String>,
|
||||||
|
pub residence_city: Option<String>,
|
||||||
|
pub residence_zip: Option<String>,
|
||||||
|
pub residence_country: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
|||||||
21
src/models/_entities/payment_methods.rs
Normal file
21
src/models/_entities/payment_methods.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "payment_methods")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
#[sea_orm(unique)]
|
||||||
|
pub code: String,
|
||||||
|
pub name: String,
|
||||||
|
pub enabled: bool,
|
||||||
|
pub position: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
@@ -1,21 +1,25 @@
|
|||||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
|
||||||
|
|
||||||
|
pub use super::account_cart_items::Entity as AccountCartItems;
|
||||||
pub use super::account_discount_profiles::Entity as AccountDiscountProfiles;
|
pub use super::account_discount_profiles::Entity as AccountDiscountProfiles;
|
||||||
pub use super::account_product_prices::Entity as AccountProductPrices;
|
pub use super::account_product_prices::Entity as AccountProductPrices;
|
||||||
pub use super::account_product_resolutions::Entity as AccountProductResolutions;
|
pub use super::account_product_resolutions::Entity as AccountProductResolutions;
|
||||||
pub use super::audience_discount_profiles::Entity as AudienceDiscountProfiles;
|
pub use super::audience_discount_profiles::Entity as AudienceDiscountProfiles;
|
||||||
pub use super::audit_logs::Entity as AuditLogs;
|
pub use super::audit_logs::Entity as AuditLogs;
|
||||||
pub use super::categories::Entity as Categories;
|
pub use super::categories::Entity as Categories;
|
||||||
|
pub use super::currencies::Entity as Currencies;
|
||||||
pub use super::customer_profiles::Entity as CustomerProfiles;
|
pub use super::customer_profiles::Entity as CustomerProfiles;
|
||||||
pub use super::discount_profile_products::Entity as DiscountProfileProducts;
|
pub use super::discount_profile_products::Entity as DiscountProfileProducts;
|
||||||
pub use super::discount_profiles::Entity as DiscountProfiles;
|
pub use super::discount_profiles::Entity as DiscountProfiles;
|
||||||
pub use super::o_auth2_sessions::Entity as OAuth2Sessions;
|
pub use super::o_auth2_sessions::Entity as OAuth2Sessions;
|
||||||
pub use super::order_items::Entity as OrderItems;
|
pub use super::order_items::Entity as OrderItems;
|
||||||
pub use super::orders::Entity as Orders;
|
pub use super::orders::Entity as Orders;
|
||||||
|
pub use super::payment_methods::Entity as PaymentMethods;
|
||||||
pub use super::product_images::Entity as ProductImages;
|
pub use super::product_images::Entity as ProductImages;
|
||||||
pub use super::product_product_tags::Entity as ProductProductTags;
|
pub use super::product_product_tags::Entity as ProductProductTags;
|
||||||
pub use super::product_tags::Entity as ProductTags;
|
pub use super::product_tags::Entity as ProductTags;
|
||||||
pub use super::product_variants::Entity as ProductVariants;
|
pub use super::product_variants::Entity as ProductVariants;
|
||||||
pub use super::products::Entity as Products;
|
pub use super::products::Entity as Products;
|
||||||
pub use super::shipping_methods::Entity as ShippingMethods;
|
pub use super::shipping_methods::Entity as ShippingMethods;
|
||||||
|
pub use super::shop_settings::Entity as ShopSettings;
|
||||||
pub use super::users::Entity as Users;
|
pub use super::users::Entity as Users;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ pub struct Model {
|
|||||||
pub label: String,
|
pub label: String,
|
||||||
pub position: i32,
|
pub position: i32,
|
||||||
pub sku: Option<String>,
|
pub sku: Option<String>,
|
||||||
pub stock: i32,
|
pub stock: Option<i32>,
|
||||||
pub price_cents: i64,
|
pub price_cents: i64,
|
||||||
pub sale_price_cents: Option<i64>,
|
pub sale_price_cents: Option<i64>,
|
||||||
pub business_sale_price_cents: Option<i64>,
|
pub business_sale_price_cents: Option<i64>,
|
||||||
@@ -22,6 +22,8 @@ pub struct Model {
|
|||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
pub enum Relation {
|
pub enum Relation {
|
||||||
|
#[sea_orm(has_many = "super::account_cart_items::Entity")]
|
||||||
|
AccountCartItems,
|
||||||
#[sea_orm(has_many = "super::account_product_prices::Entity")]
|
#[sea_orm(has_many = "super::account_product_prices::Entity")]
|
||||||
AccountProductPrices,
|
AccountProductPrices,
|
||||||
#[sea_orm(has_many = "super::account_product_resolutions::Entity")]
|
#[sea_orm(has_many = "super::account_product_resolutions::Entity")]
|
||||||
@@ -38,6 +40,12 @@ pub enum Relation {
|
|||||||
Products,
|
Products,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Related<super::account_cart_items::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::AccountCartItems.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Related<super::account_product_prices::Entity> for Entity {
|
impl Related<super::account_product_prices::Entity> for Entity {
|
||||||
fn to() -> RelationDef {
|
fn to() -> RelationDef {
|
||||||
Relation::AccountProductPrices.def()
|
Relation::AccountProductPrices.def()
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ pub struct Model {
|
|||||||
pub slug: String,
|
pub slug: String,
|
||||||
#[sea_orm(column_type = "Text", nullable)]
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub currency: String,
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub short_description: Option<String>,
|
||||||
pub view_count: i32,
|
pub view_count: i32,
|
||||||
pub published: bool,
|
pub published: bool,
|
||||||
pub published_at: Option<DateTimeWithTimeZone>,
|
pub published_at: Option<DateTimeWithTimeZone>,
|
||||||
|
|||||||
20
src/models/_entities/shop_settings.rs
Normal file
20
src/models/_entities/shop_settings.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "shop_settings")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
#[sea_orm(unique)]
|
||||||
|
pub key: String,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub value: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
@@ -31,6 +31,7 @@ pub struct Model {
|
|||||||
pub totp_enabled_at: Option<DateTimeWithTimeZone>,
|
pub totp_enabled_at: Option<DateTimeWithTimeZone>,
|
||||||
#[sea_orm(column_type = "Text", nullable)]
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
pub totp_backup_codes: Option<String>,
|
pub totp_backup_codes: Option<String>,
|
||||||
|
pub avatar_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
|||||||
55
src/models/account_cart_items.rs
Normal file
55
src/models/account_cart_items.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
pub use crate::models::_entities::account_cart_items::{ActiveModel, Column, Entity, Model};
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use sea_orm::{ActiveValue, QueryFilter, QueryOrder};
|
||||||
|
|
||||||
|
pub type AccountCartItems = Entity;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ActiveModelBehavior for ActiveModel {
|
||||||
|
async fn before_save<C>(self, _db: &C, _insert: bool) -> std::result::Result<Self, DbErr>
|
||||||
|
where
|
||||||
|
C: ConnectionTrait,
|
||||||
|
{
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Model {
|
||||||
|
pub async fn find_for_user(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
user_id: i32,
|
||||||
|
) -> Result<Vec<(i32, i32)>, DbErr> {
|
||||||
|
Ok(Entity::find()
|
||||||
|
.filter(Column::UserId.eq(user_id))
|
||||||
|
.order_by_asc(Column::Id)
|
||||||
|
.all(db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|item| (item.quantity > 0).then_some((item.variant_id, item.quantity)))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn replace_for_user(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
user_id: i32,
|
||||||
|
items: &[(i32, i32)],
|
||||||
|
) -> Result<(), DbErr> {
|
||||||
|
Entity::delete_many()
|
||||||
|
.filter(Column::UserId.eq(user_id))
|
||||||
|
.exec(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for (variant_id, quantity) in items.iter().copied().filter(|(_, qty)| *qty > 0) {
|
||||||
|
ActiveModel {
|
||||||
|
user_id: ActiveValue::set(user_id),
|
||||||
|
variant_id: ActiveValue::set(variant_id),
|
||||||
|
quantity: ActiveValue::set(quantity),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
40
src/models/currencies.rs
Normal file
40
src/models/currencies.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
pub use crate::models::_entities::currencies::{ActiveModel, Column, Entity, Model};
|
||||||
|
pub type Currencies = Entity;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ActiveModelBehavior for ActiveModel {
|
||||||
|
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
|
||||||
|
where
|
||||||
|
C: ConnectionTrait,
|
||||||
|
{
|
||||||
|
if !insert && self.updated_at.is_unchanged() {
|
||||||
|
let mut this = self;
|
||||||
|
this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
|
||||||
|
Ok(this)
|
||||||
|
} else {
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// implement your read-oriented logic here
|
||||||
|
impl Model {}
|
||||||
|
|
||||||
|
// implement your write-oriented logic here
|
||||||
|
impl ActiveModel {}
|
||||||
|
|
||||||
|
// implement your custom finders, selectors oriented logic here
|
||||||
|
impl Entity {
|
||||||
|
/// An enabled currency by its ISO code (case-insensitive), or `None`.
|
||||||
|
pub async fn find_enabled_by_code<C: ConnectionTrait>(
|
||||||
|
db: &C,
|
||||||
|
code: &str,
|
||||||
|
) -> Result<Option<Model>, DbErr> {
|
||||||
|
Entity::find()
|
||||||
|
.filter(Column::Code.eq(code.to_uppercase()))
|
||||||
|
.filter(Column::Enabled.eq(true))
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,22 +6,26 @@
|
|||||||
|
|
||||||
pub mod _entities;
|
pub mod _entities;
|
||||||
|
|
||||||
|
pub mod account_cart_items;
|
||||||
pub mod account_discount_profiles;
|
pub mod account_discount_profiles;
|
||||||
pub mod account_product_prices;
|
pub mod account_product_prices;
|
||||||
pub mod account_product_resolutions;
|
pub mod account_product_resolutions;
|
||||||
pub mod audience_discount_profiles;
|
pub mod audience_discount_profiles;
|
||||||
pub mod audit_logs;
|
pub mod audit_logs;
|
||||||
pub mod categories;
|
pub mod categories;
|
||||||
|
pub mod currencies;
|
||||||
pub mod discount_profile_products;
|
pub mod discount_profile_products;
|
||||||
pub mod discount_profiles;
|
pub mod discount_profiles;
|
||||||
pub mod customer_profiles;
|
pub mod customer_profiles;
|
||||||
pub mod o_auth2_sessions;
|
pub mod o_auth2_sessions;
|
||||||
pub mod order_items;
|
pub mod order_items;
|
||||||
pub mod orders;
|
pub mod orders;
|
||||||
|
pub mod payment_methods;
|
||||||
pub mod product_images;
|
pub mod product_images;
|
||||||
pub mod product_product_tags;
|
pub mod product_product_tags;
|
||||||
pub mod product_tags;
|
pub mod product_tags;
|
||||||
pub mod products;
|
pub mod products;
|
||||||
pub mod shipping_methods;
|
pub mod shipping_methods;
|
||||||
|
pub mod shop_settings;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
pub mod product_variants;
|
pub mod product_variants;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user