Compare commits
109 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
6828854f24 | ||
|
|
3a1ea7cdb4 | ||
|
|
3f798432a0 | ||
|
|
29854a972b | ||
|
|
88074c1871 | ||
|
|
68f3472760 | ||
|
|
85f1657c67 | ||
|
|
4a736a8c85 | ||
|
|
77d5c0fc25 | ||
|
|
09634e1cd8 | ||
|
|
088fcb60a1 | ||
|
|
bf8f8e54c9 | ||
|
|
534ba9e8ec | ||
|
|
262ec1bfdb | ||
|
|
e98c70aa63 | ||
|
|
d2b463135b | ||
|
|
1df8d66d5d | ||
|
|
c713627a2c | ||
|
|
ed566b5347 | ||
|
|
9ce1cb97f0 | ||
|
|
2ee87fbdd7 | ||
|
|
c9eb47860d | ||
|
|
8dc153efcc | ||
|
|
db6b609937 | ||
|
|
86888b3877 | ||
|
|
5b203ed248 | ||
|
|
b787d48665 | ||
|
|
e138fb6579 | ||
|
|
3da840c0c9 | ||
|
|
0310f2d2f4 | ||
|
|
42f30261d0 | ||
|
|
ffda718a46 | ||
|
|
673b28c361 | ||
|
|
454d5cb349 | ||
|
|
14ae859152 | ||
|
|
43c6c04dcf | ||
|
|
e51eda9a8c | ||
|
|
12e00a782d | ||
|
|
5278988842 | ||
|
|
e70743996b | ||
|
|
11762728c9 | ||
|
|
ebb208baba | ||
|
|
7cba3d9eba | ||
|
|
35e2b6edc9 | ||
|
|
f3daa27ce7 | ||
|
|
46cc2459bd | ||
|
|
996358be87 | ||
|
|
c6624e1b3d | ||
|
|
b9c1277876 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -19,6 +19,8 @@ target/
|
|||||||
*.sqlite-*
|
*.sqlite-*
|
||||||
.env
|
.env
|
||||||
.env.production
|
.env.production
|
||||||
|
.envrc
|
||||||
|
.direnv/
|
||||||
uploads/
|
uploads/
|
||||||
*.report.html
|
*.report.html
|
||||||
favicon_io.zip
|
favicon_io.zip
|
||||||
|
|||||||
709
Cargo.lock
generated
709
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
21
Cargo.toml
21
Cargo.toml
@@ -3,7 +3,7 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "kompress_eshop"
|
name = "kompress_eshop"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2024"
|
||||||
publish = false
|
publish = false
|
||||||
default-run = "kompress-eshop-cli"
|
default-run = "kompress-eshop-cli"
|
||||||
|
|
||||||
@@ -16,14 +16,14 @@ loco-rs = { version = "0.16" }
|
|||||||
loco-rs = { workspace = true }
|
loco-rs = { workspace = true }
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = { version = "1" }
|
serde_json = { version = "1" }
|
||||||
tokio = { version = "1.45", default-features = false, features = [
|
tokio = { version = "1.52", default-features = false, features = [
|
||||||
"rt-multi-thread",
|
"rt-multi-thread",
|
||||||
] }
|
] }
|
||||||
async-trait = { version = "0.1" }
|
async-trait = { version = "0.1" }
|
||||||
axum = { version = "0.8", features = ["multipart"] }
|
axum = { version = "0.8", features = ["multipart"] }
|
||||||
tracing = { version = "0.1" }
|
tracing = { version = "0.1" }
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
|
||||||
regex = { version = "1.11" }
|
regex = { version = "1.12" }
|
||||||
migration = { path = "migration" }
|
migration = { path = "migration" }
|
||||||
sea-orm = { version = "1.1", features = [
|
sea-orm = { version = "1.1", features = [
|
||||||
"sqlx-sqlite",
|
"sqlx-sqlite",
|
||||||
@@ -35,7 +35,7 @@ chrono = { version = "0.4" }
|
|||||||
time = { version = "0.3" }
|
time = { version = "0.3" }
|
||||||
dotenvy = { version = "0.15" }
|
dotenvy = { version = "0.15" }
|
||||||
validator = { version = "0.20" }
|
validator = { version = "0.20" }
|
||||||
uuid = { version = "1.6", features = ["v4"] }
|
uuid = { version = "1.23", features = ["v4"] }
|
||||||
include_dir = { version = "0.7" }
|
include_dir = { version = "0.7" }
|
||||||
# outbound HTTP for carrier shipment APIs (Packeta / DPD / DHL)
|
# outbound HTTP for carrier shipment APIs (Packeta / DPD / DHL)
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
@@ -49,6 +49,15 @@ axum-casbin = "1.3.0"
|
|||||||
loco-oauth2 = "0.5.0"
|
loco-oauth2 = "0.5.0"
|
||||||
passwords = "3.1.16"
|
passwords = "3.1.16"
|
||||||
tower-sessions = "0.14"
|
tower-sessions = "0.14"
|
||||||
|
# TOTP (Google Authenticator) for optional two-factor auth
|
||||||
|
totp-rs = { version = "5", features = ["qr", "gen_secret"] }
|
||||||
|
# CSRF: HMAC-signed double-submit token + body inspection for the `_csrf` field
|
||||||
|
hmac = { version = "0.12" }
|
||||||
|
sha2 = { version = "0.10" }
|
||||||
|
subtle = { version = "2.6" }
|
||||||
|
form_urlencoded = { version = "1" }
|
||||||
|
multer = { version = "3" }
|
||||||
|
futures-util = { version = "0.3" }
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "kompress-eshop-cli"
|
name = "kompress-eshop-cli"
|
||||||
@@ -57,6 +66,6 @@ required-features = []
|
|||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
loco-rs = { workspace = true, features = ["testing"] }
|
loco-rs = { workspace = true, features = ["testing"] }
|
||||||
serial_test = { version = "3.1.1" }
|
serial_test = { version = "3.5.0" }
|
||||||
rstest = { version = "0.25" }
|
rstest = { version = "0.25" }
|
||||||
insta = { version = "1.34", features = ["redactions", "yaml", "filters"] }
|
insta = { version = "1.48", features = ["redactions", "yaml", "filters"] }
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -70,6 +71,7 @@ auth-or = or
|
|||||||
auth-google = Continue with Google
|
auth-google = Continue with Google
|
||||||
nav-login = Sign in
|
nav-login = Sign in
|
||||||
nav-register = Register
|
nav-register = Register
|
||||||
|
nav-profile = My profile
|
||||||
register-title = Create account
|
register-title = Create account
|
||||||
register-name = Name
|
register-name = Name
|
||||||
register-submit = Create account
|
register-submit = Create account
|
||||||
@@ -170,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.
|
||||||
@@ -207,19 +212,96 @@ edit-category = Edit category
|
|||||||
product = Product
|
product = Product
|
||||||
name = Name
|
name = Name
|
||||||
price = Price
|
price = Price
|
||||||
|
sale-price = Sale price
|
||||||
|
variants-options = Variants / options
|
||||||
|
add-option = Add option
|
||||||
|
option-label = Option label
|
||||||
|
optional = optional
|
||||||
|
stock-untracked-hint = Leave blank = available without stock tracking
|
||||||
|
available = Available
|
||||||
|
choose-option = Options
|
||||||
|
from-price = from { $price }
|
||||||
|
admin-discounts = Discounts
|
||||||
|
admin-discounts-desc = Set discounted product prices. A discount shows up as a sale in the shop.
|
||||||
|
business-discount-desc = A baseline discount for all business accounts (off the regular price). Profiles and negotiated prices apply on top (lowest price wins).
|
||||||
|
audience-personal = Personal
|
||||||
|
audience-business = Business
|
||||||
|
apply-profiles-personal-hint = These profiles lower the public price for all customers.
|
||||||
|
apply-profiles-business-hint = These profiles lower the price for all business accounts. Businesses always get the lower of the personal and business price.
|
||||||
|
on-sale = On sale
|
||||||
|
no-discount = No discount
|
||||||
|
discount = Discount
|
||||||
|
set-discount = Set discount
|
||||||
|
remove-discount = Remove discount
|
||||||
|
remove = Remove
|
||||||
|
discount-mode-fixed = Fixed price
|
||||||
|
discount-mode-percent = Percentage
|
||||||
|
discount-percent = Discount (%)
|
||||||
|
discount-preview-before = Original price
|
||||||
|
discount-preview-after = New price
|
||||||
|
discount-preview-save = You save
|
||||||
|
discount-invalid = Invalid price.
|
||||||
|
discount-must-be-positive = The sale price must be greater than zero.
|
||||||
|
discount-below-regular = The sale price must be below the regular price.
|
||||||
|
discount-percent-range = The percentage must be between 0 and 100.
|
||||||
|
discount-apply-confirm = Apply these discounts to the shop?
|
||||||
|
discount-remove-confirm = Remove this discount?
|
||||||
|
profile-applied = Applied
|
||||||
|
profile-will-apply = Will apply
|
||||||
|
profile-will-remove = Will remove
|
||||||
|
profiles-unsaved = Unsaved changes — Save to apply
|
||||||
|
profiles-no-changes = No changes
|
||||||
|
admin-customers = Business accounts
|
||||||
|
admin-customers-desc = Manage negotiated prices for business (B2B) accounts.
|
||||||
|
admin-no-customers = No business accounts yet.
|
||||||
|
email = Email
|
||||||
|
back = Back
|
||||||
|
negotiated-prices = Negotiated prices
|
||||||
|
negotiated-prices-hint = Set a price for a specific product for this business account. The customer always pays the lower of the public and negotiated price.
|
||||||
|
manage-prices = Manage prices
|
||||||
|
public-price = Public price
|
||||||
|
business-price = Business price
|
||||||
|
negotiated-price = Negotiated price
|
||||||
|
set-negotiated-price = Set price
|
||||||
|
negotiated-price-hint = Set a negotiated price for this product for this business account. The customer always pays the lowest of the public, business and negotiated price.
|
||||||
|
negotiated-remove-confirm = Remove this negotiated price?
|
||||||
|
effective-price = Effective price
|
||||||
|
admin-discount-profiles = Discount profiles
|
||||||
|
admin-discount-profiles-desc = Create reusable discount layers (a % over chosen products) and assign them to business accounts.
|
||||||
|
admin-no-profiles = No discount profiles yet.
|
||||||
|
new-profile = New profile
|
||||||
|
edit-profile = Edit profile
|
||||||
|
profile-name-required = Profile name is required.
|
||||||
|
scope = Scope
|
||||||
|
products = Products
|
||||||
|
scope-include = Selected products
|
||||||
|
scope-all-except = All except selected
|
||||||
|
scope-include-hint = Applies only to the products selected below.
|
||||||
|
scope-all-except-hint = Applies to every product except those selected below.
|
||||||
|
automated-price = Automated price
|
||||||
|
discount-profiles = Discount profiles
|
||||||
|
collision = Conflict
|
||||||
|
resolve = Resolve
|
||||||
|
no-profiles-assigned = No profiles assigned.
|
||||||
stock = Stock
|
stock = Stock
|
||||||
sku = SKU
|
sku = SKU
|
||||||
currency = Currency
|
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
|
||||||
|
position-auto = added to the end
|
||||||
|
position-hint = Sort order in the menu (lowest first). Leave blank to add it last.
|
||||||
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
|
||||||
@@ -229,8 +311,36 @@ 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
|
||||||
cart-title = Cart
|
cart-title = Cart
|
||||||
cart-empty = Your cart is empty.
|
cart-empty = Your cart is empty.
|
||||||
cart-total = Total
|
cart-total = Total
|
||||||
@@ -241,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
|
||||||
@@ -256,8 +368,89 @@ 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 residence address to my profile
|
||||||
|
payment-none = No payment method is currently available.
|
||||||
|
account-type = Account type
|
||||||
|
account-personal = Individual
|
||||||
|
account-company = Company
|
||||||
|
account-company-details = Company details
|
||||||
|
company-name = Company name
|
||||||
|
company-ico = Company ID (IČO)
|
||||||
|
company-dic = Tax ID (DIČ)
|
||||||
|
company-icdph = VAT ID (IČ DPH)
|
||||||
|
field-optional = optional
|
||||||
checkout-place-order = Place order
|
checkout-place-order = Place order
|
||||||
checkout-summary = Order summary
|
checkout-summary = Order summary
|
||||||
|
profile-title = My profile
|
||||||
|
profile-intro = We'll use these details to prefill checkout.
|
||||||
|
profile-saved = Profile saved.
|
||||||
|
profile-save = Save profile
|
||||||
|
profile-company-required = For a company account, please fill in company name, IČO and DIČ.
|
||||||
|
profile-first-name = First name
|
||||||
|
profile-last-name = Surname
|
||||||
|
profile-edit = Edit profile
|
||||||
|
profile-cancel = Cancel
|
||||||
|
profile-not-set = Not set
|
||||||
|
profile-avatar = Profile picture
|
||||||
|
profile-avatar-hint = PNG, JPG, WEBP or GIF, up to 10 MB.
|
||||||
|
profile-avatar-choose = Choose a picture
|
||||||
|
profile-avatar-upload = Upload
|
||||||
|
profile-avatar-remove = Remove picture
|
||||||
|
nav-account = My account
|
||||||
|
account-orders = My orders
|
||||||
|
account-change-password = Change password
|
||||||
|
orders-active = Active orders
|
||||||
|
orders-past = Past orders
|
||||||
|
orders-empty = You don't have any orders yet.
|
||||||
|
password-change-title = Change password
|
||||||
|
password-current = Current password
|
||||||
|
password-current-wrong = Your current password is incorrect.
|
||||||
|
password-changed = Your password has been changed.
|
||||||
|
|
||||||
|
# Two-factor authentication (TOTP / Google Authenticator)
|
||||||
|
security-title = Security
|
||||||
|
security-2fa-intro = Two-factor authentication (2FA) adds a one-time code from an app like Google Authenticator to your sign-in.
|
||||||
|
security-2fa-on = 2FA is on
|
||||||
|
security-2fa-off = 2FA is off
|
||||||
|
security-2fa-enable = Enable two-factor authentication
|
||||||
|
security-2fa-scan = Scan this QR code in Google Authenticator (or any compatible app).
|
||||||
|
security-2fa-manual = Or enter the key manually:
|
||||||
|
security-2fa-enter-code = Enter the 6-digit code from the app
|
||||||
|
security-2fa-confirm = Confirm and enable
|
||||||
|
security-2fa-code-wrong = That code is wrong or expired. Please try again.
|
||||||
|
security-2fa-enroll-error = Could not start 2FA setup. Please try again.
|
||||||
|
security-2fa-enabled-ok = Two-factor authentication is enabled.
|
||||||
|
security-2fa-backup-intro = Save these backup codes somewhere safe. Each can be used once if you lose access to your app.
|
||||||
|
security-2fa-backup-remaining = Backup codes remaining
|
||||||
|
security-2fa-regenerate = Generate new backup codes
|
||||||
|
security-2fa-disable = Disable two-factor authentication
|
||||||
|
security-2fa-disable-hint = Enter your current password to confirm.
|
||||||
|
|
||||||
|
# Second login step (after password)
|
||||||
|
login-totp-title = Two-factor authentication
|
||||||
|
login-totp-intro = Enter the code from your authenticator app.
|
||||||
|
login-totp-error = That code is wrong or expired.
|
||||||
|
login-totp-code = Verification code
|
||||||
|
login-totp-submit = Verify
|
||||||
|
login-totp-backup-hint = No access to your app? Enter one of your backup codes.
|
||||||
|
|
||||||
|
account-type-locked = Account type can't be changed after registration.
|
||||||
|
checkout-create-account = Create an account from this order
|
||||||
|
checkout-create-account-hint = We'll email you a link to set your password. This order will be linked to your account.
|
||||||
|
order-account-created = We created an account for you. Check your email to set your password.
|
||||||
|
set-password-title = Set your password
|
||||||
|
set-password-intro = Choose a password to finish setting up your account.
|
||||||
|
set-password-new = New password
|
||||||
|
set-password-confirm = Confirm password
|
||||||
|
set-password-submit = Set password
|
||||||
|
set-password-invalid = This link is invalid or has expired.
|
||||||
|
set-password-weak = Password must be at least 8 characters.
|
||||||
|
set-password-mismatch = Passwords don't match.
|
||||||
|
resend-verification-title = Resend verification email
|
||||||
|
resend-verification-intro = Enter your email and we'll send a fresh verification link.
|
||||||
|
resend-verification-submit = Resend
|
||||||
|
resend-verification-done = If that email belongs to an unverified account, we've sent a new verification link. Check your inbox (and spam). You can request another in a minute.
|
||||||
|
login-resend = Didn't get the verification email? Resend it
|
||||||
order-confirmed-title = Thank you for your order!
|
order-confirmed-title = Thank you for your order!
|
||||||
order-confirmed-sub = We have received your order.
|
order-confirmed-sub = We have received your order.
|
||||||
order-number = Order number
|
order-number = Order number
|
||||||
@@ -270,6 +463,7 @@ admin-no-orders = No orders yet.
|
|||||||
order-status-pending = Pending
|
order-status-pending = Pending
|
||||||
order-status-paid = Paid
|
order-status-paid = Paid
|
||||||
order-status-shipped = Shipped
|
order-status-shipped = Shipped
|
||||||
|
order-status-delivered = Delivered
|
||||||
order-status-cancelled = Cancelled
|
order-status-cancelled = Cancelled
|
||||||
order-update-status = Update status
|
order-update-status = Update status
|
||||||
|
|
||||||
@@ -291,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
|
||||||
@@ -308,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
|
||||||
@@ -70,6 +71,7 @@ auth-or = alebo
|
|||||||
auth-google = Pokračovať cez Google
|
auth-google = Pokračovať cez Google
|
||||||
nav-login = Prihlásiť sa
|
nav-login = Prihlásiť sa
|
||||||
nav-register = Registrácia
|
nav-register = Registrácia
|
||||||
|
nav-profile = Môj profil
|
||||||
register-title = Vytvoriť účet
|
register-title = Vytvoriť účet
|
||||||
register-name = Meno
|
register-name = Meno
|
||||||
register-submit = Zaregistrovať sa
|
register-submit = Zaregistrovať sa
|
||||||
@@ -170,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.
|
||||||
@@ -207,19 +212,96 @@ edit-category = Upraviť kategóriu
|
|||||||
product = Produkt
|
product = Produkt
|
||||||
name = Názov
|
name = Názov
|
||||||
price = Cena
|
price = Cena
|
||||||
|
sale-price = Zľavnená cena
|
||||||
|
variants-options = Varianty / možnosti
|
||||||
|
add-option = Pridať možnosť
|
||||||
|
option-label = Označenie možnosti
|
||||||
|
optional = voliteľné
|
||||||
|
stock-untracked-hint = Nechajte prázdne = dostupné bez sledovania zásob
|
||||||
|
available = Dostupné
|
||||||
|
choose-option = Options
|
||||||
|
from-price = od { $price }
|
||||||
|
admin-discounts = Zľavy
|
||||||
|
admin-discounts-desc = Nastavte zľavnené ceny produktov. Zľava sa v obchode zobrazí ako akcia.
|
||||||
|
business-discount-desc = Základná zľava pre všetky firemné účty (z bežnej ceny). Profily a dohodnuté ceny sa uplatnia navyše (platí najnižšia cena).
|
||||||
|
audience-personal = Osobné
|
||||||
|
audience-business = Firemné
|
||||||
|
apply-profiles-personal-hint = Tieto profily znížia verejnú cenu pre všetkých zákazníkov.
|
||||||
|
apply-profiles-business-hint = Tieto profily znížia cenu pre všetky firemné účty. Firmy vždy dostanú nižšiu z osobnej a firemnej ceny.
|
||||||
|
on-sale = V akcii
|
||||||
|
no-discount = Bez zľavy
|
||||||
|
discount = Zľava
|
||||||
|
set-discount = Nastaviť zľavu
|
||||||
|
remove-discount = Zrušiť zľavu
|
||||||
|
remove = Odstrániť
|
||||||
|
discount-mode-fixed = Pevná cena
|
||||||
|
discount-mode-percent = Percentá
|
||||||
|
discount-percent = Zľava (%)
|
||||||
|
discount-preview-before = Pôvodná cena
|
||||||
|
discount-preview-after = Nová cena
|
||||||
|
discount-preview-save = Ušetríte
|
||||||
|
discount-invalid = Neplatná cena.
|
||||||
|
discount-must-be-positive = Zľavnená cena musí byť väčšia ako nula.
|
||||||
|
discount-below-regular = Zľavnená cena musí byť nižšia ako bežná cena.
|
||||||
|
discount-percent-range = Percento musí byť medzi 0 a 100.
|
||||||
|
discount-apply-confirm = Uplatniť tieto zľavy v obchode?
|
||||||
|
discount-remove-confirm = Zrušiť túto zľavu?
|
||||||
|
profile-applied = Uplatnené
|
||||||
|
profile-will-apply = Bude uplatnené
|
||||||
|
profile-will-remove = Bude zrušené
|
||||||
|
profiles-unsaved = Neuložené zmeny — uložte na uplatnenie
|
||||||
|
profiles-no-changes = Žiadne zmeny
|
||||||
|
admin-customers = Firemné účty
|
||||||
|
admin-customers-desc = Spravujte dohodnuté ceny pre firemné (B2B) účty.
|
||||||
|
admin-no-customers = Zatiaľ žiadne firemné účty.
|
||||||
|
email = E-mail
|
||||||
|
back = Späť
|
||||||
|
negotiated-prices = Dohodnuté ceny
|
||||||
|
negotiated-prices-hint = Nastavte cenu pre konkrétny produkt pre tento firemný účet. Zákazník vždy zaplatí najnižšiu z verejnej a dohodnutej ceny.
|
||||||
|
manage-prices = Spravovať ceny
|
||||||
|
public-price = Verejná cena
|
||||||
|
business-price = Firemná cena
|
||||||
|
negotiated-price = Dohodnutá cena
|
||||||
|
set-negotiated-price = Nastaviť cenu
|
||||||
|
negotiated-price-hint = Nastavte dohodnutú cenu tohto produktu pre tento firemný účet. Zákazník vždy zaplatí najnižšiu z verejnej, firemnej a dohodnutej ceny.
|
||||||
|
negotiated-remove-confirm = Zrušiť túto dohodnutú cenu?
|
||||||
|
effective-price = Výsledná cena
|
||||||
|
admin-discount-profiles = Zľavové profily
|
||||||
|
admin-discount-profiles-desc = Vytvorte opakovane použiteľné zľavové vrstvy (% na vybrané produkty) a priraďte ich firemným účtom.
|
||||||
|
admin-no-profiles = Zatiaľ žiadne zľavové profily.
|
||||||
|
new-profile = Nový profil
|
||||||
|
edit-profile = Upraviť profil
|
||||||
|
profile-name-required = Názov profilu je povinný.
|
||||||
|
scope = Rozsah
|
||||||
|
products = Produkty
|
||||||
|
scope-include = Vybrané produkty
|
||||||
|
scope-all-except = Všetky okrem vybraných
|
||||||
|
scope-include-hint = Platí len pre vybrané produkty nižšie.
|
||||||
|
scope-all-except-hint = Platí pre všetky produkty okrem vybraných nižšie.
|
||||||
|
automated-price = Automatická cena
|
||||||
|
discount-profiles = Zľavové profily
|
||||||
|
collision = Konflikt
|
||||||
|
resolve = Vyriešiť
|
||||||
|
no-profiles-assigned = Žiadne priradené profily.
|
||||||
stock = Sklad
|
stock = Sklad
|
||||||
sku = Kód (SKU)
|
sku = Kód (SKU)
|
||||||
currency = Mena
|
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
|
||||||
|
position-auto = pridá sa na koniec
|
||||||
|
position-hint = Poradie v menu (najnižšie ako prvé). Nechajte prázdne a pridá sa na koniec.
|
||||||
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é
|
||||||
@@ -229,8 +311,36 @@ 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
|
||||||
cart-title = Košík
|
cart-title = Košík
|
||||||
cart-empty = Váš košík je prázdny.
|
cart-empty = Váš košík je prázdny.
|
||||||
cart-total = Spolu
|
cart-total = Spolu
|
||||||
@@ -242,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
|
||||||
@@ -256,8 +368,89 @@ 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ť adresu bydliska do môjho profilu
|
||||||
|
payment-none = Momentálne nie je dostupný žiadny spôsob platby.
|
||||||
|
account-type = Typ účtu
|
||||||
|
account-personal = Súkromná osoba
|
||||||
|
account-company = Firma
|
||||||
|
account-company-details = Firemné údaje
|
||||||
|
company-name = Názov firmy
|
||||||
|
company-ico = IČO
|
||||||
|
company-dic = DIČ
|
||||||
|
company-icdph = IČ DPH
|
||||||
|
field-optional = nepovinné
|
||||||
checkout-place-order = Odoslať objednávku
|
checkout-place-order = Odoslať objednávku
|
||||||
checkout-summary = Súhrn objednávky
|
checkout-summary = Súhrn objednávky
|
||||||
|
profile-title = Môj profil
|
||||||
|
profile-intro = Tieto údaje použijeme na predvyplnenie pokladne.
|
||||||
|
profile-saved = Profil bol uložený.
|
||||||
|
profile-save = Uložiť profil
|
||||||
|
profile-company-required = Pri firemnom účte vyplňte názov firmy, IČO a DIČ.
|
||||||
|
profile-first-name = Meno
|
||||||
|
profile-last-name = Priezvisko
|
||||||
|
profile-edit = Upraviť profil
|
||||||
|
profile-cancel = Zrušiť
|
||||||
|
profile-not-set = Neuvedené
|
||||||
|
profile-avatar = Profilová fotka
|
||||||
|
profile-avatar-hint = PNG, JPG, WEBP alebo GIF, max. 10 MB.
|
||||||
|
profile-avatar-choose = Vybrať fotku
|
||||||
|
profile-avatar-upload = Nahrať
|
||||||
|
profile-avatar-remove = Odstrániť fotku
|
||||||
|
nav-account = Môj účet
|
||||||
|
account-orders = Moje objednávky
|
||||||
|
account-change-password = Zmeniť heslo
|
||||||
|
orders-active = Aktívne objednávky
|
||||||
|
orders-past = Staršie objednávky
|
||||||
|
orders-empty = Zatiaľ nemáte žiadne objednávky.
|
||||||
|
password-change-title = Zmeniť heslo
|
||||||
|
password-current = Súčasné heslo
|
||||||
|
password-current-wrong = Vaše súčasné heslo je nesprávne.
|
||||||
|
password-changed = Vaše heslo bolo zmenené.
|
||||||
|
|
||||||
|
# Two-factor authentication (TOTP / Google Authenticator)
|
||||||
|
security-title = Zabezpečenie
|
||||||
|
security-2fa-intro = Dvojfaktorové overenie (2FA) pridáva k prihláseniu jednorazový kód z aplikácie ako Google Authenticator.
|
||||||
|
security-2fa-on = 2FA je zapnuté
|
||||||
|
security-2fa-off = 2FA je vypnuté
|
||||||
|
security-2fa-enable = Zapnúť dvojfaktorové overenie
|
||||||
|
security-2fa-scan = Naskenujte tento QR kód v aplikácii Google Authenticator (alebo inej kompatibilnej).
|
||||||
|
security-2fa-manual = Alebo zadajte kľúč ručne:
|
||||||
|
security-2fa-enter-code = Zadajte 6-miestny kód z aplikácie
|
||||||
|
security-2fa-confirm = Potvrdiť a zapnúť
|
||||||
|
security-2fa-code-wrong = Kód je nesprávny alebo vypršal. Skúste to znova.
|
||||||
|
security-2fa-enroll-error = Nepodarilo sa pripraviť 2FA. Skúste to znova.
|
||||||
|
security-2fa-enabled-ok = Dvojfaktorové overenie je zapnuté.
|
||||||
|
security-2fa-backup-intro = Uložte si tieto záložné kódy na bezpečné miesto. Každý sa dá použiť iba raz, ak nemáte prístup k aplikácii.
|
||||||
|
security-2fa-backup-remaining = Zostávajúce záložné kódy
|
||||||
|
security-2fa-regenerate = Vygenerovať nové záložné kódy
|
||||||
|
security-2fa-disable = Vypnúť dvojfaktorové overenie
|
||||||
|
security-2fa-disable-hint = Na potvrdenie zadajte svoje súčasné heslo.
|
||||||
|
|
||||||
|
# Second login step (after password)
|
||||||
|
login-totp-title = Dvojfaktorové overenie
|
||||||
|
login-totp-intro = Zadajte kód z vašej autentifikačnej aplikácie.
|
||||||
|
login-totp-error = Kód je nesprávny alebo vypršal.
|
||||||
|
login-totp-code = Overovací kód
|
||||||
|
login-totp-submit = Overiť
|
||||||
|
login-totp-backup-hint = Nemáte prístup k aplikácii? Zadajte jeden zo svojich záložných kódov.
|
||||||
|
|
||||||
|
account-type-locked = Typ účtu sa po registrácii nedá zmeniť.
|
||||||
|
checkout-create-account = Vytvoriť účet z tejto objednávky
|
||||||
|
checkout-create-account-hint = Pošleme vám e-mail na nastavenie hesla. Objednávka sa priradí k vášmu účtu.
|
||||||
|
order-account-created = Vytvorili sme vám účet. Skontrolujte si e-mail a nastavte si heslo.
|
||||||
|
set-password-title = Nastavte si heslo
|
||||||
|
set-password-intro = Zvoľte si heslo a dokončite vytvorenie účtu.
|
||||||
|
set-password-new = Nové heslo
|
||||||
|
set-password-confirm = Potvrďte heslo
|
||||||
|
set-password-submit = Nastaviť heslo
|
||||||
|
set-password-invalid = Odkaz je neplatný alebo vypršal.
|
||||||
|
set-password-weak = Heslo musí mať aspoň 8 znakov.
|
||||||
|
set-password-mismatch = Heslá sa nezhodujú.
|
||||||
|
resend-verification-title = Znova odoslať overovací e-mail
|
||||||
|
resend-verification-intro = Zadajte svoj e-mail a pošleme vám nový overovací odkaz.
|
||||||
|
resend-verification-submit = Odoslať znova
|
||||||
|
resend-verification-done = Ak k tomuto e-mailu patrí neoverený účet, poslali sme naň nový overovací odkaz. Skontrolujte si schránku aj priečinok so spamom. Ďalšiu žiadosť môžete odoslať o minútu.
|
||||||
|
login-resend = Nedostali ste overovací e-mail? Poslať znova
|
||||||
order-confirmed-title = Ďakujeme za objednávku!
|
order-confirmed-title = Ďakujeme za objednávku!
|
||||||
order-confirmed-sub = Vašu objednávku sme prijali.
|
order-confirmed-sub = Vašu objednávku sme prijali.
|
||||||
order-number = Číslo objednávky
|
order-number = Číslo objednávky
|
||||||
@@ -270,6 +463,7 @@ admin-no-orders = Zatiaľ žiadne objednávky.
|
|||||||
order-status-pending = Čaká na spracovanie
|
order-status-pending = Čaká na spracovanie
|
||||||
order-status-paid = Zaplatené
|
order-status-paid = Zaplatené
|
||||||
order-status-shipped = Odoslané
|
order-status-shipped = Odoslané
|
||||||
|
order-status-delivered = Doručené
|
||||||
order-status-cancelled = Zrušené
|
order-status-cancelled = Zrušené
|
||||||
order-update-status = Zmeniť stav
|
order-update-status = Zmeniť stav
|
||||||
|
|
||||||
@@ -291,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
|
||||||
@@ -308,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); });
|
||||||
|
})();
|
||||||
15
assets/static/vendor/alpine/alpine-focus-3.14.9.min.js
vendored
Normal file
15
assets/static/vendor/alpine/alpine-focus-3.14.9.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
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
87
assets/views/account/order_detail.html
Normal file
87
assets/views/account/order_detail.html
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{{ order.order_number }}{% endblock title %}
|
||||||
|
|
||||||
|
{% macro status_badge(status) %}
|
||||||
|
{% if status == "delivered" %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="success") }}
|
||||||
|
{% elif status == "shipped" %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="primary") }}
|
||||||
|
{% elif status == "paid" %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="info") }}
|
||||||
|
{% elif status == "cancelled" %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="danger") }}
|
||||||
|
{% else %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="warning") }}
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro status_badge %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mx-auto max-w-2xl space-y-6">
|
||||||
|
<a href="/account/orders" class="inline-flex items-center gap-1 text-sm text-primary underline-offset-2 hover:underline dark:text-primary-dark">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M15.75 19.5 8.25 12l7.5-7.5" /></svg>
|
||||||
|
{{ t(key="account-orders", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<h1 class="font-mono text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.order_number }}</h1>
|
||||||
|
{{ self::status_badge(status=order.status) }}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ order.created_at | truncate(length=10, end="") }}</p>
|
||||||
|
|
||||||
|
<div class="rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<ul class="space-y-2 pb-3 text-sm">
|
||||||
|
{% for item in items %}
|
||||||
|
<li class="flex justify-between gap-2">
|
||||||
|
<span class="text-on-surface/80 dark:text-on-surface-dark/80">{{ item.product_name }}{% if item.variant_label %} · {{ item.variant_label }}{% endif %} × {{ item.quantity }}</span>
|
||||||
|
<span class="tabular-nums">{{ item.line_total }} €</span>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
<div class="space-y-1 border-t border-outline py-3 text-sm dark:border-outline-dark">
|
||||||
|
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }}</span><span class="tabular-nums">{{ order.subtotal }} €</span></div>
|
||||||
|
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ order.carrier_name }}</span><span class="tabular-nums">{{ order.shipping }} €</span></div>
|
||||||
|
{% if order.pickup_point_name %}<div class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ order.pickup_point_name }}</div>{% endif %}
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between border-t border-outline pt-3 font-bold dark:border-outline-dark">
|
||||||
|
<span>{{ t(key="order-total", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span class="tabular-nums text-primary dark:text-primary-dark">{{ order.total }} €</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if order.tracking_number %}
|
||||||
|
<div class="rounded-radius border border-outline bg-surface p-4 text-sm dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<div class="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="order-tracking", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span class="font-mono font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.tracking_number }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="rounded-radius border border-outline bg-surface p-6 text-sm dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<div class="space-y-4">
|
||||||
|
{% if order.residence_address %}
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-2 font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-residence-address", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.residence_address }}</p>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.residence_zip }} {{ order.residence_city }}{% if order.residence_country %}, {{ order.residence_country }}{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-2 font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.customer_name }}</p>
|
||||||
|
{% if order.address %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.address }}</p>{% endif %}
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.zip }} {{ order.city }}{% if order.country %}, {{ order.country }}{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if order.payment_method == "bank_transfer" and order.status == "pending" %}
|
||||||
|
<div class="space-y-2 rounded-radius border border-primary/40 bg-primary/5 p-6 text-sm dark:border-primary-dark/40">
|
||||||
|
<p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-bank-instructions", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
|
||||||
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-account-name", lang=lang | default(value='sk')) }}</span><span class="font-medium">{{ order.bank_account_name }}</span>
|
||||||
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">IBAN</span><span class="font-mono font-medium">{{ order.bank_iban }}</span>
|
||||||
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-variable-symbol", lang=lang | default(value='sk')) }}</span><span class="font-mono font-medium">{{ order.variable_symbol }}</span>
|
||||||
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-amount", lang=lang | default(value='sk')) }}</span><span class="font-medium tabular-nums">{{ order.total }} €</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
52
assets/views/account/orders.html
Normal file
52
assets/views/account/orders.html
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="account-orders", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
|
||||||
|
{# status → badge variant #}
|
||||||
|
{% macro status_badge(status) %}
|
||||||
|
{% if status == "delivered" %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="success") }}
|
||||||
|
{% elif status == "shipped" %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="primary") }}
|
||||||
|
{% elif status == "paid" %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="info") }}
|
||||||
|
{% elif status == "cancelled" %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="danger") }}
|
||||||
|
{% else %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="warning") }}
|
||||||
|
{% endif %}
|
||||||
|
{% endmacro status_badge %}
|
||||||
|
|
||||||
|
{% macro order_row(order) %}
|
||||||
|
<a href="/account/orders/{{ order.order_number }}"
|
||||||
|
class="flex flex-wrap items-center justify-between gap-3 rounded-radius border border-outline bg-surface p-4 transition hover:border-primary hover:bg-primary/5 dark:border-outline-dark dark:bg-surface-dark-alt dark:hover:border-primary-dark">
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="font-mono text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.order_number }}</p>
|
||||||
|
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ order.created_at | truncate(length=10, end="") }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4">
|
||||||
|
{{ self::status_badge(status=order.status) }}
|
||||||
|
<span class="tabular-nums text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.total }} €</span>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endmacro order_row %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mx-auto max-w-3xl space-y-8">
|
||||||
|
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="account-orders", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
|
||||||
|
{% if active_orders | length == 0 and past_orders | length == 0 %}
|
||||||
|
<p class="rounded-radius border border-outline bg-surface p-6 text-sm text-on-surface/70 dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark/70">{{ t(key="orders-empty", lang=lang | default(value='sk')) }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if active_orders | length > 0 %}
|
||||||
|
<section class="space-y-3">
|
||||||
|
<h2 class="text-lg font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="orders-active", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
{% for order in active_orders %}{{ self::order_row(order=order) }}{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if past_orders | length > 0 %}
|
||||||
|
<section class="space-y-3">
|
||||||
|
<h2 class="text-lg font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="orders-past", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
{% for order in past_orders %}{{ self::order_row(order=order) }}{% endfor %}
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
44
assets/views/account/password.html
Normal file
44
assets/views/account/password.html
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="password-change-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mx-auto max-w-md">
|
||||||
|
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="password-change-title", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
|
||||||
|
{% if changed %}
|
||||||
|
<div class="mt-4 rounded-radius border border-success bg-success/10 px-4 py-3 text-sm text-success" role="status">
|
||||||
|
{{ t(key="password-changed", lang=lang | default(value='sk')) }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if error == "current" %}
|
||||||
|
{{ ui::alert_danger(message=t(key="password-current-wrong", lang=lang | default(value='sk')), extra="mt-4") }}
|
||||||
|
{% elif error == "mismatch" %}
|
||||||
|
{{ ui::alert_danger(message=t(key="set-password-mismatch", lang=lang | default(value='sk')), extra="mt-4") }}
|
||||||
|
{% elif error == "weak" %}
|
||||||
|
{{ ui::alert_danger(message=t(key="set-password-weak", lang=lang | default(value='sk')), extra="mt-4") }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/account/password" hx-boost="false" class="mt-6 flex flex-col gap-4"
|
||||||
|
x-data="{ password: '', confirm: '' }">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="current_password" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="password-current", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="current_password", id="current_password", type="password", required=true, autocomplete="current-password") }}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="password" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="set-password-new", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="password", id="password", type="password", required=true, autocomplete="new-password", attrs='x-model="password"') }}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="password_confirm" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="set-password-confirm", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="password_confirm", id="password_confirm", type="password", required=true, autocomplete="new-password", attrs='x-model="confirm"') }}
|
||||||
|
<span x-cloak x-show="confirm.length > 0 && password !== confirm" class="text-xs text-danger dark:text-danger">
|
||||||
|
{{ t(key="set-password-mismatch", lang=lang | default(value='sk')) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{{ ui::button(label=t(key="password-change-title", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full", attrs=':disabled="password !== confirm"') }}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
268
assets/views/account/profile.html
Normal file
268
assets/views/account/profile.html
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="profile-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
|
||||||
|
{% macro field(label, value) %}
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ label }}</label>
|
||||||
|
{% if value %}
|
||||||
|
<p class="text-sm text-on-surface/80 dark:text-on-surface-dark/80">{{ value }}</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm italic text-on-surface/50 dark:text-on-surface-dark/50">{{ t(key="profile-not-set", lang=lang | default(value='sk')) }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endmacro field %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mx-auto max-w-2xl" x-data="{ editing: {% if error %}true{% else %}false{% endif %} }">
|
||||||
|
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="profile-title", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
<p class="mt-2 text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="profile-intro", lang=lang | default(value='sk')) }}</p>
|
||||||
|
|
||||||
|
{% if saved %}
|
||||||
|
<div class="mt-4 rounded-radius border border-success bg-success/10 px-4 py-3 text-sm text-success" role="status">
|
||||||
|
{{ t(key="profile-saved", lang=lang | default(value='sk')) }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if error %}
|
||||||
|
{{ ui::alert_danger(message=t(key="profile-company-required", lang=lang | default(value='sk')), extra="mt-4") }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# initials fallback when no avatar is set, e.g. "Filip Priec" -> "FP" #}
|
||||||
|
{% set _name = name | default(value='') | trim %}
|
||||||
|
{% set _parts = _name | split(pat=' ') %}
|
||||||
|
{% set _initials = _parts.0 | truncate(length=1, end='') | upper %}
|
||||||
|
{% if _parts | length > 1 %}{% set _second = _parts | last | truncate(length=1, end='') | upper %}{% set _initials = _initials ~ _second %}{% endif %}
|
||||||
|
|
||||||
|
<!-- avatar: upload / replace / remove. Own multipart form, independent of the
|
||||||
|
profile edit toggle below, so it works in both view and edit modes. -->
|
||||||
|
<fieldset class="mt-6 space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt"
|
||||||
|
x-data="{ name: '' }">
|
||||||
|
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="profile-avatar", lang=lang | default(value='sk')) }}</legend>
|
||||||
|
<div class="flex items-center gap-5">
|
||||||
|
<span class="flex size-20 shrink-0 items-center justify-center overflow-hidden rounded-full border border-primary bg-primary text-2xl font-bold tracking-wider text-on-primary/90 dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark/90">
|
||||||
|
{%- if avatar_id %}<img src="/images/{{ avatar_id }}" alt="{{ _name }}" class="size-full object-cover">{% elif _initials %}{{ _initials }}{% endif -%}
|
||||||
|
</span>
|
||||||
|
<div class="min-w-0 space-y-3">
|
||||||
|
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="profile-avatar-hint", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<form method="post" action="/account/profile/avatar" enctype="multipart/form-data" hx-boost="false" class="flex flex-wrap items-center gap-3">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<label class="inline-flex cursor-pointer items-center gap-2 rounded-radius border border-outline bg-surface-alt px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-primary/5 hover:text-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark dark:hover:text-primary-dark">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-4 shrink-0" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5m-13.5-9L12 3m0 0 4.5 4.5M12 3v13.5" /></svg>
|
||||||
|
<span class="truncate max-w-[12rem]" x-text="name || '{{ t(key='profile-avatar-choose', lang=lang | default(value='sk')) }}'">{{ t(key="profile-avatar-choose", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<input type="file" name="image" accept="image/png,image/jpeg,image/webp,image/gif" class="sr-only"
|
||||||
|
@change="name = $event.target.files.length ? $event.target.files[0].name : ''">
|
||||||
|
</label>
|
||||||
|
{{ ui::button(label=t(key="profile-avatar-upload", lang=lang | default(value='sk')), type="submit", size="px-4 py-2 text-sm", attrs='x-show="name" x-cloak') }}
|
||||||
|
</form>
|
||||||
|
{% if avatar_id %}
|
||||||
|
<form method="post" action="/account/profile/avatar/remove" hx-boost="false">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
{{ ui::button(label=t(key="profile-avatar-remove", lang=lang | default(value='sk')), type="submit", variant="outline-secondary", size="px-4 py-2 text-sm") }}
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- read-only view (default) -->
|
||||||
|
<div x-show="!editing" class="mt-6 space-y-6">
|
||||||
|
<fieldset class="space-y-2 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="account-type", lang=lang | default(value='sk')) }}</legend>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if account_type == "company" %}
|
||||||
|
{{ ui::badge(label=t(key="account-company", lang=lang | default(value='sk')), variant="primary") }}
|
||||||
|
{% else %}
|
||||||
|
{{ ui::badge(label=t(key="account-personal", lang=lang | default(value='sk')), variant="neutral") }}
|
||||||
|
{% endif %}
|
||||||
|
<span class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="account-type-locked", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{% if account_type == "company" %}
|
||||||
|
<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="account-company-details", lang=lang | default(value='sk')) }}</legend>
|
||||||
|
{{ self::field(label=t(key="company-name", lang=lang | default(value='sk')), value=company_name) }}
|
||||||
|
<div class="grid gap-4 sm:grid-cols-3">
|
||||||
|
{{ self::field(label=t(key="company-ico", lang=lang | default(value='sk')), value=company_id) }}
|
||||||
|
{{ self::field(label=t(key="company-dic", lang=lang | default(value='sk')), value=tax_id) }}
|
||||||
|
{{ self::field(label=t(key="company-icdph", lang=lang | default(value='sk')), value=vat_id) }}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<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-contact", lang=lang | default(value='sk')) }}</legend>
|
||||||
|
{{ self::field(label=t(key="checkout-name", lang=lang | default(value='sk')), value=name) }}
|
||||||
|
{{ self::field(label=t(key="checkout-email", lang=lang | default(value='sk')), value=email) }}
|
||||||
|
{% if phone %}
|
||||||
|
{% set phone_full = phone_prefix | default(value='') %}
|
||||||
|
{% set phone_full = phone_full ~ ' ' ~ phone %}
|
||||||
|
{{ self::field(label=t(key="checkout-phone", lang=lang | default(value='sk')), value=phone_full) }}
|
||||||
|
{% else %}
|
||||||
|
{{ self::field(label=t(key="checkout-phone", lang=lang | default(value='sk')), value='') }}
|
||||||
|
{% endif %}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-residence-address", lang=lang | default(value='sk')) }}</legend>
|
||||||
|
{{ self::field(label=t(key="checkout-address", lang=lang | default(value='sk')), value=address) }}
|
||||||
|
<div class="grid gap-4 sm:grid-cols-3">
|
||||||
|
{{ self::field(label=t(key="checkout-city", lang=lang | default(value='sk')), value=city) }}
|
||||||
|
{{ self::field(label=t(key="checkout-zip", lang=lang | default(value='sk')), value=zip) }}
|
||||||
|
{{ self::field(label=t(key="checkout-country", lang=lang | default(value='sk')), value=country) }}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{{ ui::button(label=t(key="profile-edit", lang=lang | default(value='sk')), type="button", size="px-6 py-2.5 text-sm", attrs='@click="editing = true"') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- edit form -->
|
||||||
|
<form x-show="editing" x-cloak method="post" action="/account/profile" hx-boost="false" class="mt-6 space-y-6">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<!-- account type is fixed at registration and shown read-only -->
|
||||||
|
<fieldset class="space-y-2 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="account-type", lang=lang | default(value='sk')) }}</legend>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if account_type == "company" %}
|
||||||
|
{{ ui::badge(label=t(key="account-company", lang=lang | default(value='sk')), variant="primary") }}
|
||||||
|
{% else %}
|
||||||
|
{{ ui::badge(label=t(key="account-personal", lang=lang | default(value='sk')), variant="neutral") }}
|
||||||
|
{% endif %}
|
||||||
|
<span class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="account-type-locked", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
{% if account_type == "company" %}
|
||||||
|
<!-- company billing details (company accounts only) -->
|
||||||
|
<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="account-company-details", lang=lang | default(value='sk')) }}</legend>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="company_name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-name", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
||||||
|
{{ ui::input(name="company_name", id="company_name", value=company_name | default(value=''), autocomplete="organization") }}
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="company_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-ico", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
||||||
|
{{ ui::input(name="company_id", id="company_id", value=company_id | default(value='')) }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="tax_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-dic", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
||||||
|
{{ ui::input(name="tax_id", id="tax_id", value=tax_id | default(value='')) }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="vat_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-icdph", lang=lang | default(value='sk')) }} <span class="text-on-surface/50 dark:text-on-surface-dark/50">({{ t(key="field-optional", lang=lang | default(value='sk')) }})</span></label>
|
||||||
|
{{ ui::input(name="vat_id", id="vat_id", value=vat_id | default(value='')) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- contact (name/email are managed by the login) -->
|
||||||
|
<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-contact", lang=lang | default(value='sk')) }}</legend>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="first_name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="profile-first-name", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="first_name", id="first_name", value=first_name | default(value=''), autocomplete="given-name") }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="last_name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="profile-last-name", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="last_name", id="last_name", value=last_name | default(value=''), autocomplete="family-name") }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-email", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<p class="text-sm text-on-surface/80 dark:text-on-surface-dark/80">{{ email }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="phone" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-phone", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<!-- editable combobox: type freely or pick from the dropdown -->
|
||||||
|
<div class="relative w-28 shrink-0" @click.outside="prefixOpen = false"
|
||||||
|
x-data="{ prefixOpen: false, prefix: '{{ phone_prefix | default(value='+421') }}', opts: [
|
||||||
|
{ v: '+421', l: '🇸🇰 +421' }, { v: '+420', l: '🇨🇿 +420' },
|
||||||
|
{ v: '+43', l: '🇦🇹 +43' }, { v: '+49', l: '🇩🇪 +49' },
|
||||||
|
{ v: '+48', l: '🇵🇱 +48' }, { v: '+36', l: '🇭🇺 +36' },
|
||||||
|
{ v: '+44', l: '🇬🇧 +44' }, { v: '+39', l: '🇮🇹 +39' }, { v: '+33', l: '🇫🇷 +33' }
|
||||||
|
], get filtered() { return this.opts.filter(o => !this.prefix || o.v.includes(this.prefix)) } }">
|
||||||
|
<input name="phone_prefix" type="text" x-model="prefix" @focus="prefixOpen = true" @input="prefixOpen = true"
|
||||||
|
aria-label="{{ t(key='checkout-phone', lang=lang | default(value='sk')) }}" autocomplete="tel-country-code" inputmode="tel"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface py-2 pl-3 pr-7 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="prefixOpen = !prefixOpen"
|
||||||
|
class="absolute inset-y-0 right-0 flex w-7 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="prefixOpen && '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="prefixOpen" 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="prefix = o.v; prefixOpen = 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>
|
||||||
|
{{ ui::input(name="phone", id="phone", type="tel", value=phone | default(value=''), autocomplete="tel", placeholder="900 000 000", attrs='inputmode="tel"') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- residence address -->
|
||||||
|
<fieldset class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-residence-address", lang=lang | default(value='sk')) }}</legend>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="address" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-address", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="address", id="address", value=address | default(value=''), autocomplete="street-address") }}
|
||||||
|
</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')) }}</label>
|
||||||
|
{{ ui::input(name="city", id="city", value=city | default(value=''), autocomplete="address-level2") }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="zip" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-zip", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="zip", id="zip", value=zip | default(value=''), autocomplete="postal-code") }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="country" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-country", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<div class="relative" @click.outside="countryOpen = false"
|
||||||
|
x-data="{ countryOpen: false, country: '{{ country | default(value='') }}', 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" @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>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{{ ui::button(label=t(key="profile-save", lang=lang | default(value='sk')), type="submit", size="px-6 py-2.5 text-sm") }}
|
||||||
|
{{ ui::button(label=t(key="profile-cancel", lang=lang | default(value='sk')), type="button", variant="outline-secondary", size="px-6 py-2.5 text-sm", attrs='@click="editing = false"') }}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
84
assets/views/account/security.html
Normal file
84
assets/views/account/security.html
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="security-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mx-auto max-w-md">
|
||||||
|
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="security-title", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
<p class="mt-2 text-sm text-on-surface dark:text-on-surface-dark">{{ t(key="security-2fa-intro", lang=lang | default(value='sk')) }}</p>
|
||||||
|
|
||||||
|
{% if error == "password" %}
|
||||||
|
{{ ui::alert_danger(message=t(key="password-current-wrong", lang=lang | default(value='sk')), extra="mt-4") }}
|
||||||
|
{% elif error == "code" %}
|
||||||
|
{{ ui::alert_danger(message=t(key="security-2fa-code-wrong", lang=lang | default(value='sk')), extra="mt-4") }}
|
||||||
|
{% elif error == "enroll" %}
|
||||||
|
{{ ui::alert_danger(message=t(key="security-2fa-enroll-error", lang=lang | default(value='sk')), extra="mt-4") }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{# --- One-time backup codes, shown right after enabling / regenerating --- #}
|
||||||
|
{% if backup_codes and backup_codes | length > 0 %}
|
||||||
|
<div class="mt-6 rounded-radius border border-success bg-success/10 px-4 py-3" role="status">
|
||||||
|
<p class="text-sm font-medium text-success">{{ t(key="security-2fa-enabled-ok", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<p class="mt-2 text-sm text-on-surface dark:text-on-surface-dark">{{ t(key="security-2fa-backup-intro", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<ul class="mt-3 grid grid-cols-2 gap-2 font-mono text-sm text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
|
{% for code in backup_codes %}
|
||||||
|
<li class="rounded-radius bg-surface px-3 py-1.5 text-center tracking-wider dark:bg-surface-dark">{{ code }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if enrolling %}
|
||||||
|
{# --- Step 2: scan the QR and confirm a code --- #}
|
||||||
|
<div class="mt-6 flex flex-col gap-4 rounded-radius border border-outline bg-surface-alt p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<p class="text-sm text-on-surface dark:text-on-surface-dark">{{ t(key="security-2fa-scan", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<img src="{{ qr }}" alt="TOTP QR" class="mx-auto size-48 rounded-radius bg-white p-2" />
|
||||||
|
<div class="text-center">
|
||||||
|
<p class="text-xs text-on-surface dark:text-on-surface-dark">{{ t(key="security-2fa-manual", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<code class="mt-1 inline-block break-all font-mono text-sm text-on-surface-strong dark:text-on-surface-dark-strong">{{ secret }}</code>
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/account/security/confirm" hx-boost="false" class="flex flex-col gap-3">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<label for="code" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="security-2fa-enter-code", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="code", id="code", type="text", required=true, autocomplete="one-time-code", attrs='inputmode="numeric" pattern="[0-9]*" maxlength="6" autofocus') }}
|
||||||
|
{{ ui::button(label=t(key="security-2fa-confirm", lang=lang | default(value='sk')), type="submit", extra="w-full") }}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% elif totp_enabled %}
|
||||||
|
{# --- Enabled: status + remaining backup codes + disable / regenerate --- #}
|
||||||
|
<div class="mt-6 flex items-center gap-2">
|
||||||
|
{{ ui::badge(label=t(key="security-2fa-on", lang=lang | default(value='sk')), variant="success") }}
|
||||||
|
<span class="text-sm text-on-surface dark:text-on-surface-dark">{{ t(key="security-2fa-backup-remaining", lang=lang | default(value='sk')) }}: {{ backup_remaining }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form method="post" action="/account/security/backup-codes" hx-boost="false" class="mt-6 flex flex-col gap-3 rounded-radius border border-outline bg-surface-alt p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<p class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="security-2fa-regenerate", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<label for="regen_pw" class="text-sm text-on-surface dark:text-on-surface-dark">{{ t(key="password-current", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="current_password", id="regen_pw", type="password", required=true, autocomplete="current-password") }}
|
||||||
|
{{ ui::button(label=t(key="security-2fa-regenerate", lang=lang | default(value='sk')), type="submit", variant="outline-secondary", extra="w-full") }}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<form method="post" action="/account/security/disable" hx-boost="false" class="mt-4 flex flex-col gap-3 rounded-radius border border-danger/40 bg-danger/5 p-5">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<p class="text-sm font-medium text-danger">{{ t(key="security-2fa-disable", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<p class="text-xs text-on-surface dark:text-on-surface-dark">{{ t(key="security-2fa-disable-hint", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<label for="disable_pw" class="text-sm text-on-surface dark:text-on-surface-dark">{{ t(key="password-current", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="current_password", id="disable_pw", type="password", required=true, autocomplete="current-password") }}
|
||||||
|
{{ ui::button(label=t(key="security-2fa-disable", lang=lang | default(value='sk')), type="submit", variant="danger", extra="w-full") }}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% else %}
|
||||||
|
{# --- Disabled: offer to enable --- #}
|
||||||
|
<form method="post" action="/account/security/enable" hx-boost="false" class="mt-6">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{{ ui::badge(label=t(key="security-2fa-off", lang=lang | default(value='sk')), variant="neutral") }}
|
||||||
|
</div>
|
||||||
|
{{ ui::button(label=t(key="security-2fa-enable", lang=lang | default(value='sk')), type="submit", extra="mt-4 w-full") }}
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
@@ -45,7 +45,15 @@
|
|||||||
<script defer src="/static/vendor/alpine/alpinejs-3.14.9.min.js"></script>
|
<script defer src="/static/vendor/alpine/alpinejs-3.14.9.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body
|
<body
|
||||||
x-data="{ showSidebar: false }"
|
hx-headers='{"X-CSRF-Token": "{{ csrf_token() }}"}'
|
||||||
|
x-data="{
|
||||||
|
showSidebar: false,
|
||||||
|
collapsed: localStorage.getItem('adminSidebarCollapsed') === '1',
|
||||||
|
toggleCollapsed() {
|
||||||
|
this.collapsed = !this.collapsed;
|
||||||
|
localStorage.setItem('adminSidebarCollapsed', this.collapsed ? '1' : '0');
|
||||||
|
}
|
||||||
|
}"
|
||||||
class="min-h-screen bg-surface text-on-surface antialiased dark:bg-surface-dark dark:text-on-surface-dark">
|
class="min-h-screen bg-surface text-on-surface antialiased dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
|
||||||
<!-- dark overlay for the open sidebar on small screens -->
|
<!-- dark overlay for the open sidebar on small screens -->
|
||||||
@@ -55,8 +63,8 @@
|
|||||||
|
|
||||||
<!-- sidebar -->
|
<!-- sidebar -->
|
||||||
<nav aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}"
|
<nav aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}"
|
||||||
x-bind:class="showSidebar ? 'translate-x-0' : '-translate-x-60'"
|
x-bind:class="(showSidebar ? 'translate-x-0' : '-translate-x-60') + ' ' + (collapsed ? 'md:-translate-x-60' : 'md:translate-x-0')"
|
||||||
class="fixed inset-y-0 left-0 z-40 flex w-60 flex-col border-r border-outline bg-surface-alt transition-transform duration-300 md:translate-x-0 dark:border-outline-dark dark:bg-surface-dark-alt">
|
class="fixed inset-y-0 left-0 z-40 flex w-60 flex-col border-r border-outline bg-surface-alt transition-transform duration-300 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
|
||||||
{# Sidebar nav links — adapted from the vendored Penguin UI component
|
{# Sidebar nav links — adapted from the vendored Penguin UI component
|
||||||
penguinui-components/sidebar/simple-sidebar.html: Penguin's link
|
penguinui-components/sidebar/simple-sidebar.html: Penguin's link
|
||||||
@@ -77,6 +85,10 @@
|
|||||||
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||||
{{ t(key="admin-products", lang=lang | default(value='sk')) }}
|
{{ t(key="admin-products", lang=lang | default(value='sk')) }}
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/catalog/discount-profiles" data-nav="/admin/catalog/discount-profiles"
|
||||||
|
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||||
|
{{ t(key="admin-discount-profiles", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
<a href="/admin/catalog/categories" data-nav="/admin/catalog/categories"
|
<a href="/admin/catalog/categories" data-nav="/admin/catalog/categories"
|
||||||
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
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-categories", lang=lang | default(value='sk')) }}
|
{{ t(key="admin-categories", lang=lang | default(value='sk')) }}
|
||||||
@@ -85,10 +97,22 @@
|
|||||||
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-orders", lang=lang | default(value='sk')) }}
|
{{ t(key="admin-orders", lang=lang | default(value='sk')) }}
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/customers" data-nav="/admin/customers"
|
||||||
|
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||||
|
{{ t(key="admin-customers", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
<a href="/admin/shipping" data-nav="/admin/shipping"
|
<a href="/admin/shipping" data-nav="/admin/shipping"
|
||||||
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
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">
|
||||||
@@ -96,6 +120,7 @@
|
|||||||
{{ t(key="admin-exit", lang=lang | default(value='sk')) }}
|
{{ t(key="admin-exit", lang=lang | default(value='sk')) }}
|
||||||
</a>
|
</a>
|
||||||
<form method="post" action="/logout">
|
<form method="post" action="/logout">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
<button type="submit" class="flex w-full items-center gap-2 rounded-radius px-2 py-1.5 text-left text-sm font-medium text-danger underline-offset-2 transition hover:bg-danger/5 focus:outline-hidden focus-visible:underline">
|
<button type="submit" class="flex w-full items-center gap-2 rounded-radius px-2 py-1.5 text-left text-sm font-medium text-danger underline-offset-2 transition hover:bg-danger/5 focus:outline-hidden focus-visible:underline">
|
||||||
{{ t(key="logout", lang=lang | default(value='sk')) }}
|
{{ t(key="logout", lang=lang | default(value='sk')) }}
|
||||||
</button>
|
</button>
|
||||||
@@ -104,7 +129,8 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- content column -->
|
<!-- content column -->
|
||||||
<div class="flex min-h-screen flex-col md:ml-60">
|
<div :class="collapsed ? 'md:ml-0' : 'md:ml-60'"
|
||||||
|
class="flex min-h-screen flex-col transition-[margin] duration-300">
|
||||||
<header class="sticky top-0 z-20 flex h-16 items-center gap-4 border-b border-outline bg-surface/95 px-4 backdrop-blur dark:border-outline-dark dark:bg-surface-dark/95">
|
<header class="sticky top-0 z-20 flex h-16 items-center gap-4 border-b border-outline bg-surface/95 px-4 backdrop-blur dark:border-outline-dark dark:bg-surface-dark/95">
|
||||||
<!-- Penguin animated hamburger (bars ↔ X) in our ghost-square shell -->
|
<!-- Penguin animated hamburger (bars ↔ X) in our ghost-square shell -->
|
||||||
<button type="button" @click="showSidebar = !showSidebar" :aria-expanded="showSidebar" aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}"
|
<button type="button" @click="showSidebar = !showSidebar" :aria-expanded="showSidebar" aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}"
|
||||||
@@ -113,17 +139,23 @@
|
|||||||
{{ ui::icon(name="close", size="size-6", attrs='x-cloak x-show="showSidebar"') }}
|
{{ ui::icon(name="close", size="size-6", attrs='x-cloak x-show="showSidebar"') }}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- desktop sidebar collapse toggle (chevron flips when collapsed) -->
|
||||||
|
<button type="button" @click="toggleCollapsed()" :aria-expanded="(!collapsed).toString()" aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}"
|
||||||
|
class="hidden size-9 shrink-0 items-center justify-center rounded-radius bg-transparent text-secondary transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-secondary active:opacity-100 active:outline-offset-0 md:inline-flex dark:text-secondary-dark dark:focus-visible:outline-secondary-dark">
|
||||||
|
{{ ui::icon(name="chevron-double-left", size="size-6", extra="transition-transform duration-300", attrs=`x-bind:class="collapsed ? 'rotate-180' : ''"`) }}
|
||||||
|
</button>
|
||||||
|
|
||||||
<span class="text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">
|
<span class="text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
{% block crumb %}{{ t(key="admin-title", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
{% block crumb %}{{ t(key="admin-title", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- settings (language + theme) dropdown -->
|
<!-- settings (language + theme) dropdown (self-contained Alpine state) -->
|
||||||
<div x-data="{ open: false }" @keydown.escape="open = false" class="relative ml-auto">
|
<div class="ml-auto">
|
||||||
{% include "partials/settings_dropdown.html" %}
|
{% include "partials/settings_dropdown.html" %}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="mx-auto w-full max-w-5xl flex-1 px-4 py-8">
|
<main class="mx-auto w-full flex-1 px-4 py-8 {% block main_class %}max-w-5xl{% endblock main_class %}">
|
||||||
{% block content %}{% endblock content %}
|
{% block content %}{% endblock content %}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
8
assets/views/admin/catalog/_price_preview.html
Normal file
8
assets/views/admin/catalog/_price_preview.html
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{# OOB fragment: effective-price cells recomputed from the unsaved profile
|
||||||
|
selection on the products page. Each span replaces the matching #eff-<id>
|
||||||
|
span in the table via htmx out-of-band swap. Rendered by
|
||||||
|
admin_products::profiles_preview. #}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
{% for product in products %}
|
||||||
|
<span id="eff-{{ product.id }}" hx-swap-oob="true">{{ ui::eff_price(p=product, preview=true) }}</span>
|
||||||
|
{% endfor %}
|
||||||
@@ -46,6 +46,7 @@
|
|||||||
{{ ui::button(variant="outline-secondary", label=t(key="edit", lang=lang | default(value='sk')), href="/admin/catalog/categories/" ~ row.category.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/categories/" ~ row.category.id ~ "/edit", size="px-3 py-1.5 text-xs") }}
|
||||||
<form method="post" action="/admin/catalog/categories/{{ row.category.id }}/delete"
|
<form method="post" action="/admin/catalog/categories/{{ row.category.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')) }}')">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
{{ ui::button(variant="outline-danger", label=t(key="delete", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}
|
{{ ui::button(variant="outline-danger", label=t(key="delete", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -15,11 +15,12 @@
|
|||||||
<form method="post" enctype="multipart/form-data"
|
<form method="post" enctype="multipart/form-data"
|
||||||
action="{% if category %}/admin/catalog/categories/{{ category.id }}{% else %}/admin/catalog/categories{% endif %}"
|
action="{% if category %}/admin/catalog/categories/{{ category.id }}{% else %}/admin/catalog/categories{% endif %}"
|
||||||
class="mt-6 space-y-5 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
class="mt-6 space-y-5 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
|
||||||
{% if category %}
|
{% if category %}
|
||||||
{% set v_name = category.name %}{% set v_slug = category.slug %}{% set v_pos = category.position %}{% set v_desc = category.description | default(value="") %}{% set v_pub = category.published %}
|
{% set v_name = category.name %}{% set v_pos = category.position %}{% set v_desc = category.description | default(value="") %}{% set v_pub = category.published %}
|
||||||
{% else %}
|
{% else %}
|
||||||
{% set v_name = "" %}{% set v_slug = "" %}{% set v_pos = 0 %}{% set v_desc = "" %}{% set v_pub = false %}
|
{% set v_name = "" %}{% set v_pos = "" %}{% set v_desc = "" %}{% set v_pub = false %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
@@ -27,17 +28,6 @@
|
|||||||
{{ ui::input(name="name", id="name", required=true, value=v_name) }}
|
{{ ui::input(name="name", id="name", required=true, value=v_name) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-5 sm:grid-cols-2">
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<label for="slug" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="slug", lang=lang | default(value='sk')) }}</label>
|
|
||||||
{{ ui::input(name="slug", id="slug", value=v_slug, placeholder=t(key='slug-auto', lang=lang | default(value='sk'))) }}
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1.5">
|
|
||||||
<label for="position" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="position", lang=lang | default(value='sk')) }}</label>
|
|
||||||
{{ ui::input(name="position", id="position", type="number", value=v_pos) }}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="parent_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="parent-category", lang=lang | default(value='sk')) }}</label>
|
<label for="parent_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="parent-category", lang=lang | default(value='sk')) }}</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
@@ -67,6 +57,15 @@
|
|||||||
{{ ui::file_input(name="image", id="image", accept="image/*") }}
|
{{ ui::file_input(name="image", id="image", accept="image/*") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="position" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
|
{{ t(key="position", lang=lang | default(value='sk')) }}
|
||||||
|
<span class="font-normal text-on-surface/60 dark:text-on-surface-dark/60">({{ t(key="field-optional", lang=lang | default(value='sk')) }})</span>
|
||||||
|
</label>
|
||||||
|
{{ ui::input(name="position", id="position", type="number", value=v_pos, placeholder=t(key='position-auto', lang=lang | default(value='sk'))) }}
|
||||||
|
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="position-hint", lang=lang | default(value='sk')) }}</p>
|
||||||
|
</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) }}
|
||||||
|
|
||||||
<div class="flex gap-3 pt-2">
|
<div class="flex gap-3 pt-2">
|
||||||
|
|||||||
131
assets/views/admin/catalog/discount_form.html
Normal file
131
assets/views/admin/catalog/discount_form.html
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="set-discount", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
{% block crumb %}{{ t(key="admin-discounts", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h1>
|
||||||
|
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">
|
||||||
|
{% if audience == "business" %}{{ t(key="audience-business", lang=lang | default(value='sk')) }}{% else %}{{ t(key="audience-personal", lang=lang | default(value='sk')) }}{% endif %}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products?audience=" ~ audience, size="px-3 py-2 text-sm") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# One discount row per option (variant). Each row picks a fixed sale price or a #}
|
||||||
|
{# percentage off its own regular price; a blank input clears that option's #}
|
||||||
|
{# discount. Both the fixed and percent inputs always submit (the server reads the #}
|
||||||
|
{# active mode); rows are pre-filled from `rows` (DB values, or submitted values #}
|
||||||
|
{# when repainting after a validation error) and indexed as v[<variant id>][...]. #}
|
||||||
|
<script id="discount-data" type="application/json">{{ rows | json_encode() | safe }}</script>
|
||||||
|
|
||||||
|
<form method="post" action="/admin/catalog/products/{{ product.id }}/discount?audience={{ audience }}"
|
||||||
|
x-data="discountEditor(JSON.parse(document.getElementById('discount-data').textContent))"
|
||||||
|
class="mt-6 max-w-2xl space-y-5">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
{{ ui::alert_danger(message=t(key=error, lang=lang | default(value='sk'))) }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<template x-for="row in rows" :key="row.id">
|
||||||
|
<div class="space-y-4 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<span class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong"
|
||||||
|
x-text="row.label || ('#' + row.id)"></span>
|
||||||
|
<span class="text-sm tabular-nums text-on-surface/70 dark:text-on-surface-dark/70">
|
||||||
|
{{ t(key="price", lang=lang | default(value='sk')) }}:
|
||||||
|
<span x-text="row.regular_price"></span> €
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input type="hidden" :name="`v[${row.id}][mode]`" :value="row.mode">
|
||||||
|
|
||||||
|
<div class="grid gap-4 sm:grid-cols-2">
|
||||||
|
<!-- mode toggle -->
|
||||||
|
<div class="grid grid-cols-2 gap-2">
|
||||||
|
<label class="flex cursor-pointer items-center justify-center gap-2 rounded-radius border px-3 py-2 text-sm transition"
|
||||||
|
:class="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>
|
||||||
|
|
||||||
|
<!-- value input: both fields stay in the DOM and submit; the server reads
|
||||||
|
whichever matches the row's mode -->
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<div x-show="row.mode === 'fixed'">
|
||||||
|
<label class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="sale-price", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input :name="`v[${row.id}][fixed]`" x-model="row.fixed" inputmode="decimal" placeholder="0.00"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface-alt px-4 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark">
|
||||||
|
</div>
|
||||||
|
<div x-show="row.mode === 'percent'">
|
||||||
|
<label class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="discount-percent", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input :name="`v[${row.id}][percent]`" x-model="row.percent" inputmode="decimal" min="0" max="100" placeholder="0"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface-alt px-4 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- live preview -->
|
||||||
|
<div x-show="afterCents(row) !== null" x-cloak
|
||||||
|
class="flex flex-wrap items-center justify-between gap-3 rounded-radius border border-outline bg-surface-alt px-4 py-2.5 text-sm dark:border-outline-dark dark:bg-surface-dark/40">
|
||||||
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="discount-preview-after", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span class="flex items-center gap-2">
|
||||||
|
<span class="tabular-nums text-on-surface/50 line-through dark:text-on-surface-dark/50" x-text="money(row.regular_cents) + ' €'"></span>
|
||||||
|
<span class="text-base font-semibold tabular-nums" :class="valid(row) ? 'text-danger' : 'text-on-surface/40 dark:text-on-surface-dark/40'"
|
||||||
|
x-text="money(afterCents(row)) + ' €'"></span>
|
||||||
|
<span x-show="valid(row)" class="text-xs text-on-surface/60 dark:text-on-surface-dark/60" x-text="'(−' + percentOff(row) + '%)'"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p x-show="afterCents(row) !== null && !valid(row)" class="text-xs text-danger">{{ t(key="discount-below-regular", lang=lang | default(value='sk')) }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3 pt-2">
|
||||||
|
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", attrs=`onclick="return confirm('` ~ t(key="discount-apply-confirm", lang=lang | default(value='sk')) ~ `')"`) }}
|
||||||
|
{% if has_discount %}
|
||||||
|
{{ ui::button(variant="outline-danger", label=t(key="remove-discount", lang=lang | default(value='sk')), type="submit", attrs=`formaction="/admin/catalog/products/` ~ product.id ~ `/discount/remove?audience=` ~ audience ~ `" onclick="return confirm('` ~ t(key="discount-remove-confirm", lang=lang | default(value='sk')) ~ `')"`) }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function discountEditor(initial) {
|
||||||
|
return {
|
||||||
|
rows: (initial || []).map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
label: r.label || '',
|
||||||
|
regular_cents: r.regular_cents,
|
||||||
|
regular_price: r.regular_price,
|
||||||
|
mode: r.mode || 'fixed',
|
||||||
|
fixed: r.fixed || '',
|
||||||
|
percent: r.percent || '',
|
||||||
|
})),
|
||||||
|
num(v) { let n = parseFloat(String(v).replace(',', '.')); return isFinite(n) ? n : null; },
|
||||||
|
money(c) { return (c / 100).toFixed(2); },
|
||||||
|
afterCents(row) {
|
||||||
|
if (row.mode === 'percent') {
|
||||||
|
let p = this.num(row.percent); if (p === null) return null;
|
||||||
|
return row.regular_cents - Math.round(row.regular_cents * p / 100);
|
||||||
|
}
|
||||||
|
let f = this.num(row.fixed); if (f === null) return null;
|
||||||
|
return Math.round(f * 100);
|
||||||
|
},
|
||||||
|
valid(row) { let a = this.afterCents(row); return a !== null && a > 0 && a < row.regular_cents; },
|
||||||
|
percentOff(row) {
|
||||||
|
let a = this.afterCents(row);
|
||||||
|
return (a === null || row.regular_cents <= 0) ? null : Math.round((row.regular_cents - a) / row.regular_cents * 100);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock content %}
|
||||||
71
assets/views/admin/catalog/discount_profile_form.html
Normal file
71
assets/views/admin/catalog/discount_profile_form.html
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{% if profile %}{{ t(key="edit-profile", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-profile", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
|
||||||
|
{% block crumb %}{{ t(key="admin-discount-profiles", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
|
{% if profile %}{{ t(key="edit-profile", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-profile", lang=lang | default(value='sk')) }}{% endif %}
|
||||||
|
</h1>
|
||||||
|
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/discount-profiles", size="px-3 py-2 text-sm") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if profile %}{% set v_name = profile.name %}{% set v_percent = profile.percent %}{% set v_scope = profile.scope_type %}
|
||||||
|
{% else %}{% set v_name = "" %}{% set v_percent = "" %}{% set v_scope = "include" %}{% endif %}
|
||||||
|
|
||||||
|
<form method="post"
|
||||||
|
action="{% if profile %}/admin/catalog/discount-profiles/{{ profile.id }}{% else %}/admin/catalog/discount-profiles{% endif %}"
|
||||||
|
class="mt-6 space-y-5 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
|
||||||
|
{% if error %}{{ ui::alert_danger(message=t(key=error, lang=lang | default(value='sk'))) }}{% endif %}
|
||||||
|
|
||||||
|
<div class="grid gap-5 sm:grid-cols-2">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="name", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="name", id="name", required=true, value=v_name) }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="percent" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="discount-percent", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="percent", id="percent", required=true, value=v_percent, placeholder="0", attrs='inputmode="decimal" min="0" max="100"') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<fieldset class="space-y-2">
|
||||||
|
<legend class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="scope", lang=lang | default(value='sk')) }}</legend>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-on-surface dark:text-on-surface-dark">
|
||||||
|
<input type="radio" name="scope_type" value="include" {% if v_scope != "all_except" %}checked{% endif %}>
|
||||||
|
{{ t(key="scope-include-hint", lang=lang | default(value='sk')) }}
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 text-sm text-on-surface dark:text-on-surface-dark">
|
||||||
|
<input type="radio" name="scope_type" value="all_except" {% if v_scope == "all_except" %}checked{% endif %}>
|
||||||
|
{{ t(key="scope-all-except-hint", lang=lang | default(value='sk')) }}
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="products", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<div class="max-h-72 overflow-y-auto rounded-radius border border-outline p-3 dark:border-outline-dark">
|
||||||
|
{% if products | length > 0 %}
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
|
{% for product in products %}
|
||||||
|
<label class="flex items-center gap-2 text-sm text-on-surface dark:text-on-surface-dark">
|
||||||
|
<input type="checkbox" name="product_ids" value="{{ product.id }}" {% if product.selected %}checked{% endif %}>
|
||||||
|
{{ product.name }}
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="admin-no-products", lang=lang | default(value='sk')) }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex gap-3 pt-2">
|
||||||
|
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit") }}
|
||||||
|
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/discount-profiles") }}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endblock content %}
|
||||||
58
assets/views/admin/catalog/discount_profiles.html
Normal file
58
assets/views/admin/catalog/discount_profiles.html
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="admin-discount-profiles", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
{% block crumb %}{{ t(key="admin-discount-profiles", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-discount-profiles", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-discount-profiles-desc", lang=lang | default(value='sk')) }}</p>
|
||||||
|
</div>
|
||||||
|
{{ ui::button(label=t(key="new-profile", lang=lang | default(value='sk')), href="/admin/catalog/discount-profiles/new") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 {{ ui::table_wrap_cls() }}">
|
||||||
|
{% if profiles | length > 0 %}
|
||||||
|
<table class="{{ ui::table_cls() }}">
|
||||||
|
<thead class="{{ ui::thead_cls() }}">
|
||||||
|
<tr>
|
||||||
|
{{ ui::th(label=t(key="name", lang=lang | default(value='sk'))) }}
|
||||||
|
{{ ui::th(label=t(key="discount-percent", lang=lang | default(value='sk'))) }}
|
||||||
|
{{ ui::th(label=t(key="scope", lang=lang | default(value='sk'))) }}
|
||||||
|
{{ ui::th(label=t(key="products", lang=lang | default(value='sk'))) }}
|
||||||
|
{{ ui::th(label=t(key="actions", lang=lang | default(value='sk')), align="text-right") }}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="{{ ui::tbody_cls() }}">
|
||||||
|
{% for profile in profiles %}
|
||||||
|
<tr class="{{ ui::row_cls() }}">
|
||||||
|
<td class="px-4 py-3 font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ profile.name }}</td>
|
||||||
|
<td class="px-4 py-3 tabular-nums">−{{ profile.percent }}%</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{% if profile.scope_type == "all_except" %}{{ t(key="scope-all-except", lang=lang | default(value='sk')) }}{% else %}{{ t(key="scope-include", lang=lang | default(value='sk')) }}{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 tabular-nums">{{ profile.product_count }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex flex-wrap justify-end gap-2">
|
||||||
|
{{ ui::button(variant="outline-secondary", label=t(key="edit", lang=lang | default(value='sk')), href="/admin/catalog/discount-profiles/" ~ profile.id ~ "/edit", size="px-3 py-1.5 text-xs") }}
|
||||||
|
<form method="post" action="/admin/catalog/discount-profiles/{{ profile.id }}/delete"
|
||||||
|
onsubmit="return confirm('{{ t(key="confirm-delete", lang=lang | default(value='sk')) }}')">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
{{ ui::button(variant="outline-danger", label=t(key="delete", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="flex flex-col items-center gap-3 px-4 py-16 text-center">
|
||||||
|
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-no-profiles", lang=lang | default(value='sk')) }}</p>
|
||||||
|
{{ ui::button(label=t(key="new-profile", lang=lang | default(value='sk')), href="/admin/catalog/discount-profiles/new") }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
@@ -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">
|
||||||
@@ -15,39 +18,87 @@
|
|||||||
<form method="post" enctype="multipart/form-data"
|
<form method="post" enctype="multipart/form-data"
|
||||||
action="{% if product %}/admin/catalog/products/{{ product.id }}{% else %}/admin/catalog/products{% endif %}"
|
action="{% if product %}/admin/catalog/products/{{ product.id }}{% else %}/admin/catalog/products{% endif %}"
|
||||||
class="mt-6 space-y-5 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
class="mt-6 space-y-5 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
|
||||||
{% if product %}
|
{% if product %}
|
||||||
{% set v_name = product.name %}{% set v_price = product.price %}{% set v_currency = product.currency %}{% set v_stock = product.stock %}{% set v_sku = product.sku | default(value="") %}{% set v_slug = product.slug %}{% 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_price = "" %}{% set v_currency = "EUR" %}{% set v_stock = 0 %}{% set v_sku = "" %}{% set v_slug = "" %}{% 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 sublabel = "text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70" %}
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="name", lang=lang | default(value='sk')) }}</label>
|
<label for="name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="name", lang=lang | default(value='sk')) }}</label>
|
||||||
{{ ui::input(name="name", id="name", required=true, value=v_name) }}
|
{{ ui::input(name="name", id="name", required=true, value=v_name) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-5 sm:grid-cols-2">
|
{# --- Variants / options editor ------------------------------------------- #}
|
||||||
<div class="space-y-1.5">
|
{# Each product is sold as one or more variants (a free-text label such as #}
|
||||||
<label for="price" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
|
{# "10cm x 13cm" or "5ml" plus its own price). Price is required. Stock is #}
|
||||||
{{ ui::input(name="price", id="price", required=true, value=v_price, placeholder="0.00", attrs='inputmode="decimal"') }}
|
{# optional — leave it blank ("∞") to mark the option simply available (not #}
|
||||||
</div>
|
{# inventory-tracked). SKU and business price are optional too. Rows are #}
|
||||||
<div class="space-y-1.5">
|
{# managed client-side; names are indexed (variants[i][…]) and read back by #}
|
||||||
<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>
|
{# the controller. #}
|
||||||
{{ ui::input(name="currency", id="currency", value=v_currency, attrs='maxlength="3"', extra="uppercase") }}
|
{% set opt = " (" ~ t(key="optional", lang=lang | default(value='sk')) ~ ")" %}
|
||||||
|
<script id="variants-data" type="application/json">{{ variants | json_encode() | safe }}</script>
|
||||||
|
<div class="space-y-3" x-data="variantEditor(JSON.parse(document.getElementById('variants-data').textContent))">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="variants-options", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<button type="button" @click="add()"
|
||||||
|
class="rounded-radius border border-outline px-3 py-1.5 text-sm font-medium text-on-surface hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt/50">
|
||||||
|
+ {{ t(key="add-option", lang=lang | default(value='sk')) }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<template x-for="(row, i) in rows" :key="i">
|
||||||
|
<div class="flex items-end gap-3 rounded-radius border border-outline bg-surface-alt/40 p-3 dark:border-outline-dark dark:bg-surface-dark-alt/30">
|
||||||
|
<input type="hidden" :name="`variants[${i}][id]`" :value="row.id">
|
||||||
|
|
||||||
|
{# items-end bottom-aligns every input regardless of how many lines each
|
||||||
|
label takes, so the row stays aligned even with the "(optional)" notes. #}
|
||||||
|
<div class="grid flex-1 grid-cols-2 gap-3 sm:grid-cols-12 sm:items-end">
|
||||||
|
<div class="space-y-1 col-span-2 sm:col-span-6">
|
||||||
|
<label class="{{ sublabel }} block truncate">{{ t(key="option-label", lang=lang | default(value='sk')) }}{{ opt }}</label>
|
||||||
|
<input :name="`variants[${i}][label]`" x-model="row.label" class="{{ inp }}" placeholder="napr. 10cm x 13cm">
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 sm:col-span-2">
|
||||||
|
<label class="{{ sublabel }} block truncate">{{ t(key="sku", lang=lang | default(value='sk')) }}{{ opt }}</label>
|
||||||
|
<input :name="`variants[${i}][sku]`" x-model="row.sku" class="{{ inp }}">
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 sm:col-span-2">
|
||||||
|
<label class="{{ sublabel }} block truncate">{{ t(key="stock", lang=lang | default(value='sk')) }}{{ opt }}</label>
|
||||||
|
<input type="number" min="0" :name="`variants[${i}][stock]`" x-model="row.stock" class="{{ inp }}" placeholder="∞" title="{{ t(key='stock-untracked-hint', lang=lang | default(value='sk')) }}">
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1 sm:col-span-2">
|
||||||
|
<label class="{{ sublabel }} block truncate">{{ t(key="price", lang=lang | default(value='sk')) }} (€)</label>
|
||||||
|
<input :name="`variants[${i}][price]`" x-model="row.price" inputmode="decimal" required class="{{ inp }}" placeholder="0.00">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" @click="remove(i)"
|
||||||
|
class="mb-1 shrink-0 rounded-radius px-2 py-2 text-sm text-danger hover:bg-danger/10" title="{{ t(key='delete', lang=lang | default(value='sk')) }}">✕</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-5 sm:grid-cols-2">
|
<script>
|
||||||
<div class="space-y-1.5">
|
function variantEditor(initial) {
|
||||||
<label for="stock" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="stock", lang=lang | default(value='sk')) }}</label>
|
const blank = () => ({ id: '', label: '', sku: '', stock: '', price: '' });
|
||||||
{{ ui::input(name="stock", id="stock", type="number", value=v_stock, attrs='min="0"') }}
|
return {
|
||||||
</div>
|
rows: (initial || []).map(r => ({
|
||||||
<div class="space-y-1.5">
|
id: r.id || '',
|
||||||
<label for="sku" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="sku", lang=lang | default(value='sk')) }}</label>
|
label: r.label || '',
|
||||||
{{ ui::input(name="sku", id="sku", value=v_sku) }}
|
sku: r.sku || '',
|
||||||
</div>
|
stock: (r.stock === null || r.stock === undefined) ? '' : r.stock,
|
||||||
</div>
|
price: r.price || '',
|
||||||
|
})),
|
||||||
|
init() { if (this.rows.length === 0) this.add(); },
|
||||||
|
add() { this.rows.push(blank()); },
|
||||||
|
remove(i) { this.rows.splice(i, 1); if (this.rows.length === 0) this.add(); },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="category_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="category", lang=lang | default(value='sk')) }}</label>
|
<label for="category_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="category", lang=lang | default(value='sk')) }}</label>
|
||||||
@@ -64,21 +115,96 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="slug" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="slug", 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::input(name="slug", id="slug", value=v_slug, placeholder=t(key='slug-auto', lang=lang | default(value='sk'))) }}
|
<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="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="description", lang=lang | default(value='sk')) }}</span>
|
||||||
{{ ui::textarea(name="description", id="description", rows="5", value=v_desc) }}
|
{{ ui::rich_editor(name="description", lang=lang | default(value='sk'), value=v_desc, min_height="16rem") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
{# --- Images gallery ------------------------------------------------------- #}
|
||||||
<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>
|
{# Unified drag-orderable gallery: existing images (with id) and new uploads #}
|
||||||
{% if product and product.image %}
|
{# (placeholder blobs) live in a single list. The full order is submitted as #}
|
||||||
<img src="/images/{{ product.image }}" alt="" class="size-24 rounded-radius object-cover">
|
{# repeated `image_order` fields — an integer id for kept images or `new` for #}
|
||||||
{% endif %}
|
{# each uploaded file. The DataTransfer backing the hidden `image` file input #}
|
||||||
{{ ui::file_input(name="image", id="image", accept="image/*") }}
|
{# 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) }}
|
||||||
@@ -88,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 %}
|
||||||
|
|||||||
@@ -3,8 +3,12 @@
|
|||||||
|
|
||||||
{% block title %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock title %}
|
{% block title %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
{% block crumb %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
{% block crumb %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
{% block main_class %}max-w-none{% endblock main_class %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
{% set business = audience == "business" %}
|
||||||
|
{% set L = lang | default(value='sk') %}
|
||||||
|
{% set q_enc = query | default(value='') | urlencode %}
|
||||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
<div 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>
|
||||||
@@ -13,13 +17,90 @@
|
|||||||
{{ ui::button(label=t(key="new-product", lang=lang | default(value='sk')), href="/admin/catalog/products/new") }}
|
{{ ui::button(label=t(key="new-product", lang=lang | default(value='sk')), href="/admin/catalog/products/new") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-6 {{ ui::table_wrap_cls() }}">
|
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<!-- audience tabs -->
|
||||||
|
<div class="inline-flex rounded-radius border border-outline p-1 dark:border-outline-dark">
|
||||||
|
<a href="/admin/catalog/products?audience=personal&q={{ q_enc }}"
|
||||||
|
class="rounded-radius px-4 py-1.5 text-sm font-medium {% if not business %}bg-primary/10 text-on-surface-strong dark:bg-primary-dark/10 dark:text-on-surface-dark-strong{% else %}text-on-surface/70 dark:text-on-surface-dark/70{% endif %}">
|
||||||
|
{{ t(key="audience-personal", lang=L) }}
|
||||||
|
</a>
|
||||||
|
<a href="/admin/catalog/products?audience=business&q={{ q_enc }}"
|
||||||
|
class="rounded-radius px-4 py-1.5 text-sm font-medium {% if business %}bg-primary/10 text-on-surface-strong dark:bg-primary-dark/10 dark:text-on-surface-dark-strong{% else %}text-on-surface/70 dark:text-on-surface-dark/70{% endif %}">
|
||||||
|
{{ t(key="audience-business", lang=L) }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- product search (drafts included); keeps the active audience + category -->
|
||||||
|
<form method="get" action="/admin/catalog/products" role="search" class="relative w-full max-w-xs">
|
||||||
|
<input type="hidden" name="audience" value="{{ audience }}">
|
||||||
|
<input type="hidden" name="category" value="{{ selected_category }}">
|
||||||
|
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-on-surface/50 dark:text-on-surface-dark/50">
|
||||||
|
{{ ui::icon(name="search", size="size-5") }}
|
||||||
|
</span>
|
||||||
|
<input type="search" name="q" value="{{ query | default(value='') }}" autocomplete="off"
|
||||||
|
placeholder="{{ t(key='search-placeholder', lang=L) }}" aria-label="{{ t(key='search-placeholder', lang=L) }}"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface py-2 pl-10 pr-3 text-sm text-on-surface placeholder:text-on-surface/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50 dark:focus-visible:outline-primary-dark">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% set category_base = "/admin/catalog/products" %}
|
||||||
|
{% set category_suffix = "&audience=" ~ audience ~ "&q=" ~ q_enc %}
|
||||||
|
<div class="mt-4 flex flex-col gap-6 md:flex-row md:items-start">
|
||||||
|
{% include "admin/partials/category_filter.html" %}
|
||||||
|
|
||||||
|
<!-- discount profiles applied to this audience -->
|
||||||
|
<section class="min-w-0 flex-1 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<h2 class="text-lg font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="discount-profiles", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
<p class="mt-1 text-sm text-on-surface/70 dark:text-on-surface-dark/70">
|
||||||
|
{% if business %}{{ t(key="apply-profiles-business-hint", lang=lang | default(value='sk')) }}{% else %}{{ t(key="apply-profiles-personal-hint", lang=lang | default(value='sk')) }}{% endif %}
|
||||||
|
</p>
|
||||||
|
{% if profiles | length > 0 %}
|
||||||
|
<form method="post" action="/admin/catalog/products/profiles?audience={{ audience }}" class="mt-3 space-y-3"
|
||||||
|
hx-post="/admin/catalog/products/profiles/preview?audience={{ audience }}&category={{ selected_category }}"
|
||||||
|
hx-trigger="change"
|
||||||
|
hx-swap="none"
|
||||||
|
x-data="{
|
||||||
|
orig: { {% for p in profiles %}'{{ p.id }}': {% if p.assigned %}true{% else %}false{% endif %}{% if not loop.last %}, {% endif %}{% endfor %} },
|
||||||
|
sel: { {% for p in profiles %}'{{ p.id }}': {% if p.assigned %}true{% else %}false{% endif %}{% if not loop.last %}, {% endif %}{% endfor %} },
|
||||||
|
get changed() { return Object.keys(this.orig).some(k => this.orig[k] !== this.sel[k]) }
|
||||||
|
}"
|
||||||
|
onsubmit="return confirm('{{ t(key="discount-apply-confirm", lang=lang | default(value='sk')) }}')">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
|
{% for profile in profiles %}
|
||||||
|
<label class="flex items-center gap-2 text-sm text-on-surface dark:text-on-surface-dark">
|
||||||
|
<input type="checkbox" name="profile_ids" value="{{ profile.id }}" x-model="sel['{{ profile.id }}']" {% if profile.assigned %}checked{% endif %}>
|
||||||
|
<span>{{ profile.name }} <span class="text-on-surface/60 dark:text-on-surface-dark/60">(−{{ profile.percent }}%, {% if profile.scope_type == "all_except" %}{{ t(key="scope-all-except", lang=lang | default(value='sk')) }}{% else %}{{ t(key="scope-include", lang=lang | default(value='sk')) }}{% endif %})</span></span>
|
||||||
|
<span x-cloak x-show="sel['{{ profile.id }}'] && orig['{{ profile.id }}']" class="inline-flex items-center rounded-radius border border-success px-1.5 py-0.5 text-xs font-medium text-success">{{ t(key="profile-applied", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span x-cloak x-show="sel['{{ profile.id }}'] && !orig['{{ profile.id }}']" class="inline-flex items-center rounded-radius border border-primary px-1.5 py-0.5 text-xs font-medium text-primary dark:border-primary-dark dark:text-primary-dark">{{ t(key="profile-will-apply", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span x-cloak x-show="!sel['{{ profile.id }}'] && orig['{{ profile.id }}']" class="inline-flex items-center rounded-radius border border-warning px-1.5 py-0.5 text-xs font-medium text-warning">{{ t(key="profile-will-remove", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", size="px-4 py-2 text-sm", attrs='x-bind:disabled="!changed"') }}
|
||||||
|
<span x-cloak x-show="changed" class="text-xs font-medium text-warning">{{ t(key="profiles-unsaved", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span x-cloak x-show="!changed" class="text-xs text-on-surface/50 dark:text-on-surface-dark/50">{{ t(key="profiles-no-changes", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-2 text-sm text-on-surface/70 dark:text-on-surface-dark/70">
|
||||||
|
{{ t(key="admin-no-profiles", lang=lang | default(value='sk')) }}
|
||||||
|
<a href="/admin/catalog/discount-profiles/new" class="text-primary dark:text-primary-dark">{{ t(key="new-profile", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4 {{ ui::table_wrap_cls() }}">
|
||||||
{% if products | length > 0 %}
|
{% if products | length > 0 %}
|
||||||
<table class="{{ ui::table_cls() }}">
|
<table class="{{ ui::table_cls() }}">
|
||||||
<thead class="{{ ui::thead_cls() }}">
|
<thead class="{{ ui::thead_cls() }}">
|
||||||
<tr>
|
<tr>
|
||||||
{{ ui::th(label=t(key="product", lang=lang | default(value='sk'))) }}
|
{{ ui::th(label=t(key="product", lang=lang | default(value='sk'))) }}
|
||||||
{{ ui::th(label=t(key="price", lang=lang | default(value='sk'))) }}
|
{{ ui::th(label=t(key="price", lang=lang | default(value='sk'))) }}
|
||||||
|
{{ ui::th(label=t(key="variants-options", lang=lang | default(value='sk'))) }}
|
||||||
|
{{ ui::th(label=t(key="effective-price", lang=lang | default(value='sk'))) }}
|
||||||
{{ ui::th(label=t(key="stock", lang=lang | default(value='sk'))) }}
|
{{ ui::th(label=t(key="stock", lang=lang | default(value='sk'))) }}
|
||||||
{{ ui::th(label=t(key="status", lang=lang | default(value='sk'))) }}
|
{{ ui::th(label=t(key="status", lang=lang | default(value='sk'))) }}
|
||||||
{{ ui::th(label=t(key="actions", lang=lang | default(value='sk')), align="text-right") }}
|
{{ ui::th(label=t(key="actions", lang=lang | default(value='sk')), align="text-right") }}
|
||||||
@@ -41,7 +122,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 tabular-nums">{{ product.price }} {{ 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">
|
||||||
|
<span id="eff-{{ product.id }}">{{ ui::eff_price(p=product) }}</span>
|
||||||
|
</td>
|
||||||
<td class="px-4 py-3 tabular-nums">{{ product.stock }}</td>
|
<td class="px-4 py-3 tabular-nums">{{ product.stock }}</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
{% if product.published %}
|
{% if product.published %}
|
||||||
@@ -53,9 +138,18 @@
|
|||||||
<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')) }}')">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
{{ ui::button(variant="outline-danger", label=t(key="delete", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}
|
{{ ui::button(variant="outline-danger", label=t(key="delete", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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 %}
|
||||||
45
assets/views/admin/customers/index.html
Normal file
45
assets/views/admin/customers/index.html
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="admin-customers", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
{% block crumb %}{{ t(key="admin-customers", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-customers", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-customers-desc", lang=lang | default(value='sk')) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 {{ ui::table_wrap_cls() }}">
|
||||||
|
{% if customers | length > 0 %}
|
||||||
|
<table class="{{ ui::table_cls() }}">
|
||||||
|
<thead class="{{ ui::thead_cls() }}">
|
||||||
|
<tr>
|
||||||
|
{{ ui::th(label=t(key="name", lang=lang | default(value='sk'))) }}
|
||||||
|
{{ ui::th(label=t(key="email", lang=lang | default(value='sk'))) }}
|
||||||
|
{{ ui::th(label=t(key="negotiated-prices", lang=lang | default(value='sk'))) }}
|
||||||
|
{{ ui::th(label=t(key="actions", lang=lang | default(value='sk')), align="text-right") }}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="{{ ui::tbody_cls() }}">
|
||||||
|
{% for customer in customers %}
|
||||||
|
<tr class="{{ ui::row_cls() }}">
|
||||||
|
<td class="px-4 py-3 font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ customer.name }}</td>
|
||||||
|
<td class="px-4 py-3 text-on-surface/70 dark:text-on-surface-dark/70">{{ customer.email }}</td>
|
||||||
|
<td class="px-4 py-3 tabular-nums">{{ customer.negotiated_count }}</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
{{ ui::button(variant="outline-secondary", label=t(key="manage-prices", lang=lang | default(value='sk')), href="/admin/customers/" ~ customer.id, size="px-3 py-1.5 text-xs") }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="flex flex-col items-center gap-3 px-4 py-16 text-center">
|
||||||
|
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-no-customers", lang=lang | default(value='sk')) }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
97
assets/views/admin/customers/price_form.html
Normal file
97
assets/views/admin/customers/price_form.html
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="set-negotiated-price", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
{% block crumb %}{{ t(key="admin-customers", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}{% if product.variant_label %} <span class="text-on-surface/60 dark:text-on-surface-dark/60">· {{ product.variant_label }}</span>{% endif %}</h1>
|
||||||
|
{{ ui::badge(label=t(key="negotiated-price", lang=lang | default(value='sk')), variant="info") }}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ customer.name }}</p>
|
||||||
|
</div>
|
||||||
|
{{ ui::button(variant="outline-secondary", label=t(key="back", lang=lang | default(value='sk')), href="/admin/customers/" ~ customer.id, size="px-3 py-2 text-sm") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="mt-4 max-w-md">{{ ui::alert_danger(message=t(key=error, lang=lang | default(value='sk'))) }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/admin/customers/{{ customer.id }}/prices/{{ product.variant_id }}"
|
||||||
|
x-data="{
|
||||||
|
price: '{{ negotiated }}',
|
||||||
|
regular: {{ product.regular_cents }},
|
||||||
|
num(v) { let n = parseFloat(String(v).replace(',', '.')); return isFinite(n) ? n : null; },
|
||||||
|
get afterCents() { let f = this.num(this.price); return f === null ? null : Math.round(f * 100); },
|
||||||
|
money(c) { return (c / 100).toFixed(2); },
|
||||||
|
get valid() { let a = this.afterCents; return a !== null && a > 0; }
|
||||||
|
}"
|
||||||
|
class="mt-6 max-w-md space-y-5 rounded-radius border-2 border-secondary/60 bg-surface p-6 dark:border-secondary-dark/60 dark:bg-surface-dark-alt">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
|
||||||
|
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="negotiated-price-hint", lang=lang | default(value='sk')) }}</p>
|
||||||
|
|
||||||
|
<!-- reference prices -->
|
||||||
|
<div class="space-y-2 rounded-radius bg-surface-alt px-4 py-3 dark:bg-surface-dark/40">
|
||||||
|
<div class="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="price", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span class="tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.regular_price }} €</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="business-price", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span class="tabular-nums {% if product.business_reduced %}font-medium text-danger{% else %}text-on-surface-strong dark:text-on-surface-dark-strong{% endif %}">{{ product.business_price }} €</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between gap-3 text-sm">
|
||||||
|
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="effective-price", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span class="tabular-nums font-medium {% if product.effective_differs %}text-primary dark:text-primary-dark{% else %}text-on-surface-strong dark:text-on-surface-dark-strong{% endif %}">{{ product.effective_price }} €</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- negotiated price input -->
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="price" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="negotiated-price", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="price", id="price", value=negotiated, placeholder="0.00", attrs='inputmode="decimal" x-model="price"') }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- live preview -->
|
||||||
|
<div x-show="afterCents !== null" x-cloak
|
||||||
|
class="space-y-2 rounded-radius border border-outline bg-surface-alt px-4 py-3 dark:border-outline-dark dark:bg-surface-dark/40">
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<span class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="negotiated-price", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span class="text-lg font-semibold tabular-nums" :class="valid ? 'text-secondary dark:text-secondary-dark' : 'text-on-surface/40 dark:text-on-surface-dark/40'">
|
||||||
|
<span x-text="money(afterCents)"></span> €
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p x-show="!valid" class="text-xs text-danger">{{ t(key="discount-must-be-positive", lang=lang | default(value='sk')) }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3 pt-2">
|
||||||
|
{{ ui::button(variant="secondary", label=t(key="save", lang=lang | default(value='sk')), type="submit") }}
|
||||||
|
{% if has_negotiated %}
|
||||||
|
{{ ui::button(variant="outline-danger", label=t(key="remove", lang=lang | default(value='sk')), type="submit", attrs=`formaction="/admin/customers/` ~ customer.id ~ `/prices/` ~ product.variant_id ~ `/remove" onclick="return confirm('` ~ t(key="negotiated-remove-confirm", lang=lang | default(value='sk')) ~ `')"`) }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{% if collision %}
|
||||||
|
<!-- collision resolution: two assigned profiles cover this product -->
|
||||||
|
<section class="mt-4 max-w-md rounded-radius border border-warning/60 bg-surface p-6 dark:bg-surface-dark-alt">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<h2 class="text-lg font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="automated-price", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
{{ ui::badge(label=t(key="collision", lang=lang | default(value='sk')), variant="warning") }}
|
||||||
|
</div>
|
||||||
|
<form method="post" action="/admin/customers/{{ customer.id }}/resolutions/{{ product.variant_id }}" class="mt-3 flex items-center gap-2">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<select name="profile_id" class="rounded-radius border border-outline bg-surface-alt px-2 py-1.5 text-sm dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark">
|
||||||
|
{% for c in covering %}
|
||||||
|
<option value="{{ c.id }}" {% if c.id == auto_profile_id %}selected{% endif %}>{{ c.name }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
{{ ui::button(label=t(key="resolve", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-sm") }}
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
{% endblock content %}
|
||||||
118
assets/views/admin/customers/show.html
Normal file
118
assets/views/admin/customers/show.html
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{{ customer.name }}{% endblock title %}
|
||||||
|
{% block crumb %}{{ t(key="admin-customers", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
{% set L = lang | default(value='sk') %}
|
||||||
|
{% set q_enc = query | default(value='') | urlencode %}
|
||||||
|
<div class="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ customer.name }}</h1>
|
||||||
|
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ customer.email }}</p>
|
||||||
|
</div>
|
||||||
|
{{ ui::button(variant="outline-secondary", label=t(key="back", lang=lang | default(value='sk')), href="/admin/customers", size="px-3 py-2 text-sm") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
<div class="mt-4">{{ ui::alert_danger(message=t(key=error, lang=lang | default(value='sk'))) }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- assigned discount profiles -->
|
||||||
|
<section class="mt-6 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<h2 class="text-lg font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="discount-profiles", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
{% if profiles | length > 0 %}
|
||||||
|
<form method="post" action="/admin/customers/{{ customer.id }}/profiles" class="mt-3 space-y-3">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<div class="grid gap-2 sm:grid-cols-2">
|
||||||
|
{% for profile in profiles %}
|
||||||
|
<label class="flex items-center gap-2 text-sm text-on-surface dark:text-on-surface-dark">
|
||||||
|
<input type="checkbox" name="profile_ids" value="{{ profile.id }}" {% if profile.assigned %}checked{% endif %}>
|
||||||
|
<span>{{ profile.name }} <span class="text-on-surface/60 dark:text-on-surface-dark/60">(−{{ profile.percent }}%, {% if profile.scope_type == "all_except" %}{{ t(key="scope-all-except", lang=lang | default(value='sk')) }}{% else %}{{ t(key="scope-include", lang=lang | default(value='sk')) }}{% endif %})</span></span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", size="px-4 py-2 text-sm") }}
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-2 text-sm text-on-surface/70 dark:text-on-surface-dark/70">
|
||||||
|
{{ t(key="admin-no-profiles", lang=lang | default(value='sk')) }}
|
||||||
|
<a href="/admin/catalog/discount-profiles/new" class="text-primary dark:text-primary-dark">{{ t(key="new-profile", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</p>
|
||||||
|
{% endif %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="mt-6 flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="negotiated-prices-hint", lang=L) }}</p>
|
||||||
|
|
||||||
|
<!-- product search (drafts included); keeps the active category -->
|
||||||
|
<form method="get" action="/admin/customers/{{ customer.id }}" role="search" class="relative w-full max-w-xs">
|
||||||
|
<input type="hidden" name="category" value="{{ selected_category }}">
|
||||||
|
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-on-surface/50 dark:text-on-surface-dark/50">
|
||||||
|
{{ ui::icon(name="search", size="size-5") }}
|
||||||
|
</span>
|
||||||
|
<input type="search" name="q" value="{{ query | default(value='') }}" autocomplete="off"
|
||||||
|
placeholder="{{ t(key='search-placeholder', lang=L) }}" aria-label="{{ t(key='search-placeholder', lang=L) }}"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface py-2 pl-10 pr-3 text-sm text-on-surface placeholder:text-on-surface/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50 dark:focus-visible:outline-primary-dark">
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% set category_base = "/admin/customers/" ~ customer.id %}
|
||||||
|
{% set category_suffix = "&q=" ~ q_enc %}
|
||||||
|
<div class="mt-3 flex flex-col gap-6 md:flex-row md:items-start">
|
||||||
|
{% include "admin/partials/category_filter.html" %}
|
||||||
|
<div class="min-w-0 flex-1 {{ ui::table_wrap_cls() }}">
|
||||||
|
{% if products | length > 0 %}
|
||||||
|
<table class="{{ ui::table_cls() }}">
|
||||||
|
<thead class="{{ ui::thead_cls() }}">
|
||||||
|
<tr>
|
||||||
|
{{ ui::th(label=t(key="product", lang=lang | default(value='sk'))) }}
|
||||||
|
{{ ui::th(label=t(key="business-price", lang=lang | default(value='sk'))) }}
|
||||||
|
{{ ui::th(label=t(key="effective-price", lang=lang | default(value='sk'))) }}
|
||||||
|
{{ ui::th(label=t(key="actions", lang=lang | default(value='sk')), align="text-right") }}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="{{ ui::tbody_cls() }}">
|
||||||
|
{% for product in products %}
|
||||||
|
<tr class="{{ ui::row_cls() }}">
|
||||||
|
<td class="px-4 py-3 font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
|
{{ product.name }}
|
||||||
|
{% if product.variant_label %}<span class="block text-xs font-normal text-on-surface/60 dark:text-on-surface-dark/60">{{ product.variant_label }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 tabular-nums">
|
||||||
|
{% if product.business_reduced %}
|
||||||
|
<span class="font-medium text-danger">{{ product.business_price }} €</span>
|
||||||
|
<span class="ml-1 text-xs text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }}</span>
|
||||||
|
{% else %}
|
||||||
|
{{ product.business_price }} €
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 tabular-nums">
|
||||||
|
<span class="font-medium {% if product.effective_differs %}text-primary dark:text-primary-dark{% else %}text-on-surface-strong dark:text-on-surface-dark-strong{% endif %}">{{ product.effective_price }} €</span>
|
||||||
|
{% if product.collision %}<span class="ml-1">{{ ui::badge(label=t(key="collision", lang=lang | default(value='sk')), variant="warning") }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<div class="flex flex-wrap justify-end gap-2">
|
||||||
|
{{ ui::button(variant="outline-secondary", label=t(key="set-negotiated-price", lang=lang | default(value='sk')), href="/admin/customers/" ~ customer.id ~ "/prices/" ~ product.variant_id ~ "/edit", size="px-3 py-1.5 text-xs") }}
|
||||||
|
{% if product.has_negotiated %}
|
||||||
|
<form method="post" action="/admin/customers/{{ customer.id }}/prices/{{ product.variant_id }}/remove"
|
||||||
|
onsubmit="return confirm('{{ t(key="negotiated-remove-confirm", lang=lang | default(value='sk')) }}')">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
{{ ui::button(variant="outline-danger", label=t(key="remove", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="flex flex-col items-center gap-3 px-4 py-16 text-center">
|
||||||
|
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-no-products", lang=lang | default(value='sk')) }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -6,7 +6,15 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||||
<h1 class="font-mono text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.order_number }}</h1>
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
|
<h1 class="font-mono text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.order_number }}</h1>
|
||||||
|
{% if order.status == "delivered" %}{{ ui::badge(label=t(key="order-status-" ~ order.status, lang=lang | default(value='sk')), variant="success") }}
|
||||||
|
{% elif order.status == "shipped" %}{{ ui::badge(label=t(key="order-status-" ~ order.status, lang=lang | default(value='sk')), variant="primary") }}
|
||||||
|
{% elif order.status == "paid" %}{{ ui::badge(label=t(key="order-status-" ~ order.status, lang=lang | default(value='sk')), variant="info") }}
|
||||||
|
{% elif order.status == "cancelled" %}{{ ui::badge(label=t(key="order-status-" ~ order.status, lang=lang | default(value='sk')), variant="danger") }}
|
||||||
|
{% else %}{{ ui::badge(label=t(key="order-status-" ~ order.status, lang=lang | default(value='sk')), variant="warning") }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{{ ui::button(variant="outline-secondary", label=t(key="admin-orders", lang=lang | default(value='sk')), href="/admin/orders", size="px-3 py-2 text-sm") }}
|
{{ ui::button(variant="outline-secondary", label=t(key="admin-orders", lang=lang | default(value='sk')), href="/admin/orders", size="px-3 py-2 text-sm") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -28,16 +36,16 @@
|
|||||||
<tbody class="{{ ui::tbody_cls() }}">
|
<tbody class="{{ ui::tbody_cls() }}">
|
||||||
{% for item in items %}
|
{% for item in items %}
|
||||||
<tr>
|
<tr>
|
||||||
<td class="px-4 py-3">{{ item.product_name }}</td>
|
<td class="px-4 py-3">{{ item.product_name }}{% if item.variant_label %} <span class="text-on-surface/60 dark:text-on-surface-dark/60">· {{ item.variant_label }}</span>{% endif %}</td>
|
||||||
<td class="px-4 py-3 tabular-nums">{{ item.quantity }}</td>
|
<td class="px-4 py-3 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>
|
||||||
@@ -52,13 +60,26 @@
|
|||||||
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.email }}</p>
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.email }}</p>
|
||||||
{% if order.phone %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.phone }}</p>{% endif %}
|
{% if order.phone %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.phone }}</p>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% if order.account_type == "company" %}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="account-company-details", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<p class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.company_name }}</p>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ t(key="company-ico", lang=lang | default(value='sk')) }}: {{ order.company_id }}</p>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ t(key="company-dic", lang=lang | default(value='sk')) }}: {{ order.tax_id }}</p>
|
||||||
|
{% if order.vat_id %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ t(key="company-icdph", lang=lang | default(value='sk')) }}: {{ order.vat_id }}</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-residence-address", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{% if order.residence_address %}{{ order.residence_address }}<br>{{ order.residence_zip }} {{ order.residence_city }}<br>{{ order.residence_country }}{% else %}{{ t(key="profile-not-set", lang=lang | default(value='sk')) }}{% endif %}</p>
|
||||||
|
</div>
|
||||||
<div>
|
<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>
|
||||||
@@ -93,6 +114,7 @@
|
|||||||
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="order-send-hint", lang=lang | default(value='sk')) }}</p>
|
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="order-send-hint", lang=lang | default(value='sk')) }}</p>
|
||||||
<form method="post" action="/admin/orders/{{ order.id }}/ship"
|
<form method="post" action="/admin/orders/{{ order.id }}/ship"
|
||||||
onsubmit="return confirm('{{ t(key="order-send-confirm", lang=lang | default(value='sk')) }}')">
|
onsubmit="return confirm('{{ t(key="order-send-confirm", lang=lang | default(value='sk')) }}')">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
{% set carrier_up = carrier | upper %}
|
{% set carrier_up = carrier | upper %}
|
||||||
{% set ship_label = t(key="order-send-to-carrier", lang=lang | default(value='sk')) ~ " " ~ carrier_up %}
|
{% set ship_label = t(key="order-send-to-carrier", lang=lang | default(value='sk')) ~ " " ~ carrier_up %}
|
||||||
{{ ui::button(label=ship_label, type="submit", extra="w-full") }}
|
{{ ui::button(label=ship_label, type="submit", extra="w-full") }}
|
||||||
@@ -101,6 +123,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="/admin/orders/{{ order.id }}/status" class="space-y-3 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
|
<form method="post" action="/admin/orders/{{ order.id }}/status" class="space-y-3 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
<label for="status" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="order-status", lang=lang | default(value='sk')) }}</label>
|
<label for="status" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="order-status", lang=lang | default(value='sk')) }}</label>
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<select id="status" name="status"
|
<select id="status" name="status"
|
||||||
|
|||||||
75
assets/views/admin/partials/category_filter.html
Normal file
75
assets/views/admin/partials/category_filter.html
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
{# Category-filter sidebar for admin product listings. Clicking a category
|
||||||
|
reloads the page with `?category=<id>` so the table server-side filters to
|
||||||
|
that category and its descendants. Expects in context:
|
||||||
|
- category_groups: [{ id, name, count, children: [{ id, name, count }] }]
|
||||||
|
(from views::shop::admin_category_groups)
|
||||||
|
- selected_category: "all" | "none" | "<id>" — the active filter
|
||||||
|
- total_count, uncategorized_count: ints
|
||||||
|
- category_base: page path, e.g. "/admin/catalog/products"
|
||||||
|
- category_suffix: extra query appended after the category param, e.g.
|
||||||
|
"&audience=business", or "" — set by the including template.
|
||||||
|
|
||||||
|
The link treatment mirrors shop/_sidebar.html (Penguin UI), but active state
|
||||||
|
is server-driven via aria-current (these links share a path, differing only
|
||||||
|
by query, so markActiveNav() can't pick the active one — hence no data-nav).
|
||||||
|
Numeric compare uses `| int(default=0)` because Tera string==number is false. #}
|
||||||
|
{% set sel = selected_category | int(default=0) %}
|
||||||
|
{% set link_cls = "flex flex-1 items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong" %}
|
||||||
|
<aside class="w-full shrink-0 md:w-56">
|
||||||
|
<p class="px-2 pb-2 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
|
{{ t(key="categories", lang=lang | default(value='sk')) }}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<a href="{{ category_base }}?category=all{{ category_suffix }}"
|
||||||
|
{% if selected_category == "all" %}aria-current="page"{% endif %} class="{{ link_cls }}">
|
||||||
|
<span class="flex-1 truncate">{{ t(key="all-products", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span class="text-xs text-on-surface/50 dark:text-on-surface-dark/50">{{ total_count }}</span>
|
||||||
|
</a>
|
||||||
|
{% for group in category_groups %}
|
||||||
|
{% set open_group = sel == group.id %}
|
||||||
|
{% for child in group.children %}{% if sel == child.id %}{% set_global open_group = true %}{% endif %}{% endfor %}
|
||||||
|
{% if group.children | length > 0 %}
|
||||||
|
<div x-data="{ open: {% if open_group %}true{% else %}false{% endif %} }" class="flex flex-col">
|
||||||
|
<div class="flex items-stretch">
|
||||||
|
<a href="{{ category_base }}?category={{ group.id }}{{ category_suffix }}"
|
||||||
|
{% if sel == group.id %}aria-current="page"{% endif %} class="{{ link_cls }} rounded-l-radius">
|
||||||
|
<span class="flex-1 truncate">{{ group.name }}</span>
|
||||||
|
<span class="text-xs text-on-surface/50 dark:text-on-surface-dark/50">{{ group.count }}</span>
|
||||||
|
</a>
|
||||||
|
<button type="button" x-on:click="open = ! open" x-bind:aria-expanded="open ? 'true' : 'false'"
|
||||||
|
aria-label="{{ group.name }}"
|
||||||
|
class="inline-flex w-8 shrink-0 items-center justify-center rounded-r-radius text-on-surface/60 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark/60 dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
|
||||||
|
class="size-5 shrink-0 transition-transform rotate-0" x-bind:class="open ? 'rotate-180' : 'rotate-0'" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul x-show="open" x-cloak x-transition class="ml-3 mt-0.5 flex flex-col gap-0.5 border-l border-outline pl-1 dark:border-outline-dark">
|
||||||
|
{% for child in group.children %}
|
||||||
|
<li class="flex">
|
||||||
|
<a href="{{ category_base }}?category={{ child.id }}{{ category_suffix }}"
|
||||||
|
{% if sel == child.id %}aria-current="page"{% endif %}
|
||||||
|
class="flex flex-1 items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||||
|
<span class="flex-1 truncate">{{ child.name }}</span>
|
||||||
|
<span class="text-xs text-on-surface/50 dark:text-on-surface-dark/50">{{ child.count }}</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<a href="{{ category_base }}?category={{ group.id }}{{ category_suffix }}"
|
||||||
|
{% if sel == group.id %}aria-current="page"{% endif %} class="{{ link_cls }}">
|
||||||
|
<span class="flex-1 truncate">{{ group.name }}</span>
|
||||||
|
<span class="text-xs text-on-surface/50 dark:text-on-surface-dark/50">{{ group.count }}</span>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<a href="{{ category_base }}?category=none{{ category_suffix }}"
|
||||||
|
{% if selected_category == "none" %}aria-current="page"{% endif %} class="{{ link_cls }}">
|
||||||
|
<span class="flex-1 truncate">{{ t(key="uncategorized", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span class="text-xs text-on-surface/50 dark:text-on-surface-dark/50">{{ uncategorized_count }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
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 %}
|
||||||
@@ -14,15 +14,25 @@
|
|||||||
{% for method in methods %}
|
{% for method in methods %}
|
||||||
<form method="post" action="/admin/shipping/{{ method.id }}"
|
<form method="post" action="/admin/shipping/{{ method.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">
|
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">
|
<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 %}
|
||||||
|
|||||||
@@ -22,11 +22,15 @@
|
|||||||
|
|
||||||
{% if error == "unverified" %}
|
{% if error == "unverified" %}
|
||||||
{{ ui::alert_danger(message=t(key="login-error-unverified", lang=lang | default(value='sk')), extra="mt-3") }}
|
{{ ui::alert_danger(message=t(key="login-error-unverified", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||||
|
<p class="mt-2 text-sm text-on-surface dark:text-on-surface-dark">
|
||||||
|
<a href="/resend-verification" class="font-medium text-primary underline-offset-2 hover:underline dark:text-primary-dark">{{ t(key="login-resend", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</p>
|
||||||
{% elif error %}
|
{% elif error %}
|
||||||
{{ ui::alert_danger(message=t(key="login-error", lang=lang | default(value='sk')), extra="mt-3") }}
|
{{ ui::alert_danger(message=t(key="login-error", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" action="/login" hx-boost="false" class="mt-4 flex flex-col gap-4">
|
<form method="post" action="/login" hx-boost="false" class="mt-4 flex flex-col gap-4">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
<label for="email"
|
<label for="email"
|
||||||
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
|
|||||||
48
assets/views/auth/login_totp.html
Normal file
48
assets/views/auth/login_totp.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="login-totp-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mx-auto mt-8 max-w-sm">
|
||||||
|
<div
|
||||||
|
class="rounded-radius border border-outline bg-surface-alt shadow-sm dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-between border-b border-outline px-5 py-3 dark:border-outline-dark">
|
||||||
|
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
|
{{ t(key="brand", lang=lang | default(value='sk')) }}
|
||||||
|
</span>
|
||||||
|
{{ ui::badge(label=t(key="auth", lang=lang | default(value='sk')), variant="primary") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-5">
|
||||||
|
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
|
{{ t(key="login-totp-title", lang=lang | default(value='sk')) }}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-sm text-on-surface dark:text-on-surface-dark">
|
||||||
|
{{ t(key="login-totp-intro", lang=lang | default(value='sk')) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{% if error %}
|
||||||
|
{{ ui::alert_danger(message=t(key="login-totp-error", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/login/totp" hx-boost="false" class="mt-4 flex flex-col gap-4">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="code"
|
||||||
|
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
|
{{ t(key="login-totp-code", lang=lang | default(value='sk')) }}
|
||||||
|
</label>
|
||||||
|
{{ ui::input(name="code", id="code", type="text", required=true, autocomplete="one-time-code", attrs='inputmode="numeric" autofocus') }}
|
||||||
|
</div>
|
||||||
|
{{ ui::button(label=t(key="login-totp-submit", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full") }}
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p class="mt-4 text-xs text-on-surface dark:text-on-surface-dark">
|
||||||
|
{{ t(key="login-totp-backup-hint", lang=lang | default(value='sk')) }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
@@ -22,17 +22,30 @@
|
|||||||
|
|
||||||
{% if error == "exists" %}
|
{% if error == "exists" %}
|
||||||
{{ ui::alert_danger(message=t(key="register-error-exists", lang=lang | default(value='sk')), extra="mt-3") }}
|
{{ ui::alert_danger(message=t(key="register-error-exists", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||||
|
{% elif error == "mismatch" %}
|
||||||
|
{{ ui::alert_danger(message=t(key="set-password-mismatch", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||||
|
{% elif error == "weak" %}
|
||||||
|
{{ ui::alert_danger(message=t(key="set-password-weak", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||||
{% elif error %}
|
{% elif error %}
|
||||||
{{ ui::alert_danger(message=t(key="register-error-invalid", lang=lang | default(value='sk')), extra="mt-3") }}
|
{{ ui::alert_danger(message=t(key="register-error-invalid", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="post" action="/register" hx-boost="false" class="mt-4 flex flex-col gap-4">
|
<form method="post" action="/register" hx-boost="false" class="mt-4 flex flex-col gap-4"
|
||||||
<div class="flex flex-col gap-1">
|
x-data="{ password: '', confirm: '' }">
|
||||||
<label for="name"
|
{{ ui::csrf_field() }}
|
||||||
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
<div class="flex flex-col gap-1.5">
|
||||||
{{ t(key="register-name", lang=lang | default(value='sk')) }}
|
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="account-type", lang=lang | default(value='sk')) }}</span>
|
||||||
</label>
|
<div class="grid grid-cols-2 gap-2">
|
||||||
{{ ui::input(name="name", id="name", required=true, autocomplete="name", attrs="autofocus") }}
|
<label class="flex cursor-pointer items-center gap-2 rounded-radius border border-outline px-3 py-2 text-sm transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark">
|
||||||
|
{{ ui::radio(name="account_type", value="personal", checked=true) }}
|
||||||
|
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="account-personal", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex cursor-pointer items-center gap-2 rounded-radius border border-outline px-3 py-2 text-sm transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark">
|
||||||
|
{{ ui::radio(name="account_type", value="company") }}
|
||||||
|
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="account-company", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="account-type-locked", lang=lang | default(value='sk')) }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
@@ -40,7 +53,7 @@
|
|||||||
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
{{ t(key="login-email", lang=lang | default(value='sk')) }}
|
{{ t(key="login-email", lang=lang | default(value='sk')) }}
|
||||||
</label>
|
</label>
|
||||||
{{ ui::input(name="email", id="email", type="email", required=true, autocomplete="email") }}
|
{{ ui::input(name="email", id="email", type="email", required=true, autocomplete="email", attrs="autofocus") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
@@ -48,10 +61,22 @@
|
|||||||
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
{{ t(key="login-password", lang=lang | default(value='sk')) }}
|
{{ t(key="login-password", lang=lang | default(value='sk')) }}
|
||||||
</label>
|
</label>
|
||||||
{{ ui::input(name="password", id="password", type="password", required=true, autocomplete="new-password") }}
|
{{ ui::input(name="password", id="password", type="password", required=true, autocomplete="new-password", attrs='x-model="password"') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ ui::button(label=t(key="register-submit", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full") }}
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="password_confirm"
|
||||||
|
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
|
{{ t(key="set-password-confirm", lang=lang | default(value='sk')) }}
|
||||||
|
</label>
|
||||||
|
{{ ui::input(name="password_confirm", id="password_confirm", type="password", required=true, autocomplete="new-password", attrs='x-model="confirm"') }}
|
||||||
|
<span x-cloak x-show="confirm.length > 0 && password !== confirm"
|
||||||
|
class="text-xs text-danger dark:text-danger">
|
||||||
|
{{ t(key="set-password-mismatch", lang=lang | default(value='sk')) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{{ ui::button(label=t(key="register-submit", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full", attrs=':disabled="password !== confirm"') }}
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="mt-5 flex items-center gap-3 text-xs text-on-surface/50 dark:text-on-surface-dark/50">
|
<div class="mt-5 flex items-center gap-3 text-xs text-on-surface/50 dark:text-on-surface-dark/50">
|
||||||
|
|||||||
38
assets/views/auth/resend_verification.html
Normal file
38
assets/views/auth/resend_verification.html
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="resend-verification-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mx-auto mt-8 max-w-sm">
|
||||||
|
<div class="rounded-radius border border-outline bg-surface-alt shadow-sm dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<div class="flex items-center justify-between border-b border-outline px-5 py-3 dark:border-outline-dark">
|
||||||
|
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="brand", lang=lang | default(value='sk')) }}</span>
|
||||||
|
{{ ui::badge(label=t(key="auth", lang=lang | default(value='sk')), variant="primary") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-5">
|
||||||
|
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="resend-verification-title", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
|
||||||
|
{% if done %}
|
||||||
|
<div class="mt-3 rounded-radius border border-success bg-success/10 px-4 py-3 text-sm text-success" role="status">
|
||||||
|
{{ t(key="resend-verification-done", lang=lang | default(value='sk')) }}
|
||||||
|
</div>
|
||||||
|
<p class="mt-4 text-sm text-on-surface dark:text-on-surface-dark">
|
||||||
|
<a href="/login" class="font-medium text-primary underline-offset-2 hover:underline dark:text-primary-dark">{{ t(key="nav-login", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-1 text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="resend-verification-intro", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<form method="post" action="/resend-verification" hx-boost="false" class="mt-4 flex flex-col gap-4">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="email" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="login-email", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="email", id="email", type="email", required=true, autocomplete="email", attrs="autofocus") }}
|
||||||
|
</div>
|
||||||
|
{{ ui::button(label=t(key="resend-verification-submit", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full") }}
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
48
assets/views/auth/set_password.html
Normal file
48
assets/views/auth/set_password.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="set-password-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="mx-auto mt-8 max-w-sm">
|
||||||
|
<div class="rounded-radius border border-outline bg-surface-alt shadow-sm dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<div class="flex items-center justify-between border-b border-outline px-5 py-3 dark:border-outline-dark">
|
||||||
|
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="brand", lang=lang | default(value='sk')) }}</span>
|
||||||
|
{{ ui::badge(label=t(key="auth", lang=lang | default(value='sk')), variant="primary") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-5">
|
||||||
|
<h1 class="text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="set-password-title", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
|
||||||
|
{% if not valid %}
|
||||||
|
{{ ui::alert_danger(message=t(key="set-password-invalid", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||||
|
<p class="mt-4 text-sm text-on-surface dark:text-on-surface-dark">
|
||||||
|
<a href="/login" class="font-medium text-primary underline-offset-2 hover:underline dark:text-primary-dark">{{ t(key="nav-login", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p class="mt-1 text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="set-password-intro", lang=lang | default(value='sk')) }}</p>
|
||||||
|
|
||||||
|
{% if error == "mismatch" %}
|
||||||
|
{{ ui::alert_danger(message=t(key="set-password-mismatch", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||||
|
{% elif error == "weak" %}
|
||||||
|
{{ ui::alert_danger(message=t(key="set-password-weak", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<form method="post" action="/set-password" hx-boost="false" class="mt-4 flex flex-col gap-4">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<input type="hidden" name="token" value="{{ token }}">
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="password" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="set-password-new", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="password", id="password", type="password", required=true, autocomplete="new-password", attrs="autofocus") }}
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<label for="password_confirm" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="set-password-confirm", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="password_confirm", id="password_confirm", type="password", required=true, autocomplete="new-password") }}
|
||||||
|
</div>
|
||||||
|
{{ ui::button(label=t(key="set-password-submit", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full") }}
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock content %}
|
||||||
@@ -48,6 +48,12 @@
|
|||||||
if (!v) return 0;
|
if (!v) return 0;
|
||||||
return v.split(',').reduce(function (s, e) { return s + (parseInt(e.split(':')[1]) || 0) }, 0);
|
return v.split(',').reduce(function (s, e) { return s + (parseInt(e.split(':')[1]) || 0) }, 0);
|
||||||
}
|
}
|
||||||
|
// True while any other navbar menu (profile / settings / mobile / category
|
||||||
|
// toggle) is open — those triggers expose aria-expanded="true". Used to
|
||||||
|
// suppress the cart hover preview so menus don't stack/overlap.
|
||||||
|
function anyMenuOpen() {
|
||||||
|
return !!document.querySelector('header [aria-expanded="true"]');
|
||||||
|
}
|
||||||
// Show a floating toast notification. Usage: toast('Saved').
|
// Show a floating toast notification. Usage: toast('Saved').
|
||||||
// Bridges to the vendored Penguin UI toast component, which listens for a
|
// Bridges to the vendored Penguin UI toast component, which listens for a
|
||||||
// `notify` event with { variant, title, message }.
|
// `notify` event with { variant, title, message }.
|
||||||
@@ -57,85 +63,138 @@
|
|||||||
</script>
|
</script>
|
||||||
<link href="/static/css/app.css?v=2026-06-16" rel="stylesheet" type="text/css">
|
<link href="/static/css/app.css?v=2026-06-16" rel="stylesheet" type="text/css">
|
||||||
<script src="/static/vendor/htmx/htmx-1.9.12.min.js"></script>
|
<script src="/static/vendor/htmx/htmx-1.9.12.min.js"></script>
|
||||||
|
<!-- Alpine Focus plugin (x-trap / $focus) — must load before Alpine core;
|
||||||
|
required by the Penguin UI keyboard-accessible dropdowns. -->
|
||||||
|
<script defer src="/static/vendor/alpine/alpine-focus-3.14.9.min.js"></script>
|
||||||
<script defer src="/static/vendor/alpine/alpinejs-3.14.9.min.js"></script>
|
<script defer src="/static/vendor/alpine/alpinejs-3.14.9.min.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body hx-boost="true"
|
<body hx-boost="true"
|
||||||
|
hx-headers='{"X-CSRF-Token": "{{ csrf_token() }}"}'
|
||||||
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). Only on the home
|
||||||
<ul class="ml-2 hidden items-center gap-6 md:flex">
|
page; elsewhere the shop's own toolbar carries the search box. Hidden
|
||||||
<li>{{ ui::nav_link(label=t(key="nav-home", lang=lang | default(value='sk')), href="/", data_nav="/") }}</li>
|
on small screens (a compact copy lives in the mobile menu below). -->
|
||||||
<li>{{ ui::nav_link(label=t(key="nav-shop", lang=lang | default(value='sk')), href="/shop", data_nav="/shop") }}</li>
|
{% if on_home | default(value=false) %}
|
||||||
|
<form action="/search" method="get" role="search" class="hidden min-w-0 flex-1 md:flex md:max-w-xl">
|
||||||
|
<div class="flex min-w-0 flex-1 overflow-hidden rounded-radius border border-outline transition focus-within:border-primary dark:border-outline-dark dark:focus-within:border-primary-dark">
|
||||||
|
<span class="pointer-events-none flex items-center bg-surface-alt pl-3.5 text-on-surface/40 dark:bg-surface-dark-alt dark:text-on-surface-dark/40">{{ ui::icon(name="search", size="size-[18px]") }}</span>
|
||||||
|
<input type="search" name="q" autocomplete="off"
|
||||||
|
placeholder="{{ t(key='search-placeholder', lang=lang | default(value='sk')) }}"
|
||||||
|
aria-label="{{ t(key='search-placeholder', lang=lang | default(value='sk')) }}"
|
||||||
|
class="min-w-0 flex-1 border-0 bg-surface-alt px-2.5 py-2.5 text-sm text-on-surface placeholder:text-on-surface/50 focus:outline-none dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50" />
|
||||||
|
<button type="submit" class="shrink-0 bg-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>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- right side: kurz + account + cart + settings + mobile toggle -->
|
||||||
|
<div class="ml-auto flex items-center gap-2 sm:gap-3">
|
||||||
|
<!-- exchange-rate ("kurz") display: the admin-set EUR→alt rate(s).
|
||||||
|
Hidden when the store is EUR-only (no enabled alternatives). -->
|
||||||
|
{% set nav_cc = currencies() %}
|
||||||
|
{% if nav_cc.alts | length > 0 %}
|
||||||
|
<div class="hidden items-center gap-2 text-xs text-on-surface/70 dark:text-on-surface-dark/70 sm:flex">
|
||||||
|
<span class="font-semibold uppercase tracking-wide">{{ t(key="currency-rate", lang=lang | default(value='sk')) }}</span>
|
||||||
|
{% for a in nav_cc.alts %}
|
||||||
|
<span class="tabular-nums">1 {{ nav_cc.base.symbol }} = {{ a.rate }} {{ a.symbol }}</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<!-- account area: admin quick links / customer profile dropdown /
|
||||||
|
guest two-line "Vitajte · Prihláste sa" button (Kompress design) -->
|
||||||
{% if logged_in_admin %}
|
{% if logged_in_admin %}
|
||||||
<li>{{ ui::nav_link(label=t(key="admin-title", lang=lang | default(value='sk')), href="/admin/dashboard", data_nav="/admin", variant="warning", attrs='hx-boost="false"') }}</li>
|
<div class="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() }}
|
||||||
<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 %}
|
||||||
|
{% include "partials/profile_menu.html" %}
|
||||||
{% else %}
|
{% else %}
|
||||||
<li>{{ ui::nav_link(label=t(key="nav-login", lang=lang | default(value='sk')), href="/login", data_nav="/login") }}</li>
|
<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">
|
||||||
<li>{{ ui::nav_link(label=t(key="nav-register", lang=lang | default(value='sk')), href="/register", data_nav="/register") }}</li>
|
<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>
|
||||||
{% endif %}
|
<span class="hidden flex-col items-start leading-tight sm:flex">
|
||||||
</ul>
|
<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>
|
||||||
<!-- right side: cart + settings + mobile toggle -->
|
</span>
|
||||||
<div class="ml-auto flex items-center gap-1">
|
|
||||||
<!-- cart with live item-count badge read from the `cart` cookie -->
|
|
||||||
<a href="/cart" data-nav="/cart"
|
|
||||||
x-data="{ count: 0 }"
|
|
||||||
x-init="count = cartCount(); ['htmx:afterSwap', 'htmx:afterRequest'].forEach(function (e) { window.addEventListener(e, function () { count = cartCount() }) })"
|
|
||||||
aria-label="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
|
|
||||||
title="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
|
|
||||||
class="relative inline-flex size-9 shrink-0 items-center justify-center rounded-radius bg-transparent text-secondary transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-secondary active:opacity-100 active:outline-offset-0 dark:text-secondary-dark dark:focus-visible:outline-secondary-dark">
|
|
||||||
{{ ui::icon(name="cart") }}
|
|
||||||
<span x-show="count > 0" x-cloak x-text="count"
|
|
||||||
class="absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-4 text-on-primary dark:bg-primary-dark dark:text-on-primary-dark"></span>
|
|
||||||
</a>
|
</a>
|
||||||
<!-- settings (language + theme) dropdown -->
|
{% endif %}
|
||||||
<div x-data="{ open: false }" @keydown.escape="open = false" class="relative">
|
<!-- cart: hover opens an Alza-style mini-cart preview (Penguin
|
||||||
{% include "partials/settings_dropdown.html" %}
|
dropdown-with-hover), lazy-loaded from /partials/cart on each hover
|
||||||
|
so it's always fresh. Click still does a full navigation to /cart
|
||||||
|
(hx-boost=false; the explicit hx-trigger is mouseenter, so click is
|
||||||
|
not an htmx trigger). The badge reads the `cart` cookie client-side. -->
|
||||||
|
<div x-data="{ isOpen: false, leaveTimeout: null }"
|
||||||
|
x-on:mouseleave="leaveTimeout = setTimeout(() => isOpen = false, 250)"
|
||||||
|
x-on:mouseenter="leaveTimeout && clearTimeout(leaveTimeout)"
|
||||||
|
x-on:keydown.esc.window="isOpen = false"
|
||||||
|
class="relative">
|
||||||
|
<a href="/cart" data-nav="/cart" hx-boost="false"
|
||||||
|
x-on:mouseenter="if (!anyMenuOpen()) isOpen = true"
|
||||||
|
x-data="{ count: 0 }"
|
||||||
|
x-init="count = cartCount(); ['htmx:afterSwap', 'htmx:afterRequest'].forEach(function (e) { window.addEventListener(e, function () { count = cartCount() }) })"
|
||||||
|
hx-get="/partials/cart" hx-trigger="mouseenter delay:150ms" hx-target="#cart-preview-body" hx-swap="innerHTML"
|
||||||
|
aria-label="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
|
||||||
|
title="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
|
||||||
|
class="flex shrink-0 items-center gap-2.5 rounded-radius border border-outline bg-surface-alt px-2.5 py-1.5 text-on-surface transition hover:border-outline-strong focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:border-outline-dark-strong dark:focus-visible:outline-primary-dark">
|
||||||
|
<span class="relative inline-flex text-primary dark:text-primary-dark">
|
||||||
|
{{ ui::icon(name="cart", size="size-6") }}
|
||||||
|
<span x-show="count > 0" x-cloak x-text="count"
|
||||||
|
class="absolute -right-2 -top-2 inline-flex min-w-[18px] items-center justify-center rounded-full bg-danger px-1 text-[10px] font-bold leading-[18px] text-on-danger ring-2 ring-surface-alt dark:ring-surface-dark-alt"></span>
|
||||||
|
</span>
|
||||||
|
<span class="hidden flex-col items-start leading-tight sm:flex">
|
||||||
|
<span class="text-[11px] text-on-surface/50 dark:text-on-surface-dark/50">{{ t(key="cart-title", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span class="text-sm font-bold text-on-surface-strong dark:text-on-surface-dark-strong"><span x-text="count">0</span> {{ t(key="cart-units", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
<!-- hover preview panel (no id on the panel → not htmx-settled on boosted nav) -->
|
||||||
|
<div x-cloak x-show="isOpen" x-transition
|
||||||
|
x-on:mouseenter="isOpen = true"
|
||||||
|
class="absolute right-0 mt-2 w-80 overflow-hidden rounded-radius border border-outline bg-surface-alt shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt"
|
||||||
|
role="dialog" aria-label="{{ t(key='cart-title', lang=lang | default(value='sk')) }}">
|
||||||
|
<div id="cart-preview-body">
|
||||||
|
<div class="px-4 py-10 text-center text-sm text-on-surface dark:text-on-surface-dark">…</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- mobile hamburger — Penguin animated icon swap (bars ↔ X), kept in
|
<!-- settings (language + theme) dropdown (self-contained Alpine state) -->
|
||||||
our ghost-square icon-button shell for consistency with cart/gear -->
|
{% include "partials/settings_dropdown.html" %}
|
||||||
<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">
|
|
||||||
<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>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -143,21 +202,87 @@
|
|||||||
<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 %}
|
||||||
|
<!-- account-area sidebar: replaces the storefront categories while the
|
||||||
|
customer is inside /account/*. -->
|
||||||
|
<aside x-cloak x-show="cats || lg" aria-label="{{ t(key='nav-account', lang=lang | default(value='sk')) }}"
|
||||||
|
class="fixed inset-y-0 left-0 z-40 w-64 overflow-y-auto border-r border-outline bg-surface-alt p-4 lg:sticky lg:top-24 lg:z-auto lg:w-64 lg:shrink-0 lg:self-start lg:overflow-visible lg:rounded-radius lg:border lg:p-3 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<h2 class="px-3 pb-2 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="nav-account", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
<ul class="space-y-1">
|
||||||
|
<li><a href="/account/orders" data-nav="/account/orders" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="account-orders", lang=lang | default(value='sk')) }}</a></li>
|
||||||
|
<li><a href="/account/profile" data-nav="/account/profile" 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="profile-title", lang=lang | default(value='sk')) }}</a></li>
|
||||||
|
<li><a href="/account/password" data-nav="/account/password" 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-change-password", lang=lang | default(value='sk')) }}</a></li>
|
||||||
|
<li><a href="/account/security" data-nav="/account/security" 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="security-title", lang=lang | default(value='sk')) }}</a></li>
|
||||||
|
</ul>
|
||||||
|
<form method="post" action="/logout" hx-boost="false" class="mt-4 border-t border-outline pt-3 dark:border-outline-dark">
|
||||||
|
{{ 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>
|
||||||
|
</aside>
|
||||||
|
{% else %}
|
||||||
<!-- persistent category sidebar (off-canvas drawer on mobile).
|
<!-- persistent category sidebar (off-canvas drawer on mobile).
|
||||||
hx-preserve keeps this node across boosted page swaps, so it is
|
hx-preserve keeps this node across boosted page swaps, so it is
|
||||||
fetched once (hx-trigger=load) and never reloaded on navigation. -->
|
fetched once (hx-trigger=load) and never reloaded on navigation. -->
|
||||||
<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 %}
|
||||||
|
|
||||||
<main class="min-w-0 flex-1">
|
<main class="min-w-0 flex-1">
|
||||||
{% block content %}{% endblock content %}
|
{% block content %}{% endblock content %}
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- site footer (Kompress design): brand blurb + Informácie / Účet / Kontakt
|
||||||
|
link columns + copyright bar. Static links; reuses the nav i18n keys. -->
|
||||||
|
<footer class="border-t border-outline bg-surface dark:border-outline-dark dark:bg-surface-dark">
|
||||||
|
<div class="mx-auto grid max-w-7xl grid-cols-2 gap-8 px-4 py-10 md:grid-cols-4 md:px-8">
|
||||||
|
<div class="col-span-2 md:col-span-1">
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<span class="inline-flex size-8 items-center justify-center rounded-radius bg-primary text-on-primary dark:bg-primary-dark dark:text-on-primary-dark">
|
||||||
|
<svg width="17" height="17" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="10" y="3" width="4" height="18" rx="1.5"></rect><rect x="3" y="10" width="18" height="4" rx="1.5"></rect></svg>
|
||||||
|
</span>
|
||||||
|
<span class="text-lg font-extrabold tracking-tight text-primary dark:text-primary-dark">{{ t(key="brand", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-3 max-w-xs text-sm leading-relaxed text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="footer-tagline", lang=lang | default(value='sk')) }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2.5">
|
||||||
|
<div class="text-xs font-bold uppercase tracking-wider text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="footer-info", lang=lang | default(value='sk')) }}</div>
|
||||||
|
<a href="/obchodne-podmienky" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="footer-terms", lang=lang | default(value='sk')) }}</a>
|
||||||
|
<a href="/o-nas" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="footer-about", lang=lang | default(value='sk')) }}</a>
|
||||||
|
<a href="/predajne" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="footer-stores", lang=lang | default(value='sk')) }}</a>
|
||||||
|
<a href="/doprava-a-platba" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="footer-shipping", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2.5">
|
||||||
|
<div class="text-xs font-bold uppercase tracking-wider text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="footer-account", lang=lang | default(value='sk')) }}</div>
|
||||||
|
{% if logged_in_customer %}
|
||||||
|
<a href="/account/orders" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="footer-orders", lang=lang | default(value='sk')) }}</a>
|
||||||
|
<a href="/account/profile" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="nav-profile", lang=lang | default(value='sk')) }}</a>
|
||||||
|
{% else %}
|
||||||
|
<a href="/login" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="nav-login", lang=lang | default(value='sk')) }}</a>
|
||||||
|
<a href="/register" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="nav-register", lang=lang | default(value='sk')) }}</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="/cart" hx-boost="false" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="cart-title", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col gap-2.5">
|
||||||
|
<div class="text-xs font-bold uppercase tracking-wider text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="footer-contact", lang=lang | default(value='sk')) }}</div>
|
||||||
|
<a href="tel:+421903410476" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="hotline", lang=lang | default(value='sk')) }}</a>
|
||||||
|
<a href="mailto:info@kompress.sk" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="footer-email", lang=lang | default(value='sk')) }}</a>
|
||||||
|
<span class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="footer-hours", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-outline dark:border-outline-dark">
|
||||||
|
<div class="mx-auto max-w-7xl px-4 py-4 text-xs text-on-surface/50 md:px-8 dark:text-on-surface-dark/50">{{ t(key="footer-rights", lang=lang | default(value='sk')) }}</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
<!-- toast notifications: fire from anywhere with toast('message').
|
<!-- toast notifications: fire from anywhere with toast('message').
|
||||||
Adapted from the vendored Penguin UI component
|
Adapted from the vendored Penguin UI component
|
||||||
(penguinui-components/toast-notification/stacking-toast-notification.html):
|
(penguinui-components/toast-notification/stacking-toast-notification.html):
|
||||||
|
|||||||
@@ -3,28 +3,112 @@
|
|||||||
|
|
||||||
{% 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-primary dark:text-primary-dark">{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ currency_symbol }}</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
{% endfor %}
|
{% 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>
|
||||||
|
|
||||||
|
<!-- contact CTA (static, brand blue) -->
|
||||||
|
<section class="overflow-hidden rounded-radius bg-cta text-on-cta dark:bg-cta-dark dark:text-on-cta-dark">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="text-xs font-bold uppercase tracking-wider opacity-80">{{ t(key="home-contact-title", lang=L) }}</div>
|
||||||
|
<p class="mt-2.5 text-sm leading-relaxed opacity-90">{{ t(key="home-contact-text", lang=L) }}</p>
|
||||||
|
<a href="tel:+421903410476" class="mt-3.5 flex items-center gap-2.5 text-xl font-extrabold tracking-tight">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 4h4l2 5-3 2a12 12 0 0 0 5 5l2-3 5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2Z"></path></svg>
|
||||||
|
+421 903 410 476
|
||||||
|
</a>
|
||||||
|
<a href="tel:+421903410476" class="mt-3.5 block w-full rounded-radius bg-surface px-4 py-3 text-center text-sm font-bold text-cta transition hover:opacity-90 dark:bg-surface-dark dark:text-cta-dark">{{ t(key="home-contact-cta", lang=L) }}</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -29,7 +29,14 @@
|
|||||||
outline : outline-primary | outline-secondary | outline-alternate | outline-danger
|
outline : outline-primary | outline-secondary | outline-alternate | outline-danger
|
||||||
ghost : ghost-primary | ghost-secondary | ghost-danger #}
|
ghost : ghost-primary | ghost-secondary | ghost-danger #}
|
||||||
|
|
||||||
{% macro button(label, variant="primary", type="button", href="", attrs="", extra="", icon="", size="px-4 py-2 text-sm") -%}
|
{# CSRF hidden field for native (non-htmx) <form method="post"> submits. htmx
|
||||||
|
requests instead inherit the X-CSRF-Token header from <body hx-headers>.
|
||||||
|
`csrf_token()` is a global Tera function bound per-request by shared::csrf. #}
|
||||||
|
{% macro csrf_field() -%}
|
||||||
|
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||||
|
{%- endmacro %}
|
||||||
|
|
||||||
|
{% macro button(label, variant="primary", type="button", href="", attrs="", extra="", icon="", size="px-4 py-2 text-sm", 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" -%}
|
||||||
@@ -42,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,
|
||||||
@@ -74,6 +81,10 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" /></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><path stroke-linecap="round" stroke-linejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" /></svg>
|
||||||
{%- elif name == "close" -%}
|
{%- elif name == "close" -%}
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>
|
||||||
|
{%- elif name == "chevron-double-left" -%}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><path stroke-linecap="round" stroke-linejoin="round" d="m18.75 4.5-7.5 7.5 7.5 7.5m-6-15L5.25 12l7.5 7.5" /></svg>
|
||||||
|
{%- elif name == "search" -%}
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
|
||||||
{%- else -%}
|
{%- 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 -%}
|
||||||
@@ -82,6 +93,11 @@
|
|||||||
{# Compact danger alert (form/inline errors). Adapted from
|
{# Compact danger alert (form/inline errors). Adapted from
|
||||||
penguinui/alert/default-alert.html (danger variant), trimmed to a single line
|
penguinui/alert/default-alert.html (danger variant), trimmed to a single line
|
||||||
with the danger icon. #}
|
with the danger icon. #}
|
||||||
|
{# Required-field marker: a red asterisk appended to a field label. #}
|
||||||
|
{% macro req() -%}
|
||||||
|
<span class="ml-0.5 text-danger" aria-hidden="true">*</span>
|
||||||
|
{%- endmacro req %}
|
||||||
|
|
||||||
{% macro alert_danger(message, extra="") -%}
|
{% macro alert_danger(message, extra="") -%}
|
||||||
<div class="flex w-full items-center gap-2 overflow-hidden rounded-radius border border-danger bg-danger/10 px-3 py-2 text-sm text-danger {{ extra }}" role="alert">
|
<div class="flex w-full items-center gap-2 overflow-hidden rounded-radius border border-danger bg-danger/10 px-3 py-2 text-sm text-danger {{ extra }}" role="alert">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5 shrink-0" aria-hidden="true">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5 shrink-0" aria-hidden="true">
|
||||||
@@ -108,6 +124,21 @@
|
|||||||
{%- endif %}
|
{%- endif %}
|
||||||
{%- endmacro badge %}
|
{%- endmacro badge %}
|
||||||
|
|
||||||
|
{# Effective-price cell content for the admin products table. The value is
|
||||||
|
colored only when it differs from the regular price (effective_reduced);
|
||||||
|
when equal it renders in the plain text color, unified with the Price column.
|
||||||
|
`preview=true` uses the info color (an unsaved profile-toggle preview) instead
|
||||||
|
of the saved primary color. No t() calls, so it is safe inside a macro. #}
|
||||||
|
{% macro eff_price(p, preview=false) -%}
|
||||||
|
{%- if preview -%}{% set strong = "text-info" %}{%- else -%}{% set strong = "text-primary dark:text-primary-dark" %}{%- endif -%}
|
||||||
|
{% if p.effective_reduced %}
|
||||||
|
<span class="font-medium {{ strong }}">{{ p.effective_price }} €</span>
|
||||||
|
<span class="ml-1 text-xs text-on-surface/60 dark:text-on-surface-dark/60">(−{{ p.effective_percent_off }}%)</span>
|
||||||
|
{% else %}
|
||||||
|
{{ p.effective_price }} €
|
||||||
|
{% endif %}
|
||||||
|
{%- endmacro eff_price %}
|
||||||
|
|
||||||
{# ---- Form controls. Verbatim Penguin classes from
|
{# ---- Form controls. Verbatim Penguin classes from
|
||||||
penguinui/{text-input,text-area,select,checkbox,file-input}/default-*.html.
|
penguinui/{text-input,text-area,select,checkbox,file-input}/default-*.html.
|
||||||
These macros emit only the control (callers keep their own <label>/layout), so
|
These macros emit only the control (callers keep their own <label>/layout), so
|
||||||
@@ -115,13 +146,41 @@
|
|||||||
|
|
||||||
{# Text/email/number/password input. #}
|
{# Text/email/number/password input. #}
|
||||||
{% macro input(name, type="text", id="", value="", placeholder="", required=false, autocomplete="", attrs="", extra="", width="w-full") -%}
|
{% macro input(name, type="text", id="", value="", placeholder="", required=false, autocomplete="", attrs="", extra="", width="w-full") -%}
|
||||||
<input {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" type="{{ type }}"{% if value != "" %} value="{{ value }}"{% endif %}{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if required %} required{% endif %}{% if autocomplete %} autocomplete="{{ autocomplete }}"{% endif %} class="{{ width }} rounded-radius border border-outline bg-surface-alt px-2 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 }}/>
|
<input {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" type="{{ type }}"{% if value is number or value != "" %} value="{{ value }}"{% endif %}{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if required %} required{% endif %}{% if autocomplete %} autocomplete="{{ autocomplete }}"{% endif %} class="{{ width }} rounded-radius border border-outline bg-surface-alt px-2 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 }}/>
|
||||||
{%- endmacro input %}
|
{%- endmacro input %}
|
||||||
|
|
||||||
{% macro textarea(name, id="", value="", rows="3", placeholder="", required=false, attrs="", extra="") -%}
|
{% macro textarea(name, id="", value="", rows="3", placeholder="", required=false, attrs="", extra="") -%}
|
||||||
<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 }}/>
|
||||||
@@ -208,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 %}
|
||||||
78
assets/views/partials/profile_menu.html
Normal file
78
assets/views/partials/profile_menu.html
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
{# Customer profile dropdown in the storefront navbar.
|
||||||
|
|
||||||
|
Proper Penguin UI dropdown: behaviour is the vendored
|
||||||
|
dropdowns/dropdown-with-icons.html verbatim (isOpen / openedWithKeyboard,
|
||||||
|
x-trap + $focus keyboard nav, x-cloak x-show, @click.outside). Trigger is the
|
||||||
|
round initials avatar (avatar-with-initials.html, primary variant). Menu items
|
||||||
|
are our account links.
|
||||||
|
|
||||||
|
Needs the Alpine Focus plugin (loaded before Alpine core in base.html) for
|
||||||
|
x-trap / $focus. Self-contained Alpine state; the host only needs to place it
|
||||||
|
in the navbar flex row. The panel has NO id on purpose — an id would make htmx
|
||||||
|
hx-boost "settle" it across boosted navigations and reappear; id-less Penguin
|
||||||
|
dropdowns are unaffected. #}
|
||||||
|
|
||||||
|
{# initials from the full name, e.g. "Filip Priec" -> "FP" #}
|
||||||
|
{% set _name = customer_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 %}
|
||||||
|
{% if customer_account_type == "company" %}{% set _type_label = t(key="account-company", lang=lang | default(value='sk')) %}{% else %}{% set _type_label = t(key="account-personal", lang=lang | default(value='sk')) %}{% endif %}
|
||||||
|
|
||||||
|
{% set _person_icon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" class="size-5"><path fill-rule="evenodd" d="M7.5 6a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM3.751 20.105a8.25 8.25 0 0116.498 0 .75.75 0 01-.437.695A18.683 18.683 0 0112 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 01-.437-.695z" clip-rule="evenodd"/></svg>' %}
|
||||||
|
|
||||||
|
<div x-data="{ isOpen: false, openedWithKeyboard: false }"
|
||||||
|
x-on:keydown.esc.window="isOpen = false, openedWithKeyboard = false"
|
||||||
|
class="relative">
|
||||||
|
<!-- Toggle Button: round initials avatar -->
|
||||||
|
<button type="button" 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"
|
||||||
|
aria-label="{{ t(key='nav-account', lang=lang | default(value='sk')) }}"
|
||||||
|
class="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full border border-primary bg-primary text-sm font-bold tracking-wider text-on-primary/90 transition hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark/90 dark:focus-visible:outline-primary-dark">
|
||||||
|
{%- if customer_avatar %}<img src="/images/{{ customer_avatar }}" alt="{{ _name }}" class="size-full object-cover">{% elif _initials %}{{ _initials }}{% else %}{{ _person_icon | safe }}{% endif -%}
|
||||||
|
</button>
|
||||||
|
<!-- Dropdown Menu (positioned like the settings cog: right-0 mt-2) -->
|
||||||
|
<div x-cloak x-show="isOpen || openedWithKeyboard" x-transition x-trap="openedWithKeyboard"
|
||||||
|
x-on:click.outside="isOpen = false, openedWithKeyboard = false"
|
||||||
|
x-on:keydown.down.prevent="$focus.wrap().next()" x-on:keydown.up.prevent="$focus.wrap().previous()"
|
||||||
|
class="absolute right-0 mt-2 flex w-60 min-w-48 flex-col divide-y divide-outline overflow-hidden rounded-radius border border-outline bg-surface-alt shadow-lg dark:divide-outline-dark dark:border-outline-dark dark:bg-surface-dark-alt" role="menu">
|
||||||
|
<!-- header: avatar + name + account type -->
|
||||||
|
<div class="flex items-center gap-3 px-4 py-2.5">
|
||||||
|
<span class="flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-full border border-primary bg-primary text-base font-bold tracking-wider text-on-primary/90 dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark/90">
|
||||||
|
{%- if customer_avatar %}<img src="/images/{{ customer_avatar }}" alt="{{ _name }}" class="size-full object-cover">{% elif _initials %}{{ _initials }}{% else %}{{ _person_icon | safe }}{% endif -%}
|
||||||
|
</span>
|
||||||
|
<div class="flex min-w-0 flex-col">
|
||||||
|
<span class="truncate text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ _name }}</span>
|
||||||
|
<p class="truncate text-xs text-on-surface dark:text-on-surface-dark">{{ _type_label }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- account links (with icons) -->
|
||||||
|
<div class="flex flex-col py-1.5">
|
||||||
|
<a href="/account/orders" data-nav="/account/orders" role="menuitem" class="flex items-center gap-2 bg-surface-alt px-4 py-2 text-sm text-on-surface hover:bg-surface-dark-alt/5 hover:text-on-surface-strong focus-visible:bg-surface-dark-alt/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:bg-surface-alt/5 dark:hover:text-on-surface-dark-strong dark:focus-visible:bg-surface-alt/10 dark:focus-visible:text-on-surface-dark-strong">
|
||||||
|
{{ ui::icon(name="cart", size="size-4", extra="shrink-0") }}
|
||||||
|
{{ t(key="account-orders", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
<a href="/account/profile" data-nav="/account/profile" role="menuitem" class="flex items-center gap-2 bg-surface-alt px-4 py-2 text-sm text-on-surface hover:bg-surface-dark-alt/5 hover:text-on-surface-strong focus-visible:bg-surface-dark-alt/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:bg-surface-alt/5 dark:hover:text-on-surface-dark-strong dark:focus-visible:bg-surface-alt/10 dark:focus-visible:text-on-surface-dark-strong">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" class="size-4 shrink-0"><path fill-rule="evenodd" d="M7.5 6a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM3.751 20.105a8.25 8.25 0 0116.498 0 .75.75 0 01-.437.695A18.683 18.683 0 0112 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 01-.437-.695z" clip-rule="evenodd"/></svg>
|
||||||
|
{{ t(key="profile-title", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
<a href="/account/password" data-nav="/account/password" role="menuitem" class="flex items-center gap-2 bg-surface-alt px-4 py-2 text-sm text-on-surface hover:bg-surface-dark-alt/5 hover:text-on-surface-strong focus-visible:bg-surface-dark-alt/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:bg-surface-alt/5 dark:hover:text-on-surface-dark-strong dark:focus-visible:bg-surface-alt/10 dark:focus-visible:text-on-surface-dark-strong">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" class="size-4 shrink-0"><path fill-rule="evenodd" d="M15.75 1.5a6.75 6.75 0 00-6.651 7.906c.067.39-.032.717-.221.906l-6.5 6.499a3 3 0 00-.878 2.121v2.818c0 .414.336.75.75.75H6a.75.75 0 00.75-.75v-1.5h1.5A.75.75 0 009 21v-1.5h1.5a.75.75 0 00.53-.22l2.658-2.658c.19-.189.517-.288.906-.22A6.75 6.75 0 1015.75 1.5zm0 3a.75.75 0 000 1.5A2.25 2.25 0 0118 8.25a.75.75 0 001.5 0 3.75 3.75 0 00-3.75-3.75z" clip-rule="evenodd"/></svg>
|
||||||
|
{{ t(key="account-change-password", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
<a href="/account/security" data-nav="/account/security" role="menuitem" class="flex items-center gap-2 bg-surface-alt px-4 py-2 text-sm text-on-surface hover:bg-surface-dark-alt/5 hover:text-on-surface-strong focus-visible:bg-surface-dark-alt/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:bg-surface-alt/5 dark:hover:text-on-surface-dark-strong dark:focus-visible:bg-surface-alt/10 dark:focus-visible:text-on-surface-dark-strong">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" class="size-4 shrink-0"><path fill-rule="evenodd" d="M12 1.5a5.25 5.25 0 00-5.25 5.25v3a3 3 0 00-3 3v6.75a3 3 0 003 3h10.5a3 3 0 003-3v-6.75a3 3 0 00-3-3v-3c0-2.9-2.35-5.25-5.25-5.25zm3.75 8.25v-3a3.75 3.75 0 10-7.5 0v3h7.5z" clip-rule="evenodd"/></svg>
|
||||||
|
{{ t(key="security-title", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<!-- logout -->
|
||||||
|
<div class="flex flex-col py-1.5">
|
||||||
|
<form method="post" action="/logout" hx-boost="false">
|
||||||
|
<input type="hidden" name="_csrf" value="{{ csrf_token() }}"><button type="submit" role="menuitem" class="flex w-full items-center gap-2 bg-surface-alt px-4 py-2 text-left text-sm text-on-surface hover:bg-surface-dark-alt/5 hover:text-on-surface-strong focus-visible:bg-surface-dark-alt/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:bg-surface-alt/5 dark:hover:text-on-surface-dark-strong dark:focus-visible:bg-surface-alt/10 dark:focus-visible:text-on-surface-dark-strong">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" class="size-4 shrink-0"><path fill-rule="evenodd" d="M7.5 3.75A1.5 1.5 0 006 5.25v13.5a1.5 1.5 0 001.5 1.5h6a1.5 1.5 0 001.5-1.5V15a.75.75 0 011.5 0v3.75a3 3 0 01-3 3h-6a3 3 0 01-3-3V5.25a3 3 0 013-3h6a3 3 0 013 3V9A.75.75 0 0115 9V5.25a1.5 1.5 0 00-1.5-1.5h-6zm10.72 4.72a.75.75 0 011.06 0l3 3a.75.75 0 010 1.06l-3 3a.75.75 0 11-1.06-1.06l1.72-1.72H9a.75.75 0 010-1.5h10.94l-1.72-1.72a.75.75 0 010-1.06z" clip-rule="evenodd"/></svg>
|
||||||
|
{{ t(key="logout", lang=lang | default(value='sk')) }}
|
||||||
|
</button></form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -1,21 +1,26 @@
|
|||||||
{# Settings dropdown (language + theme). Shared by base.html and admin/base.html
|
{# Settings dropdown (language + theme). Shared by base.html and admin/base.html
|
||||||
to kill the former ~100-line copy-paste duplication.
|
to kill the former ~100-line copy-paste duplication.
|
||||||
|
|
||||||
Adapted from the vendored Penguin UI component
|
Proper Penguin UI dropdown: behaviour is the vendored
|
||||||
penguinui-components/dropdowns/dropdown-with-click.html: Penguin's dropdown
|
dropdowns/dropdown-with-icons.html verbatim (isOpen / openedWithKeyboard,
|
||||||
menu container + item treatment. Deviations: kept our gear icon-only trigger
|
x-trap + $focus keyboard nav, x-cloak x-show, @click.outside). Trigger is our
|
||||||
and our core-Alpine open / @click.outside toggle (upstream's x-trap / $focus
|
gear icon-only button; content is the language form + theme toggle. Needs the
|
||||||
need the Alpine Focus plugin, which we don't bundle); item hover uses
|
Alpine Focus plugin (loaded in base.html) for x-trap / $focus.
|
||||||
bg-primary/5 to stay consistent with the rest of our Penguin-ified UI.
|
|
||||||
|
|
||||||
The host template provides the wrapper
|
Self-contained Alpine state + relative positioning; the host only places it
|
||||||
<div x-data="{ open: false }" @keydown.escape="open = false" class="relative ...">
|
(e.g. ml-auto in admin). The panel has NO id on purpose (see profile_menu.html
|
||||||
so it controls its own positioning (e.g. ml-auto in admin). #}
|
for why — htmx hx-boost settles by id). #}
|
||||||
{{ ui::icon_button(aria_label=t(key='settings', lang=lang | default(value='sk')), attrs='@click="open = !open" :aria-expanded="open"', 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-data="{ isOpen: false, openedWithKeyboard: false }"
|
||||||
<div x-show="open" x-cloak @click.outside="open = false" x-transition.origin.top.right
|
x-on:keydown.esc.window="isOpen = false, openedWithKeyboard = false"
|
||||||
|
class="relative self-stretch">
|
||||||
|
{{ ui::icon_button(size="h-full w-9", aria_label=t(key='settings', lang=lang | default(value='sk')), attrs='x-on:click="isOpen = ! isOpen" x-on:keydown.space.prevent="openedWithKeyboard = true" x-on:keydown.enter.prevent="openedWithKeyboard = true" x-on:keydown.down.prevent="openedWithKeyboard = true" x-bind:aria-expanded="isOpen || openedWithKeyboard" aria-haspopup="true"', icon='<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>') }}
|
||||||
|
<div x-cloak x-show="isOpen || openedWithKeyboard" x-transition x-trap="openedWithKeyboard"
|
||||||
|
x-on:click.outside="isOpen = false, openedWithKeyboard = false"
|
||||||
|
x-on:keydown.down.prevent="$focus.wrap().next()" x-on:keydown.up.prevent="$focus.wrap().previous()"
|
||||||
class="absolute right-0 mt-2 flex w-56 flex-col overflow-hidden rounded-radius border border-outline bg-surface-alt py-1 shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt"
|
class="absolute right-0 mt-2 flex w-56 flex-col overflow-hidden rounded-radius border border-outline bg-surface-alt py-1 shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt"
|
||||||
role="menu">
|
role="menu">
|
||||||
<form method="post" action="/lang" hx-boost="false">
|
<form method="post" action="/lang" hx-boost="false">
|
||||||
|
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||||
<p class="px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
<p class="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-language", lang=lang | default(value='sk')) }}
|
{{ t(key="settings-language", lang=lang | default(value='sk')) }}
|
||||||
</p>
|
</p>
|
||||||
@@ -30,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>
|
||||||
@@ -54,3 +85,4 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
@@ -1,34 +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>
|
||||||
<span class="whitespace-nowrap text-xl"><span class="sr-only">Price</span>{{ product.price }} {{ product.currency }}</span>
|
{# Short blurb for the card; falls back to the full description (clamped)
|
||||||
|
for products without a dedicated short one. Both are authored as rich
|
||||||
|
text (Quill), so render the stored HTML — `.rich-blurb` strips block
|
||||||
|
spacing so the line-clamp stays tidy. Overflow is truncated with an
|
||||||
|
ellipsis: 2 lines in the grid, 3 in the roomier list row. #}
|
||||||
|
{% if product.short_description or product.description %}
|
||||||
|
<div class="rich-blurb line-clamp-2 break-words text-sm text-on-surface/70 dark:text-on-surface-dark/70"
|
||||||
|
:class="view === 'list' && 'line-clamp-3'">{% if product.short_description %}{{ product.short_description | safe }}{% else %}{{ product.description | safe }}{% endif %}</div>
|
||||||
|
{% endif %}
|
||||||
|
{% if product.on_sale %}
|
||||||
|
<div class="flex flex-wrap items-baseline gap-x-2 leading-tight">
|
||||||
|
<span class="text-xl font-semibold text-danger"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ currency_symbol }}</span>
|
||||||
|
<span class="text-sm text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }} {{ currency_symbol }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="break-words text-xl"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ currency_symbol }}</span>
|
||||||
|
{% endif %}
|
||||||
|
<!-- stock pill (Kompress design): green "in stock" / red "sold out" -->
|
||||||
|
<div class="mt-0.5">
|
||||||
|
{% if product.in_stock %}
|
||||||
|
<span class="inline-flex items-center gap-1.5 rounded-full bg-success/10 px-2 py-0.5 text-xs font-semibold text-success">
|
||||||
|
<span class="size-1.5 rounded-full bg-success" aria-hidden="true"></span>{{ t(key="in-stock", lang=lang | default(value='sk')) }}
|
||||||
|
</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="inline-flex items-center gap-1.5 rounded-full bg-danger/10 px-2 py-0.5 text-xs font-semibold text-danger">
|
||||||
|
<span class="size-1.5 rounded-full bg-danger" aria-hidden="true"></span>{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="flex flex-col gap-2 p-6 pt-0">
|
<div class="flex flex-col gap-2"
|
||||||
{% if product.stock > 0 %}
|
:class="view === 'list' ? 'w-full justify-center border-t border-outline p-4 sm:w-48 sm:self-stretch sm:border-l sm:border-t-0 sm:p-5 dark:border-outline-dark' : 'px-4 pb-4 pt-0'">
|
||||||
<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>
|
{% if product.has_options %}
|
||||||
|
{# Multiple variants: customer must pick on the product page. #}
|
||||||
|
{{ ui::button(label=t(key="choose-option", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, extra="w-full", nowrap=false) }}
|
||||||
|
{% elif product.in_stock %}
|
||||||
|
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{% if product.tracked %}{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}{% else %}{{ t(key="available", lang=lang | default(value='sk')) }}{% endif %}</p>
|
||||||
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none"
|
<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="product_id" value="{{ product.id }}">
|
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||||
|
<input type="hidden" name="variant_id" value="{{ product.variant_id }}">
|
||||||
<input type="hidden" name="quantity" value="1">
|
<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>
|
||||||
|
|||||||
@@ -19,16 +19,25 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<a href="/shop/{{ item.slug }}" class="font-medium text-on-surface-strong hover:text-primary dark:text-on-surface-dark-strong dark:hover:text-primary-dark">{{ item.name }}</a>
|
<a href="/shop/{{ item.slug }}" class="font-medium text-on-surface-strong hover:text-primary dark:text-on-surface-dark-strong dark:hover:text-primary-dark">{{ item.name }}</a>
|
||||||
|
{% if item.variant_label %}<span class="block text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ item.variant_label }}</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 tabular-nums">
|
||||||
|
{% if item.on_sale %}
|
||||||
|
<span class="font-medium text-danger">{{ item.price }} {{ currency_symbol }}</span>
|
||||||
|
<span class="ml-1 text-xs text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ item.regular_price }}</span>
|
||||||
|
{% else %}
|
||||||
|
{{ item.price }} {{ currency_symbol }}
|
||||||
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="px-4 py-3 tabular-nums">{{ item.price }} {{ item.currency }}</td>
|
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
{# Changing the quantity posts via htmx (custom `cartchange` event) and
|
{# Changing the quantity posts via htmx (custom `cartchange` event) and
|
||||||
swaps only #cart-body. Dropping to 0 asks for confirmation first,
|
swaps only #cart-body. Dropping to 0 asks for confirmation first,
|
||||||
reverting to the previous quantity if the customer cancels. #}
|
reverting to the previous quantity if the customer cancels. #}
|
||||||
<form method="post" action="/cart/update"
|
<form method="post" action="/cart/update"
|
||||||
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">
|
||||||
<input type="hidden" name="product_id" value="{{ item.id }}">
|
{{ ui::csrf_field() }}
|
||||||
<input type="number" name="quantity" min="0" max="{{ item.stock }}" value="{{ item.quantity }}"
|
<input type="hidden" name="variant_id" value="{{ item.id }}">
|
||||||
|
<input type="number" name="quantity" min="0" {% if item.stock %}max="{{ item.stock }}"{% endif %} value="{{ item.quantity }}"
|
||||||
@change="
|
@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 }}';
|
||||||
@@ -39,11 +48,12 @@
|
|||||||
class="w-20 rounded-radius border border-outline bg-surface-alt px-2 py-1 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark">
|
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">
|
||||||
<input type="hidden" name="product_id" value="{{ item.id }}">
|
{{ ui::csrf_field() }}
|
||||||
|
<input type="hidden" name="variant_id" value="{{ item.id }}">
|
||||||
{{ ui::button(variant="ghost-danger", label=t(key="cart-remove", lang=lang | default(value='sk')), type="submit", size="px-2 py-1 text-xs") }}
|
{{ ui::button(variant="ghost-danger", label=t(key="cart-remove", lang=lang | default(value='sk')), type="submit", size="px-2 py-1 text-xs") }}
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</td>
|
||||||
@@ -53,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>
|
||||||
@@ -62,7 +72,7 @@
|
|||||||
|
|
||||||
<div class="mt-6 flex flex-wrap justify-between gap-3">
|
<div class="mt-6 flex flex-wrap justify-between gap-3">
|
||||||
{{ ui::button(variant="outline-secondary", label=t(key="cart-continue", lang=lang | default(value='sk')), href="/shop") }}
|
{{ ui::button(variant="outline-secondary", label=t(key="cart-continue", lang=lang | default(value='sk')), href="/shop") }}
|
||||||
{{ ui::button(label=t(key="cart-checkout", lang=lang | default(value='sk')), href="/checkout", size="px-5 py-2 text-sm") }}
|
{{ ui::button(label=t(key="cart-checkout", lang=lang | default(value='sk')), href="/checkout", size="px-5 py-2 text-sm", attrs='hx-boost="false"') }}
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="rounded-radius border border-outline px-6 py-16 text-center dark:border-outline-dark">
|
<div class="rounded-radius border border-outline px-6 py-16 text-center dark:border-outline-dark">
|
||||||
|
|||||||
32
assets/views/shop/_cart_preview.html
Normal file
32
assets/views/shop/_cart_preview.html
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
{# Mini-cart preview shown on hover over the navbar cart (Alza-style).
|
||||||
|
Lazy-loaded via htmx from /partials/cart into the hover dropdown panel in
|
||||||
|
base.html. Receives: items[], total, lang. #}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
{% if items | length > 0 %}
|
||||||
|
<div class="max-h-80 divide-y divide-outline overflow-y-auto dark:divide-outline-dark">
|
||||||
|
{% for item in items %}
|
||||||
|
<div class="flex items-start gap-3 px-4 py-3">
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<a href="/shop/{{ item.slug }}" class="block truncate text-sm font-medium text-on-surface-strong hover:text-primary dark:text-on-surface-dark-strong dark:hover:text-primary-dark">{{ item.name }}</a>
|
||||||
|
{% if item.variant_label %}<span class="block truncate text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ item.variant_label }}</span>{% endif %}
|
||||||
|
<p class="mt-0.5 text-xs tabular-nums text-on-surface dark:text-on-surface-dark">{{ item.quantity }} × {{ item.price }} {{ currency_symbol }}</p>
|
||||||
|
</div>
|
||||||
|
<span class="shrink-0 text-sm font-semibold tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{{ item.line_total }} {{ currency_symbol }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<div class="border-t border-outline px-4 py-3 dark:border-outline-dark">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<span class="text-sm text-on-surface dark:text-on-surface-dark">{{ t(key="cart-total", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<span class="text-base font-bold tabular-nums text-primary dark:text-primary-dark">{{ total }} {{ currency_symbol }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
{{ ui::button(href="/cart", variant="outline-primary", label=t(key="cart-title", lang=lang | default(value='sk')), extra="flex-1", attrs='hx-boost="false"') }}
|
||||||
|
{{ ui::button(href="/checkout", variant="primary", label=t(key="cart-checkout", lang=lang | default(value='sk')), extra="flex-1", attrs='hx-boost="false"') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="px-4 py-10 text-center text-sm text-on-surface dark:text-on-surface-dark">
|
||||||
|
{{ t(key="cart-empty", lang=lang | default(value='sk')) }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
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="flex max-w-xl gap-2">
|
||||||
|
<div class="relative flex-1">
|
||||||
|
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-on-surface/50 dark:text-on-surface-dark/50">
|
||||||
|
{{ ui::icon(name="search", size="size-5") }}
|
||||||
|
</span>
|
||||||
|
<input type="search" name="q" value="{{ query | default(value='') }}" autocomplete="off"
|
||||||
|
placeholder="{{ t(key='search-placeholder', lang=L) }}"
|
||||||
|
aria-label="{{ t(key='search-placeholder', lang=L) }}"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface py-2 pl-10 pr-10 text-sm text-on-surface placeholder:text-on-surface/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50 dark:focus-visible:outline-primary-dark" />
|
||||||
|
<span id="search-spinner" class="htmx-indicator pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-on-surface/50 dark:text-on-surface-dark/50">
|
||||||
|
<svg class="size-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.4 0 0 5.4 0 12h4Z"></path>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="shrink-0 rounded-radius bg-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,6 +16,11 @@
|
|||||||
{{ 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-primary/10 aria-[current=page]:text-on-surface-strong 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-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||||
|
{{ 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-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="all-products", lang=lang | default(value='sk')) }}
|
{{ t(key="all-products", lang=lang | default(value='sk')) }}
|
||||||
@@ -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" class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">{{ t(key="footer-terms", lang=L) }}</a>
|
||||||
|
<a href="/predajne" class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">{{ t(key="footer-stores", lang=L) }}</a>
|
||||||
|
<a href="/doprava-a-platba" class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">{{ t(key="footer-shipping", lang=L) }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
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 %}
|
||||||
|
|||||||
@@ -11,6 +11,8 @@
|
|||||||
<form method="post" action="/checkout" hx-boost="false"
|
<form method="post" action="/checkout" hx-boost="false"
|
||||||
x-data="{
|
x-data="{
|
||||||
paymentMethod: '',
|
paymentMethod: '',
|
||||||
|
accountType: '{{ prefill_account_type | default(value='personal') }}',
|
||||||
|
deliverySame: false,
|
||||||
carrier: '',
|
carrier: '',
|
||||||
carrierPrice: 0,
|
carrierPrice: 0,
|
||||||
requiresPoint: false,
|
requiresPoint: false,
|
||||||
@@ -29,25 +31,76 @@
|
|||||||
}
|
}
|
||||||
}"
|
}"
|
||||||
class="mt-6 grid gap-8 lg:grid-cols-3">
|
class="mt-6 grid gap-8 lg:grid-cols-3">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
|
||||||
<div class="space-y-6 lg:col-span-2">
|
<div class="space-y-6 lg:col-span-2">
|
||||||
|
<!-- personal vs company. Fixed (read-only) for a logged-in account; a guest
|
||||||
|
picks it and the choice will type any account they create. -->
|
||||||
|
<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="account-type", lang=lang | default(value='sk')) }}</legend>
|
||||||
|
{% if account_fixed %}
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
{% if prefill_account_type == "company" %}
|
||||||
|
{{ ui::badge(label=t(key="account-company", lang=lang | default(value='sk')), variant="primary") }}
|
||||||
|
{% else %}
|
||||||
|
{{ ui::badge(label=t(key="account-personal", lang=lang | default(value='sk')), variant="neutral") }}
|
||||||
|
{% endif %}
|
||||||
|
<span class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="account-type-locked", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="grid gap-3 sm:grid-cols-2">
|
||||||
|
<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="account_type", value="personal", attrs='x-model="accountType"') }}
|
||||||
|
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="account-personal", 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="account_type", value="company", attrs='x-model="accountType"') }}
|
||||||
|
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="account-company", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<!-- company billing details (company accounts only) -->
|
||||||
|
<fieldset x-show="accountType === 'company'" 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="account-company-details", lang=lang | default(value='sk')) }}</legend>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="company_name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-name", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
||||||
|
{{ ui::input(name="company_name", id="company_name", value=prefill_company_name | default(value=''), autocomplete="organization") }}
|
||||||
|
</div>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-3">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="company_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-ico", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
||||||
|
{{ ui::input(name="company_id", id="company_id", value=prefill_company_id | default(value='')) }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="tax_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-dic", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
||||||
|
{{ ui::input(name="tax_id", id="tax_id", value=prefill_tax_id | default(value='')) }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="vat_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-icdph", lang=lang | default(value='sk')) }} <span class="text-on-surface/50 dark:text-on-surface-dark/50">({{ t(key="field-optional", lang=lang | default(value='sk')) }})</span></label>
|
||||||
|
{{ ui::input(name="vat_id", id="vat_id", value=prefill_vat_id | default(value='')) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
<!-- contact -->
|
<!-- contact -->
|
||||||
<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-contact", 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-contact", lang=lang | default(value='sk')) }}</legend>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="email" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-email", lang=lang | default(value='sk')) }}</label>
|
<label for="email" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-email", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
||||||
{{ ui::input(name="email", id="email", type="email", required=true, autocomplete="email") }}
|
{{ ui::input(name="email", id="email", type="email", value=prefill_email | default(value=''), required=true, autocomplete="email") }}
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="customer_name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-name", lang=lang | default(value='sk')) }}</label>
|
<label for="customer_name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-name", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
||||||
{{ ui::input(name="customer_name", id="customer_name", required=true, autocomplete="name") }}
|
{{ ui::input(name="customer_name", id="customer_name", value=prefill_name | default(value=''), required=true, autocomplete="name") }}
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="phone" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-phone", lang=lang | default(value='sk')) }}</label>
|
<label for="phone" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-phone", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<!-- editable combobox: type freely or pick from the dropdown -->
|
<!-- editable combobox: type freely or pick from the dropdown -->
|
||||||
<div class="relative w-28 shrink-0" @click.outside="prefixOpen = false"
|
<div class="relative w-28 shrink-0" @click.outside="prefixOpen = false"
|
||||||
x-data="{ prefixOpen: false, prefix: '+421', opts: [
|
x-data="{ prefixOpen: false, prefix: '{{ prefill_phone_prefix | default(value='+421') }}', opts: [
|
||||||
{ v: '+421', l: '🇸🇰 +421' }, { v: '+420', l: '🇨🇿 +420' },
|
{ v: '+421', l: '🇸🇰 +421' }, { v: '+420', l: '🇨🇿 +420' },
|
||||||
{ v: '+43', l: '🇦🇹 +43' }, { v: '+49', l: '🇩🇪 +49' },
|
{ v: '+43', l: '🇦🇹 +43' }, { v: '+49', l: '🇩🇪 +49' },
|
||||||
{ v: '+48', l: '🇵🇱 +48' }, { v: '+36', l: '🇭🇺 +36' },
|
{ v: '+48', l: '🇵🇱 +48' }, { v: '+36', l: '🇭🇺 +36' },
|
||||||
@@ -71,29 +124,79 @@
|
|||||||
</template>
|
</template>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{{ ui::input(name="phone", id="phone", type="tel", required=true, autocomplete="tel", placeholder="900 000 000", attrs='inputmode="tel"') }}
|
{{ ui::input(name="phone", id="phone", type="tel", value=prefill_phone | default(value=''), required=true, autocomplete="tel", placeholder="900 000 000", attrs='inputmode="tel"') }}
|
||||||
</div>
|
</div>
|
||||||
</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')) }}</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", 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')) }}</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", 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')) }}</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", 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')) }}</label>
|
<label for="residence_country" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-country", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
||||||
|
<div class="relative" @click.outside="countryOpen = false"
|
||||||
|
x-data="{ countryOpen: false, country: '{{ prefill_residence_country | default(value=t(key='country-sk', lang=lang | default(value='sk'))) }}', opts: [
|
||||||
|
{ v: '{{ t(key='country-sk', lang=lang | default(value='sk')) }}', l: '🇸🇰 {{ t(key='country-sk', lang=lang | default(value='sk')) }}' },
|
||||||
|
{ v: '{{ t(key='country-cz', lang=lang | default(value='sk')) }}', l: '🇨🇿 {{ t(key='country-cz', lang=lang | default(value='sk')) }}' },
|
||||||
|
{ v: '{{ t(key='country-at', lang=lang | default(value='sk')) }}', l: '🇦🇹 {{ t(key='country-at', lang=lang | default(value='sk')) }}' },
|
||||||
|
{ 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="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"
|
<div class="relative" @click.outside="countryOpen = false"
|
||||||
x-data="{ countryOpen: false, country: '{{ t(key='country-sk', lang=lang | default(value='sk')) }}', opts: [
|
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-sk', lang=lang | default(value='sk')) }}', l: '🇸🇰 {{ t(key='country-sk', lang=lang | default(value='sk')) }}' },
|
||||||
@@ -103,7 +206,7 @@
|
|||||||
{ 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="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">
|
||||||
@@ -126,7 +229,7 @@
|
|||||||
|
|
||||||
<!-- carrier -->
|
<!-- carrier -->
|
||||||
<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-carrier", 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-carrier", lang=lang | default(value='sk')) }}{{ ui::req() }}</legend>
|
||||||
{% for m in shipping_methods %}
|
{% for m in shipping_methods %}
|
||||||
<label class="flex cursor-pointer items-center justify-between 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 justify-between 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">
|
||||||
<span class="flex items-center gap-3">
|
<span class="flex items-center gap-3">
|
||||||
@@ -136,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 %}
|
||||||
|
|
||||||
@@ -162,21 +265,37 @@
|
|||||||
|
|
||||||
<!-- 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')) }}</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">
|
||||||
<label for="note" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-note", lang=lang | default(value='sk')) }}</label>
|
<label for="note" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-note", lang=lang | default(value='sk')) }}</label>
|
||||||
{{ ui::textarea(name="note", id="note", rows="3") }}
|
{{ ui::textarea(name="note", id="note", rows="3") }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if logged_in_customer and not profile_filled %}
|
||||||
|
<!-- offered only when the profile has no saved address yet; if it was filled
|
||||||
|
in advance we leave it untouched -->
|
||||||
|
{{ ui::checkbox(name="save_profile", id="save_profile", label=t(key="checkout-save-profile", lang=lang | default(value='sk'))) }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if can_create_account %}
|
||||||
|
<!-- guests may turn this order into an account (typed by their choice above) -->
|
||||||
|
<div class="space-y-1.5 rounded-radius border border-outline bg-surface p-4 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
{{ ui::checkbox(name="create_account", id="create_account", label=t(key="checkout-create-account", lang=lang | default(value='sk'))) }}
|
||||||
|
<p class="pl-6 text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-create-account-hint", lang=lang | default(value='sk')) }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- summary -->
|
<!-- summary -->
|
||||||
@@ -186,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 %}
|
||||||
|
|||||||
@@ -15,6 +15,12 @@
|
|||||||
<p class="mt-1 text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="order-confirmed-sub", lang=lang | default(value='sk')) }}</p>
|
<p class="mt-1 text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="order-confirmed-sub", lang=lang | default(value='sk')) }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if account_created %}
|
||||||
|
<div class="rounded-radius border border-primary/40 bg-primary/5 p-4 text-sm text-on-surface dark:border-primary-dark/40 dark:text-on-surface-dark" role="status">
|
||||||
|
{{ t(key="order-account-created", lang=lang | default(value='sk')) }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
<div class="rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
<div class="flex flex-wrap justify-between gap-2 border-b border-outline pb-3 dark:border-outline-dark">
|
<div class="flex flex-wrap justify-between gap-2 border-b border-outline pb-3 dark:border-outline-dark">
|
||||||
<span class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="order-number", lang=lang | default(value='sk')) }}</span>
|
<span class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="order-number", lang=lang | default(value='sk')) }}</span>
|
||||||
@@ -23,19 +29,34 @@
|
|||||||
<ul class="space-y-2 py-3 text-sm">
|
<ul class="space-y-2 py-3 text-sm">
|
||||||
{% 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 }} × {{ 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>
|
||||||
|
|
||||||
@@ -46,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
|
||||||
@@ -49,31 +50,98 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- details -->
|
<!-- details -->
|
||||||
<div class="space-y-6">
|
{% set fld = "w-full rounded-radius border border-outline bg-surface-alt px-3 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark" %}
|
||||||
|
{% set btn = "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-radius px-5 py-2 text-sm text-center font-medium tracking-wide transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75 border border-cta bg-cta text-on-cta focus-visible:outline-cta dark:border-cta-dark dark:bg-cta-dark dark:text-on-cta-dark dark:focus-visible:outline-cta-dark" %}
|
||||||
|
<script id="variant-data" type="application/json">{{ variants | json_encode() | safe }}</script>
|
||||||
|
<div class="space-y-6"
|
||||||
|
x-data="{
|
||||||
|
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>
|
||||||
<p class="text-2xl font-semibold text-primary dark:text-primary-dark">{{ product.price }} {{ product.currency }}</p>
|
|
||||||
|
|
||||||
{% if product.description %}
|
{% if product.short_description %}
|
||||||
<div class="whitespace-pre-line leading-relaxed text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description }}</div>
|
<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 %}
|
{% endif %}
|
||||||
|
|
||||||
{% if product.stock > 0 %}
|
<template x-if="current">
|
||||||
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none" class="flex flex-wrap items-end gap-3"
|
<div class="space-y-6">
|
||||||
hx-on::after-request="if (event.detail.successful) toast('{{ t(key='cart-added', lang=lang | default(value='sk')) }}')">
|
<!-- option picker (only when there's a real choice); first option is
|
||||||
<input type="hidden" name="product_id" value="{{ product.id }}">
|
selected by default and switching it updates the price + buy form -->
|
||||||
<div class="space-y-1.5">
|
<template x-if="variants.length > 1">
|
||||||
<label for="quantity" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="quantity", lang=lang | default(value='sk')) }}</label>
|
<div class="max-w-sm space-y-1.5">
|
||||||
{{ ui::input(name="quantity", id="quantity", type="number", value="1", width="w-24", attrs='min="1" max="' ~ product.stock ~ '"') }}
|
<label for="variant-select" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="choose-option", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<select id="variant-select" x-model.number="sel" class="{{ fld }}">
|
||||||
|
<template x-for="(v, i) in variants" :key="v.id">
|
||||||
|
<option :value="i" x-text="(v.label || '—') + ' · ' + v.price + ' {{ currency_symbol }}' + (v.in_stock ? '' : ' ({{ t(key='out-of-stock', lang=lang | default(value='sk')) }})')"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="flex items-baseline gap-3">
|
||||||
|
<p class="text-2xl font-semibold" :class="current.on_sale ? 'text-danger' : 'text-primary dark:text-primary-dark'">
|
||||||
|
<span x-text="current.price"></span> {{ currency_symbol }}
|
||||||
|
</p>
|
||||||
|
<template x-if="current.on_sale">
|
||||||
|
<p class="text-lg text-on-surface/50 line-through dark:text-on-surface-dark/50"><span x-text="current.regular_price"></span> {{ currency_symbol }}</p>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-if="current.in_stock">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none" class="flex flex-wrap items-end gap-3"
|
||||||
|
hx-on::after-request="if (event.detail.successful) toast('{{ t(key='cart-added', lang=lang | default(value='sk')) }}')">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<input type="hidden" name="variant_id" :value="current.id">
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="quantity" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="quantity", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<input type="number" id="quantity" name="quantity" value="1" min="1" :max="current.stock" class="{{ fld }} w-24">
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="{{ btn }}">{{ t(key="add-to-cart", lang=lang | default(value='sk')) }}</button>
|
||||||
|
</form>
|
||||||
|
<p class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
|
<template x-if="current.tracked">
|
||||||
|
<span>{{ t(key="in-stock", lang=lang | default(value='sk')) }}: <span x-text="current.stock"></span></span>
|
||||||
|
</template>
|
||||||
|
<template x-if="!current.tracked">
|
||||||
|
<span>{{ t(key="available", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template x-if="!current.in_stock">
|
||||||
|
<p class="inline-flex rounded-radius bg-danger/10 px-3 py-2 text-sm font-medium text-danger">{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}</p>
|
||||||
|
</template>
|
||||||
</div>
|
</div>
|
||||||
{{ ui::button(label=t(key="add-to-cart", lang=lang | default(value='sk')), type="submit", size="px-5 py-2 text-sm") }}
|
</template>
|
||||||
</form>
|
|
||||||
<p class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}</p>
|
<template x-if="!current">
|
||||||
{% else %}
|
<p class="inline-flex rounded-radius bg-danger/10 px-3 py-2 text-sm font-medium text-danger">{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}</p>
|
||||||
<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>
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% if product.description %}
|
||||||
|
<section id="product-description" class="scroll-mt-28 border-t border-outline pt-8 dark:border-outline-dark">
|
||||||
|
<h2 class="mb-4 text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="description", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
{# Authored as rich text (Quill) in the admin; render the stored HTML. #}
|
||||||
|
<div class="rich-content text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description | safe }}</div>
|
||||||
|
</section>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -45,19 +45,24 @@ workers:
|
|||||||
|
|
||||||
# Mailer Configuration.
|
# Mailer Configuration.
|
||||||
mailer:
|
mailer:
|
||||||
# SMTP mailer configuration.
|
# SMTP mailer configuration. Defaults target a local catcher (MailHog/Mailpit
|
||||||
|
# on localhost:1025); set the SMTP_* env vars to point at a real server. The
|
||||||
|
# auth block is only emitted when SMTP_PASSWORD is provided, so the secret is
|
||||||
|
# never stored here — pass it in at launch (e.g. from `pass`).
|
||||||
smtp:
|
smtp:
|
||||||
# Enable/Disable smtp mailer.
|
# Enable/Disable smtp mailer.
|
||||||
enable: true
|
enable: {{ get_env(name="SMTP_ENABLE", default="true") }}
|
||||||
# SMTP server host. e.x localhost, smtp.gmail.com
|
# SMTP server host. e.x localhost, smtp.gmail.com
|
||||||
host: localhost
|
host: "{{ get_env(name="SMTP_HOST", default="localhost") }}"
|
||||||
# SMTP server port
|
# SMTP server port
|
||||||
port: 1025
|
port: {{ get_env(name="SMTP_PORT", default="1025") }}
|
||||||
# Use secure connection (SSL/TLS).
|
# Use secure connection (SSL/TLS).
|
||||||
secure: false
|
secure: {{ get_env(name="SMTP_SECURE", default="false") }}
|
||||||
# auth:
|
{% if get_env(name="SMTP_PASSWORD", default="") != "" %}
|
||||||
# user:
|
auth:
|
||||||
# password:
|
user: "{{ get_env(name="SMTP_USER", default="") }}"
|
||||||
|
password: "{{ get_env(name="SMTP_PASSWORD", default="") }}"
|
||||||
|
{% endif %}
|
||||||
# Override the SMTP hello name (default is the machine's hostname)
|
# Override the SMTP hello name (default is the machine's hostname)
|
||||||
# hello_name:
|
# hello_name:
|
||||||
|
|
||||||
|
|||||||
@@ -42,33 +42,58 @@ workers:
|
|||||||
|
|
||||||
|
|
||||||
# Mailer Configuration.
|
# Mailer Configuration.
|
||||||
|
# Defaults keep the whole suite on the in-memory stub mailer. The real-SMTP
|
||||||
|
# smoke test (tests/mailer/smtp_send.rs) opts in by setting these env vars
|
||||||
|
# before boot; nothing else in the suite sends real mail.
|
||||||
mailer:
|
mailer:
|
||||||
stub: true
|
stub: {{ get_env(name="MAILER_STUB", default="true") }}
|
||||||
# SMTP mailer configuration.
|
# SMTP mailer configuration.
|
||||||
smtp:
|
smtp:
|
||||||
# Enable/Disable smtp mailer.
|
# Enable/Disable smtp mailer.
|
||||||
enable: true
|
enable: {{ get_env(name="SMTP_ENABLE", default="true") }}
|
||||||
# SMTP server host. e.x localhost, smtp.gmail.com
|
# SMTP server host. e.x localhost, smtp.gmail.com
|
||||||
host: localhost
|
host: "{{ get_env(name="SMTP_HOST", default="localhost") }}"
|
||||||
# SMTP server port
|
# SMTP server port
|
||||||
port: 1025
|
port: {{ get_env(name="SMTP_PORT", default="1025") }}
|
||||||
# Use secure connection (SSL/TLS).
|
# Use secure connection (SSL/TLS).
|
||||||
secure: false
|
secure: {{ get_env(name="SMTP_SECURE", default="false") }}
|
||||||
# auth:
|
auth:
|
||||||
# user:
|
user: "{{ get_env(name="SMTP_USER", default="") }}"
|
||||||
# password:
|
password: "{{ get_env(name="SMTP_PASSWORD", default="") }}"
|
||||||
|
|
||||||
# Initializers Configuration
|
# Initializers Configuration
|
||||||
# initializers:
|
# OAuth2StoreInitializer requires this block to boot (it builds the client store
|
||||||
# oauth2:
|
# in after_routes). Static, non-secret placeholders: tests never perform a real
|
||||||
# authorization_code: # Authorization code grant type
|
# OAuth2 handshake, they just need the store to construct successfully.
|
||||||
# - client_identifier: google # Identifier for the OAuth2 provider. Replace 'google' with your provider's name if different, must be unique within the oauth2 config.
|
initializers:
|
||||||
# ... other fields
|
oauth2:
|
||||||
|
# Private-cookie key: a ", "-separated list of >=64 byte values (not a
|
||||||
|
# plain string). This is loco-oauth2's documented sample key; fine for tests.
|
||||||
|
secret_key: "144, 76, 183, 1, 15, 184, 233, 174, 214, 251, 190, 186, 122, 61, 74, 84, 225, 110, 189, 115, 10, 251, 133, 128, 52, 46, 15, 66, 85, 1, 245, 73, 27, 113, 189, 15, 209, 205, 61, 100, 73, 31, 18, 58, 235, 105, 141, 36, 70, 92, 231, 151, 27, 32, 243, 117, 30, 244, 110, 89, 233, 196, 137, 130"
|
||||||
|
authorization_code:
|
||||||
|
- client_identifier: google
|
||||||
|
client_credentials:
|
||||||
|
client_id: test-client-id
|
||||||
|
client_secret: test-client-secret
|
||||||
|
url_config:
|
||||||
|
auth_url: https://accounts.google.com/o/oauth2/auth
|
||||||
|
token_url: https://www.googleapis.com/oauth2/v3/token
|
||||||
|
redirect_url: http://localhost:5150/api/oauth2/google/callback
|
||||||
|
profile_url: https://openidconnect.googleapis.com/v1/userinfo
|
||||||
|
scopes:
|
||||||
|
- https://www.googleapis.com/auth/userinfo.email
|
||||||
|
- https://www.googleapis.com/auth/userinfo.profile
|
||||||
|
cookie_config:
|
||||||
|
protected_url: http://localhost:5150/
|
||||||
|
timeout_seconds: 600
|
||||||
|
|
||||||
# Database Configuration
|
# Database Configuration
|
||||||
database:
|
database:
|
||||||
# Database connection URI
|
# Database connection URI. Pinned to the throwaway test DB and intentionally
|
||||||
uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/kompress_eshop_test") }}
|
# NOT read from `DATABASE_URL`: the app loads `.env` on boot (app.rs
|
||||||
|
# `load_config`), and this config has `dangerously_recreate: true`, so honoring
|
||||||
|
# an env override here would let `cargo test` recreate the dev/prod database.
|
||||||
|
uri: "postgres://uni_loco_web_user:3@localhost:5432/kompress_eshop_test"
|
||||||
# When enabled, the sql query will be logged.
|
# When enabled, the sql query will be logged.
|
||||||
enable_logging: false
|
enable_logging: false
|
||||||
# Set the timeout duration when acquiring a connection.
|
# Set the timeout duration when acquiring a connection.
|
||||||
|
|||||||
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).
|
||||||
@@ -13,7 +13,7 @@ loco-rs = { workspace = true }
|
|||||||
|
|
||||||
|
|
||||||
[dependencies.sea-orm-migration]
|
[dependencies.sea-orm-migration]
|
||||||
version = "1.1.0"
|
version = "1.1.20"
|
||||||
features = [
|
features = [
|
||||||
# Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI.
|
# Enable at least one `ASYNC_RUNTIME` and `DATABASE_DRIVER` feature if you want to run migration via CLI.
|
||||||
# View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime.
|
# View the list of supported features at https://www.sea-ql.org/SeaORM/docs/install-and-config/database-and-async-runtime.
|
||||||
|
|||||||
@@ -31,6 +31,28 @@ mod m20260617_000001_add_carrier_to_shipping_methods;
|
|||||||
mod m20260617_000002_add_shipment_to_orders;
|
mod m20260617_000002_add_shipment_to_orders;
|
||||||
mod m20260617_000003_add_phone_to_orders;
|
mod m20260617_000003_add_phone_to_orders;
|
||||||
mod m20260618_000001_o_auth2_sessions;
|
mod m20260618_000001_o_auth2_sessions;
|
||||||
|
mod m20260618_000002_customer_profiles;
|
||||||
|
mod m20260618_000003_account_type;
|
||||||
|
mod m20260618_000004_account_ownership;
|
||||||
|
mod m20260620_000001_add_totp_to_users;
|
||||||
|
mod m20260621_000001_add_sale_price_to_products;
|
||||||
|
mod m20260621_000002_account_product_prices;
|
||||||
|
mod m20260621_000003_discount_profiles;
|
||||||
|
mod m20260621_000004_add_business_sale_price_to_products;
|
||||||
|
mod m20260622_000001_audience_discount_profiles;
|
||||||
|
mod m20260622_000002_product_variants;
|
||||||
|
mod m20260622_000003_variant_stock_nullable;
|
||||||
|
mod m20260622_000004_product_search;
|
||||||
|
mod m20260622_000005_product_search_aggregate;
|
||||||
|
mod m20260622_000006_order_search_indexes;
|
||||||
|
mod m20260623_000001_add_short_description_to_products;
|
||||||
|
mod m20260623_000002_strip_html_from_product_search;
|
||||||
|
mod m20260623_000003_drop_currency;
|
||||||
|
mod m20260623_000004_currencies;
|
||||||
|
mod m20260625_000001_add_avatar_to_users;
|
||||||
|
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]
|
||||||
@@ -66,7 +88,29 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260617_000002_add_shipment_to_orders::Migration),
|
Box::new(m20260617_000002_add_shipment_to_orders::Migration),
|
||||||
Box::new(m20260617_000003_add_phone_to_orders::Migration),
|
Box::new(m20260617_000003_add_phone_to_orders::Migration),
|
||||||
Box::new(m20260618_000001_o_auth2_sessions::Migration),
|
Box::new(m20260618_000001_o_auth2_sessions::Migration),
|
||||||
|
Box::new(m20260618_000002_customer_profiles::Migration),
|
||||||
|
Box::new(m20260618_000003_account_type::Migration),
|
||||||
|
Box::new(m20260618_000004_account_ownership::Migration),
|
||||||
|
Box::new(m20260620_000001_add_totp_to_users::Migration),
|
||||||
|
Box::new(m20260621_000001_add_sale_price_to_products::Migration),
|
||||||
|
Box::new(m20260621_000002_account_product_prices::Migration),
|
||||||
|
Box::new(m20260621_000003_discount_profiles::Migration),
|
||||||
|
Box::new(m20260621_000004_add_business_sale_price_to_products::Migration),
|
||||||
|
Box::new(m20260622_000001_audience_discount_profiles::Migration),
|
||||||
|
Box::new(m20260622_000002_product_variants::Migration),
|
||||||
|
Box::new(m20260622_000003_variant_stock_nullable::Migration),
|
||||||
|
Box::new(m20260622_000004_product_search::Migration),
|
||||||
|
Box::new(m20260622_000005_product_search_aggregate::Migration),
|
||||||
|
Box::new(m20260622_000006_order_search_indexes::Migration),
|
||||||
|
Box::new(m20260623_000001_add_short_description_to_products::Migration),
|
||||||
|
Box::new(m20260623_000002_strip_html_from_product_search::Migration),
|
||||||
|
Box::new(m20260623_000003_drop_currency::Migration),
|
||||||
|
Box::new(m20260623_000004_currencies::Migration),
|
||||||
|
Box::new(m20260625_000001_add_avatar_to_users::Migration),
|
||||||
|
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)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
44
migration/src/m20260618_000002_customer_profiles.rs
Normal file
44
migration/src/m20260618_000002_customer_profiles.rs
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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> {
|
||||||
|
// One shipping/contact profile per customer, used to prefill the
|
||||||
|
// checkout form. `name`/`email` already live on `users`; this table
|
||||||
|
// holds only the address + phone fields. `user` adds a user_id FK; the
|
||||||
|
// unique index below makes the relationship 1:1.
|
||||||
|
create_table(
|
||||||
|
m,
|
||||||
|
"customer_profiles",
|
||||||
|
&[
|
||||||
|
("id", ColType::PkAuto),
|
||||||
|
("phone_prefix", ColType::StringNull),
|
||||||
|
("phone", ColType::StringNull),
|
||||||
|
("address", ColType::StringNull),
|
||||||
|
("city", ColType::StringNull),
|
||||||
|
("zip", ColType::StringNull),
|
||||||
|
("country", ColType::StringNull),
|
||||||
|
],
|
||||||
|
&[("user", "")],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
m.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_customer_profiles_user_id_unique")
|
||||||
|
.table(Alias::new("customer_profiles"))
|
||||||
|
.col(Alias::new("user_id"))
|
||||||
|
.unique()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
drop_table(m, "customer_profiles").await
|
||||||
|
}
|
||||||
|
}
|
||||||
39
migration/src/m20260618_000003_account_type.rs
Normal file
39
migration/src/m20260618_000003_account_type.rs
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
// Personal vs company purchasing. `account_type` is "personal" or "company";
|
||||||
|
// the company_* columns hold the Slovak invoicing identifiers (IČO, DIČ and the
|
||||||
|
// optional VAT id IČ DPH) and are only filled for company accounts/orders.
|
||||||
|
const COMPANY_COLUMNS: [&str; 4] = ["company_name", "company_id", "tax_id", "vat_id"];
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
for table in ["customer_profiles", "orders"] {
|
||||||
|
add_column(
|
||||||
|
m,
|
||||||
|
table,
|
||||||
|
"account_type",
|
||||||
|
ColType::StringWithDefault("personal".to_string()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
for col in COMPANY_COLUMNS {
|
||||||
|
add_column(m, table, col, ColType::StringNull).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
for table in ["customer_profiles", "orders"] {
|
||||||
|
remove_column(m, table, "account_type").await?;
|
||||||
|
for col in COMPANY_COLUMNS {
|
||||||
|
remove_column(m, table, col).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
38
migration/src/m20260618_000004_account_ownership.rs
Normal file
38
migration/src/m20260618_000004_account_ownership.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
// Account type becomes a permanent property of the *user* (chosen at
|
||||||
|
// registration, never switchable), so it moves off `customer_profiles`. Orders
|
||||||
|
// gain a nullable `user_id` linking them to the account that placed them
|
||||||
|
// (null for guest orders that didn't create an account).
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
add_column(
|
||||||
|
m,
|
||||||
|
"users",
|
||||||
|
"account_type",
|
||||||
|
ColType::StringWithDefault("personal".to_string()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
add_column(m, "orders", "user_id", ColType::IntegerNull).await?;
|
||||||
|
remove_column(m, "customer_profiles", "account_type").await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
add_column(
|
||||||
|
m,
|
||||||
|
"customer_profiles",
|
||||||
|
"account_type",
|
||||||
|
ColType::StringWithDefault("personal".to_string()),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
remove_column(m, "orders", "user_id").await?;
|
||||||
|
remove_column(m, "users", "account_type").await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
32
migration/src/m20260620_000001_add_totp_to_users.rs
Normal file
32
migration/src/m20260620_000001_add_totp_to_users.rs
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
// Optional TOTP (Google Authenticator) two-factor auth. All three columns are
|
||||||
|
// nullable and only populated once a user opts in:
|
||||||
|
// - `totp_secret` base32 shared secret; present while enrolling/enabled.
|
||||||
|
// TODO(security): stored PLAINTEXT and is password-
|
||||||
|
// equivalent (must stay reversible to recompute codes).
|
||||||
|
// Encrypt at rest later with an out-of-DB key. See the
|
||||||
|
// TODO(security) block in src/models/users.rs.
|
||||||
|
// - `totp_enabled_at` NULL = 2FA off. Set only after the user confirms a
|
||||||
|
// code, so a half-finished enrollment never gates login.
|
||||||
|
// - `totp_backup_codes` JSON array of hashed one-time recovery codes.
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
add_column(m, "users", "totp_secret", ColType::TextNull).await?;
|
||||||
|
add_column(m, "users", "totp_enabled_at", ColType::TimestampWithTimeZoneNull).await?;
|
||||||
|
add_column(m, "users", "totp_backup_codes", ColType::TextNull).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
remove_column(m, "users", "totp_backup_codes").await?;
|
||||||
|
remove_column(m, "users", "totp_enabled_at").await?;
|
||||||
|
remove_column(m, "users", "totp_secret").await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
19
migration/src/m20260621_000001_add_sale_price_to_products.rs
Normal file
19
migration/src/m20260621_000001_add_sale_price_to_products.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// Optional discounted price in minor units. When set (and below
|
||||||
|
// `price_cents`) the product is on sale; the regular price is shown
|
||||||
|
// struck through and this is the effective price everywhere.
|
||||||
|
add_column(m, "products", "sale_price_cents", ColType::BigIntegerNull).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
remove_column(m, "products", "sale_price_cents").await
|
||||||
|
}
|
||||||
|
}
|
||||||
40
migration/src/m20260621_000002_account_product_prices.rs
Normal file
40
migration/src/m20260621_000002_account_product_prices.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// A manually negotiated price (in minor units) for one product, for one
|
||||||
|
// business account — the "personal agreement" layer. `user`/`product`
|
||||||
|
// add the user_id/product_id FKs; the unique index below keeps it to one
|
||||||
|
// row per (account, product).
|
||||||
|
create_table(
|
||||||
|
m,
|
||||||
|
"account_product_prices",
|
||||||
|
&[
|
||||||
|
("id", ColType::PkAuto),
|
||||||
|
("price_cents", ColType::BigInteger),
|
||||||
|
],
|
||||||
|
&[("user", ""), ("product", "")],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
m.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_account_product_prices_user_product_unique")
|
||||||
|
.table(Alias::new("account_product_prices"))
|
||||||
|
.col(Alias::new("user_id"))
|
||||||
|
.col(Alias::new("product_id"))
|
||||||
|
.unique()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
drop_table(m, "account_product_prices").await
|
||||||
|
}
|
||||||
|
}
|
||||||
91
migration/src/m20260621_000003_discount_profiles.rs
Normal file
91
migration/src/m20260621_000003_discount_profiles.rs
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// A reusable, named discount layer: a percentage (basis points, 5% = 500)
|
||||||
|
// over a product scope. `scope_type` is 'include' (covers the listed
|
||||||
|
// products) or 'all_except' (covers everything but the listed products).
|
||||||
|
create_table(
|
||||||
|
m,
|
||||||
|
"discount_profiles",
|
||||||
|
&[
|
||||||
|
("id", ColType::PkAuto),
|
||||||
|
("name", ColType::String),
|
||||||
|
("percent_bp", ColType::Integer),
|
||||||
|
("scope_type", ColType::StringWithDefault("include".to_string())),
|
||||||
|
],
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Which products the scope lists (meaning depends on scope_type).
|
||||||
|
create_table(
|
||||||
|
m,
|
||||||
|
"discount_profile_products",
|
||||||
|
&[("id", ColType::PkAuto)],
|
||||||
|
&[("discount_profile", ""), ("product", "")],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
m.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_discount_profile_products_unique")
|
||||||
|
.table(Alias::new("discount_profile_products"))
|
||||||
|
.col(Alias::new("discount_profile_id"))
|
||||||
|
.col(Alias::new("product_id"))
|
||||||
|
.unique()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Which profiles a business account has (mixable).
|
||||||
|
create_table(
|
||||||
|
m,
|
||||||
|
"account_discount_profiles",
|
||||||
|
&[("id", ColType::PkAuto)],
|
||||||
|
&[("user", ""), ("discount_profile", "")],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
m.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_account_discount_profiles_unique")
|
||||||
|
.table(Alias::new("account_discount_profiles"))
|
||||||
|
.col(Alias::new("user_id"))
|
||||||
|
.col(Alias::new("discount_profile_id"))
|
||||||
|
.unique()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// The admin's chosen winning profile when two assigned profiles cover the
|
||||||
|
// same product for an account (collision resolution).
|
||||||
|
create_table(
|
||||||
|
m,
|
||||||
|
"account_product_resolutions",
|
||||||
|
&[("id", ColType::PkAuto)],
|
||||||
|
&[("user", ""), ("product", ""), ("discount_profile", "")],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
m.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_account_product_resolutions_unique")
|
||||||
|
.table(Alias::new("account_product_resolutions"))
|
||||||
|
.col(Alias::new("user_id"))
|
||||||
|
.col(Alias::new("product_id"))
|
||||||
|
.unique()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
drop_table(m, "account_product_resolutions").await?;
|
||||||
|
drop_table(m, "account_discount_profiles").await?;
|
||||||
|
drop_table(m, "discount_profile_products").await?;
|
||||||
|
drop_table(m, "discount_profiles").await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// Optional per-product discounted price (minor units) shown to ALL
|
||||||
|
// business (company) accounts as a baseline, computed off the regular
|
||||||
|
// price like the personal sale. Per-company profiles/negotiated prices
|
||||||
|
// still layer on top (lowest price wins).
|
||||||
|
add_column(m, "products", "business_sale_price_cents", ColType::BigIntegerNull).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
remove_column(m, "products", "business_sale_price_cents").await
|
||||||
|
}
|
||||||
|
}
|
||||||
40
migration/src/m20260622_000001_audience_discount_profiles.rs
Normal file
40
migration/src/m20260622_000001_audience_discount_profiles.rs
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
use loco_rs::schema::*;
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
// Discount profiles applied globally to a whole audience, set on the
|
||||||
|
// discounts page: "personal" lowers the public price for everyone,
|
||||||
|
// "business" lowers the price for all company accounts. Per-company
|
||||||
|
// assignments (account_discount_profiles) still layer on top.
|
||||||
|
create_table(
|
||||||
|
m,
|
||||||
|
"audience_discount_profiles",
|
||||||
|
&[
|
||||||
|
("id", ColType::PkAuto),
|
||||||
|
("audience", ColType::String),
|
||||||
|
],
|
||||||
|
&[("discount_profile", "")],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
m.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_audience_discount_profiles_unique")
|
||||||
|
.table(Alias::new("audience_discount_profiles"))
|
||||||
|
.col(Alias::new("audience"))
|
||||||
|
.col(Alias::new("discount_profile_id"))
|
||||||
|
.unique()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
drop_table(m, "audience_discount_profiles").await
|
||||||
|
}
|
||||||
|
}
|
||||||
204
migration/src/m20260622_000002_product_variants.rs
Normal file
204
migration/src/m20260622_000002_product_variants.rs
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
//! Introduce product variants as the purchasable unit.
|
||||||
|
//!
|
||||||
|
//! A product becomes a presentation grouping (name, description, images,
|
||||||
|
//! category, tags, percentage discount profiles). Each product owns one or more
|
||||||
|
//! `product_variants`, and the variant is what carries the things that actually
|
||||||
|
//! differ between options: a free-text `label` (e.g. "rolovaná 90cm x 10m",
|
||||||
|
//! "5ml"), its own `sku`, `stock`, regular `price_cents`, and its own optional
|
||||||
|
//! public/business quick-sale prices.
|
||||||
|
//!
|
||||||
|
//! This migration:
|
||||||
|
//! 1. creates `product_variants`,
|
||||||
|
//! 2. backfills one variant per existing product from the product's current
|
||||||
|
//! price/stock/sku/sale columns,
|
||||||
|
//! 3. moves the per-account negotiated price and collision-resolution tables
|
||||||
|
//! from keying on `product_id` to `variant_id`,
|
||||||
|
//! 4. snapshots the variant onto `order_items`,
|
||||||
|
//! 5. drops the now-moved purchasable columns from `products`.
|
||||||
|
|
||||||
|
use sea_orm_migration::prelude::*;
|
||||||
|
|
||||||
|
#[derive(DeriveMigrationName)]
|
||||||
|
pub struct Migration;
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl MigrationTrait for Migration {
|
||||||
|
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let db = m.get_connection();
|
||||||
|
|
||||||
|
// 1. The variants table.
|
||||||
|
db.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE product_variants (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
product_id INTEGER NOT NULL REFERENCES products(id) ON DELETE CASCADE,
|
||||||
|
label VARCHAR NOT NULL DEFAULT '',
|
||||||
|
position INTEGER NOT NULL DEFAULT 0,
|
||||||
|
sku VARCHAR,
|
||||||
|
stock INTEGER NOT NULL DEFAULT 0,
|
||||||
|
price_cents BIGINT NOT NULL,
|
||||||
|
sale_price_cents BIGINT,
|
||||||
|
business_sale_price_cents BIGINT
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_product_variants_product ON product_variants (product_id);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 2. One variant per existing product, carrying its current pricing.
|
||||||
|
db.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
INSERT INTO product_variants
|
||||||
|
(product_id, label, position, sku, stock,
|
||||||
|
price_cents, sale_price_cents, business_sale_price_cents)
|
||||||
|
SELECT id, '', 0, sku, stock,
|
||||||
|
price_cents, sale_price_cents, business_sale_price_cents
|
||||||
|
FROM products;
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 3a. Negotiated prices: product_id -> variant_id.
|
||||||
|
db.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
ALTER TABLE account_product_prices ADD COLUMN variant_id INTEGER;
|
||||||
|
UPDATE account_product_prices a
|
||||||
|
SET variant_id = pv.id
|
||||||
|
FROM product_variants pv
|
||||||
|
WHERE pv.product_id = a.product_id;
|
||||||
|
DROP INDEX IF EXISTS idx_account_product_prices_user_product_unique;
|
||||||
|
ALTER TABLE account_product_prices DROP COLUMN product_id;
|
||||||
|
ALTER TABLE account_product_prices ALTER COLUMN variant_id SET NOT NULL;
|
||||||
|
ALTER TABLE account_product_prices
|
||||||
|
ADD CONSTRAINT fk_account_product_prices_variant
|
||||||
|
FOREIGN KEY (variant_id) REFERENCES product_variants(id) ON DELETE CASCADE;
|
||||||
|
CREATE UNIQUE INDEX idx_account_product_prices_user_variant_unique
|
||||||
|
ON account_product_prices (user_id, variant_id);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 3b. Collision resolutions: product_id -> variant_id.
|
||||||
|
db.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
ALTER TABLE account_product_resolutions ADD COLUMN variant_id INTEGER;
|
||||||
|
UPDATE account_product_resolutions a
|
||||||
|
SET variant_id = pv.id
|
||||||
|
FROM product_variants pv
|
||||||
|
WHERE pv.product_id = a.product_id;
|
||||||
|
DROP INDEX IF EXISTS idx_account_product_resolutions_unique;
|
||||||
|
ALTER TABLE account_product_resolutions DROP COLUMN product_id;
|
||||||
|
ALTER TABLE account_product_resolutions ALTER COLUMN variant_id SET NOT NULL;
|
||||||
|
ALTER TABLE account_product_resolutions
|
||||||
|
ADD CONSTRAINT fk_account_product_resolutions_variant
|
||||||
|
FOREIGN KEY (variant_id) REFERENCES product_variants(id) ON DELETE CASCADE;
|
||||||
|
CREATE UNIQUE INDEX idx_account_product_resolutions_unique
|
||||||
|
ON account_product_resolutions (user_id, variant_id);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 4. Snapshot the variant on order lines (label is frozen at order time;
|
||||||
|
// the FK is nullable + SET NULL so deleting a variant keeps history).
|
||||||
|
db.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
ALTER TABLE order_items ADD COLUMN variant_label VARCHAR NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE order_items ADD COLUMN variant_id INTEGER
|
||||||
|
REFERENCES product_variants(id) ON DELETE SET NULL;
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// 5. Drop the purchasable columns now owned by the variant.
|
||||||
|
db.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
ALTER TABLE products DROP COLUMN price_cents;
|
||||||
|
ALTER TABLE products DROP COLUMN sale_price_cents;
|
||||||
|
ALTER TABLE products DROP COLUMN business_sale_price_cents;
|
||||||
|
ALTER TABLE products DROP COLUMN sku;
|
||||||
|
ALTER TABLE products DROP COLUMN stock;
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
let db = m.get_connection();
|
||||||
|
|
||||||
|
// Restore product columns from each product's first variant.
|
||||||
|
db.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
ALTER TABLE products ADD COLUMN price_cents BIGINT NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE products ADD COLUMN sale_price_cents BIGINT;
|
||||||
|
ALTER TABLE products ADD COLUMN business_sale_price_cents BIGINT;
|
||||||
|
ALTER TABLE products ADD COLUMN sku VARCHAR;
|
||||||
|
ALTER TABLE products ADD COLUMN stock INTEGER NOT NULL DEFAULT 0;
|
||||||
|
UPDATE products p SET
|
||||||
|
price_cents = pv.price_cents,
|
||||||
|
sale_price_cents = pv.sale_price_cents,
|
||||||
|
business_sale_price_cents = pv.business_sale_price_cents,
|
||||||
|
sku = pv.sku,
|
||||||
|
stock = pv.stock
|
||||||
|
FROM (
|
||||||
|
SELECT DISTINCT ON (product_id) product_id, price_cents,
|
||||||
|
sale_price_cents, business_sale_price_cents, sku, stock
|
||||||
|
FROM product_variants ORDER BY product_id, position, id
|
||||||
|
) pv
|
||||||
|
WHERE pv.product_id = p.id;
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
db.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
ALTER TABLE order_items DROP COLUMN variant_id;
|
||||||
|
ALTER TABLE order_items DROP COLUMN variant_label;
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
db.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
ALTER TABLE account_product_resolutions ADD COLUMN product_id INTEGER;
|
||||||
|
UPDATE account_product_resolutions a
|
||||||
|
SET product_id = pv.product_id
|
||||||
|
FROM product_variants pv WHERE pv.id = a.variant_id;
|
||||||
|
DROP INDEX IF EXISTS idx_account_product_resolutions_unique;
|
||||||
|
ALTER TABLE account_product_resolutions DROP COLUMN variant_id;
|
||||||
|
ALTER TABLE account_product_resolutions ALTER COLUMN product_id SET NOT NULL;
|
||||||
|
ALTER TABLE account_product_resolutions
|
||||||
|
ADD CONSTRAINT fk_account_product_resolutions_product
|
||||||
|
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE;
|
||||||
|
CREATE UNIQUE INDEX idx_account_product_resolutions_unique
|
||||||
|
ON account_product_resolutions (user_id, product_id);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
db.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
ALTER TABLE account_product_prices ADD COLUMN product_id INTEGER;
|
||||||
|
UPDATE account_product_prices a
|
||||||
|
SET product_id = pv.product_id
|
||||||
|
FROM product_variants pv WHERE pv.id = a.variant_id;
|
||||||
|
DROP INDEX IF EXISTS idx_account_product_prices_user_variant_unique;
|
||||||
|
ALTER TABLE account_product_prices DROP COLUMN variant_id;
|
||||||
|
ALTER TABLE account_product_prices ALTER COLUMN product_id SET NOT NULL;
|
||||||
|
ALTER TABLE account_product_prices
|
||||||
|
ADD CONSTRAINT fk_account_product_prices_product
|
||||||
|
FOREIGN KEY (product_id) REFERENCES products(id) ON DELETE CASCADE;
|
||||||
|
CREATE UNIQUE INDEX idx_account_product_prices_user_product_unique
|
||||||
|
ON account_product_prices (user_id, product_id);
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
db.execute_unprepared("DROP TABLE product_variants;").await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
22
src/app.rs
22
src/app.rs
@@ -17,9 +17,10 @@ use std::{path::Path, sync::Arc};
|
|||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use crate::{
|
use crate::{
|
||||||
controllers::{
|
controllers::{
|
||||||
admin_categories, admin_dashboard, admin_form, admin_orders,
|
account, admin_categories, admin_currencies, admin_customers, admin_dashboard,
|
||||||
admin_products, admin_shipping, auth, auth_pages, cart, checkout, home, i18n, media, oauth2,
|
admin_discount_profiles, admin_form, admin_orders, admin_payments, admin_products, admin_shipping,
|
||||||
shop,
|
auth, auth_pages, cart, checkout, currency, home, i18n, media, oauth2,
|
||||||
|
pages, shop,
|
||||||
},
|
},
|
||||||
initializers,
|
initializers,
|
||||||
models::_entities::users,
|
models::_entities::users,
|
||||||
@@ -68,6 +69,12 @@ impl Hooks for App {
|
|||||||
.layer(axum::middleware::from_fn_with_state(
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
ctx.clone(),
|
ctx.clone(),
|
||||||
crate::shared::rbac::inject_subject,
|
crate::shared::rbac::inject_subject,
|
||||||
|
))
|
||||||
|
// CSRF runs outermost so it validates the double-submit token before
|
||||||
|
// any handler sees the request and stamps the cookie on safe ones.
|
||||||
|
.layer(axum::middleware::from_fn_with_state(
|
||||||
|
ctx.clone(),
|
||||||
|
crate::shared::csrf::protect,
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,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),
|
||||||
])
|
])
|
||||||
@@ -88,18 +97,25 @@ 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())
|
||||||
|
.add_route(account::routes())
|
||||||
.add_route(oauth2::routes())
|
.add_route(oauth2::routes())
|
||||||
.add_route(i18n::routes())
|
.add_route(i18n::routes())
|
||||||
.add_route(media::routes())
|
.add_route(media::routes())
|
||||||
// admin
|
// admin
|
||||||
.add_route(admin_dashboard::routes())
|
.add_route(admin_dashboard::routes())
|
||||||
.add_route(admin_products::routes())
|
.add_route(admin_products::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_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> {
|
||||||
|
|||||||
625
src/controllers/account.rs
Normal file
625
src/controllers/account.rs
Normal file
@@ -0,0 +1,625 @@
|
|||||||
|
//! Customer account area. Currently just the shipping/contact profile, whose
|
||||||
|
//! fields prefill the checkout form. Gated to authenticated non-admin users:
|
||||||
|
//! anonymous visitors are bounced to `/login`. Admins have their own area and
|
||||||
|
//! are sent to the dashboard.
|
||||||
|
//!
|
||||||
|
//! The account *type* (personal vs company) is fixed at registration and lives
|
||||||
|
//! on the user — it is shown here read-only and can never be changed. The
|
||||||
|
//! profile only edits the type-specific details (company identity + address).
|
||||||
|
|
||||||
|
use axum::extract::{DefaultBodyLimit, Multipart};
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::QueryOrder;
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
controllers::{
|
||||||
|
admin_form::{read_multipart_form, store_image},
|
||||||
|
i18n::current_lang,
|
||||||
|
media::IMAGE_MAX_BYTES,
|
||||||
|
},
|
||||||
|
models::{
|
||||||
|
customer_profiles::{self, ProfileFields},
|
||||||
|
order_items, orders, users,
|
||||||
|
},
|
||||||
|
shared::{guard, settings},
|
||||||
|
views::checkout as order_view,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Active (still-being-fulfilled) order statuses. Anything else
|
||||||
|
/// (`delivered`, `cancelled`) is considered closed/past.
|
||||||
|
const ACTIVE_STATUSES: [&str; 3] = ["pending", "paid", "shipped"];
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ProfileForm {
|
||||||
|
first_name: Option<String>,
|
||||||
|
last_name: Option<String>,
|
||||||
|
company_name: Option<String>,
|
||||||
|
company_id: Option<String>,
|
||||||
|
tax_id: Option<String>,
|
||||||
|
vat_id: Option<String>,
|
||||||
|
phone_prefix: Option<String>,
|
||||||
|
phone: Option<String>,
|
||||||
|
address: Option<String>,
|
||||||
|
city: Option<String>,
|
||||||
|
zip: Option<String>,
|
||||||
|
country: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trimmed(value: Option<&str>) -> Option<String> {
|
||||||
|
value.map(str::trim).filter(|v| !v.is_empty()).map(String::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Split a stored full name into (first name, surname). The surname is
|
||||||
|
/// everything after the first whitespace, so multi-word surnames round-trip.
|
||||||
|
fn split_name(name: &str) -> (String, String) {
|
||||||
|
match name.trim().split_once(char::is_whitespace) {
|
||||||
|
Some((first, rest)) => (first.to_string(), rest.trim().to_string()),
|
||||||
|
None => (name.trim().to_string(), String::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recombine the two name fields into the single stored `name`. Returns `None`
|
||||||
|
/// when the result is too short to be a valid name (the user can't blank it out).
|
||||||
|
fn full_name_from_form(form: &ProfileForm) -> Option<String> {
|
||||||
|
let first = form.first_name.as_deref().unwrap_or("").trim();
|
||||||
|
let last = form.last_name.as_deref().unwrap_or("").trim();
|
||||||
|
let full = format!("{first} {last}").trim().to_string();
|
||||||
|
(full.chars().count() >= 2).then_some(full)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the persisted fields from the submitted form. Company identifiers are
|
||||||
|
/// only kept for company accounts (a personal account can never carry them).
|
||||||
|
fn fields_from_form(form: &ProfileForm, is_company: bool) -> ProfileFields {
|
||||||
|
let company = |v: Option<&str>| if is_company { trimmed(v) } else { None };
|
||||||
|
ProfileFields {
|
||||||
|
company_name: company(form.company_name.as_deref()),
|
||||||
|
company_id: company(form.company_id.as_deref()),
|
||||||
|
tax_id: company(form.tax_id.as_deref()),
|
||||||
|
vat_id: company(form.vat_id.as_deref()),
|
||||||
|
phone_prefix: trimmed(form.phone_prefix.as_deref()),
|
||||||
|
phone: trimmed(form.phone.as_deref()),
|
||||||
|
address: trimmed(form.address.as_deref()),
|
||||||
|
city: trimmed(form.city.as_deref()),
|
||||||
|
zip: trimmed(form.zip.as_deref()),
|
||||||
|
country: trimmed(form.country.as_deref()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The profile fields held by a saved profile, for re-prefilling the form.
|
||||||
|
fn fields_of(profile: Option<&customer_profiles::Model>) -> ProfileFields {
|
||||||
|
match profile {
|
||||||
|
Some(p) => ProfileFields {
|
||||||
|
company_name: p.company_name.clone(),
|
||||||
|
company_id: p.company_id.clone(),
|
||||||
|
tax_id: p.tax_id.clone(),
|
||||||
|
vat_id: p.vat_id.clone(),
|
||||||
|
phone_prefix: p.phone_prefix.clone(),
|
||||||
|
phone: p.phone.clone(),
|
||||||
|
address: p.address.clone(),
|
||||||
|
city: p.city.clone(),
|
||||||
|
zip: p.zip.clone(),
|
||||||
|
country: p.country.clone(),
|
||||||
|
},
|
||||||
|
None => ProfileFields::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A company account must carry its invoicing identity (company name + IČO +
|
||||||
|
/// DIČ; IČ DPH stays optional). Personal accounts have no such requirement.
|
||||||
|
fn company_fields_missing(fields: &ProfileFields) -> bool {
|
||||||
|
fields.company_name.is_none() || fields.company_id.is_none() || fields.tax_id.is_none()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the profile form for `user`, prefilled from `fields`. `saved` shows
|
||||||
|
/// the success banner; `error` shows the company-required validation message.
|
||||||
|
fn profile_view(
|
||||||
|
v: &TeraView,
|
||||||
|
jar: &CookieJar,
|
||||||
|
user: &users::Model,
|
||||||
|
fields: &ProfileFields,
|
||||||
|
saved: bool,
|
||||||
|
error: bool,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let (first_name, last_name) = split_name(&user.name);
|
||||||
|
format::view(
|
||||||
|
v,
|
||||||
|
"account/profile.html",
|
||||||
|
json!({
|
||||||
|
"logged_in_admin": false,
|
||||||
|
"logged_in_customer": true,
|
||||||
|
"account_nav": true,
|
||||||
|
"customer_name": user.name,
|
||||||
|
"customer_account_type": user.account_type,
|
||||||
|
"customer_avatar": user.avatar_id,
|
||||||
|
"avatar_id": user.avatar_id,
|
||||||
|
"saved": saved,
|
||||||
|
"error": error,
|
||||||
|
"name": user.name,
|
||||||
|
"first_name": first_name,
|
||||||
|
"last_name": last_name,
|
||||||
|
"email": user.email,
|
||||||
|
"account_type": user.account_type,
|
||||||
|
"company_name": fields.company_name,
|
||||||
|
"company_id": fields.company_id,
|
||||||
|
"tax_id": fields.tax_id,
|
||||||
|
"vat_id": fields.vat_id,
|
||||||
|
"phone_prefix": fields.phone_prefix,
|
||||||
|
"phone": fields.phone,
|
||||||
|
"address": fields.address,
|
||||||
|
"city": fields.city,
|
||||||
|
"zip": fields.zip,
|
||||||
|
"country": fields.country,
|
||||||
|
"lang": current_lang(jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn profile_page(
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
let profile = customer_profiles::Model::find_for_user(&ctx.db, user.id).await?;
|
||||||
|
profile_view(&v, &jar, &user, &fields_of(profile.as_ref()), false, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn save_profile(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(form): Form<ProfileForm>,
|
||||||
|
) -> 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");
|
||||||
|
}
|
||||||
|
// Apply the edited name to a working copy so it's reflected on both the
|
||||||
|
// success and re-rendered-error views. A blank/too-short name is ignored —
|
||||||
|
// the field can't be cleared.
|
||||||
|
let mut user = user;
|
||||||
|
let new_name = full_name_from_form(&form).filter(|n| *n != user.name);
|
||||||
|
if let Some(name) = new_name.clone() {
|
||||||
|
user.name = name;
|
||||||
|
}
|
||||||
|
let fields = fields_from_form(&form, user.is_company());
|
||||||
|
// A company account's profile is rejected (and re-shown with the entered
|
||||||
|
// values) until it carries its required identifiers.
|
||||||
|
if user.is_company() && company_fields_missing(&fields) {
|
||||||
|
return profile_view(&v, &jar, &user, &fields, false, true);
|
||||||
|
}
|
||||||
|
if let Some(name) = new_name {
|
||||||
|
let mut active = user.clone().into_active_model();
|
||||||
|
active.name = ActiveValue::set(name);
|
||||||
|
active.update(&ctx.db).await?;
|
||||||
|
}
|
||||||
|
customer_profiles::Model::upsert(&ctx.db, user.id, fields.clone()).await?;
|
||||||
|
profile_view(&v, &jar, &user, &fields, true, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist `avatar_id` (a stored image filename, or `None` to clear) on the
|
||||||
|
/// signed-in customer and re-render the profile page with the success banner.
|
||||||
|
async fn set_avatar(
|
||||||
|
v: &TeraView,
|
||||||
|
jar: &CookieJar,
|
||||||
|
ctx: &AppContext,
|
||||||
|
user: users::Model,
|
||||||
|
avatar_id: Option<String>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let mut active = user.clone().into_active_model();
|
||||||
|
active.avatar_id = ActiveValue::set(avatar_id.clone());
|
||||||
|
let user = active.update(&ctx.db).await?;
|
||||||
|
let profile = customer_profiles::Model::find_for_user(&ctx.db, user.id).await?;
|
||||||
|
profile_view(v, jar, &user, &fields_of(profile.as_ref()), true, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload (or replace) the signed-in customer's avatar picture. The single
|
||||||
|
/// `image` file part is validated and stored through the shared image storage,
|
||||||
|
/// then its generated filename is saved as the user's `avatar_id`.
|
||||||
|
#[debug_handler]
|
||||||
|
async fn upload_avatar(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
multipart: Multipart,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let Some(user) = guard::current_user(&ctx, &jar).await else {
|
||||||
|
return format::redirect("/login");
|
||||||
|
};
|
||||||
|
if guard::is_admin(&ctx, &user) {
|
||||||
|
return format::redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
|
let form = read_multipart_form(multipart).await?;
|
||||||
|
let Some(image) = form.single_image() else {
|
||||||
|
// No file chosen — nothing to do, just re-show the profile.
|
||||||
|
let profile = customer_profiles::Model::find_for_user(&ctx.db, user.id).await?;
|
||||||
|
return profile_view(&v, &jar, &user, &fields_of(profile.as_ref()), false, false);
|
||||||
|
};
|
||||||
|
let filename = store_image(&ctx, image).await?;
|
||||||
|
set_avatar(&v, &jar, &ctx, user, Some(filename)).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the signed-in customer's avatar, reverting to the initials fallback.
|
||||||
|
#[debug_handler]
|
||||||
|
async fn remove_avatar(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let Some(user) = guard::current_user(&ctx, &jar).await else {
|
||||||
|
return format::redirect("/login");
|
||||||
|
};
|
||||||
|
if guard::is_admin(&ctx, &user) {
|
||||||
|
return format::redirect("/admin/dashboard");
|
||||||
|
}
|
||||||
|
set_avatar(&v, &jar, &ctx, user, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Lists the signed-in customer's orders, split into still-active and past.
|
||||||
|
#[debug_handler]
|
||||||
|
async fn orders_page(
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
let rows = orders::Entity::find()
|
||||||
|
.filter(orders::Column::UserId.eq(user.id))
|
||||||
|
.order_by_desc(orders::Column::CreatedAt)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let (active, past): (Vec<_>, Vec<_>) = rows
|
||||||
|
.iter()
|
||||||
|
.partition(|o| ACTIVE_STATUSES.contains(&o.status.as_str()));
|
||||||
|
let shape = |list: Vec<&orders::Model>| -> Vec<_> {
|
||||||
|
list.into_iter().map(order_view::summary).collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"account/orders.html",
|
||||||
|
json!({
|
||||||
|
"logged_in_admin": false,
|
||||||
|
"logged_in_customer": true,
|
||||||
|
"account_nav": true,
|
||||||
|
"customer_name": user.name,
|
||||||
|
"customer_account_type": user.account_type,
|
||||||
|
"customer_avatar": user.avatar_id,
|
||||||
|
"active_orders": shape(active),
|
||||||
|
"past_orders": shape(past),
|
||||||
|
"lang": current_lang(&jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows a single order belonging to the signed-in customer. Orders owned by
|
||||||
|
/// someone else (or guest orders) are not found here.
|
||||||
|
#[debug_handler]
|
||||||
|
async fn order_detail_page(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Path(order_number): Path<String>,
|
||||||
|
) -> 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 order = orders::Entity::find()
|
||||||
|
.filter(orders::Column::OrderNumber.eq(order_number))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.filter(|o| o.user_id == Some(user.id))
|
||||||
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
let items = order_items::Entity::find()
|
||||||
|
.filter(order_items::Column::OrderId.eq(order.id))
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let (bank_iban, bank_account_name) = settings::bank_details(&ctx).await?;
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"account/order_detail.html",
|
||||||
|
json!({
|
||||||
|
"logged_in_admin": false,
|
||||||
|
"logged_in_customer": true,
|
||||||
|
"account_nav": true,
|
||||||
|
"customer_name": user.name,
|
||||||
|
"customer_account_type": user.account_type,
|
||||||
|
"customer_avatar": user.avatar_id,
|
||||||
|
"order": order_view::detail(
|
||||||
|
&order,
|
||||||
|
&bank_iban,
|
||||||
|
&bank_account_name,
|
||||||
|
),
|
||||||
|
"items": order_view::items(&items),
|
||||||
|
"lang": current_lang(&jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ChangePasswordForm {
|
||||||
|
current_password: String,
|
||||||
|
password: String,
|
||||||
|
password_confirm: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn password_view(
|
||||||
|
v: &TeraView,
|
||||||
|
jar: &CookieJar,
|
||||||
|
user: &users::Model,
|
||||||
|
changed: bool,
|
||||||
|
error: Option<&str>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
format::view(
|
||||||
|
v,
|
||||||
|
"account/password.html",
|
||||||
|
json!({
|
||||||
|
"logged_in_admin": false,
|
||||||
|
"logged_in_customer": true,
|
||||||
|
"account_nav": true,
|
||||||
|
"customer_name": user.name,
|
||||||
|
"customer_account_type": user.account_type,
|
||||||
|
"customer_avatar": user.avatar_id,
|
||||||
|
"changed": changed,
|
||||||
|
"error": error,
|
||||||
|
"lang": current_lang(jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn change_password_page(
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
password_view(&v, &jar, &user, false, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn change_password(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(form): Form<ChangePasswordForm>,
|
||||||
|
) -> 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");
|
||||||
|
}
|
||||||
|
if !user.verify_password(&form.current_password) {
|
||||||
|
return password_view(&v, &jar, &user, false, Some("current"));
|
||||||
|
}
|
||||||
|
if form.password != form.password_confirm {
|
||||||
|
return password_view(&v, &jar, &user, false, Some("mismatch"));
|
||||||
|
}
|
||||||
|
if form.password.len() < 8 {
|
||||||
|
return password_view(&v, &jar, &user, false, Some("weak"));
|
||||||
|
}
|
||||||
|
let user = user
|
||||||
|
.into_active_model()
|
||||||
|
.reset_password(&ctx.db, &form.password)
|
||||||
|
.await?;
|
||||||
|
password_view(&v, &jar, &user, true, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Two-factor authentication (TOTP / Google Authenticator) -------------
|
||||||
|
//
|
||||||
|
// Entirely opt-in. The security page has three shapes, all rendered from
|
||||||
|
// `security.html`:
|
||||||
|
// * disabled -> an "enable" button,
|
||||||
|
// * enrolling -> the QR + a confirm-code field (secret staged, not yet on),
|
||||||
|
// * enabled -> status, remaining backup codes, disable/regenerate forms.
|
||||||
|
// Both turning 2FA off and regenerating backup codes require re-entering the
|
||||||
|
// account password, so a walk-up attacker on an open session can't weaken it.
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ConfirmTotpForm {
|
||||||
|
code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct PasswordConfirmForm {
|
||||||
|
current_password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the security page. Exactly one of (`enrolling`, plain status) applies;
|
||||||
|
/// `backup_codes` is non-empty only on the one render right after enabling or
|
||||||
|
/// regenerating, where the plaintext codes are shown once.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
fn security_view(
|
||||||
|
v: &TeraView,
|
||||||
|
jar: &CookieJar,
|
||||||
|
user: &users::Model,
|
||||||
|
enrolling: bool,
|
||||||
|
qr: Option<&str>,
|
||||||
|
secret: Option<&str>,
|
||||||
|
backup_codes: &[String],
|
||||||
|
error: Option<&str>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
format::view(
|
||||||
|
v,
|
||||||
|
"account/security.html",
|
||||||
|
json!({
|
||||||
|
"logged_in_admin": false,
|
||||||
|
"logged_in_customer": true,
|
||||||
|
"account_nav": true,
|
||||||
|
"customer_name": user.name,
|
||||||
|
"customer_account_type": user.account_type,
|
||||||
|
"customer_avatar": user.avatar_id,
|
||||||
|
"totp_enabled": user.totp_enabled(),
|
||||||
|
"enrolling": enrolling,
|
||||||
|
"qr": qr,
|
||||||
|
"secret": secret,
|
||||||
|
"backup_codes": backup_codes,
|
||||||
|
"backup_remaining": user.backup_codes_remaining(),
|
||||||
|
"error": error,
|
||||||
|
"lang": current_lang(jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Common guard for every security handler: a signed-in, non-admin customer.
|
||||||
|
async fn require_customer(ctx: &AppContext, jar: &CookieJar) -> Result<users::Model> {
|
||||||
|
match guard::current_user(ctx, jar).await {
|
||||||
|
Some(user) if guard::is_admin(ctx, &user) => Err(Error::string("admin")),
|
||||||
|
Some(user) => Ok(user),
|
||||||
|
None => Err(Error::Unauthorized("login required".into())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn security_page(
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
security_view(&v, &jar, &user, false, None, None, &[], None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stage a fresh secret and show the QR + confirm-code field.
|
||||||
|
#[debug_handler]
|
||||||
|
async fn enable_totp(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let Ok(user) = require_customer(&ctx, &jar).await else {
|
||||||
|
return format::redirect("/login");
|
||||||
|
};
|
||||||
|
// Already on — nothing to enroll.
|
||||||
|
if user.totp_enabled() {
|
||||||
|
return security_view(&v, &jar, &user, false, None, None, &[], None);
|
||||||
|
}
|
||||||
|
let user = user.into_active_model().begin_totp_enrollment(&ctx.db).await?;
|
||||||
|
let Some((qr, secret)) = user.totp_provisioning() else {
|
||||||
|
return security_view(&v, &jar, &user, false, None, None, &[], Some("enroll"));
|
||||||
|
};
|
||||||
|
security_view(&v, &jar, &user, true, Some(&qr), Some(&secret), &[], None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify the first code against the staged secret; on success flip 2FA on and
|
||||||
|
/// show the one-time backup codes. On a wrong code, re-show the QR to retry.
|
||||||
|
#[debug_handler]
|
||||||
|
async fn confirm_totp(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(form): Form<ConfirmTotpForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let Ok(user) = require_customer(&ctx, &jar).await else {
|
||||||
|
return format::redirect("/login");
|
||||||
|
};
|
||||||
|
if user.totp_enabled() {
|
||||||
|
return security_view(&v, &jar, &user, false, None, None, &[], None);
|
||||||
|
}
|
||||||
|
if !user.verify_totp_code(&form.code) {
|
||||||
|
let qr = user.totp_provisioning();
|
||||||
|
let (qr, secret) = match &qr {
|
||||||
|
Some((q, s)) => (Some(q.as_str()), Some(s.as_str())),
|
||||||
|
None => (None, None),
|
||||||
|
};
|
||||||
|
return security_view(&v, &jar, &user, true, qr, secret, &[], Some("code"));
|
||||||
|
}
|
||||||
|
let (user, backup_codes) = user.into_active_model().enable_totp(&ctx.db).await?;
|
||||||
|
security_view(&v, &jar, &user, false, None, None, &backup_codes, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turn 2FA off — requires the account password as confirmation.
|
||||||
|
#[debug_handler]
|
||||||
|
async fn disable_totp(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(form): Form<PasswordConfirmForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let Ok(user) = require_customer(&ctx, &jar).await else {
|
||||||
|
return format::redirect("/login");
|
||||||
|
};
|
||||||
|
if !user.totp_enabled() {
|
||||||
|
return security_view(&v, &jar, &user, false, None, None, &[], None);
|
||||||
|
}
|
||||||
|
if !user.verify_password(&form.current_password) {
|
||||||
|
return security_view(&v, &jar, &user, false, None, None, &[], Some("password"));
|
||||||
|
}
|
||||||
|
let user = user.into_active_model().disable_totp(&ctx.db).await?;
|
||||||
|
security_view(&v, &jar, &user, false, None, None, &[], None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Issue a fresh set of backup codes (invalidating the old ones) — also gated by
|
||||||
|
/// the account password.
|
||||||
|
#[debug_handler]
|
||||||
|
async fn regenerate_backup_codes(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(form): Form<PasswordConfirmForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let Ok(user) = require_customer(&ctx, &jar).await else {
|
||||||
|
return format::redirect("/login");
|
||||||
|
};
|
||||||
|
if !user.totp_enabled() {
|
||||||
|
return security_view(&v, &jar, &user, false, None, None, &[], None);
|
||||||
|
}
|
||||||
|
if !user.verify_password(&form.current_password) {
|
||||||
|
return security_view(&v, &jar, &user, false, None, None, &[], Some("password"));
|
||||||
|
}
|
||||||
|
let (user, backup_codes) =
|
||||||
|
user.into_active_model().regenerate_backup_codes(&ctx.db).await?;
|
||||||
|
security_view(&v, &jar, &user, false, None, None, &backup_codes, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.add("/account/profile", get(profile_page))
|
||||||
|
.add("/account/profile", post(save_profile))
|
||||||
|
.add(
|
||||||
|
"/account/profile/avatar",
|
||||||
|
post(upload_avatar).layer(DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024)),
|
||||||
|
)
|
||||||
|
.add("/account/profile/avatar/remove", post(remove_avatar))
|
||||||
|
.add("/account/orders", get(orders_page))
|
||||||
|
.add("/account/orders/{order_number}", get(order_detail_page))
|
||||||
|
.add("/account/password", get(change_password_page))
|
||||||
|
.add("/account/password", post(change_password))
|
||||||
|
.add("/account/security", get(security_page))
|
||||||
|
.add("/account/security/enable", post(enable_totp))
|
||||||
|
.add("/account/security/confirm", post(confirm_totp))
|
||||||
|
.add("/account/security/disable", post(disable_totp))
|
||||||
|
.add("/account/security/backup-codes", post(regenerate_backup_codes))
|
||||||
|
}
|
||||||
@@ -49,10 +49,6 @@ async fn parse_category_fields(
|
|||||||
.text("name")
|
.text("name")
|
||||||
.ok_or_else(|| Error::BadRequest("category name is required".to_string()))?;
|
.ok_or_else(|| Error::BadRequest("category name is required".to_string()))?;
|
||||||
let description = form.text("description");
|
let description = form.text("description");
|
||||||
let position = form
|
|
||||||
.text("position")
|
|
||||||
.and_then(|s| s.parse::<i32>().ok())
|
|
||||||
.unwrap_or(0);
|
|
||||||
let published = form.checked("published");
|
let published = form.checked("published");
|
||||||
|
|
||||||
// Resolve the chosen parent, rejecting cycles: a category may not be its
|
// Resolve the chosen parent, rejecting cycles: a category may not be its
|
||||||
@@ -81,6 +77,28 @@ async fn parse_category_fields(
|
|||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Position is optional: an explicit value sorts the category among its
|
||||||
|
// siblings, but a blank field appends it to the end of its parent's group
|
||||||
|
// (one past the current max), so new categories land last instead of first.
|
||||||
|
let position = match form.text("position").and_then(|s| s.parse::<i32>().ok()) {
|
||||||
|
Some(explicit) => explicit,
|
||||||
|
None => {
|
||||||
|
let mut query = categories::Entity::find();
|
||||||
|
query = match parent_id {
|
||||||
|
Some(pid) => query.filter(categories::Column::ParentId.eq(pid)),
|
||||||
|
None => query.filter(categories::Column::ParentId.is_null()),
|
||||||
|
};
|
||||||
|
query
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.filter(|c| Some(c.id) != current_id)
|
||||||
|
.map(|c| c.position)
|
||||||
|
.max()
|
||||||
|
.map_or(0, |max| max + 1)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
let desired = form
|
let desired = form
|
||||||
.text("slug")
|
.text("slug")
|
||||||
.map(|s| slugify(&s))
|
.map(|s| slugify(&s))
|
||||||
@@ -180,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,
|
||||||
};
|
};
|
||||||
@@ -234,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))
|
||||||
|
}
|
||||||
427
src/controllers/admin_customers.rs
Normal file
427
src/controllers/admin_customers.rs
Normal file
@@ -0,0 +1,427 @@
|
|||||||
|
//! Admin management of business (company) accounts and their pricing.
|
||||||
|
//!
|
||||||
|
//! Per company the admin can: assign reusable discount profiles (the automated
|
||||||
|
//! layer), resolve per-product collisions when two assigned profiles cover the
|
||||||
|
//! same product, and set a manually negotiated price per product. The effective
|
||||||
|
//! price the business pays is always resolved by [`crate::shared::pricing`]
|
||||||
|
//! (lowest of public / automated / negotiated), shown here for reference.
|
||||||
|
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, PaginatorTrait, QueryFilter,
|
||||||
|
QueryOrder, Set, TransactionTrait,
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
controllers::i18n::current_lang,
|
||||||
|
models::{
|
||||||
|
account_discount_profiles, account_product_prices, account_product_resolutions,
|
||||||
|
categories, discount_profiles, product_variants, products, _entities::users,
|
||||||
|
},
|
||||||
|
shared::{
|
||||||
|
guard,
|
||||||
|
money::{format_bp, format_price, parse_price_to_cents},
|
||||||
|
pricing,
|
||||||
|
},
|
||||||
|
views::shop as view,
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMPANY: &str = "company";
|
||||||
|
const BUSINESS_AUDIENCE: &str = "business";
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct PriceForm {
|
||||||
|
price: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct ResolutionForm {
|
||||||
|
profile_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn company_by_id(ctx: &AppContext, id: i32) -> Result<users::Model> {
|
||||||
|
let user = users::Entity::find_by_id(id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
if user.account_type != COMPANY {
|
||||||
|
return Err(Error::NotFound);
|
||||||
|
}
|
||||||
|
Ok(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn assigned_profile_ids(ctx: &AppContext, user_id: i32) -> Result<HashSet<i32>> {
|
||||||
|
Ok(account_discount_profiles::Entity::find()
|
||||||
|
.filter(account_discount_profiles::Column::UserId.eq(user_id))
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| a.discount_profile_id)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn index(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let companies = users::Entity::find()
|
||||||
|
.filter(users::Column::AccountType.eq(COMPANY))
|
||||||
|
.order_by_asc(users::Column::Name)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut rows = Vec::with_capacity(companies.len());
|
||||||
|
for company in &companies {
|
||||||
|
let negotiated = account_product_prices::Entity::find()
|
||||||
|
.filter(account_product_prices::Column::UserId.eq(company.id))
|
||||||
|
.count(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
rows.push(json!({
|
||||||
|
"id": company.id,
|
||||||
|
"name": company.name,
|
||||||
|
"email": company.email,
|
||||||
|
"negotiated_count": negotiated,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"admin/customers/index.html",
|
||||||
|
json!({ "customers": rows, "lang": current_lang(&jar) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn show(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let company = company_by_id(&ctx, id).await?;
|
||||||
|
|
||||||
|
// All profiles (for the assignment section + name lookup) and which are
|
||||||
|
// assigned to this company.
|
||||||
|
let all_profiles = discount_profiles::Entity::find()
|
||||||
|
.order_by_asc(discount_profiles::Column::Name)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let assigned = assigned_profile_ids(&ctx, company.id).await?;
|
||||||
|
let profiles_json: Vec<serde_json::Value> = all_profiles
|
||||||
|
.iter()
|
||||||
|
.map(|p| {
|
||||||
|
json!({
|
||||||
|
"id": p.id,
|
||||||
|
"name": p.name,
|
||||||
|
"percent": format_bp(p.percent_bp),
|
||||||
|
"scope_type": p.scope_type,
|
||||||
|
"assigned": assigned.contains(&p.id),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let all_categories = categories::Entity::find()
|
||||||
|
.order_by_asc(categories::Column::Position)
|
||||||
|
.order_by_asc(categories::Column::Name)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
// Optional text search (drafts included), otherwise the whole catalog by
|
||||||
|
// name. Reuses the storefront's hybrid full-text + fuzzy product search.
|
||||||
|
let query = params.get("q").map(String::as_str).unwrap_or("").trim().to_string();
|
||||||
|
let list = if query.is_empty() {
|
||||||
|
products::Entity::find()
|
||||||
|
.order_by_asc(products::Column::Name)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?
|
||||||
|
} else {
|
||||||
|
products::Entity::search(&ctx.db, &query, 1000, false).await?
|
||||||
|
};
|
||||||
|
|
||||||
|
// Category sidebar tree (counts over the full, unfiltered product list) plus
|
||||||
|
// the active `?category=` filter applied to the rows.
|
||||||
|
let category_ids: Vec<Option<i32>> = list.iter().map(|p| p.category_id).collect();
|
||||||
|
let category_groups = view::admin_category_groups(&all_categories, &category_ids);
|
||||||
|
let selected_category = params
|
||||||
|
.get("category")
|
||||||
|
.map(String::as_str)
|
||||||
|
.unwrap_or("all")
|
||||||
|
.to_string();
|
||||||
|
let filter = view::category_filter_ids(&all_categories, &selected_category);
|
||||||
|
|
||||||
|
// Pricing is per variant. Flatten the (filtered) products into their variants
|
||||||
|
// in product-name then variant-position order, carrying each variant's
|
||||||
|
// product for the row's display name.
|
||||||
|
let product_ids: Vec<i32> = list.iter().map(|p| p.id).collect();
|
||||||
|
let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &product_ids).await?;
|
||||||
|
let mut variant_rows: Vec<(&products::Model, product_variants::Model)> = Vec::new();
|
||||||
|
for product in &list {
|
||||||
|
if !view::category_filter_keep(&filter, product.category_id) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(variants) = grouped.get(&product.id) {
|
||||||
|
for variant in variants {
|
||||||
|
variant_rows.push((product, variant.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Two prices per variant:
|
||||||
|
// - the generic business price a freshly-registered company sees (business
|
||||||
|
// baseline + business-audience profiles, no per-company deals), and
|
||||||
|
// - this company's effective price (its negotiated price + assigned profiles).
|
||||||
|
// The effective price is highlighted only when it differs from the generic one.
|
||||||
|
let variants_only: Vec<product_variants::Model> =
|
||||||
|
variant_rows.iter().map(|(_, v)| v.clone()).collect();
|
||||||
|
let business = pricing::audience_price_variants(&ctx, &variants_only, BUSINESS_AUDIENCE).await?;
|
||||||
|
let details = pricing::detail_variants(&ctx, &variants_only, Some(&company)).await?;
|
||||||
|
|
||||||
|
let rows: Vec<serde_json::Value> = variant_rows
|
||||||
|
.iter()
|
||||||
|
.zip(business.iter())
|
||||||
|
.zip(details.iter())
|
||||||
|
.map(|(((product, variant), b), d)| {
|
||||||
|
json!({
|
||||||
|
"product_id": product.id,
|
||||||
|
"variant_id": variant.id,
|
||||||
|
"name": product.name,
|
||||||
|
"variant_label": variant.label,
|
||||||
|
"regular_price": format_price(d.regular_cents),
|
||||||
|
"business_price": format_price(b.price_cents),
|
||||||
|
"business_reduced": b.price_cents < d.regular_cents,
|
||||||
|
"has_negotiated": d.manual_cents.is_some(),
|
||||||
|
"collision": d.collision,
|
||||||
|
"effective_price": format_price(d.price_cents),
|
||||||
|
"effective_differs": d.price_cents != b.price_cents,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"admin/customers/show.html",
|
||||||
|
json!({
|
||||||
|
"customer": { "id": company.id, "name": company.name, "email": company.email },
|
||||||
|
"profiles": profiles_json,
|
||||||
|
"products": rows,
|
||||||
|
"category_groups": category_groups,
|
||||||
|
"selected_category": selected_category,
|
||||||
|
"query": query,
|
||||||
|
"total_count": list.len(),
|
||||||
|
"uncategorized_count": category_ids.iter().filter(|c| c.is_none()).count(),
|
||||||
|
"error": params.get("error"),
|
||||||
|
"lang": current_lang(&jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dedicated per-product page for the negotiated price (and, when two assigned
|
||||||
|
/// profiles collide, the resolution selector). Mirrors the catalog "Set discount"
|
||||||
|
/// page but for a single company.
|
||||||
|
#[debug_handler]
|
||||||
|
async fn price_edit(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
Path((id, variant_id)): Path<(i32, i32)>,
|
||||||
|
Query(params): Query<HashMap<String, String>>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let company = company_by_id(&ctx, id).await?;
|
||||||
|
let variant = product_variants::Entity::find_by_id(variant_id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
let product = products::Entity::find_by_id(variant.product_id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
|
||||||
|
let business =
|
||||||
|
pricing::audience_price_variants(&ctx, std::slice::from_ref(&variant), BUSINESS_AUDIENCE)
|
||||||
|
.await?;
|
||||||
|
let business_cents = business[0].price_cents;
|
||||||
|
let detail =
|
||||||
|
pricing::detail_variants(&ctx, std::slice::from_ref(&variant), Some(&company)).await?;
|
||||||
|
let d = &detail[0];
|
||||||
|
|
||||||
|
// Names for the covering profiles, used by the collision resolution selector.
|
||||||
|
let covering: Vec<serde_json::Value> = if d.covering_profile_ids.is_empty() {
|
||||||
|
Vec::new()
|
||||||
|
} else {
|
||||||
|
let profiles = discount_profiles::Entity::find()
|
||||||
|
.filter(discount_profiles::Column::Id.is_in(d.covering_profile_ids.clone()))
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let name: HashMap<i32, String> =
|
||||||
|
profiles.iter().map(|p| (p.id, p.name.clone())).collect();
|
||||||
|
d.covering_profile_ids
|
||||||
|
.iter()
|
||||||
|
.map(|pid| json!({ "id": pid, "name": name.get(pid) }))
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"admin/customers/price_form.html",
|
||||||
|
json!({
|
||||||
|
"customer": { "id": company.id, "name": company.name },
|
||||||
|
"product": {
|
||||||
|
"id": product.id,
|
||||||
|
"variant_id": variant.id,
|
||||||
|
"name": product.name,
|
||||||
|
"variant_label": variant.label,
|
||||||
|
"regular_price": format_price(d.regular_cents),
|
||||||
|
"regular_cents": d.regular_cents,
|
||||||
|
"business_price": format_price(business_cents),
|
||||||
|
"business_reduced": business_cents < d.regular_cents,
|
||||||
|
"effective_price": format_price(d.price_cents),
|
||||||
|
"effective_differs": d.price_cents != business_cents,
|
||||||
|
},
|
||||||
|
"negotiated": d.manual_cents.map(format_price).unwrap_or_default(),
|
||||||
|
"has_negotiated": d.manual_cents.is_some(),
|
||||||
|
"collision": d.collision,
|
||||||
|
"covering": covering,
|
||||||
|
"auto_profile_id": d.auto_profile_id,
|
||||||
|
"error": params.get("error"),
|
||||||
|
"lang": current_lang(&jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn set_price(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path((id, variant_id)): Path<(i32, i32)>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(form): Form<PriceForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let company = company_by_id(&ctx, id).await?;
|
||||||
|
|
||||||
|
let entered = form.price.trim().to_string();
|
||||||
|
if entered.is_empty() {
|
||||||
|
account_product_prices::Model::clear(&ctx.db, company.id, variant_id).await?;
|
||||||
|
return format::redirect(&format!("/admin/customers/{id}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let cents = match parse_price_to_cents(&entered) {
|
||||||
|
Ok(cents) if cents > 0 => cents,
|
||||||
|
_ => {
|
||||||
|
return format::redirect(&format!(
|
||||||
|
"/admin/customers/{id}/prices/{variant_id}/edit?error=discount-must-be-positive"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
account_product_prices::Model::upsert(&ctx.db, company.id, variant_id, cents).await?;
|
||||||
|
format::redirect(&format!("/admin/customers/{id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn remove_price(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path((id, variant_id)): Path<(i32, i32)>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let company = company_by_id(&ctx, id).await?;
|
||||||
|
account_product_prices::Model::clear(&ctx.db, company.id, variant_id).await?;
|
||||||
|
format::redirect(&format!("/admin/customers/{id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace the company's assigned profiles with the submitted set of checkboxes
|
||||||
|
/// (`profile_ids`, a repeated field axum `Form` can't collect, parsed directly).
|
||||||
|
#[debug_handler]
|
||||||
|
async fn sync_profiles(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
body: String,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let company = company_by_id(&ctx, id).await?;
|
||||||
|
|
||||||
|
let profile_ids: Vec<i32> = form_urlencoded::parse(body.as_bytes())
|
||||||
|
.filter(|(k, _)| k == "profile_ids")
|
||||||
|
.filter_map(|(_, v)| v.parse::<i32>().ok())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let txn = ctx.db.begin().await?;
|
||||||
|
account_discount_profiles::Entity::delete_many()
|
||||||
|
.filter(account_discount_profiles::Column::UserId.eq(company.id))
|
||||||
|
.exec(&txn)
|
||||||
|
.await?;
|
||||||
|
for profile_id in profile_ids {
|
||||||
|
account_discount_profiles::ActiveModel {
|
||||||
|
user_id: Set(company.id),
|
||||||
|
discount_profile_id: Set(profile_id),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&txn)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
txn.commit().await?;
|
||||||
|
format::redirect(&format!("/admin/customers/{id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record the admin's chosen winning profile for a colliding product.
|
||||||
|
#[debug_handler]
|
||||||
|
async fn set_resolution(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path((id, variant_id)): Path<(i32, i32)>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(form): Form<ResolutionForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let company = company_by_id(&ctx, id).await?;
|
||||||
|
|
||||||
|
let existing = account_product_resolutions::Entity::find()
|
||||||
|
.filter(account_product_resolutions::Column::UserId.eq(company.id))
|
||||||
|
.filter(account_product_resolutions::Column::VariantId.eq(variant_id))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let mut active = match existing {
|
||||||
|
Some(row) => row.into_active_model(),
|
||||||
|
None => account_product_resolutions::ActiveModel {
|
||||||
|
user_id: Set(company.id),
|
||||||
|
variant_id: Set(variant_id),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
active.discount_profile_id = Set(form.profile_id);
|
||||||
|
active.save(&ctx.db).await?;
|
||||||
|
format::redirect(&format!("/admin/customers/{id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.add("/admin/customers", get(index))
|
||||||
|
.add("/admin/customers/{id}", get(show))
|
||||||
|
.add("/admin/customers/{id}/profiles", post(sync_profiles))
|
||||||
|
.add(
|
||||||
|
"/admin/customers/{id}/prices/{variant_id}/edit",
|
||||||
|
get(price_edit),
|
||||||
|
)
|
||||||
|
.add("/admin/customers/{id}/prices/{variant_id}", post(set_price))
|
||||||
|
.add(
|
||||||
|
"/admin/customers/{id}/prices/{variant_id}/remove",
|
||||||
|
post(remove_price),
|
||||||
|
)
|
||||||
|
.add(
|
||||||
|
"/admin/customers/{id}/resolutions/{variant_id}",
|
||||||
|
post(set_resolution),
|
||||||
|
)
|
||||||
|
}
|
||||||
298
src/controllers/admin_discount_profiles.rs
Normal file
298
src/controllers/admin_discount_profiles.rs
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
//! Admin CRUD for reusable discount profiles (a named percentage over a product
|
||||||
|
//! scope). Profiles are assigned to business accounts on the customer page; here
|
||||||
|
//! the admin only defines them.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{
|
||||||
|
ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, ModelTrait, PaginatorTrait,
|
||||||
|
QueryFilter, QueryOrder, Set, TransactionTrait,
|
||||||
|
};
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
controllers::i18n::current_lang,
|
||||||
|
models::{discount_profile_products, discount_profiles, products},
|
||||||
|
shared::{
|
||||||
|
guard,
|
||||||
|
money::{format_bp, parse_percent, percent_to_bp},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Scalar + repeated fields parsed from the profile form. `product_ids` is a
|
||||||
|
/// repeated checkbox field, which `serde_urlencoded` (axum `Form`) can't collect,
|
||||||
|
/// so the body is parsed directly.
|
||||||
|
struct ProfileInput {
|
||||||
|
name: String,
|
||||||
|
percent: String,
|
||||||
|
scope_type: String,
|
||||||
|
product_ids: Vec<i32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_profile_form(body: &str) -> ProfileInput {
|
||||||
|
let mut name = String::new();
|
||||||
|
let mut percent = String::new();
|
||||||
|
let mut scope_type = discount_profiles::SCOPE_INCLUDE.to_string();
|
||||||
|
let mut product_ids = Vec::new();
|
||||||
|
for (key, value) in form_urlencoded::parse(body.as_bytes()) {
|
||||||
|
match key.as_ref() {
|
||||||
|
"name" => name = value.into_owned(),
|
||||||
|
"percent" => percent = value.into_owned(),
|
||||||
|
"scope_type" => scope_type = value.into_owned(),
|
||||||
|
"product_ids" => {
|
||||||
|
if let Ok(id) = value.parse::<i32>() {
|
||||||
|
product_ids.push(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ProfileInput {
|
||||||
|
name,
|
||||||
|
percent,
|
||||||
|
scope_type,
|
||||||
|
product_ids,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn profile_by_id(ctx: &AppContext, id: i32) -> Result<discount_profiles::Model> {
|
||||||
|
discount_profiles::Entity::find_by_id(id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn index(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let profiles = discount_profiles::Entity::find()
|
||||||
|
.order_by_asc(discount_profiles::Column::Name)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let mut rows = Vec::with_capacity(profiles.len());
|
||||||
|
for profile in &profiles {
|
||||||
|
let count = discount_profile_products::Entity::find()
|
||||||
|
.filter(discount_profile_products::Column::DiscountProfileId.eq(profile.id))
|
||||||
|
.count(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
rows.push(json!({
|
||||||
|
"id": profile.id,
|
||||||
|
"name": profile.name,
|
||||||
|
"percent": format_bp(profile.percent_bp),
|
||||||
|
"scope_type": profile.scope_type,
|
||||||
|
"product_count": count,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"admin/catalog/discount_profiles.html",
|
||||||
|
json!({ "profiles": rows, "lang": current_lang(&jar) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the create/edit form. `profile` is null on create.
|
||||||
|
async fn render_form(
|
||||||
|
ctx: &AppContext,
|
||||||
|
v: &TeraView,
|
||||||
|
jar: &CookieJar,
|
||||||
|
profile: Option<&discount_profiles::Model>,
|
||||||
|
selected: &HashSet<i32>,
|
||||||
|
error: Option<&str>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let all_products = products::Entity::find()
|
||||||
|
.order_by_asc(products::Column::Name)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let product_rows: Vec<serde_json::Value> = all_products
|
||||||
|
.iter()
|
||||||
|
.map(|p| json!({ "id": p.id, "name": p.name, "selected": selected.contains(&p.id) }))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let profile_json = match profile {
|
||||||
|
Some(p) => json!({
|
||||||
|
"id": p.id,
|
||||||
|
"name": p.name,
|
||||||
|
"percent": format_bp(p.percent_bp),
|
||||||
|
"scope_type": p.scope_type,
|
||||||
|
}),
|
||||||
|
None => serde_json::Value::Null,
|
||||||
|
};
|
||||||
|
|
||||||
|
format::view(
|
||||||
|
v,
|
||||||
|
"admin/catalog/discount_profile_form.html",
|
||||||
|
json!({
|
||||||
|
"profile": profile_json,
|
||||||
|
"products": product_rows,
|
||||||
|
"error": error,
|
||||||
|
"lang": current_lang(jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn new(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
render_form(&ctx, &v, &jar, None, &HashSet::new(), None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn edit(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let profile = profile_by_id(&ctx, id).await?;
|
||||||
|
let selected = member_ids(&ctx, id).await?;
|
||||||
|
render_form(&ctx, &v, &jar, Some(&profile), &selected, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn member_ids(ctx: &AppContext, profile_id: i32) -> Result<HashSet<i32>> {
|
||||||
|
Ok(discount_profile_products::Entity::find()
|
||||||
|
.filter(discount_profile_products::Column::DiscountProfileId.eq(profile_id))
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| r.product_id)
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate the parsed form into `(name, percent_bp, scope_type)`, or an error key.
|
||||||
|
fn validate(input: &ProfileInput) -> std::result::Result<(String, i32, String), &'static str> {
|
||||||
|
let name = input.name.trim().to_string();
|
||||||
|
if name.is_empty() {
|
||||||
|
return Err("profile-name-required");
|
||||||
|
}
|
||||||
|
let pct = parse_percent(&input.percent).ok_or("discount-invalid")?;
|
||||||
|
if pct <= 0.0 || pct >= 100.0 {
|
||||||
|
return Err("discount-percent-range");
|
||||||
|
}
|
||||||
|
let scope = if input.scope_type == discount_profiles::SCOPE_ALL_EXCEPT {
|
||||||
|
discount_profiles::SCOPE_ALL_EXCEPT
|
||||||
|
} else {
|
||||||
|
discount_profiles::SCOPE_INCLUDE
|
||||||
|
};
|
||||||
|
Ok((name, percent_to_bp(pct), scope.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replace a profile's product membership with `product_ids`.
|
||||||
|
async fn sync_membership(
|
||||||
|
ctx: &AppContext,
|
||||||
|
profile_id: i32,
|
||||||
|
product_ids: &[i32],
|
||||||
|
) -> Result<()> {
|
||||||
|
let txn = ctx.db.begin().await?;
|
||||||
|
discount_profile_products::Entity::delete_many()
|
||||||
|
.filter(discount_profile_products::Column::DiscountProfileId.eq(profile_id))
|
||||||
|
.exec(&txn)
|
||||||
|
.await?;
|
||||||
|
for product_id in product_ids {
|
||||||
|
discount_profile_products::ActiveModel {
|
||||||
|
discount_profile_id: Set(profile_id),
|
||||||
|
product_id: Set(*product_id),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&txn)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
txn.commit().await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn create(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
body: String,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let input = parse_profile_form(&body);
|
||||||
|
let (name, percent_bp, scope_type) = match validate(&input) {
|
||||||
|
Ok(values) => values,
|
||||||
|
Err(key) => {
|
||||||
|
let selected: HashSet<i32> = input.product_ids.iter().copied().collect();
|
||||||
|
return render_form(&ctx, &v, &jar, None, &selected, Some(key)).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let profile = discount_profiles::ActiveModel {
|
||||||
|
name: Set(name),
|
||||||
|
percent_bp: Set(percent_bp),
|
||||||
|
scope_type: Set(scope_type),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
sync_membership(&ctx, profile.id, &input.product_ids).await?;
|
||||||
|
format::redirect("/admin/catalog/discount-profiles")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn update(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
body: String,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let profile = profile_by_id(&ctx, id).await?;
|
||||||
|
let input = parse_profile_form(&body);
|
||||||
|
let (name, percent_bp, scope_type) = match validate(&input) {
|
||||||
|
Ok(values) => values,
|
||||||
|
Err(key) => {
|
||||||
|
let selected: HashSet<i32> = input.product_ids.iter().copied().collect();
|
||||||
|
return render_form(&ctx, &v, &jar, Some(&profile), &selected, Some(key)).await;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut active = profile.into_active_model();
|
||||||
|
active.name = Set(name);
|
||||||
|
active.percent_bp = Set(percent_bp);
|
||||||
|
active.scope_type = Set(scope_type);
|
||||||
|
active.update(&ctx.db).await?;
|
||||||
|
sync_membership(&ctx, id, &input.product_ids).await?;
|
||||||
|
format::redirect("/admin/catalog/discount-profiles")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn delete(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
// FK cascades remove membership, assignments and resolutions.
|
||||||
|
profile_by_id(&ctx, id).await?.delete(&ctx.db).await?;
|
||||||
|
format::redirect("/admin/catalog/discount-profiles")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.add("/admin/catalog/discount-profiles", get(index))
|
||||||
|
.add("/admin/catalog/discount-profiles/new", get(new))
|
||||||
|
.add("/admin/catalog/discount-profiles", post(create))
|
||||||
|
.add("/admin/catalog/discount-profiles/{id}/edit", get(edit))
|
||||||
|
.add("/admin/catalog/discount-profiles/{id}", post(update))
|
||||||
|
.add("/admin/catalog/discount-profiles/{id}/delete", post(delete))
|
||||||
|
}
|
||||||
@@ -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!(
|
||||||
@@ -38,11 +60,29 @@ impl MultipartForm {
|
|||||||
Some("on" | "true" | "1")
|
Some("on" | "true" | "1")
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The distinct row indices `N` present among `variants[N][...]` fields,
|
||||||
|
/// sorted ascending. Used to read the repeated variant rows of the product
|
||||||
|
/// form (each row's fields are uniquely keyed, so the HashMap keeps them all).
|
||||||
|
pub(crate) fn variant_indices(&self) -> Vec<usize> {
|
||||||
|
let mut idx: Vec<usize> = self
|
||||||
|
.fields
|
||||||
|
.keys()
|
||||||
|
.filter_map(|k| {
|
||||||
|
let rest = k.strip_prefix("variants[")?;
|
||||||
|
rest.split(']').next()?.parse::<usize>().ok()
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
idx.sort_unstable();
|
||||||
|
idx.dedup();
|
||||||
|
idx
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<MultipartForm> {
|
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()
|
||||||
@@ -65,8 +105,20 @@ pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<Mult
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// An empty file part (no file chosen in a slot) is ignored.
|
||||||
if !data.is_empty() {
|
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
|
||||||
@@ -77,7 +129,11 @@ pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<Mult
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(MultipartForm { fields, image })
|
Ok(MultipartForm {
|
||||||
|
fields,
|
||||||
|
images,
|
||||||
|
image_order,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Store an uploaded image's bytes and return its generated filename.
|
/// 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};
|
||||||
@@ -14,7 +17,8 @@ use crate::{
|
|||||||
shared::{guard, settings},
|
shared::{guard, settings},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) const ORDER_STATUSES: [&str; 4] = ["pending", "paid", "shipped", "cancelled"];
|
pub(crate) const ORDER_STATUSES: [&str; 5] =
|
||||||
|
["pending", "paid", "shipped", "delivered", "cancelled"];
|
||||||
|
|
||||||
/// Fallback parcel weight when products carry no weight of their own.
|
/// Fallback parcel weight when products carry no weight of their own.
|
||||||
const DEFAULT_PARCEL_WEIGHT_GRAMS: i32 = 1000;
|
const DEFAULT_PARCEL_WEIGHT_GRAMS: i32 = 1000;
|
||||||
@@ -29,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),
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,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();
|
||||||
@@ -86,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,
|
||||||
@@ -185,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))
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user