Compare commits
10 Commits
v0.1.0
...
7601fc704d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7601fc704d | ||
|
|
7be1726f1b | ||
|
|
d18bdeaf6e | ||
|
|
cd7a756a54 | ||
|
|
e8c0362a54 | ||
|
|
43562e964a | ||
|
|
f54fd3d717 | ||
|
|
e4f63b3de9 | ||
|
|
95f195a204 | ||
|
|
b88c990873 |
@@ -2,7 +2,7 @@ CONTAINER_NAME=universal-web
|
|||||||
REVERSE_PROXY_NETWORK=
|
REVERSE_PROXY_NETWORK=
|
||||||
UPLOADS_VOLUME_NAME=universal_web_uploads
|
UPLOADS_VOLUME_NAME=universal_web_uploads
|
||||||
|
|
||||||
APP_HOST=https://gitara.farmeris.sk
|
APP_HOST=https://eshop.example.com
|
||||||
PORT=5150
|
PORT=5150
|
||||||
SERVER_BINDING=0.0.0.0
|
SERVER_BINDING=0.0.0.0
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
gitara.farmeris.sk {
|
eshop.example.com {
|
||||||
encode gzip
|
encode gzip
|
||||||
|
|
||||||
@static path /static/*
|
@static path /static/*
|
||||||
@@ -6,9 +6,9 @@ gitara.farmeris.sk {
|
|||||||
|
|
||||||
rewrite /favicon.ico /static/favicon/favicon.ico
|
rewrite /favicon.ico /static/favicon/favicon.ico
|
||||||
|
|
||||||
reverse_proxy gitara-web:5150
|
reverse_proxy kompress:5150
|
||||||
}
|
}
|
||||||
|
|
||||||
www.gitara.farmeris.sk {
|
eshop.example.com {
|
||||||
redir https://gitara.farmeris.sk{uri} permanent
|
redir https://eshop.example.com{uri} permanent
|
||||||
}
|
}
|
||||||
|
|||||||
147
Cargo.lock
generated
147
Cargo.lock
generated
@@ -1504,9 +1504,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 5.3.0",
|
"r-efi 5.3.0",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1523,36 +1525,6 @@ dependencies = [
|
|||||||
"wasip3",
|
"wasip3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "gitara_web"
|
|
||||||
version = "0.1.0"
|
|
||||||
dependencies = [
|
|
||||||
"async-trait",
|
|
||||||
"axum",
|
|
||||||
"axum-extra",
|
|
||||||
"bytes",
|
|
||||||
"chrono",
|
|
||||||
"dotenvy",
|
|
||||||
"fluent-templates",
|
|
||||||
"include_dir",
|
|
||||||
"insta",
|
|
||||||
"loco-rs",
|
|
||||||
"migration",
|
|
||||||
"regex",
|
|
||||||
"rstest",
|
|
||||||
"sea-orm",
|
|
||||||
"serde",
|
|
||||||
"serde_json",
|
|
||||||
"serial_test",
|
|
||||||
"time",
|
|
||||||
"tokio",
|
|
||||||
"tracing",
|
|
||||||
"tracing-subscriber",
|
|
||||||
"unic-langid",
|
|
||||||
"uuid",
|
|
||||||
"validator",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "glob"
|
name = "glob"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
@@ -1785,6 +1757,22 @@ dependencies = [
|
|||||||
"want",
|
"want",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-rustls"
|
||||||
|
version = "0.27.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f"
|
||||||
|
dependencies = [
|
||||||
|
"http",
|
||||||
|
"hyper",
|
||||||
|
"hyper-util",
|
||||||
|
"rustls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
|
"tower-service",
|
||||||
|
"webpki-roots 1.0.7",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper-util"
|
name = "hyper-util"
|
||||||
version = "0.1.20"
|
version = "0.1.20"
|
||||||
@@ -2124,6 +2112,37 @@ dependencies = [
|
|||||||
"simple_asn1",
|
"simple_asn1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "kompress_eshop"
|
||||||
|
version = "0.1.0"
|
||||||
|
dependencies = [
|
||||||
|
"async-trait",
|
||||||
|
"axum",
|
||||||
|
"axum-extra",
|
||||||
|
"bytes",
|
||||||
|
"chrono",
|
||||||
|
"dotenvy",
|
||||||
|
"fluent-templates",
|
||||||
|
"include_dir",
|
||||||
|
"insta",
|
||||||
|
"loco-rs",
|
||||||
|
"migration",
|
||||||
|
"regex",
|
||||||
|
"reqwest",
|
||||||
|
"rstest",
|
||||||
|
"sea-orm",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serial_test",
|
||||||
|
"time",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"tracing-subscriber",
|
||||||
|
"unic-langid",
|
||||||
|
"uuid",
|
||||||
|
"validator",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "kqueue"
|
name = "kqueue"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
@@ -2332,6 +2351,12 @@ version = "0.4.29"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lru-slab"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mac"
|
name = "mac"
|
||||||
version = "0.1.1"
|
version = "0.1.1"
|
||||||
@@ -3090,6 +3115,61 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn"
|
||||||
|
version = "0.11.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"cfg_aliases",
|
||||||
|
"pin-project-lite",
|
||||||
|
"quinn-proto",
|
||||||
|
"quinn-udp",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"socket2 0.5.10",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-proto"
|
||||||
|
version = "0.11.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"getrandom 0.3.4",
|
||||||
|
"lru-slab",
|
||||||
|
"rand 0.9.4",
|
||||||
|
"ring",
|
||||||
|
"rustc-hash",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
|
"slab",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tinyvec",
|
||||||
|
"tracing",
|
||||||
|
"web-time",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quinn-udp"
|
||||||
|
version = "0.5.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg_aliases",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"socket2 0.5.10",
|
||||||
|
"tracing",
|
||||||
|
"windows-sys 0.52.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quote"
|
name = "quote"
|
||||||
version = "1.0.45"
|
version = "1.0.45"
|
||||||
@@ -3297,16 +3377,21 @@ dependencies = [
|
|||||||
"http-body",
|
"http-body",
|
||||||
"http-body-util",
|
"http-body-util",
|
||||||
"hyper",
|
"hyper",
|
||||||
|
"hyper-rustls",
|
||||||
"hyper-util",
|
"hyper-util",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
"log",
|
"log",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
|
"quinn",
|
||||||
|
"rustls",
|
||||||
|
"rustls-pki-types",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"serde_urlencoded",
|
"serde_urlencoded",
|
||||||
"sync_wrapper",
|
"sync_wrapper",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tokio-rustls",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower 0.5.3",
|
"tower 0.5.3",
|
||||||
"tower-http",
|
"tower-http",
|
||||||
@@ -3316,6 +3401,7 @@ dependencies = [
|
|||||||
"wasm-bindgen-futures",
|
"wasm-bindgen-futures",
|
||||||
"wasm-streams",
|
"wasm-streams",
|
||||||
"web-sys",
|
"web-sys",
|
||||||
|
"webpki-roots 1.0.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -3520,6 +3606,7 @@ version = "1.14.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"web-time",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
[workspace]
|
[workspace]
|
||||||
|
|
||||||
[package]
|
[package]
|
||||||
name = "gitara_web"
|
name = "kompress_eshop"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
publish = false
|
publish = false
|
||||||
default-run = "gitara_web-cli"
|
default-run = "kompress-eshop-cli"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
|
||||||
@@ -37,6 +37,8 @@ dotenvy = { version = "0.15" }
|
|||||||
validator = { version = "0.20" }
|
validator = { version = "0.20" }
|
||||||
uuid = { version = "1.6", features = ["v4"] }
|
uuid = { version = "1.6", features = ["v4"] }
|
||||||
include_dir = { version = "0.7" }
|
include_dir = { version = "0.7" }
|
||||||
|
# outbound HTTP for carrier shipment APIs (Packeta / DPD / DHL)
|
||||||
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||||
# view engine i18n
|
# view engine i18n
|
||||||
fluent-templates = { version = "0.13", features = ["tera"] }
|
fluent-templates = { version = "0.13", features = ["tera"] }
|
||||||
unic-langid = { version = "0.9" }
|
unic-langid = { version = "0.9" }
|
||||||
@@ -45,7 +47,7 @@ axum-extra = { version = "0.10", features = ["form"] }
|
|||||||
bytes = { version = "1" }
|
bytes = { version = "1" }
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "gitara_web-cli"
|
name = "kompress-eshop-cli"
|
||||||
path = "src/bin/main.rs"
|
path = "src/bin/main.rs"
|
||||||
required-features = []
|
required-features = []
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ WORKDIR /usr/src
|
|||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
RUN cargo build --release --bin gitara_web-cli
|
RUN cargo build --release --bin kompress-cli
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
|
|
||||||
@@ -17,9 +17,9 @@ WORKDIR /usr/app
|
|||||||
|
|
||||||
COPY --from=builder /usr/src/assets assets
|
COPY --from=builder /usr/src/assets assets
|
||||||
COPY --from=builder /usr/src/config config
|
COPY --from=builder /usr/src/config config
|
||||||
COPY --from=builder /usr/src/target/release/gitara_web-cli gitara_web-cli
|
COPY --from=builder /usr/src/target/release/kompress-cli kompress-cli
|
||||||
|
|
||||||
ENV LOCO_ENV=production
|
ENV LOCO_ENV=production
|
||||||
EXPOSE 5150
|
EXPOSE 5150
|
||||||
ENTRYPOINT ["/usr/app/gitara_web-cli"]
|
ENTRYPOINT ["/usr/app/kompress-cli"]
|
||||||
CMD ["start"]
|
CMD ["start"]
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
brand = My guitar
|
brand = Kompress eshop
|
||||||
hello-world = Hello world!
|
hello-world = Hello world!
|
||||||
meta-description = A guitar player's personal site. News, blog posts, albums, and songs in one place.
|
meta-description = Kompress eshop
|
||||||
nav-home = Home
|
nav-home = Home
|
||||||
nav-about = About
|
nav-about = About
|
||||||
nav-blog = Blog
|
nav-blog = Blog
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
brand = My guitar
|
brand = Kompress eshop
|
||||||
hello-world = Hello world!
|
hello-world = Hello world!
|
||||||
meta-description = A guitar player's personal site. News, blog posts, albums, and songs in one place.
|
meta-description = Kompress eshop
|
||||||
nav-home = Home
|
nav-home = Home
|
||||||
nav-about = About
|
nav-about = About
|
||||||
nav-blog = Blog
|
nav-blog = Blog
|
||||||
@@ -215,6 +215,7 @@ cart-empty = Your cart is empty.
|
|||||||
cart-total = Total
|
cart-total = Total
|
||||||
cart-checkout = Proceed to checkout
|
cart-checkout = Proceed to checkout
|
||||||
cart-remove = Remove
|
cart-remove = Remove
|
||||||
|
cart-remove-confirm = Remove this item from the cart?
|
||||||
cart-update = Update
|
cart-update = Update
|
||||||
cart-continue = Continue shopping
|
cart-continue = Continue shopping
|
||||||
checkout-title = Checkout
|
checkout-title = Checkout
|
||||||
@@ -222,10 +223,17 @@ checkout-contact = Contact details
|
|||||||
checkout-shipping = Shipping address
|
checkout-shipping = Shipping address
|
||||||
checkout-email = Email
|
checkout-email = Email
|
||||||
checkout-name = Full name
|
checkout-name = Full name
|
||||||
|
checkout-phone = Phone
|
||||||
checkout-address = Address
|
checkout-address = Address
|
||||||
checkout-city = City
|
checkout-city = City
|
||||||
checkout-zip = Postal code
|
checkout-zip = Postal code
|
||||||
checkout-country = Country
|
checkout-country = Country
|
||||||
|
country-sk = Slovakia
|
||||||
|
country-cz = Czechia
|
||||||
|
country-at = Austria
|
||||||
|
country-de = Germany
|
||||||
|
country-pl = Poland
|
||||||
|
country-hu = Hungary
|
||||||
checkout-note = Order note
|
checkout-note = Order note
|
||||||
checkout-place-order = Place order
|
checkout-place-order = Place order
|
||||||
checkout-summary = Order summary
|
checkout-summary = Order summary
|
||||||
@@ -261,5 +269,21 @@ bank-account-name = Account holder
|
|||||||
bank-variable-symbol = Variable symbol
|
bank-variable-symbol = Variable symbol
|
||||||
bank-amount = Amount
|
bank-amount = Amount
|
||||||
admin-shipping = Shipping
|
admin-shipping = Shipping
|
||||||
admin-shipping-desc = set carrier prices and availability.
|
admin-shipping-desc = set the price and availability of each delivery option.
|
||||||
shipping-enabled = Active
|
shipping-enabled = Active
|
||||||
|
shipping-new = Add delivery option
|
||||||
|
shipping-add = Add
|
||||||
|
shipping-requires-pickup = Requires pickup point
|
||||||
|
shipping-carrier = Carrier
|
||||||
|
carrier-none = Manual (no API)
|
||||||
|
carrier-packeta = Packeta
|
||||||
|
carrier-dpd = DPD
|
||||||
|
carrier-dhl = DHL
|
||||||
|
order-fulfillment = Fulfillment
|
||||||
|
order-shipped-via = Sent via
|
||||||
|
order-tracking = Tracking
|
||||||
|
order-label = Print label
|
||||||
|
order-manual-fulfillment = Manual fulfilment — no carrier API for this option.
|
||||||
|
order-send-hint = When the goods are ready, send this order to the carrier.
|
||||||
|
order-send-to-carrier = Send to
|
||||||
|
order-send-confirm = Send this order to the carrier now?
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
brand = Moja gitara
|
brand = Kompress eshop
|
||||||
hello-world = Ahoj svet!
|
hello-world = Ahoj svet!
|
||||||
meta-description = Osobná stránka gitaristu. Novinky, blog, albumy a skladby na jednom mieste.
|
meta-description = Kompress eshop
|
||||||
nav-home = Domov
|
nav-home = Domov
|
||||||
nav-about = O mne
|
nav-about = O mne
|
||||||
nav-blog = Blog
|
nav-blog = Blog
|
||||||
@@ -215,6 +215,7 @@ cart-empty = Váš košík je prázdny.
|
|||||||
cart-total = Spolu
|
cart-total = Spolu
|
||||||
cart-checkout = Pokračovať k pokladni
|
cart-checkout = Pokračovať k pokladni
|
||||||
cart-remove = Odstrániť
|
cart-remove = Odstrániť
|
||||||
|
cart-remove-confirm = Odstrániť túto položku z košíka?
|
||||||
cart-update = Aktualizovať
|
cart-update = Aktualizovať
|
||||||
cart-continue = Pokračovať v nákupe
|
cart-continue = Pokračovať v nákupe
|
||||||
checkout-title = Pokladňa
|
checkout-title = Pokladňa
|
||||||
@@ -222,10 +223,17 @@ checkout-contact = Kontaktné údaje
|
|||||||
checkout-shipping = Dodacia adresa
|
checkout-shipping = Dodacia adresa
|
||||||
checkout-email = E-mail
|
checkout-email = E-mail
|
||||||
checkout-name = Meno a priezvisko
|
checkout-name = Meno a priezvisko
|
||||||
|
checkout-phone = Telefón
|
||||||
checkout-address = Adresa
|
checkout-address = Adresa
|
||||||
checkout-city = Mesto
|
checkout-city = Mesto
|
||||||
checkout-zip = PSČ
|
checkout-zip = PSČ
|
||||||
checkout-country = Krajina
|
checkout-country = Krajina
|
||||||
|
country-sk = Slovensko
|
||||||
|
country-cz = Česko
|
||||||
|
country-at = Rakúsko
|
||||||
|
country-de = Nemecko
|
||||||
|
country-pl = Poľsko
|
||||||
|
country-hu = Maďarsko
|
||||||
checkout-note = Poznámka k objednávke
|
checkout-note = Poznámka k objednávke
|
||||||
checkout-place-order = Odoslať objednávku
|
checkout-place-order = Odoslať objednávku
|
||||||
checkout-summary = Súhrn objednávky
|
checkout-summary = Súhrn objednávky
|
||||||
@@ -261,5 +269,21 @@ bank-account-name = Príjemca
|
|||||||
bank-variable-symbol = Variabilný symbol
|
bank-variable-symbol = Variabilný symbol
|
||||||
bank-amount = Suma
|
bank-amount = Suma
|
||||||
admin-shipping = Doprava
|
admin-shipping = Doprava
|
||||||
admin-shipping-desc = nastaviť cenu a dostupnosť dopravcov.
|
admin-shipping-desc = nastaviť cenu a dostupnosť jednotlivých možností dopravy.
|
||||||
shipping-enabled = Aktívne
|
shipping-enabled = Aktívne
|
||||||
|
shipping-new = Pridať možnosť dopravy
|
||||||
|
shipping-add = Pridať
|
||||||
|
shipping-requires-pickup = Vyžaduje výdajné miesto
|
||||||
|
shipping-carrier = Dopravca
|
||||||
|
carrier-none = Manuálne (bez API)
|
||||||
|
carrier-packeta = Packeta
|
||||||
|
carrier-dpd = DPD
|
||||||
|
carrier-dhl = DHL
|
||||||
|
order-fulfillment = Expedícia
|
||||||
|
order-shipped-via = Odoslané cez
|
||||||
|
order-tracking = Sledovanie
|
||||||
|
order-label = Tlačiť štítok
|
||||||
|
order-manual-fulfillment = Manuálne spracovanie — táto možnosť nemá API dopravcu.
|
||||||
|
order-send-hint = Keď je tovar pripravený, odošlite objednávku dopravcovi.
|
||||||
|
order-send-to-carrier = Odoslať dopravcovi
|
||||||
|
order-send-confirm = Odoslať túto objednávku dopravcovi teraz?
|
||||||
|
|||||||
@@ -1,38 +1,37 @@
|
|||||||
{% extends "admin/base.html" %}
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
{% set editing = category %}
|
{% block title %}{% if category %}{{ t(key="edit-category", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-category", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
|
||||||
{% block title %}{% if editing %}{{ t(key="edit-category", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-category", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
|
|
||||||
{% block crumb %}{{ t(key="admin-categories", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
{% block crumb %}{{ t(key="admin-categories", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="flex items-center justify-between gap-3">
|
<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">
|
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
{% if editing %}{{ t(key="edit-category", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-category", lang=lang | default(value='sk')) }}{% endif %}
|
{% if category %}{{ t(key="edit-category", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-category", lang=lang | default(value='sk')) }}{% endif %}
|
||||||
</h1>
|
</h1>
|
||||||
<a href="/admin/catalog/categories"
|
<a href="/admin/catalog/categories"
|
||||||
class="inline-flex items-center rounded-radius border border-outline px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
|
class="inline-flex items-center rounded-radius border border-outline px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" enctype="multipart/form-data"
|
<form method="post" enctype="multipart/form-data"
|
||||||
action="{% if editing %}/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">
|
||||||
|
|
||||||
<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>
|
||||||
<input id="name" name="name" type="text" required value="{% if editing %}{{ category.name }}{% endif %}"
|
<input id="name" name="name" type="text" required value="{% if category %}{{ category.name }}{% endif %}"
|
||||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 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 px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-5 sm:grid-cols-2">
|
<div class="grid gap-5 sm:grid-cols-2">
|
||||||
<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>
|
<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>
|
||||||
<input id="slug" name="slug" type="text" value="{% if editing %}{{ category.slug }}{% endif %}"
|
<input id="slug" name="slug" type="text" value="{% if category %}{{ category.slug }}{% endif %}"
|
||||||
placeholder="{{ t(key='slug-auto', lang=lang | default(value='sk')) }}"
|
placeholder="{{ t(key='slug-auto', lang=lang | default(value='sk')) }}"
|
||||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 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 px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<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>
|
<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>
|
||||||
<input id="position" name="position" type="number" value="{% if editing %}{{ category.position }}{% else %}0{% endif %}"
|
<input id="position" name="position" type="number" value="{% if category %}{{ category.position }}{% else %}0{% endif %}"
|
||||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 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 px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -43,7 +42,7 @@
|
|||||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 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 px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
<option value="">{{ t(key="no-parent", lang=lang | default(value='sk')) }}</option>
|
<option value="">{{ t(key="no-parent", lang=lang | default(value='sk')) }}</option>
|
||||||
{% for parent in parents %}
|
{% for parent in parents %}
|
||||||
<option value="{{ parent.id }}" {% if editing and category.parent_id == parent.id %}selected{% endif %}>
|
<option value="{{ parent.id }}" {% if category and category.parent_id == parent.id %}selected{% endif %}>
|
||||||
{% if parent.depth > 0 %}{% for _ in range(end=parent.depth) %}— {% endfor %}{% endif %}{{ parent.name }}
|
{% if parent.depth > 0 %}{% for _ in range(end=parent.depth) %}— {% endfor %}{% endif %}{{ parent.name }}
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -53,12 +52,12 @@
|
|||||||
<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>
|
<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>
|
||||||
<textarea id="description" name="description" rows="4"
|
<textarea id="description" name="description" rows="4"
|
||||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">{% if editing and category.description %}{{ category.description }}{% endif %}</textarea>
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">{% if category and category.description %}{{ category.description }}{% endif %}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="image" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="image", lang=lang | default(value='sk')) }}</label>
|
<label for="image" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="image", lang=lang | default(value='sk')) }}</label>
|
||||||
{% if editing and category.image_id %}
|
{% if category and category.image_id %}
|
||||||
<img src="/images/{{ category.image_id }}" alt="" class="size-24 rounded-radius object-cover">
|
<img src="/images/{{ category.image_id }}" alt="" class="size-24 rounded-radius object-cover">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<input id="image" name="image" type="file" accept="image/*"
|
<input id="image" name="image" type="file" accept="image/*"
|
||||||
@@ -66,7 +65,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="flex items-center gap-2">
|
<label class="flex items-center gap-2">
|
||||||
<input type="checkbox" name="published" value="on" {% if editing and category.published %}checked{% endif %}
|
<input type="checkbox" name="published" value="on" {% if category and category.published %}checked{% endif %}
|
||||||
class="size-4 rounded border-outline text-primary focus:ring-primary dark:border-outline-dark">
|
class="size-4 rounded border-outline text-primary focus:ring-primary dark:border-outline-dark">
|
||||||
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
|
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -1,38 +1,37 @@
|
|||||||
{% extends "admin/base.html" %}
|
{% extends "admin/base.html" %}
|
||||||
|
|
||||||
{% set editing = product %}
|
{% 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 editing %}{{ 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 content %}
|
{% block content %}
|
||||||
<div class="flex items-center justify-between gap-3">
|
<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">
|
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||||
{% if editing %}{{ t(key="edit-product", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-product", lang=lang | default(value='sk')) }}{% endif %}
|
{% if product %}{{ t(key="edit-product", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-product", lang=lang | default(value='sk')) }}{% endif %}
|
||||||
</h1>
|
</h1>
|
||||||
<a href="/admin/catalog/products"
|
<a href="/admin/catalog/products"
|
||||||
class="inline-flex items-center rounded-radius border border-outline px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
|
class="inline-flex items-center rounded-radius border border-outline px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cancel", lang=lang | default(value='sk')) }}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" enctype="multipart/form-data"
|
<form method="post" enctype="multipart/form-data"
|
||||||
action="{% if editing %}/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">
|
||||||
|
|
||||||
<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>
|
||||||
<input id="name" name="name" type="text" required value="{% if editing %}{{ product.name }}{% endif %}"
|
<input id="name" name="name" type="text" required value="{% if product %}{{ product.name }}{% endif %}"
|
||||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 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 px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid gap-5 sm:grid-cols-2">
|
<div class="grid gap-5 sm:grid-cols-2">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="price" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
|
<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>
|
||||||
<input id="price" name="price" type="text" inputmode="decimal" required value="{% if editing %}{{ product.price }}{% endif %}"
|
<input id="price" name="price" type="text" inputmode="decimal" required value="{% if product %}{{ product.price }}{% endif %}"
|
||||||
placeholder="0.00"
|
placeholder="0.00"
|
||||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 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 px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="currency" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="currency", lang=lang | default(value='sk')) }}</label>
|
<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>
|
||||||
<input id="currency" name="currency" type="text" maxlength="3" value="{% if editing %}{{ product.currency }}{% else %}EUR{% endif %}"
|
<input id="currency" name="currency" type="text" maxlength="3" value="{% if product %}{{ product.currency }}{% else %}EUR{% endif %}"
|
||||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm uppercase 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 px-3 py-2 text-sm uppercase text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -40,12 +39,12 @@
|
|||||||
<div class="grid gap-5 sm:grid-cols-2">
|
<div class="grid gap-5 sm:grid-cols-2">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="stock" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="stock", lang=lang | default(value='sk')) }}</label>
|
<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>
|
||||||
<input id="stock" name="stock" type="number" min="0" value="{% if editing %}{{ product.stock }}{% else %}0{% endif %}"
|
<input id="stock" name="stock" type="number" min="0" value="{% if product %}{{ product.stock }}{% else %}0{% endif %}"
|
||||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 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 px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="sku" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="sku", lang=lang | default(value='sk')) }}</label>
|
<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>
|
||||||
<input id="sku" name="sku" type="text" value="{% if editing and product.sku %}{{ product.sku }}{% endif %}"
|
<input id="sku" name="sku" type="text" value="{% if product and product.sku %}{{ product.sku }}{% endif %}"
|
||||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 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 px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,14 +55,14 @@
|
|||||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 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 px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
<option value="">{{ t(key="no-category", lang=lang | default(value='sk')) }}</option>
|
<option value="">{{ t(key="no-category", lang=lang | default(value='sk')) }}</option>
|
||||||
{% for category in categories %}
|
{% for category in categories %}
|
||||||
<option value="{{ category.id }}" {% if editing and product.category_id == category.id %}selected{% endif %}>{{ category.name }}</option>
|
<option value="{{ category.id }}" {% if product and product.category_id == category.id %}selected{% endif %}>{{ category.name }}</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</select>
|
</select>
|
||||||
</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>
|
<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>
|
||||||
<input id="slug" name="slug" type="text" value="{% if editing %}{{ product.slug }}{% endif %}"
|
<input id="slug" name="slug" type="text" value="{% if product %}{{ product.slug }}{% endif %}"
|
||||||
placeholder="{{ t(key='slug-auto', lang=lang | default(value='sk')) }}"
|
placeholder="{{ t(key='slug-auto', lang=lang | default(value='sk')) }}"
|
||||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 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 px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
</div>
|
</div>
|
||||||
@@ -71,12 +70,12 @@
|
|||||||
<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>
|
<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>
|
||||||
<textarea id="description" name="description" rows="5"
|
<textarea id="description" name="description" rows="5"
|
||||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">{% if editing and product.description %}{{ product.description }}{% endif %}</textarea>
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">{% if product and product.description %}{{ product.description }}{% endif %}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="image" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="image", lang=lang | default(value='sk')) }}</label>
|
<label for="image" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="image", lang=lang | default(value='sk')) }}</label>
|
||||||
{% if editing and product.image %}
|
{% if product and product.image %}
|
||||||
<img src="/images/{{ product.image }}" alt="" class="size-24 rounded-radius object-cover">
|
<img src="/images/{{ product.image }}" alt="" class="size-24 rounded-radius object-cover">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<input id="image" name="image" type="file" accept="image/*"
|
<input id="image" name="image" type="file" accept="image/*"
|
||||||
@@ -84,7 +83,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<label class="flex items-center gap-2">
|
<label class="flex items-center gap-2">
|
||||||
<input type="checkbox" name="published" value="on" {% if editing and product.published %}checked{% endif %}
|
<input type="checkbox" name="published" value="on" {% if product and product.published %}checked{% endif %}
|
||||||
class="size-4 rounded border-outline text-primary focus:ring-primary dark:border-outline-dark">
|
class="size-4 rounded border-outline text-primary focus:ring-primary dark:border-outline-dark">
|
||||||
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
|
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
|
||||||
</label>
|
</label>
|
||||||
|
|||||||
@@ -9,6 +9,12 @@
|
|||||||
<a href="/admin/orders" class="inline-flex items-center rounded-radius border border-outline px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="admin-orders", lang=lang | default(value='sk')) }}</a>
|
<a href="/admin/orders" class="inline-flex items-center rounded-radius border border-outline px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="admin-orders", lang=lang | default(value='sk')) }}</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if ship_error %}
|
||||||
|
<div class="mt-4 rounded-radius border border-danger/40 bg-danger/10 px-4 py-3 text-sm font-medium text-danger">
|
||||||
|
{{ ship_error }}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<div class="mt-6 grid gap-6 lg:grid-cols-3">
|
<div class="mt-6 grid gap-6 lg:grid-cols-3">
|
||||||
<div class="space-y-6 lg:col-span-2">
|
<div class="space-y-6 lg:col-span-2">
|
||||||
<div class="overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
|
<div class="overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
|
||||||
@@ -45,6 +51,7 @@
|
|||||||
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="order-customer", 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="order-customer", lang=lang | default(value='sk')) }}</p>
|
||||||
<p class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.customer_name }}</p>
|
<p class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.customer_name }}</p>
|
||||||
<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 %}
|
||||||
</div>
|
</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>
|
||||||
@@ -69,6 +76,33 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3 rounded-radius border border-outline bg-surface p-5 text-sm dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="order-fulfillment", lang=lang | default(value='sk')) }}</p>
|
||||||
|
{% if order.tracking_number %}
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">
|
||||||
|
{{ t(key="order-shipped-via", lang=lang | default(value='sk')) }} <span class="font-medium">{{ carrier | upper }}</span>
|
||||||
|
</p>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">
|
||||||
|
{{ t(key="order-tracking", lang=lang | default(value='sk')) }}: <span class="font-mono font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.tracking_number }}</span>
|
||||||
|
</p>
|
||||||
|
{% if order.label_url %}
|
||||||
|
<a href="{{ order.label_url }}" target="_blank" rel="noopener"
|
||||||
|
class="inline-flex items-center rounded-radius border border-outline px-3 py-1.5 text-xs font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="order-label", lang=lang | default(value='sk')) }}</a>
|
||||||
|
{% endif %}
|
||||||
|
{% elif carrier == "none" %}
|
||||||
|
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="order-manual-fulfillment", lang=lang | default(value='sk')) }}</p>
|
||||||
|
{% elif can_ship %}
|
||||||
|
<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"
|
||||||
|
onsubmit="return confirm('{{ t(key="order-send-confirm", lang=lang | default(value='sk')) }}')">
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex w-full items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
|
||||||
|
{{ t(key="order-send-to-carrier", lang=lang | default(value='sk')) }} {{ carrier | upper }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
</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">
|
||||||
<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>
|
||||||
<select id="status" name="status"
|
<select id="status" name="status"
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
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">
|
||||||
<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.code }}{% 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>
|
||||||
</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>
|
||||||
|
|||||||
@@ -39,6 +39,14 @@
|
|||||||
}
|
}
|
||||||
document.addEventListener('DOMContentLoaded', markActiveNav);
|
document.addEventListener('DOMContentLoaded', markActiveNav);
|
||||||
document.addEventListener('htmx:afterSwap', markActiveNav);
|
document.addEventListener('htmx:afterSwap', markActiveNav);
|
||||||
|
// Sum the quantities stored in the `cart` cookie for the header badge.
|
||||||
|
function cartCount() {
|
||||||
|
var m = document.cookie.split('; ').find(function (c) { return c.indexOf('cart=') === 0 });
|
||||||
|
if (!m) return 0;
|
||||||
|
var v = decodeURIComponent(m.split('=')[1] || '');
|
||||||
|
if (!v) return 0;
|
||||||
|
return v.split(',').reduce(function (s, e) { return s + (parseInt(e.split(':')[1]) || 0) }, 0);
|
||||||
|
}
|
||||||
</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>
|
||||||
@@ -85,7 +93,7 @@
|
|||||||
<!-- cart with live item-count badge read from the `cart` cookie -->
|
<!-- cart with live item-count badge read from the `cart` cookie -->
|
||||||
<a href="/cart" data-nav="/cart"
|
<a href="/cart" data-nav="/cart"
|
||||||
x-data="{ count: 0 }"
|
x-data="{ count: 0 }"
|
||||||
x-init="count = (function(){ var m = document.cookie.split('; ').find(function(c){return c.indexOf('cart=')===0}); if(!m) return 0; var v = decodeURIComponent(m.split('=')[1]||''); if(!v) return 0; return v.split(',').reduce(function(s,e){ return s + (parseInt(e.split(':')[1])||0) }, 0) })()"
|
x-init="count = cartCount(); window.addEventListener('htmx:afterSwap', function () { count = cartCount() })"
|
||||||
aria-label="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
|
aria-label="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
|
||||||
title="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
|
title="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
|
||||||
class="relative inline-flex size-9 items-center justify-center rounded-radius text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
|
class="relative inline-flex size-9 items-center justify-center rounded-radius text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
|
||||||
|
|||||||
@@ -1,12 +1,29 @@
|
|||||||
<a href="/shop/{{ product.slug }}"
|
<div
|
||||||
class="group flex flex-col overflow-hidden rounded-radius border border-outline bg-surface transition hover:border-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:hover:border-primary-dark">
|
class="group flex flex-col overflow-hidden rounded-radius border border-outline bg-surface transition hover:border-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:hover:border-primary-dark">
|
||||||
|
<a href="/shop/{{ product.slug }}" class="flex flex-1 flex-col">
|
||||||
<div class="aspect-square overflow-hidden bg-surface-alt dark:bg-surface-dark">
|
<div class="aspect-square overflow-hidden bg-surface-alt dark:bg-surface-dark">
|
||||||
{% if product.image %}
|
{% if product.image %}
|
||||||
<img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover transition group-hover:scale-105">
|
<img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover transition group-hover:scale-105">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-1 flex-col gap-1 p-4">
|
<div class="flex flex-1 flex-col gap-1 p-4 pb-2">
|
||||||
<h3 class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
|
<h3 class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
|
||||||
<p class="mt-auto pt-2 font-semibold text-primary dark:text-primary-dark">{{ product.price }} {{ product.currency }}</p>
|
<p class="mt-auto pt-2 font-semibold text-primary dark:text-primary-dark">{{ product.price }} {{ product.currency }}</p>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
<div class="flex flex-col gap-2 px-4 pb-4">
|
||||||
|
{% if product.stock > 0 %}
|
||||||
|
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}</p>
|
||||||
|
<form method="post" action="/cart/add" hx-boost="false">
|
||||||
|
<input type="hidden" name="product_id" value="{{ product.id }}">
|
||||||
|
<input type="hidden" name="quantity" value="1">
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex w-full items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
|
||||||
|
{{ t(key="add-to-cart", lang=lang | default(value='sk')) }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
{% else %}
|
||||||
|
<p class="inline-flex justify-center rounded-radius bg-danger/10 px-3 py-2 text-xs font-medium text-danger">{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|||||||
71
assets/views/shop/_cart_body.html
Normal file
71
assets/views/shop/_cart_body.html
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
{# Cart contents, swapped in via htmx on quantity change / removal so the page
|
||||||
|
never does a full reload. Rendered inside <div id="cart-body"> in cart.html
|
||||||
|
and returned on its own by /cart/update and /cart/remove. #}
|
||||||
|
{% if items | length > 0 %}
|
||||||
|
<div class="overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
|
||||||
|
<table class="w-full text-left text-sm">
|
||||||
|
<thead class="border-b border-outline bg-surface-alt text-xs uppercase tracking-wide text-on-surface/70 dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark/70">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-3 font-semibold">{{ t(key="product", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3 font-semibold">{{ t(key="price", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3 font-semibold">{{ t(key="quantity", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3 text-right font-semibold">{{ t(key="cart-total", lang=lang | default(value='sk')) }}</th>
|
||||||
|
<th class="px-4 py-3"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="divide-y divide-outline dark:divide-outline-dark">
|
||||||
|
{% for item in items %}
|
||||||
|
<tr>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
<a href="/shop/{{ item.slug }}" class="font-medium text-on-surface-strong hover:text-primary dark:text-on-surface-dark-strong dark:hover:text-primary-dark">{{ item.name }}</a>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 tabular-nums">{{ item.price }} {{ item.currency }}</td>
|
||||||
|
<td class="px-4 py-3">
|
||||||
|
{# Changing the quantity posts via htmx (custom `cartchange` event) and
|
||||||
|
swaps only #cart-body. Dropping to 0 asks for confirmation first,
|
||||||
|
reverting to the previous quantity if the customer cancels. #}
|
||||||
|
<form method="post" action="/cart/update"
|
||||||
|
hx-post="/cart/update" hx-trigger="cartchange" hx-target="#cart-body" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="product_id" value="{{ item.id }}">
|
||||||
|
<input type="number" name="quantity" min="0" max="{{ item.stock }}" value="{{ item.quantity }}"
|
||||||
|
@change="
|
||||||
|
if (parseInt($el.value || '0') <= 0 && !window.confirm('{{ t(key='cart-remove-confirm', lang=lang | default(value='sk')) }}')) {
|
||||||
|
$el.value = '{{ item.quantity }}';
|
||||||
|
} else {
|
||||||
|
$el.dispatchEvent(new Event('cartchange', { bubbles: true }));
|
||||||
|
}
|
||||||
|
"
|
||||||
|
class="w-20 rounded-radius border border-outline bg-surface px-2 py-1 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
<td class="px-4 py-3 text-right font-medium tabular-nums">{{ item.line_total }} {{ item.currency }}</td>
|
||||||
|
<td class="px-4 py-3 text-right">
|
||||||
|
<form method="post" action="/cart/remove"
|
||||||
|
hx-post="/cart/remove" hx-target="#cart-body" hx-swap="innerHTML">
|
||||||
|
<input type="hidden" name="product_id" value="{{ item.id }}">
|
||||||
|
<button type="submit" class="text-xs font-medium text-danger hover:underline">{{ t(key="cart-remove", lang=lang | default(value='sk')) }}</button>
|
||||||
|
</form>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
<tfoot class="border-t border-outline dark:border-outline-dark">
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="px-4 py-3 text-right font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="cart-total", lang=lang | default(value='sk')) }}</td>
|
||||||
|
<td class="px-4 py-3 text-right text-lg font-bold tabular-nums text-primary dark:text-primary-dark">{{ total }} {{ currency }}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-6 flex flex-wrap justify-between gap-3">
|
||||||
|
<a href="/shop" class="inline-flex items-center rounded-radius border border-outline px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cart-continue", lang=lang | default(value='sk')) }}</a>
|
||||||
|
<a href="/checkout" class="inline-flex items-center justify-center rounded-radius bg-primary px-5 py-2 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="cart-checkout", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="rounded-radius border border-outline px-6 py-16 text-center dark:border-outline-dark">
|
||||||
|
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="cart-empty", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<a href="/shop" class="mt-4 inline-flex items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="cart-continue", lang=lang | default(value='sk')) }}</a>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
{# Site-wide category sidebar contents, served as an htmx partial and swapped
|
{# Site-wide category menu, served as an htmx partial and swapped into the
|
||||||
into the <aside> in base.html. `category_tree` is a depth-ordered flat list
|
<aside> in base.html. `category_groups` is a two-level list of top-level
|
||||||
of { name, slug, depth }; nesting is shown via left indentation. Active state
|
categories, each `{ name, slug, children: [{ name, slug }] }`. A category
|
||||||
is set client-side by markActiveNav() via data-nav + aria-current. #}
|
with children is expandable (accordion); one without is a plain link.
|
||||||
|
Active state is set client-side by markActiveNav() via data-nav +
|
||||||
|
aria-current; groups auto-expand when the current page is the category or
|
||||||
|
one of its subcategories. #}
|
||||||
<p class="px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
<p class="px-3 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')) }}
|
{{ t(key="categories", lang=lang | default(value='sk')) }}
|
||||||
</p>
|
</p>
|
||||||
@@ -12,16 +15,46 @@
|
|||||||
{{ t(key="all-products", lang=lang | default(value='sk')) }}
|
{{ t(key="all-products", lang=lang | default(value='sk')) }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% for item in category_tree %}
|
{% for group in category_groups %}
|
||||||
|
{% if group.children | length > 0 %}
|
||||||
|
<li x-data="{ open: false }"
|
||||||
|
x-init="open = ['{{ group.slug }}'{% for child in group.children %}, '{{ child.slug }}'{% endfor %}].some(s => location.pathname === '/category/' + s)">
|
||||||
|
<div class="flex items-stretch">
|
||||||
|
<a href="/category/{{ group.slug }}" data-nav="/category/{{ group.slug }}"
|
||||||
|
class="flex-1 truncate rounded-l-radius px-3 py-1.5 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||||
|
{{ group.name }}
|
||||||
|
</a>
|
||||||
|
<button type="button" @click="open = !open" :aria-expanded="open"
|
||||||
|
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-surface hover:text-primary dark:text-on-surface-dark/60 dark:hover:bg-surface-dark dark:hover: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 transition-transform" :class="open && 'rotate-90'">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul x-show="open" x-cloak x-transition class="mt-0.5 flex flex-col gap-0.5">
|
||||||
|
{% for child in group.children %}
|
||||||
<li>
|
<li>
|
||||||
<a href="/category/{{ item.slug }}" data-nav="/category/{{ item.slug }}" style="padding-left: {{ 12 + item.depth * 16 }}px"
|
<a href="/category/{{ child.slug }}" data-nav="/category/{{ child.slug }}" style="padding-left: 28px"
|
||||||
class="flex items-center gap-1.5 rounded-radius py-1.5 pr-3 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
class="flex items-center gap-1.5 rounded-radius py-1.5 pr-3 text-sm text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||||
{% if item.depth > 0 %}<span class="text-on-surface/40 dark:text-on-surface-dark/40">↳</span>{% endif %}
|
<span class="text-on-surface/40 dark:text-on-surface-dark/40">↳</span>
|
||||||
<span class="truncate">{{ item.name }}</span>
|
<span class="truncate">{{ child.name }}</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% if category_tree | length == 0 %}
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li>
|
||||||
|
<a href="/category/{{ group.slug }}" data-nav="/category/{{ group.slug }}"
|
||||||
|
class="block truncate rounded-radius px-3 py-1.5 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
|
||||||
|
{{ group.name }}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
{% if category_groups | length == 0 %}
|
||||||
<p class="px-3 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-3 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 %}
|
||||||
|
|||||||
@@ -6,62 +6,8 @@
|
|||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="cart-title", lang=lang | default(value='sk')) }}</h1>
|
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="cart-title", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
|
||||||
{% if items | length > 0 %}
|
<div id="cart-body">
|
||||||
<div class="overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
|
{% include "shop/_cart_body.html" %}
|
||||||
<table class="w-full text-left text-sm">
|
|
||||||
<thead class="border-b border-outline bg-surface-alt text-xs uppercase tracking-wide text-on-surface/70 dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark/70">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-3 font-semibold">{{ t(key="product", lang=lang | default(value='sk')) }}</th>
|
|
||||||
<th class="px-4 py-3 font-semibold">{{ t(key="price", lang=lang | default(value='sk')) }}</th>
|
|
||||||
<th class="px-4 py-3 font-semibold">{{ t(key="quantity", lang=lang | default(value='sk')) }}</th>
|
|
||||||
<th class="px-4 py-3 text-right font-semibold">{{ t(key="cart-total", lang=lang | default(value='sk')) }}</th>
|
|
||||||
<th class="px-4 py-3"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody class="divide-y divide-outline dark:divide-outline-dark">
|
|
||||||
{% for item in items %}
|
|
||||||
<tr>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<a href="/shop/{{ item.slug }}" class="font-medium text-on-surface-strong hover:text-primary dark:text-on-surface-dark-strong dark:hover:text-primary-dark">{{ item.name }}</a>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 tabular-nums">{{ item.price }} {{ item.currency }}</td>
|
|
||||||
<td class="px-4 py-3">
|
|
||||||
<form method="post" action="/cart/update" hx-boost="false" class="flex items-center gap-2">
|
|
||||||
<input type="hidden" name="product_id" value="{{ item.id }}">
|
|
||||||
<input type="number" name="quantity" min="0" max="{{ item.stock }}" value="{{ item.quantity }}"
|
|
||||||
class="w-20 rounded-radius border border-outline bg-surface px-2 py-1 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="submit" class="rounded-radius border border-outline px-2 py-1 text-xs font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cart-update", lang=lang | default(value='sk')) }}</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
<td class="px-4 py-3 text-right font-medium tabular-nums">{{ item.line_total }} {{ item.currency }}</td>
|
|
||||||
<td class="px-4 py-3 text-right">
|
|
||||||
<form method="post" action="/cart/remove" hx-boost="false">
|
|
||||||
<input type="hidden" name="product_id" value="{{ item.id }}">
|
|
||||||
<button type="submit" class="text-xs font-medium text-danger hover:underline">{{ t(key="cart-remove", lang=lang | default(value='sk')) }}</button>
|
|
||||||
</form>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
<tfoot class="border-t border-outline dark:border-outline-dark">
|
|
||||||
<tr>
|
|
||||||
<td colspan="3" class="px-4 py-3 text-right font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="cart-total", lang=lang | default(value='sk')) }}</td>
|
|
||||||
<td class="px-4 py-3 text-right text-lg font-bold tabular-nums text-primary dark:text-primary-dark">{{ total }} {{ currency }}</td>
|
|
||||||
<td></td>
|
|
||||||
</tr>
|
|
||||||
</tfoot>
|
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-wrap justify-between gap-3">
|
|
||||||
<a href="/shop" class="inline-flex items-center rounded-radius border border-outline px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cart-continue", lang=lang | default(value='sk')) }}</a>
|
|
||||||
<a href="/checkout" class="inline-flex items-center justify-center rounded-radius bg-primary px-5 py-2 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="cart-checkout", lang=lang | default(value='sk')) }}</a>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<div class="rounded-radius border border-outline px-6 py-16 text-center dark:border-outline-dark">
|
|
||||||
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="cart-empty", lang=lang | default(value='sk')) }}</p>
|
|
||||||
<a href="/shop" class="mt-4 inline-flex items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="cart-continue", lang=lang | default(value='sk')) }}</a>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -43,6 +43,39 @@
|
|||||||
<input id="customer_name" name="customer_name" type="text" required
|
<input id="customer_name" name="customer_name" type="text" required
|
||||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 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 px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
</div>
|
</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: '+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" required @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>
|
||||||
|
<input id="phone" name="phone" type="tel" required autocomplete="tel" inputmode="tel" placeholder="900 000 000"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<!-- shipping address -->
|
<!-- shipping address -->
|
||||||
@@ -66,8 +99,32 @@
|
|||||||
</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="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>
|
||||||
<input id="country" name="country" type="text" required value="Slovensko"
|
<div class="relative" @click.outside="countryOpen = false"
|
||||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
x-data="{ countryOpen: false, country: '{{ t(key='country-sk', lang=lang | default(value='sk')) }}', opts: [
|
||||||
|
{ v: '{{ t(key='country-sk', lang=lang | default(value='sk')) }}', l: '🇸🇰 {{ t(key='country-sk', lang=lang | default(value='sk')) }}' },
|
||||||
|
{ v: '{{ t(key='country-cz', lang=lang | default(value='sk')) }}', l: '🇨🇿 {{ t(key='country-cz', lang=lang | default(value='sk')) }}' },
|
||||||
|
{ v: '{{ t(key='country-at', lang=lang | default(value='sk')) }}', l: '🇦🇹 {{ t(key='country-at', lang=lang | default(value='sk')) }}' },
|
||||||
|
{ v: '{{ t(key='country-de', lang=lang | default(value='sk')) }}', l: '🇩🇪 {{ t(key='country-de', lang=lang | default(value='sk')) }}' },
|
||||||
|
{ v: '{{ t(key='country-pl', lang=lang | default(value='sk')) }}', l: '🇵🇱 {{ t(key='country-pl', lang=lang | default(value='sk')) }}' },
|
||||||
|
{ v: '{{ t(key='country-hu', lang=lang | default(value='sk')) }}', l: '🇭🇺 {{ t(key='country-hu', lang=lang | default(value='sk')) }}' }
|
||||||
|
], get filtered() { return this.opts.filter(o => !this.country || o.v.toLowerCase().includes(this.country.toLowerCase())) } }">
|
||||||
|
<input id="country" name="country" type="text" x-model="country" required @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>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ mailer:
|
|||||||
# Database Configuration
|
# Database Configuration
|
||||||
database:
|
database:
|
||||||
# Database connection URI
|
# Database connection URI
|
||||||
uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/gitara_web_development") }}
|
uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/kompress_eshop_development") }}
|
||||||
# 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.
|
||||||
@@ -108,6 +108,20 @@ settings:
|
|||||||
# Packeta (Zásilkovna) web API key for the pickup-point picker widget.
|
# Packeta (Zásilkovna) web API key for the pickup-point picker widget.
|
||||||
# Empty falls back to a plain text field for the pickup point.
|
# Empty falls back to a plain text field for the pickup point.
|
||||||
packeta_api_key: {{ get_env(name="PACKETA_API_KEY", default="") }}
|
packeta_api_key: {{ get_env(name="PACKETA_API_KEY", default="") }}
|
||||||
|
# Packeta REST API secret + sender label, used by admin "Send to carrier"
|
||||||
|
# (manual shipment creation). See docs/integrations/packeta.md.
|
||||||
|
packeta_api_password: {{ get_env(name="PACKETA_API_PASSWORD", default="") }}
|
||||||
|
packeta_sender_label: {{ get_env(name="PACKETA_SENDER_LABEL", default="") }}
|
||||||
|
# DPD shipment API (see docs/integrations/dpd.md). Empty = not configured.
|
||||||
|
dpd_api_base: {{ get_env(name="DPD_API_BASE", default="") }}
|
||||||
|
dpd_login: {{ get_env(name="DPD_LOGIN", default="") }}
|
||||||
|
dpd_password: {{ get_env(name="DPD_PASSWORD", default="") }}
|
||||||
|
dpd_customer_number: {{ get_env(name="DPD_CUSTOMER_NUMBER", default="") }}
|
||||||
|
# DHL shipment API (see docs/integrations/dhl.md). Empty = not configured.
|
||||||
|
dhl_api_base: {{ get_env(name="DHL_API_BASE", default="") }}
|
||||||
|
dhl_api_key: {{ get_env(name="DHL_API_KEY", default="") }}
|
||||||
|
dhl_api_secret: {{ get_env(name="DHL_API_SECRET", default="") }}
|
||||||
|
dhl_account_number: {{ get_env(name="DHL_ACCOUNT_NUMBER", default="") }}
|
||||||
# Bank-transfer payment details shown on the order confirmation.
|
# Bank-transfer payment details shown on the order confirmation.
|
||||||
bank_iban: {{ get_env(name="BANK_IBAN", default="SK00 0000 0000 0000 0000 0000") }}
|
bank_iban: {{ get_env(name="BANK_IBAN", default="SK00 0000 0000 0000 0000 0000") }}
|
||||||
bank_account_name: {{ get_env(name="BANK_ACCOUNT_NAME", default="Kompress s.r.o.") }}
|
bank_account_name: {{ get_env(name="BANK_ACCOUNT_NAME", default="Kompress s.r.o.") }}
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ mailer:
|
|||||||
# Database Configuration
|
# Database Configuration
|
||||||
database:
|
database:
|
||||||
# Database connection URI
|
# Database connection URI
|
||||||
uri: {{ get_env(name="DATABASE_URL", default="postgres://uni_loco_web_user:3@localhost:5432/gitara_web_test") }}
|
uri: {{ get_env(name="DATABASE_URL", default="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.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
services:
|
services:
|
||||||
gitara-web:
|
kompress:
|
||||||
container_name: gitara-web
|
container_name: kompress
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -9,9 +9,9 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env.production
|
- .env.production
|
||||||
volumes:
|
volumes:
|
||||||
- gitara_web_data:/usr/app/data
|
- kompress_eshop_data:/usr/app/data
|
||||||
networks:
|
networks:
|
||||||
- gitara-net
|
- kompress_eshop-net
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -fsS http://localhost:5150/_ping"]
|
test: ["CMD-SHELL", "curl -fsS http://localhost:5150/_ping"]
|
||||||
@@ -21,10 +21,10 @@ services:
|
|||||||
start_period: 20s
|
start_period: 20s
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
gitara-net:
|
kompress_eshop-net:
|
||||||
external: true
|
external: true
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
gitara_web_data:
|
kompress_eshop_data:
|
||||||
external: true
|
external: true
|
||||||
name: gitara_web_data
|
name: kompress_eshop_data
|
||||||
|
|||||||
126
docs/integrations/README.md
Normal file
126
docs/integrations/README.md
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
# Carrier integrations
|
||||||
|
|
||||||
|
This eshop manages **delivery options** as plain rows in the `shipping_methods`
|
||||||
|
table (admin UI at `/admin/shipping` — add / edit price + toggle / remove). A
|
||||||
|
delivery option is just a name, a price, and two flags. **None of that talks to
|
||||||
|
a carrier yet** — it only decides what the customer can pick and how much they
|
||||||
|
pay.
|
||||||
|
|
||||||
|
Integrating a real carrier (Packeta, DPD, DHL) means wiring two *separate*
|
||||||
|
concerns on top of an existing delivery option:
|
||||||
|
|
||||||
|
1. **Pickup-point selection** (checkout, browser-side) — only for carriers that
|
||||||
|
deliver to pickup points / lockers. The customer picks a point via the
|
||||||
|
carrier's JS map widget; the chosen id + name land in the order.
|
||||||
|
2. **Shipment creation** (server-side, after the order is placed) — you call the
|
||||||
|
carrier's HTTP API to register the parcel, then store the returned tracking
|
||||||
|
number and print the label.
|
||||||
|
|
||||||
|
These are independent: you can ship to a Packeta pickup point manually (no API)
|
||||||
|
just by enabling the pickup widget, and you can create DHL labels via API for a
|
||||||
|
home-delivery option that has no pickup point at all.
|
||||||
|
|
||||||
|
> ❗ This is **not** a many-to-many / database relationship between your tables.
|
||||||
|
> A carrier is an **external HTTP API** you call from the server. The only
|
||||||
|
> schema you add is a few columns (which carrier a method maps to; a tracking
|
||||||
|
> number on the order) — see "Shared groundwork" below.
|
||||||
|
|
||||||
|
## What already exists in the codebase
|
||||||
|
|
||||||
|
| Piece | Where | Status |
|
||||||
|
|---|---|---|
|
||||||
|
| Delivery option CRUD | `src/controllers/admin_shipping.rs`, `assets/views/admin/shipping/index.html` | ✅ done |
|
||||||
|
| `shipping_methods` table (`code`, `name`, `price_cents`, `requires_pickup_point`, `enabled`, `position`) | `migration/.../m20260616_150755_shipping_methods.rs` | ✅ done |
|
||||||
|
| Carrier choice + pickup fields on checkout | `assets/views/shop/checkout.html` (`carrier_code`, `pickup_point_id`, `pickup_point_name`) | ✅ done |
|
||||||
|
| Order stores carrier + pickup point | `orders` table (`carrier_code`, `carrier_name`, `pickup_point_id`, `pickup_point_name`, `shipping_cents`) | ✅ done |
|
||||||
|
| Settings lookup | `src/shared/settings.rs` → reads `settings.*` from `config/*.yaml` | ✅ done |
|
||||||
|
| Packeta pickup-point widget | `assets/views/shop/checkout.html` (loads when `packeta_api_key` set) | ✅ scaffolded |
|
||||||
|
| `shipping_methods.carrier` (which API a method maps to) | `migration/.../m20260617_000001_*` + admin add-form dropdown | ✅ done |
|
||||||
|
| Tracking / shipment id / label on order | `migration/.../m20260617_000002_*` (`orders.tracking_number`, `shipment_id`, `label_url`) | ✅ done |
|
||||||
|
| Manual "Send to carrier" admin action | `src/controllers/admin_orders.rs` (`ship`), order detail page | ✅ done |
|
||||||
|
| Carrier client dispatch | `src/integrations/` (`create_shipment`) | ✅ done |
|
||||||
|
| Packeta shipment client | `src/integrations/packeta.rs` (real `createPacket`) | ✅ done |
|
||||||
|
| DPD / DHL shipment clients | `src/integrations/dpd.rs`, `dhl.rs` | 🟡 credential-guarded stub — fill in HTTP call per contract |
|
||||||
|
|
||||||
|
**Shipments are created only when an admin clicks "Send to carrier" on the order
|
||||||
|
page** — never automatically at checkout. Packeta is wired end-to-end (needs
|
||||||
|
just the API password + sender label). DPD/DHL run through the same flow but
|
||||||
|
their HTTP body must be finalised against your contract (clearly marked TODOs in
|
||||||
|
each file).
|
||||||
|
|
||||||
|
## Shared groundwork (do this once, before any carrier's API step)
|
||||||
|
|
||||||
|
The pickup-widget half needs nothing new. The **shipment-creation** half needs:
|
||||||
|
|
||||||
|
1. **An HTTP client dependency.** Add to `Cargo.toml`:
|
||||||
|
```toml
|
||||||
|
reqwest = { version = "0.12", features = ["json"] }
|
||||||
|
```
|
||||||
|
(Loco already pulls `tokio`/`serde`/`serde_json`.)
|
||||||
|
|
||||||
|
2. **A place for carrier clients.** Create `src/integrations/mod.rs` and a file
|
||||||
|
per carrier (`packeta.rs`, `dpd.rs`, `dhl.rs`). Register `pub mod integrations;`
|
||||||
|
in `src/lib.rs` (next to `pub mod controllers;` etc.).
|
||||||
|
|
||||||
|
3. **Map a delivery option to a carrier.** Add a `carrier` column to
|
||||||
|
`shipping_methods` so each admin-created option knows which API (if any) to
|
||||||
|
call. Generate the migration:
|
||||||
|
```bash
|
||||||
|
cargo loco generate migration add_carrier_to_shipping_methods carrier:string
|
||||||
|
```
|
||||||
|
Values: `none` (manual, the default), `packeta`, `dpd`, `dhl`. Then add a
|
||||||
|
`<select name="carrier">` to the add-form in
|
||||||
|
`assets/views/admin/shipping/index.html` and persist it in
|
||||||
|
`admin_shipping::create`.
|
||||||
|
|
||||||
|
4. **Store the tracking number / label on the order.** Generate:
|
||||||
|
```bash
|
||||||
|
cargo loco generate migration add_tracking_to_orders \
|
||||||
|
tracking_number:string shipment_id:string label_url:string
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **A "Create shipment" admin action.** In the admin order detail
|
||||||
|
(`src/controllers/admin_orders.rs`), add a button/handler that: looks up the
|
||||||
|
order's `carrier_code` → finds the `shipping_methods.carrier` → calls the
|
||||||
|
matching `integrations::<carrier>::create_shipment(...)` → saves
|
||||||
|
`tracking_number` + `label_url` back onto the order. Optionally do this
|
||||||
|
automatically in `orders::place`, but a manual admin trigger is safer to
|
||||||
|
start (you can review the order first).
|
||||||
|
|
||||||
|
After the groundwork, each carrier file implements one async function roughly
|
||||||
|
like:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct ShipmentRequest<'a> {
|
||||||
|
pub order_number: &'a str,
|
||||||
|
pub recipient_name: &'a str,
|
||||||
|
pub email: &'a str,
|
||||||
|
pub phone: Option<&'a str>,
|
||||||
|
pub address: Option<&'a str>,
|
||||||
|
pub city: Option<&'a str>,
|
||||||
|
pub zip: Option<&'a str>,
|
||||||
|
pub country: Option<&'a str>,
|
||||||
|
pub pickup_point_id: Option<&'a str>,
|
||||||
|
pub cod_cents: i64, // 0 unless cash-on-delivery
|
||||||
|
pub currency: &'a str,
|
||||||
|
pub weight_grams: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ShipmentResult {
|
||||||
|
pub shipment_id: String,
|
||||||
|
pub tracking_number: String,
|
||||||
|
pub label_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_shipment(ctx: &AppContext, req: ShipmentRequest<'_>) -> loco_rs::Result<ShipmentResult> { ... }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Read next
|
||||||
|
|
||||||
|
- [`packeta.md`](packeta.md) — Packeta / Zásilkovna (pickup points + home, SK/CZ-centric)
|
||||||
|
- [`dpd.md`](dpd.md) — DPD (home delivery + Pickup parcelshops)
|
||||||
|
- [`dhl.md`](dhl.md) — DHL (international, Parcel/Express)
|
||||||
|
|
||||||
|
> ⚠️ Carrier APIs change. Treat the endpoint names, field names, and auth
|
||||||
|
> details here as a **map of the moving parts**, and confirm exact request
|
||||||
|
> formats against each carrier's current developer portal before coding.
|
||||||
150
docs/integrations/dhl.md
Normal file
150
docs/integrations/dhl.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# DHL integration
|
||||||
|
|
||||||
|
DHL is best for **home delivery and international/express** shipments. Like DPD,
|
||||||
|
**nothing DHL-specific is scaffolded** here. DHL is mostly an **address (home)
|
||||||
|
delivery** carrier — pickup points exist (DHL ServicePoint / Packstation, mostly
|
||||||
|
DE) but most shops use DHL for door-to-door, so you can usually skip the pickup
|
||||||
|
widget entirely.
|
||||||
|
|
||||||
|
> DHL has **several separate APIs** behind one developer portal
|
||||||
|
> (<https://developer.dhl.com>). Pick the one that matches your service:
|
||||||
|
> - **DHL Parcel DE (Post & Parcel Germany) — Shipping API** for German domestic
|
||||||
|
> parcels / Packstation.
|
||||||
|
> - **DHL eCommerce (Parcel) APIs** for various countries.
|
||||||
|
> - **DHL Express — MyDHL API** for international express.
|
||||||
|
> Confirm which your contract covers before coding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Get DHL API access
|
||||||
|
|
||||||
|
1. Create an account on the **DHL Developer Portal**: <https://developer.dhl.com>.
|
||||||
|
2. Create an **app** and subscribe it to the specific API you need (e.g.
|
||||||
|
"Shipping API" or "MyDHL API"). You receive an **API key (client id) +
|
||||||
|
secret**.
|
||||||
|
3. Separately you need a **DHL business/customer account** (EKP / account
|
||||||
|
number, billing number) — the developer key alone can't bill shipments. Link
|
||||||
|
your business account credentials to the app.
|
||||||
|
4. Most DHL APIs use **OAuth2 client-credentials**: you exchange key+secret for a
|
||||||
|
short-lived **Bearer token**, then call the shipping endpoints with it. (Some
|
||||||
|
older endpoints use Basic auth — check your API's docs.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Create the delivery option
|
||||||
|
|
||||||
|
At **`/admin/shipping`** → "Add delivery option":
|
||||||
|
- **Name**: e.g. `DHL` or `DHL Express (international)`
|
||||||
|
- **Price**: your fee
|
||||||
|
- **Requires pickup point**: ❌ off for normal home delivery
|
||||||
|
(turn ✅ on *only* if you specifically offer DHL Packstation/ServicePoint and
|
||||||
|
build a picker — see section 4)
|
||||||
|
- ✅ **Active**
|
||||||
|
|
||||||
|
With the option active, customers can already choose DHL and you can create the
|
||||||
|
label manually in DHL Business Customer Portal. The API (section 3) automates
|
||||||
|
that.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Create shipments via the DHL API
|
||||||
|
|
||||||
|
Do the [shared groundwork](README.md#shared-groundwork-do-this-once-before-any-carriers-api-step)
|
||||||
|
first. Set `shipping_methods.carrier = "dhl"` for your DHL options.
|
||||||
|
|
||||||
|
### Credentials
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DHL_API_KEY=your_client_id
|
||||||
|
DHL_API_SECRET=your_client_secret
|
||||||
|
DHL_ACCOUNT_NUMBER=your_ekp_or_billing_number
|
||||||
|
DHL_API_BASE=https://api-eu.dhl.com # depends on the specific API
|
||||||
|
```
|
||||||
|
Add matching lines under `settings:` in `config/*.yaml`:
|
||||||
|
```yaml
|
||||||
|
dhl_api_key: {{ get_env(name="DHL_API_KEY", default="") }}
|
||||||
|
dhl_api_secret: {{ get_env(name="DHL_API_SECRET", default="") }}
|
||||||
|
dhl_account_number: {{ get_env(name="DHL_ACCOUNT_NUMBER", default="") }}
|
||||||
|
dhl_api_base: {{ get_env(name="DHL_API_BASE", default="") }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flow (OAuth2 + create shipment)
|
||||||
|
|
||||||
|
1. **Token** → `POST {base}/.../token` with `grant_type=client_credentials` +
|
||||||
|
key/secret → `access_token` (Bearer; cache until it expires).
|
||||||
|
2. **Create shipment** → `POST` the shipment-orders endpoint with the Bearer
|
||||||
|
token: shipper (your account/EKP), consignee (recipient from the order),
|
||||||
|
product code (domestic vs international/express), weight, customs data for
|
||||||
|
non-EU, and references (`order_number`). COD is a value-added service if you
|
||||||
|
offer it.
|
||||||
|
3. **Label** → the response includes a **tracking/shipment number** and a
|
||||||
|
**label** (PDF/base64). Store/print it.
|
||||||
|
|
||||||
|
### Client sketch (`src/integrations/dhl.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use crate::shared::settings;
|
||||||
|
|
||||||
|
async fn bearer(ctx: &AppContext) -> Result<String> {
|
||||||
|
let base = settings::get(ctx, "dhl_api_base").unwrap_or_default();
|
||||||
|
let key = settings::get(ctx, "dhl_api_key").unwrap_or_default();
|
||||||
|
let secret = settings::get(ctx, "dhl_api_secret").unwrap_or_default();
|
||||||
|
// POST client_credentials → access_token; cache with expiry.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_shipment(ctx: &AppContext, req: super::ShipmentRequest<'_>)
|
||||||
|
-> Result<super::ShipmentResult>
|
||||||
|
{
|
||||||
|
let token = bearer(ctx).await?;
|
||||||
|
let account = settings::get(ctx, "dhl_account_number").unwrap_or_default();
|
||||||
|
// Build shipment JSON:
|
||||||
|
// - shipper: your account address (account = EKP/billing number)
|
||||||
|
// - consignee: req.recipient_name / address / city / zip / country
|
||||||
|
// - details: weight, product code (domestic / express), currency
|
||||||
|
// - refs: req.order_number
|
||||||
|
// - for international: customs (HS codes, declared value, contents)
|
||||||
|
// POST {base}/.../shipments with Authorization: Bearer {token}
|
||||||
|
todo!("parse tracking number + label into ShipmentResult")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wire into the admin "Create shipment" action for `carrier == "dhl"` orders.
|
||||||
|
|
||||||
|
> 🌍 **International note:** for shipments outside the EU customs union you must
|
||||||
|
> send **customs/commodity data** (HS codes, declared value, item descriptions).
|
||||||
|
> Your `order_items` only store name + price today — if you ship internationally
|
||||||
|
> you'll likely add a customs description/HS-code field to products.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. (Optional) DHL pickup points
|
||||||
|
|
||||||
|
If you offer **Packstation / ServicePoint**, set "Requires pickup point" ✅ on
|
||||||
|
that delivery option and render DHL's **Location Finder** (a separate DHL API)
|
||||||
|
in the checkout pickup block (the `x-show="requiresPoint"` section of
|
||||||
|
`assets/views/shop/checkout.html`), writing the chosen locker id into the
|
||||||
|
existing hidden `pickup_point_id` / `pickup_point_name` fields. For Packstation
|
||||||
|
you also need the recipient's **DHL post number** — an extra field most shops
|
||||||
|
avoid unless targeting Germany.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Testing
|
||||||
|
|
||||||
|
- DHL provides a **sandbox** environment per API (separate base URL + test
|
||||||
|
credentials) on the developer portal. Get a token and create one test
|
||||||
|
shipment there before production.
|
||||||
|
- Validate the tracking number on <https://www.dhl.com/track>.
|
||||||
|
|
||||||
|
## 6. Go-live checklist
|
||||||
|
|
||||||
|
- [ ] DHL developer app created + subscribed to the right API
|
||||||
|
- [ ] DHL business account (EKP/billing number) linked
|
||||||
|
- [ ] `DHL_*` env vars set; matching `settings:` lines added to `config/production.yaml`
|
||||||
|
- [ ] Delivery option created in `/admin/shipping`; `carrier = "dhl"` set
|
||||||
|
- [ ] `src/integrations/dhl.rs` implemented; OAuth token caching working
|
||||||
|
- [ ] (International) customs data available on products/items
|
||||||
|
- [ ] Test shipment in DHL sandbox → tracking number stored on order
|
||||||
|
- [ ] Switched from sandbox to production base URL/credentials
|
||||||
147
docs/integrations/dpd.md
Normal file
147
docs/integrations/dpd.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# DPD integration
|
||||||
|
|
||||||
|
DPD offers **home/business delivery** and **DPD Pickup parcelshops & lockers**.
|
||||||
|
Unlike Packeta, **nothing DPD-specific is scaffolded yet** in this repo, so this
|
||||||
|
is a full integration: an optional pickup widget plus the shipment-creation API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Get a DPD account & API access
|
||||||
|
|
||||||
|
1. You need a **business contract** with DPD in your country (e.g. DPD SK:
|
||||||
|
<https://www.dpd.com/sk>). Ask your account manager for **API access**.
|
||||||
|
2. DPD exposes a few different APIs depending on country/era — confirm which one
|
||||||
|
your contract uses:
|
||||||
|
- **REST Shipping API** (modern; JSON) — most new integrations.
|
||||||
|
- **SOAP "Login/Shipment" web services** (older; still common in CEE).
|
||||||
|
- Some markets use the **DPD Geodata / Shop Finder API** for parcelshops.
|
||||||
|
3. You'll receive: an **API base URL**, a **delisId / login**, and a
|
||||||
|
**password** (the SOAP `login` call returns a short-lived **auth token** you
|
||||||
|
reuse on subsequent calls). REST variants use an API key/token directly.
|
||||||
|
4. Note your **sender address** and **DPD customer number** — required on every
|
||||||
|
shipment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Decide which DPD services you offer
|
||||||
|
|
||||||
|
Create one delivery option per service at **`/admin/shipping`** → "Add delivery
|
||||||
|
option":
|
||||||
|
|
||||||
|
| Option | "Requires pickup point" | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `DPD Home` (classic) | ❌ off | delivered to the address on the order |
|
||||||
|
| `DPD Pickup` (parcelshop/locker) | ✅ on | customer must choose a shop/locker |
|
||||||
|
|
||||||
|
For `DPD Pickup` you need a **point picker** (section 3). For `DPD Home` you can
|
||||||
|
skip straight to the API (section 4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. (Pickup only) Add the DPD parcelshop picker at checkout
|
||||||
|
|
||||||
|
The checkout already has the generic pickup machinery: when the selected method
|
||||||
|
has `requires_pickup_point = true`, the block with hidden `pickup_point_id` /
|
||||||
|
`pickup_point_name` shows (`assets/views/shop/checkout.html`, the `x-show=
|
||||||
|
"requiresPoint"` block). Today that block only renders the **Packeta** widget
|
||||||
|
(guarded by `{% if packeta_api_key %}`) or a text fallback.
|
||||||
|
|
||||||
|
To support DPD you make that block carrier-aware:
|
||||||
|
|
||||||
|
1. Pass a `dpd_enabled` / map-widget key flag into the checkout context from
|
||||||
|
`src/controllers/checkout.rs` (like `packeta_api_key` is passed today).
|
||||||
|
2. In the pickup block, branch on the chosen `carrier` (the Alpine `carrier`
|
||||||
|
variable already holds the method `code`) and render DPD's parcelshop map
|
||||||
|
widget when a DPD pickup method is selected. DPD provides an embeddable
|
||||||
|
**map/widget** (or you query their **Shop Finder API** and render your own
|
||||||
|
list); on selection, write the shop id into `pointId` and a human label into
|
||||||
|
`pointName` — exactly what the existing hidden inputs expect.
|
||||||
|
|
||||||
|
No new order fields are needed — `pickup_point_id` / `pickup_point_name` already
|
||||||
|
carry the DPD shop id + name.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Create shipments via the DPD API
|
||||||
|
|
||||||
|
Do the [shared groundwork](README.md#shared-groundwork-do-this-once-before-any-carriers-api-step)
|
||||||
|
first. Set `shipping_methods.carrier = "dpd"` for your DPD options.
|
||||||
|
|
||||||
|
### Auth (SOAP-style, common in CEE)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DPD_API_BASE=https://api.dpd.sk # from your account manager
|
||||||
|
DPD_LOGIN=your_delis_login
|
||||||
|
DPD_PASSWORD=your_password
|
||||||
|
DPD_CUSTOMER_NUMBER=your_customer_no
|
||||||
|
```
|
||||||
|
Add matching lines under `settings:` in `config/*.yaml`:
|
||||||
|
```yaml
|
||||||
|
dpd_api_base: {{ get_env(name="DPD_API_BASE", default="") }}
|
||||||
|
dpd_login: {{ get_env(name="DPD_LOGIN", default="") }}
|
||||||
|
dpd_password: {{ get_env(name="DPD_PASSWORD", default="") }}
|
||||||
|
dpd_customer_number: {{ get_env(name="DPD_CUSTOMER_NUMBER", default="") }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
1. **Login** → `LoginService.getAuth(delisId, password)` returns an **auth
|
||||||
|
token** (valid for a while; cache it).
|
||||||
|
2. **Create shipment** → `ShipmentService.storeOrders(...)` with the auth token,
|
||||||
|
recipient address (or parcelshop id for Pickup), parcel weight, references
|
||||||
|
(your `order_number`), and COD amount if cash-on-delivery. Returns a
|
||||||
|
**parcel number (MPS id)** = your tracking number, plus label data.
|
||||||
|
3. **Label** → the same call (or `getParcelLabels`) returns a **PDF/ZPL label**;
|
||||||
|
store or print it.
|
||||||
|
|
||||||
|
### Client sketch (`src/integrations/dpd.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use crate::shared::settings;
|
||||||
|
|
||||||
|
async fn auth_token(ctx: &AppContext) -> Result<String> {
|
||||||
|
let base = settings::get(ctx, "dpd_api_base").unwrap_or_default();
|
||||||
|
let login = settings::get(ctx, "dpd_login").unwrap_or_default();
|
||||||
|
let pass = settings::get(ctx, "dpd_password").unwrap_or_default();
|
||||||
|
// POST login → parse token from response. Cache it (e.g. in-memory w/ expiry).
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_shipment(ctx: &AppContext, req: super::ShipmentRequest<'_>)
|
||||||
|
-> Result<super::ShipmentResult>
|
||||||
|
{
|
||||||
|
let token = auth_token(ctx).await?;
|
||||||
|
let customer = settings::get(ctx, "dpd_customer_number").unwrap_or_default();
|
||||||
|
// Build storeOrders payload:
|
||||||
|
// - product: "CL" (classic/home) or "Pickup" + parcelShopId = req.pickup_point_id
|
||||||
|
// - recipient: req.recipient_name / address / city / zip / country / phone
|
||||||
|
// - cod: req.cod_cents (set cash-on-delivery service if > 0)
|
||||||
|
// - reference: req.order_number
|
||||||
|
// POST to {base}/shipment ... with `token`.
|
||||||
|
todo!("parse parcel number + label into ShipmentResult")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wire it into the admin "Create shipment" action for `carrier == "dpd"` orders.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Testing
|
||||||
|
|
||||||
|
- DPD provides a **test/integration environment** (separate base URL +
|
||||||
|
credentials) — get it from your account manager. Validate login + one
|
||||||
|
shipment there first.
|
||||||
|
- Confirm the returned parcel number tracks on
|
||||||
|
`https://tracking.dpd.de/...` / your local DPD tracking site.
|
||||||
|
|
||||||
|
## 6. Go-live checklist
|
||||||
|
|
||||||
|
- [ ] DPD business contract + API credentials obtained
|
||||||
|
- [ ] `DPD_*` env vars set; matching `settings:` lines added to `config/production.yaml`
|
||||||
|
- [ ] Delivery option(s) created in `/admin/shipping` (`DPD Home` and/or `DPD Pickup`)
|
||||||
|
- [ ] `carrier = "dpd"` set on those methods (via the shared `carrier` column)
|
||||||
|
- [ ] (Pickup) parcelshop picker rendered in checkout for DPD methods
|
||||||
|
- [ ] `src/integrations/dpd.rs` implemented; login token caching working
|
||||||
|
- [ ] Test shipment in DPD test env → tracking number stored on order
|
||||||
|
- [ ] Switched base URL/credentials from test to production
|
||||||
174
docs/integrations/packeta.md
Normal file
174
docs/integrations/packeta.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# Packeta (Zásilkovna) integration
|
||||||
|
|
||||||
|
Packeta delivers mainly to **pickup points** and **Z-BOX lockers** (plus
|
||||||
|
home delivery in some regions). It's the most common choice for SK/CZ eshops.
|
||||||
|
This repo is already **scaffolded** for Packeta's pickup-point picker — you
|
||||||
|
mostly need an API key to switch it on. Shipment creation via API is extra,
|
||||||
|
optional work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Get a Packeta account & keys
|
||||||
|
|
||||||
|
1. Register a client account at <https://client.packeta.com> (Zásilkovna /
|
||||||
|
Packeta). For SK: <https://www.packeta.sk>.
|
||||||
|
2. In the client portal open **Client support → API / Nastavenia API** (or
|
||||||
|
"Integrations"). You get **two different secrets** — don't mix them up:
|
||||||
|
- **Web/Widget API key** — public-ish key used by the browser pickup-point
|
||||||
|
widget (`Packeta.Widget.pick`). This is the one this repo already uses.
|
||||||
|
- **API password (REST/SOAP)** — secret server key used to *create packets*
|
||||||
|
(shipments). Never expose this to the browser.
|
||||||
|
3. (For real shipping) configure your **sender/pickup address and label
|
||||||
|
format** in the portal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Activate the pickup-point picker (already built)
|
||||||
|
|
||||||
|
The checkout template already loads the widget and wires the chosen point into
|
||||||
|
the order **whenever `packeta_api_key` is non-empty**
|
||||||
|
(`assets/views/shop/checkout.html`):
|
||||||
|
|
||||||
|
- loads `https://widget.packeta.com/v6/www/js/library.js`
|
||||||
|
- `Packeta.Widget.pick(packetaKey, point => …)` fills hidden
|
||||||
|
`pickup_point_id` + `pickup_point_name`
|
||||||
|
- if the key is empty it falls back to a plain text field
|
||||||
|
|
||||||
|
So to turn it on:
|
||||||
|
|
||||||
|
### a) Set the Web/Widget API key
|
||||||
|
|
||||||
|
Set the env var (read by `config/development.yaml` / `production.yaml` →
|
||||||
|
`settings.packeta_api_key`, exposed via `src/shared/settings.rs`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env (development) or your production environment
|
||||||
|
PACKETA_API_KEY=your_web_widget_api_key
|
||||||
|
```
|
||||||
|
|
||||||
|
`config/development.yaml` already contains:
|
||||||
|
```yaml
|
||||||
|
settings:
|
||||||
|
packeta_api_key: {{ get_env(name="PACKETA_API_KEY", default="") }}
|
||||||
|
```
|
||||||
|
For production, add the same line under `settings:` in `config/production.yaml`
|
||||||
|
(it isn't there yet).
|
||||||
|
|
||||||
|
### b) Create a Packeta delivery option in the admin
|
||||||
|
|
||||||
|
Go to **`/admin/shipping`** → "Add delivery option":
|
||||||
|
- **Name**: e.g. `Packeta – pickup point`
|
||||||
|
- **Price**: your fee (e.g. `2.90`)
|
||||||
|
- ✅ **Requires pickup point** ← this makes the picker appear at checkout
|
||||||
|
- ✅ **Active**
|
||||||
|
|
||||||
|
The auto-generated `code` will be `packeta-pickup-point` (or similar). Customers
|
||||||
|
now see the option, click "Choose pickup point", pick on the map, and the order
|
||||||
|
stores `pickup_point_id` + `pickup_point_name`.
|
||||||
|
|
||||||
|
**At this point you have a working Packeta flow** — you read the pickup point on
|
||||||
|
the order in `/admin/orders` and create the parcel manually in the Packeta
|
||||||
|
portal. Many small shops stop here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. (Optional) Create shipments via API
|
||||||
|
|
||||||
|
Automate "register the parcel + get tracking + print label". Do the
|
||||||
|
[shared groundwork](README.md#shared-groundwork-do-this-once-before-any-carriers-api-step)
|
||||||
|
first (HTTP client, `integrations` module, `carrier` column, tracking columns).
|
||||||
|
|
||||||
|
### Endpoint & auth
|
||||||
|
|
||||||
|
- Packeta REST API base: `https://www.zasilkovna.cz/api/rest` (SOAP also
|
||||||
|
available at `http://www.zasilkovna.cz/api/soap.wsdl`).
|
||||||
|
- Auth = your **API password** (the server secret from step 1), sent in the
|
||||||
|
request body, **not** the widget key.
|
||||||
|
- Key operation: **`createPacket`**. You send sender id, recipient
|
||||||
|
name/email/phone, the chosen **pickup point id** (`addressId`), value, weight,
|
||||||
|
and COD amount; you receive a **packet id + barcode (tracking)**. A separate
|
||||||
|
**`packetLabelPdf`** call returns the label PDF.
|
||||||
|
|
||||||
|
### Store the secret
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PACKETA_API_PASSWORD=your_secret_api_password
|
||||||
|
```
|
||||||
|
Add to `config/*.yaml` under `settings:`:
|
||||||
|
```yaml
|
||||||
|
packeta_api_password: {{ get_env(name="PACKETA_API_PASSWORD", default="") }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client sketch (`src/integrations/packeta.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use crate::shared::settings;
|
||||||
|
|
||||||
|
// createPacket accepts XML; serde_json works for the JSON REST variant.
|
||||||
|
pub async fn create_shipment(ctx: &AppContext, req: super::ShipmentRequest<'_>)
|
||||||
|
-> Result<super::ShipmentResult>
|
||||||
|
{
|
||||||
|
let api_password = settings::get(ctx, "packeta_api_password")
|
||||||
|
.ok_or_else(|| Error::string("packeta_api_password not configured"))?;
|
||||||
|
|
||||||
|
// Packeta's createPacket is XML/SOAP-ish; build the body per their docs.
|
||||||
|
// number = your order_number
|
||||||
|
// name/surname = recipient
|
||||||
|
// addressId = req.pickup_point_id (the chosen point)
|
||||||
|
// cod = req.cod_cents / 100 (0 if not COD)
|
||||||
|
// value = goods value
|
||||||
|
// eshop = your sender label/id from the portal
|
||||||
|
let body = format!(r#"<createPacket>
|
||||||
|
<apiPassword>{api_password}</apiPassword>
|
||||||
|
<packetAttributes>
|
||||||
|
<number>{}</number>
|
||||||
|
<name>{}</name>
|
||||||
|
<email>{}</email>
|
||||||
|
<addressId>{}</addressId>
|
||||||
|
<cod>{}</cod>
|
||||||
|
<value>{}</value>
|
||||||
|
<weight>{}</weight>
|
||||||
|
<eshop>YOUR_SENDER_LABEL</eshop>
|
||||||
|
</packetAttributes>
|
||||||
|
</createPacket>"#,
|
||||||
|
req.order_number, req.recipient_name, req.email,
|
||||||
|
req.pickup_point_id.unwrap_or(""),
|
||||||
|
req.cod_cents as f64 / 100.0,
|
||||||
|
req.cod_cents as f64 / 100.0,
|
||||||
|
req.weight_grams);
|
||||||
|
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.post("https://www.zasilkovna.cz/api/rest")
|
||||||
|
.body(body)
|
||||||
|
.send().await.map_err(|e| Error::string(&e.to_string()))?
|
||||||
|
.text().await.map_err(|e| Error::string(&e.to_string()))?;
|
||||||
|
|
||||||
|
// Parse <id> (packet id) and <barcode> (tracking) out of the XML response.
|
||||||
|
// Then optionally call packetLabelPdf with that id to fetch the label.
|
||||||
|
todo!("parse resp into ShipmentResult")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then call it from your admin "Create shipment" action for orders whose
|
||||||
|
`shipping_methods.carrier == "packeta"`, and save `tracking_number` /
|
||||||
|
`shipment_id` back on the order.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Testing
|
||||||
|
|
||||||
|
- Use the Packeta **sandbox/staging** portal if your account offers one, or a
|
||||||
|
test API password. Verify `createPacket` returns a packet id before going
|
||||||
|
live.
|
||||||
|
- Track the parcel at `https://tracking.packeta.com/...` using the returned
|
||||||
|
barcode.
|
||||||
|
|
||||||
|
## 5. Go-live checklist
|
||||||
|
|
||||||
|
- [ ] `PACKETA_API_KEY` (widget) set in production env
|
||||||
|
- [ ] `packeta_api_key` line added under `settings:` in `config/production.yaml`
|
||||||
|
- [ ] Packeta delivery option created in `/admin/shipping` with **Requires pickup point** ✅
|
||||||
|
- [ ] (If using API) `PACKETA_API_PASSWORD` set + `src/integrations/packeta.rs` implemented
|
||||||
|
- [ ] Sender address & label format configured in the Packeta portal
|
||||||
|
- [ ] Test order → pickup point saved on order → (API) tracking number stored
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use loco_rs::{cli::playground, prelude::*};
|
use loco_rs::{cli::playground, prelude::*};
|
||||||
use gitara_web::app::App;
|
use kompress_eshop::app::App;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> loco_rs::Result<()> {
|
async fn main() -> loco_rs::Result<()> {
|
||||||
|
|||||||
12
flake.nix
12
flake.nix
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
description = "Development Nix flake for the gitara_web (kompress_eshop) loco-rs app";
|
description = "Development Nix flake for the kompress (kompress_eshop) loco-rs app";
|
||||||
|
|
||||||
inputs = {
|
inputs = {
|
||||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
@@ -41,8 +41,8 @@
|
|||||||
cargo = pkgs.rust-bin.stable.latest.minimal;
|
cargo = pkgs.rust-bin.stable.latest.minimal;
|
||||||
rustc = pkgs.rust-bin.stable.latest.minimal;
|
rustc = pkgs.rust-bin.stable.latest.minimal;
|
||||||
};
|
};
|
||||||
gitara-web = rustPlatform.buildRustPackage {
|
kompress = rustPlatform.buildRustPackage {
|
||||||
pname = "gitara_web";
|
pname = "kompress_eshop";
|
||||||
inherit version;
|
inherit version;
|
||||||
src = ./.;
|
src = ./.;
|
||||||
cargoLock.lockFile = ./Cargo.lock;
|
cargoLock.lockFile = ./Cargo.lock;
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
];
|
];
|
||||||
|
|
||||||
# Build only the application binary.
|
# Build only the application binary.
|
||||||
cargoBuildFlags = [ "--bin" "gitara_web-cli" ];
|
cargoBuildFlags = [ "--bin" "kompress-eshop-cli" ];
|
||||||
# Tests need a database/runtime environment; skip during the build.
|
# Tests need a database/runtime environment; skip during the build.
|
||||||
doCheck = false;
|
doCheck = false;
|
||||||
|
|
||||||
@@ -66,8 +66,8 @@
|
|||||||
};
|
};
|
||||||
in
|
in
|
||||||
{
|
{
|
||||||
gitara-web = gitara-web;
|
kompress = kompress;
|
||||||
default = gitara-web;
|
default = kompress;
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ mod m20260616_132000_drop_blog_and_pages;
|
|||||||
mod m20260616_150755_shipping_methods;
|
mod m20260616_150755_shipping_methods;
|
||||||
mod m20260616_150812_add_shipping_fields_to_orders;
|
mod m20260616_150812_add_shipping_fields_to_orders;
|
||||||
mod m20260616_160000_add_parent_to_categories;
|
mod m20260616_160000_add_parent_to_categories;
|
||||||
|
mod m20260617_000001_add_carrier_to_shipping_methods;
|
||||||
|
mod m20260617_000002_add_shipment_to_orders;
|
||||||
|
mod m20260617_000003_add_phone_to_orders;
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -58,6 +61,9 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260616_150755_shipping_methods::Migration),
|
Box::new(m20260616_150755_shipping_methods::Migration),
|
||||||
Box::new(m20260616_150812_add_shipping_fields_to_orders::Migration),
|
Box::new(m20260616_150812_add_shipping_fields_to_orders::Migration),
|
||||||
Box::new(m20260616_160000_add_parent_to_categories::Migration),
|
Box::new(m20260616_160000_add_parent_to_categories::Migration),
|
||||||
|
Box::new(m20260617_000001_add_carrier_to_shipping_methods::Migration),
|
||||||
|
Box::new(m20260617_000002_add_shipment_to_orders::Migration),
|
||||||
|
Box::new(m20260617_000003_add_phone_to_orders::Migration),
|
||||||
// inject-above (do not remove this comment)
|
// inject-above (do not remove this comment)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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> {
|
||||||
|
// Which carrier API (if any) a delivery option maps to. "none" means the
|
||||||
|
// option is fulfilled manually and never calls an external API.
|
||||||
|
add_column(
|
||||||
|
m,
|
||||||
|
"shipping_methods",
|
||||||
|
"carrier",
|
||||||
|
ColType::StringWithDefault("none".to_string()),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
remove_column(m, "shipping_methods", "carrier").await
|
||||||
|
}
|
||||||
|
}
|
||||||
23
migration/src/m20260617_000002_add_shipment_to_orders.rs
Normal file
23
migration/src/m20260617_000002_add_shipment_to_orders.rs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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> {
|
||||||
|
// Populated only after an admin manually sends the order to a carrier.
|
||||||
|
add_column(m, "orders", "tracking_number", ColType::StringNull).await?;
|
||||||
|
add_column(m, "orders", "shipment_id", ColType::StringNull).await?;
|
||||||
|
add_column(m, "orders", "label_url", ColType::StringNull).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
remove_column(m, "orders", "tracking_number").await?;
|
||||||
|
remove_column(m, "orders", "shipment_id").await?;
|
||||||
|
remove_column(m, "orders", "label_url").await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
17
migration/src/m20260617_000003_add_phone_to_orders.rs
Normal file
17
migration/src/m20260617_000003_add_phone_to_orders.rs
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
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> {
|
||||||
|
// Customer contact phone, also passed to carriers for pickup SMS.
|
||||||
|
add_column(m, "orders", "phone", ColType::StringNull).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
remove_column(m, "orders", "phone").await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +0,0 @@
|
|||||||
pub mod users;
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
pub mod audit_logs;
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
//! Admin order list, detail, and status updates.
|
|
||||||
|
|
||||||
use axum_extra::extract::cookie::CookieJar;
|
|
||||||
use loco_rs::prelude::*;
|
|
||||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
|
|
||||||
use serde::Deserialize;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use crate::{
|
|
||||||
checkout::{
|
|
||||||
models::{order_items, orders},
|
|
||||||
view,
|
|
||||||
},
|
|
||||||
i18n::current_lang,
|
|
||||||
shared::{guard, settings},
|
|
||||||
};
|
|
||||||
|
|
||||||
pub(crate) const ORDER_STATUSES: [&str; 4] = ["pending", "paid", "shipped", "cancelled"];
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
|
||||||
struct StatusForm {
|
|
||||||
status: 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 list = orders::Entity::find()
|
|
||||||
.order_by_desc(orders::Column::CreatedAt)
|
|
||||||
.all(&ctx.db)
|
|
||||||
.await?;
|
|
||||||
let rows: Vec<serde_json::Value> = list.iter().map(view::summary).collect();
|
|
||||||
format::view(
|
|
||||||
&v,
|
|
||||||
"admin/orders/index.html",
|
|
||||||
json!({ "orders": rows, "lang": current_lang(&jar) }),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn show(
|
|
||||||
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 order = orders::Entity::find_by_id(id)
|
|
||||||
.one(&ctx.db)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| Error::NotFound)?;
|
|
||||||
let items = order_items::Entity::find()
|
|
||||||
.filter(order_items::Column::OrderId.eq(order.id))
|
|
||||||
.all(&ctx.db)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
format::view(
|
|
||||||
&v,
|
|
||||||
"admin/orders/show.html",
|
|
||||||
json!({
|
|
||||||
"order": view::detail(
|
|
||||||
&order,
|
|
||||||
settings::get(&ctx, "bank_iban").unwrap_or(""),
|
|
||||||
settings::get(&ctx, "bank_account_name").unwrap_or(""),
|
|
||||||
),
|
|
||||||
"items": view::items(&items),
|
|
||||||
"statuses": ORDER_STATUSES,
|
|
||||||
"lang": current_lang(&jar),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
#[debug_handler]
|
|
||||||
async fn update_status(
|
|
||||||
auth: auth::JWT,
|
|
||||||
Path(id): Path<i32>,
|
|
||||||
State(ctx): State<AppContext>,
|
|
||||||
Form(form): Form<StatusForm>,
|
|
||||||
) -> Result<Response> {
|
|
||||||
guard::current_admin(auth, &ctx).await?;
|
|
||||||
if !ORDER_STATUSES.contains(&form.status.as_str()) {
|
|
||||||
return Err(Error::BadRequest("invalid status".to_string()));
|
|
||||||
}
|
|
||||||
let order = orders::Entity::find_by_id(id)
|
|
||||||
.one(&ctx.db)
|
|
||||||
.await?
|
|
||||||
.ok_or_else(|| Error::NotFound)?;
|
|
||||||
let mut active = order.into_active_model();
|
|
||||||
active.status = Set(form.status);
|
|
||||||
active.update(&ctx.db).await?;
|
|
||||||
|
|
||||||
format::redirect(&format!("/admin/orders/{id}"))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn routes() -> Routes {
|
|
||||||
Routes::new()
|
|
||||||
.add("/admin/orders", get(index))
|
|
||||||
.add("/admin/orders/{id}", get(show))
|
|
||||||
.add("/admin/orders/{id}/status", post(update_status))
|
|
||||||
}
|
|
||||||
25
src/app.rs
25
src/app.rs
@@ -16,8 +16,14 @@ use std::{path::Path, sync::Arc};
|
|||||||
|
|
||||||
#[allow(unused_imports)]
|
#[allow(unused_imports)]
|
||||||
use crate::{
|
use crate::{
|
||||||
account, admin, cart, checkout, home, i18n, initializers, media,
|
controllers::{
|
||||||
models::_entities::users, shop, tasks, workers::downloader::DownloadWorker,
|
admin_categories, admin_dashboard, admin_form, admin_login, admin_orders,
|
||||||
|
admin_products, admin_shipping, auth, cart, checkout, home, i18n, media, shop,
|
||||||
|
},
|
||||||
|
initializers,
|
||||||
|
models::_entities::users,
|
||||||
|
tasks,
|
||||||
|
workers::downloader::DownloadWorker,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct App;
|
pub struct App;
|
||||||
@@ -66,16 +72,16 @@ impl Hooks for App {
|
|||||||
.add_route(cart::routes())
|
.add_route(cart::routes())
|
||||||
.add_route(checkout::routes())
|
.add_route(checkout::routes())
|
||||||
// cross-cutting
|
// cross-cutting
|
||||||
.add_route(account::routes())
|
.add_route(auth::routes())
|
||||||
.add_route(i18n::routes())
|
.add_route(i18n::routes())
|
||||||
.add_route(media::routes())
|
.add_route(media::routes())
|
||||||
// admin
|
// admin
|
||||||
.add_route(admin::routes())
|
.add_route(admin_dashboard::routes())
|
||||||
.add_route(admin::login::routes())
|
.add_route(admin_login::routes())
|
||||||
.add_route(admin::products::routes())
|
.add_route(admin_products::routes())
|
||||||
.add_route(admin::categories::routes())
|
.add_route(admin_categories::routes())
|
||||||
.add_route(admin::orders::routes())
|
.add_route(admin_orders::routes())
|
||||||
.add_route(admin::shipping::routes())
|
.add_route(admin_shipping::routes())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn after_context(ctx: AppContext) -> Result<AppContext> {
|
async fn after_context(ctx: AppContext) -> Result<AppContext> {
|
||||||
@@ -105,6 +111,7 @@ impl Hooks for App {
|
|||||||
async fn seed(ctx: &AppContext, base: &Path) -> Result<()> {
|
async fn seed(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||||
db::seed::<users::ActiveModel>(&ctx.db, &base.join("users.yaml").display().to_string())
|
db::seed::<users::ActiveModel>(&ctx.db, &base.join("users.yaml").display().to_string())
|
||||||
.await?;
|
.await?;
|
||||||
|
crate::seed::seed_catalog(ctx).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use loco_rs::cli;
|
use loco_rs::cli;
|
||||||
use migration::Migrator;
|
use migration::Migrator;
|
||||||
use gitara_web::app::App;
|
use kompress_eshop::app::App;
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() -> loco_rs::Result<()> {
|
async fn main() -> loco_rs::Result<()> {
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
pub mod order_items;
|
|
||||||
pub mod orders;
|
|
||||||
pub mod shipping_methods;
|
|
||||||
@@ -11,14 +11,16 @@ use sea_orm::{
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
admin::form::{read_multipart_form, store_image, MultipartForm},
|
controllers::{
|
||||||
|
admin_form::{read_multipart_form, store_image, MultipartForm},
|
||||||
i18n::current_lang,
|
i18n::current_lang,
|
||||||
media::IMAGE_MAX_BYTES,
|
media::IMAGE_MAX_BYTES,
|
||||||
|
},
|
||||||
shared::{
|
shared::{
|
||||||
guard,
|
guard,
|
||||||
slug::{slugify, unique_slug},
|
slug::{slugify, unique_slug},
|
||||||
},
|
},
|
||||||
shop::models::{categories, products},
|
models::{categories, products},
|
||||||
};
|
};
|
||||||
|
|
||||||
async fn category_by_id(ctx: &AppContext, id: i32) -> Result<categories::Model> {
|
async fn category_by_id(ctx: &AppContext, id: i32) -> Result<categories::Model> {
|
||||||
@@ -1,13 +1,4 @@
|
|||||||
//! Admin area. Each surface lives in its own submodule; this module holds the
|
//! Admin dashboard (HTML home + JSON stats).
|
||||||
//! dashboard (HTML home + JSON stats) and is the entry point for admin routes.
|
|
||||||
|
|
||||||
pub mod categories;
|
|
||||||
pub mod form;
|
|
||||||
pub mod login;
|
|
||||||
pub mod models;
|
|
||||||
pub mod orders;
|
|
||||||
pub mod products;
|
|
||||||
pub mod shipping;
|
|
||||||
|
|
||||||
use axum_extra::extract::cookie::CookieJar;
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
@@ -15,7 +6,7 @@ use sea_orm::{EntityTrait, PaginatorTrait};
|
|||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{i18n::current_lang, models::_entities, shared::guard};
|
use crate::{controllers::i18n::current_lang, models::_entities, shared::guard};
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
struct DashboardResponse {
|
struct DashboardResponse {
|
||||||
@@ -9,7 +9,7 @@ use std::collections::HashMap;
|
|||||||
use axum::extract::Multipart;
|
use axum::extract::Multipart;
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
|
|
||||||
use crate::media::{detect_image_extension, store_upload, IMAGE_MAX_BYTES, IMAGE_STORAGE_DIR};
|
use crate::controllers::media::{detect_image_extension, store_upload, IMAGE_MAX_BYTES, IMAGE_STORAGE_DIR};
|
||||||
|
|
||||||
fn normalize_empty(value: Option<String>) -> Option<String> {
|
fn normalize_empty(value: Option<String>) -> Option<String> {
|
||||||
value.and_then(|value| {
|
value.and_then(|value| {
|
||||||
@@ -6,8 +6,9 @@ use loco_rs::prelude::*;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
account::{self as auth_controller, models::users::{self, LoginParams}},
|
controllers::auth as auth_controller,
|
||||||
i18n::current_lang,
|
models::users::{self, LoginParams},
|
||||||
|
controllers::i18n::current_lang,
|
||||||
shared::guard,
|
shared::guard,
|
||||||
};
|
};
|
||||||
|
|
||||||
215
src/controllers/admin_orders.rs
Normal file
215
src/controllers/admin_orders.rs
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
//! Admin order list, detail, status updates, and manual carrier dispatch.
|
||||||
|
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
integrations::{self, ShipmentRequest},
|
||||||
|
models::{order_items, orders, shipping_methods},
|
||||||
|
views::checkout as view,
|
||||||
|
controllers::i18n::current_lang,
|
||||||
|
shared::{guard, settings},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub(crate) const ORDER_STATUSES: [&str; 4] = ["pending", "paid", "shipped", "cancelled"];
|
||||||
|
|
||||||
|
/// Fallback parcel weight when products carry no weight of their own.
|
||||||
|
const DEFAULT_PARCEL_WEIGHT_GRAMS: i32 = 1000;
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct StatusForm {
|
||||||
|
status: 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 list = orders::Entity::find()
|
||||||
|
.order_by_desc(orders::Column::CreatedAt)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let rows: Vec<serde_json::Value> = list.iter().map(view::summary).collect();
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"admin/orders/index.html",
|
||||||
|
json!({ "orders": rows, "lang": current_lang(&jar) }),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve the carrier code (`none`/`packeta`/`dpd`/`dhl`) for an order from its
|
||||||
|
/// chosen shipping method, defaulting to `none` when unknown.
|
||||||
|
async fn order_carrier(ctx: &AppContext, order: &orders::Model) -> Result<String> {
|
||||||
|
let Some(code) = order.carrier_code.as_deref() else {
|
||||||
|
return Ok("none".to_string());
|
||||||
|
};
|
||||||
|
Ok(shipping_methods::Entity::find()
|
||||||
|
.filter(shipping_methods::Column::Code.eq(code))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.map(|m| m.carrier)
|
||||||
|
.unwrap_or_else(|| "none".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the order detail page, optionally with a dispatch error banner.
|
||||||
|
async fn render_show(
|
||||||
|
jar: &CookieJar,
|
||||||
|
v: &TeraView,
|
||||||
|
ctx: &AppContext,
|
||||||
|
id: i32,
|
||||||
|
error: Option<String>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let order = orders::Entity::find_by_id(id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
let items = order_items::Entity::find()
|
||||||
|
.filter(order_items::Column::OrderId.eq(order.id))
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let carrier = order_carrier(ctx, &order).await?;
|
||||||
|
// The order can be sent only if it maps to a real carrier and hasn't been
|
||||||
|
// dispatched yet.
|
||||||
|
let can_ship = carrier != "none" && order.tracking_number.is_none();
|
||||||
|
|
||||||
|
format::view(
|
||||||
|
v,
|
||||||
|
"admin/orders/show.html",
|
||||||
|
json!({
|
||||||
|
"order": view::detail(
|
||||||
|
&order,
|
||||||
|
settings::get(ctx, "bank_iban").unwrap_or(""),
|
||||||
|
settings::get(ctx, "bank_account_name").unwrap_or(""),
|
||||||
|
),
|
||||||
|
"items": view::items(&items),
|
||||||
|
"statuses": ORDER_STATUSES,
|
||||||
|
"carrier": carrier,
|
||||||
|
"can_ship": can_ship,
|
||||||
|
"ship_error": error,
|
||||||
|
"lang": current_lang(jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn show(
|
||||||
|
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?;
|
||||||
|
render_show(&jar, &v, &ctx, id, None).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn update_status(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(form): Form<StatusForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
if !ORDER_STATUSES.contains(&form.status.as_str()) {
|
||||||
|
return Err(Error::BadRequest("invalid status".to_string()));
|
||||||
|
}
|
||||||
|
let order = orders::Entity::find_by_id(id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
let mut active = order.into_active_model();
|
||||||
|
active.status = Set(form.status);
|
||||||
|
active.update(&ctx.db).await?;
|
||||||
|
|
||||||
|
format::redirect(&format!("/admin/orders/{id}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Manually dispatch an order to its carrier. This is the *only* place that
|
||||||
|
/// calls a carrier API, and it is triggered exclusively by an admin clicking
|
||||||
|
/// "Send to carrier" after the goods are verified and ready.
|
||||||
|
#[debug_handler]
|
||||||
|
async fn ship(
|
||||||
|
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 order = orders::Entity::find_by_id(id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
|
||||||
|
// Idempotency: never create a second shipment for an already-dispatched order.
|
||||||
|
if order.tracking_number.is_some() {
|
||||||
|
return render_show(
|
||||||
|
&jar,
|
||||||
|
&v,
|
||||||
|
&ctx,
|
||||||
|
id,
|
||||||
|
Some("This order has already been sent to the carrier.".to_string()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let carrier = order_carrier(&ctx, &order).await?;
|
||||||
|
let goods_value = (order.total_cents - order.shipping_cents).max(0);
|
||||||
|
let cod_cents = match order.payment_method.as_deref() {
|
||||||
|
Some("cod") => order.total_cents,
|
||||||
|
_ => 0,
|
||||||
|
};
|
||||||
|
let recipient = order
|
||||||
|
.customer_name
|
||||||
|
.as_deref()
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.unwrap_or(&order.email);
|
||||||
|
|
||||||
|
let req = ShipmentRequest {
|
||||||
|
order_number: &order.order_number,
|
||||||
|
recipient_name: recipient,
|
||||||
|
email: &order.email,
|
||||||
|
phone: order.phone.as_deref(),
|
||||||
|
address: order.address.as_deref(),
|
||||||
|
city: order.city.as_deref(),
|
||||||
|
zip: order.zip.as_deref(),
|
||||||
|
country: order.country.as_deref(),
|
||||||
|
pickup_point_id: order.pickup_point_id.as_deref(),
|
||||||
|
cod_cents,
|
||||||
|
currency: &order.currency,
|
||||||
|
value_cents: goods_value,
|
||||||
|
weight_grams: DEFAULT_PARCEL_WEIGHT_GRAMS,
|
||||||
|
};
|
||||||
|
|
||||||
|
match integrations::create_shipment(&ctx, &carrier, req).await {
|
||||||
|
Ok(result) => {
|
||||||
|
let mut active = order.into_active_model();
|
||||||
|
active.tracking_number = Set(Some(result.tracking_number));
|
||||||
|
active.shipment_id = Set(Some(result.shipment_id));
|
||||||
|
active.label_url = Set(result.label_url);
|
||||||
|
active.status = Set("shipped".to_string());
|
||||||
|
active.update(&ctx.db).await?;
|
||||||
|
format::redirect(&format!("/admin/orders/{id}"))
|
||||||
|
}
|
||||||
|
// Show the carrier's error in-page rather than a generic error screen,
|
||||||
|
// so the admin can fix the cause and retry.
|
||||||
|
Err(err) => render_show(&jar, &v, &ctx, id, Some(err.to_string())).await,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.add("/admin/orders", get(index))
|
||||||
|
.add("/admin/orders/{id}", get(show))
|
||||||
|
.add("/admin/orders/{id}/status", post(update_status))
|
||||||
|
.add("/admin/orders/{id}/ship", post(ship))
|
||||||
|
}
|
||||||
@@ -10,18 +10,18 @@ use sea_orm::{
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
admin::form::{read_multipart_form, store_image, MultipartForm},
|
controllers::{
|
||||||
|
admin_form::{read_multipart_form, store_image, MultipartForm},
|
||||||
i18n::current_lang,
|
i18n::current_lang,
|
||||||
media::IMAGE_MAX_BYTES,
|
media::IMAGE_MAX_BYTES,
|
||||||
|
},
|
||||||
shared::{
|
shared::{
|
||||||
guard,
|
guard,
|
||||||
money::parse_price_to_cents,
|
money::parse_price_to_cents,
|
||||||
slug::{slugify, unique_slug},
|
slug::{slugify, unique_slug},
|
||||||
},
|
},
|
||||||
shop::{
|
|
||||||
models::{categories, product_images, products},
|
models::{categories, product_images, products},
|
||||||
view,
|
views::shop as view,
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async fn product_by_id(ctx: &AppContext, id: i32) -> Result<products::Model> {
|
async fn product_by_id(ctx: &AppContext, id: i32) -> Result<products::Model> {
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
//! Admin management of shipping methods (price + enabled toggle).
|
//! Admin management of the built-in delivery options (Packeta, DPD).
|
||||||
|
//!
|
||||||
|
//! The options themselves are fixed and seeded by `initializers::shipping_seeder`
|
||||||
|
//! — they cannot be added or removed here. The admin only sets each one's price
|
||||||
|
//! and toggles whether it is offered at checkout.
|
||||||
|
|
||||||
use axum_extra::extract::cookie::CookieJar;
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
@@ -7,8 +11,8 @@ use serde::Deserialize;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
checkout::models::shipping_methods,
|
models::shipping_methods,
|
||||||
i18n::current_lang,
|
controllers::i18n::current_lang,
|
||||||
shared::{
|
shared::{
|
||||||
guard,
|
guard,
|
||||||
money::{format_price, parse_price_to_cents},
|
money::{format_price, parse_price_to_cents},
|
||||||
@@ -21,6 +25,10 @@ struct ShippingForm {
|
|||||||
enabled: Option<String>,
|
enabled: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_checked(value: &Option<String>) -> bool {
|
||||||
|
matches!(value.as_deref(), Some("on" | "true" | "1"))
|
||||||
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn index(
|
async fn index(
|
||||||
auth: auth::JWT,
|
auth: auth::JWT,
|
||||||
@@ -41,6 +49,7 @@ async fn index(
|
|||||||
"code": m.code,
|
"code": m.code,
|
||||||
"name": m.name,
|
"name": m.name,
|
||||||
"price": format_price(m.price_cents),
|
"price": format_price(m.price_cents),
|
||||||
|
"carrier": m.carrier,
|
||||||
"requires_pickup_point": m.requires_pickup_point,
|
"requires_pickup_point": m.requires_pickup_point,
|
||||||
"enabled": m.enabled,
|
"enabled": m.enabled,
|
||||||
})
|
})
|
||||||
@@ -67,7 +76,7 @@ async fn update(
|
|||||||
.ok_or_else(|| Error::NotFound)?;
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
let mut active = method.into_active_model();
|
let mut active = method.into_active_model();
|
||||||
active.price_cents = Set(parse_price_to_cents(&form.price)?);
|
active.price_cents = Set(parse_price_to_cents(&form.price)?);
|
||||||
active.enabled = Set(matches!(form.enabled.as_deref(), Some("on" | "true" | "1")));
|
active.enabled = Set(is_checked(&form.enabled));
|
||||||
active.update(&ctx.db).await?;
|
active.update(&ctx.db).await?;
|
||||||
format::redirect("/admin/shipping")
|
format::redirect("/admin/shipping")
|
||||||
}
|
}
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
pub mod models;
|
|
||||||
pub mod view;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
account::models::users::{self, LoginParams, RegisterParams},
|
models::users::{self, LoginParams, RegisterParams},
|
||||||
account::view::{CurrentResponse, LoginResponse},
|
views::auth::{CurrentResponse, LoginResponse},
|
||||||
mailers::auth::AuthMailer,
|
mailers::auth::AuthMailer,
|
||||||
shared::guard::is_admin,
|
shared::guard::is_admin,
|
||||||
};
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
use crate::{i18n::current_lang, shared::money::format_price, shop::models::products};
|
use crate::{controllers::i18n::current_lang, shared::money::format_price, models::products};
|
||||||
|
use axum::{http::HeaderMap, response::Redirect};
|
||||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||||
@@ -96,6 +97,8 @@ async fn add(
|
|||||||
async fn update(
|
async fn update(
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
headers: HeaderMap,
|
||||||
Form(form): Form<UpdateForm>,
|
Form(form): Form<UpdateForm>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let stock = published_product(&ctx, form.product_id)
|
let stock = published_product(&ctx, form.product_id)
|
||||||
@@ -110,19 +113,57 @@ async fn update(
|
|||||||
}
|
}
|
||||||
items.retain(|(_, qty)| *qty > 0);
|
items.retain(|(_, qty)| *qty > 0);
|
||||||
|
|
||||||
format::render()
|
let jar = jar.add(cart_cookie(serialize_cart(&items)));
|
||||||
.cookies(&[cart_cookie(serialize_cart(&items))])?
|
cart_response(&ctx, &v, jar, &headers).await
|
||||||
.redirect("/cart")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn remove(jar: CookieJar, Form(form): Form<RemoveForm>) -> Result<Response> {
|
async fn remove(
|
||||||
|
jar: CookieJar,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Form(form): Form<RemoveForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
let mut items = parse_cart(&jar);
|
let mut items = parse_cart(&jar);
|
||||||
items.retain(|(id, _)| *id != form.product_id);
|
items.retain(|(id, _)| *id != form.product_id);
|
||||||
|
|
||||||
format::render()
|
let jar = jar.add(cart_cookie(serialize_cart(&items)));
|
||||||
.cookies(&[cart_cookie(serialize_cart(&items))])?
|
cart_response(&ctx, &v, jar, &headers).await
|
||||||
.redirect("/cart")
|
}
|
||||||
|
|
||||||
|
/// Response after a cart mutation: for an htmx request, just the `#cart-body`
|
||||||
|
/// fragment (so the page never fully reloads); otherwise a redirect back to
|
||||||
|
/// `/cart` for no-JS fallback. `jar` must already hold the updated cart cookie.
|
||||||
|
async fn cart_response(
|
||||||
|
ctx: &AppContext,
|
||||||
|
v: &TeraView,
|
||||||
|
jar: CookieJar,
|
||||||
|
headers: &HeaderMap,
|
||||||
|
) -> Result<Response> {
|
||||||
|
if !headers.contains_key("HX-Request") {
|
||||||
|
return Ok((jar, Redirect::to("/cart")).into_response());
|
||||||
|
}
|
||||||
|
|
||||||
|
let (lines, valid, total) = resolve_cart(ctx, &jar).await?;
|
||||||
|
let currency = lines
|
||||||
|
.first()
|
||||||
|
.and_then(|line| line["currency"].as_str())
|
||||||
|
.unwrap_or("EUR")
|
||||||
|
.to_string();
|
||||||
|
// Persist the re-validated cookie (drops now-invalid lines).
|
||||||
|
let jar = jar.add(cart_cookie(serialize_cart(&valid)));
|
||||||
|
let response = format::view(
|
||||||
|
v,
|
||||||
|
"shop/_cart_body.html",
|
||||||
|
json!({
|
||||||
|
"items": lines,
|
||||||
|
"total": format_price(total),
|
||||||
|
"currency": currency,
|
||||||
|
"lang": current_lang(&jar),
|
||||||
|
}),
|
||||||
|
)?;
|
||||||
|
Ok((jar, response).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the cart cookie into priced line items, dropping anything that is no
|
/// Resolve the cart cookie into priced line items, dropping anything that is no
|
||||||
@@ -8,18 +8,12 @@ use serde::Deserialize;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use time::Duration as TimeDuration;
|
use time::Duration as TimeDuration;
|
||||||
|
|
||||||
pub mod models;
|
|
||||||
pub mod view;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
cart::{resolve_cart, CART_COOKIE},
|
controllers::cart::{resolve_cart, CART_COOKIE},
|
||||||
checkout::models::{
|
models::{order_items, orders, shipping_methods},
|
||||||
order_items,
|
controllers::i18n::current_lang,
|
||||||
orders::{self, Checkout},
|
|
||||||
shipping_methods,
|
|
||||||
},
|
|
||||||
i18n::current_lang,
|
|
||||||
shared::{money::format_price, settings},
|
shared::{money::format_price, settings},
|
||||||
|
views::checkout as view,
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAYMENT_METHODS: [&str; 2] = ["cod", "bank_transfer"];
|
const PAYMENT_METHODS: [&str; 2] = ["cod", "bank_transfer"];
|
||||||
@@ -27,6 +21,8 @@ const PAYMENT_METHODS: [&str; 2] = ["cod", "bank_transfer"];
|
|||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct CheckoutForm {
|
struct CheckoutForm {
|
||||||
email: String,
|
email: String,
|
||||||
|
phone_prefix: String,
|
||||||
|
phone: String,
|
||||||
customer_name: String,
|
customer_name: String,
|
||||||
address: String,
|
address: String,
|
||||||
city: String,
|
city: String,
|
||||||
@@ -117,6 +113,25 @@ async fn place_order(
|
|||||||
}
|
}
|
||||||
let email =
|
let email =
|
||||||
trimmed(&form.email).ok_or_else(|| Error::BadRequest("email is required".to_string()))?;
|
trimmed(&form.email).ok_or_else(|| Error::BadRequest("email is required".to_string()))?;
|
||||||
|
// Combine the dialling-code prefix with the local number into one E.164-ish
|
||||||
|
// value (e.g. "+421 900123456").
|
||||||
|
let number =
|
||||||
|
trimmed(&form.phone).ok_or_else(|| Error::BadRequest("phone is required".to_string()))?;
|
||||||
|
let phone = match trimmed(&form.phone_prefix) {
|
||||||
|
Some(prefix) => format!("{prefix} {number}"),
|
||||||
|
None => number,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Contact and shipping-address fields are mandatory (also enforced in the
|
||||||
|
// browser via `required`).
|
||||||
|
let require = |value: &str, field: &str| -> Result<String> {
|
||||||
|
trimmed(value).ok_or_else(|| Error::BadRequest(format!("{field} is required")))
|
||||||
|
};
|
||||||
|
let customer_name = require(&form.customer_name, "name")?;
|
||||||
|
let address = require(&form.address, "address")?;
|
||||||
|
let city = require(&form.city, "city")?;
|
||||||
|
let zip = require(&form.zip, "zip")?;
|
||||||
|
let country = require(&form.country, "country")?;
|
||||||
|
|
||||||
if !PAYMENT_METHODS.contains(&form.payment_method.as_str()) {
|
if !PAYMENT_METHODS.contains(&form.payment_method.as_str()) {
|
||||||
return Err(Error::BadRequest("invalid payment method".to_string()));
|
return Err(Error::BadRequest("invalid payment method".to_string()));
|
||||||
@@ -145,13 +160,14 @@ async fn place_order(
|
|||||||
let order = orders::place(
|
let order = orders::place(
|
||||||
&ctx,
|
&ctx,
|
||||||
&valid,
|
&valid,
|
||||||
Checkout {
|
orders::Checkout {
|
||||||
email,
|
email,
|
||||||
customer_name: trimmed(&form.customer_name),
|
phone,
|
||||||
address: trimmed(&form.address),
|
customer_name: Some(customer_name),
|
||||||
city: trimmed(&form.city),
|
address: Some(address),
|
||||||
zip: trimmed(&form.zip),
|
city: Some(city),
|
||||||
country: trimmed(&form.country),
|
zip: Some(zip),
|
||||||
|
country: Some(country),
|
||||||
note: form.note.as_deref().and_then(trimmed),
|
note: form.note.as_deref().and_then(trimmed),
|
||||||
payment_method: form.payment_method,
|
payment_method: form.payment_method,
|
||||||
method,
|
method,
|
||||||
@@ -4,7 +4,7 @@ use axum_extra::extract::cookie::CookieJar;
|
|||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::{i18n::current_lang, shared::guard, shop};
|
use crate::{controllers::i18n::current_lang, shared::guard, controllers::shop};
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn index(
|
async fn index(
|
||||||
14
src/controllers/mod.rs
Normal file
14
src/controllers/mod.rs
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
pub mod auth;
|
||||||
|
pub mod admin_categories;
|
||||||
|
pub mod admin_dashboard;
|
||||||
|
pub mod admin_form;
|
||||||
|
pub mod admin_login;
|
||||||
|
pub mod admin_orders;
|
||||||
|
pub mod admin_products;
|
||||||
|
pub mod admin_shipping;
|
||||||
|
pub mod cart;
|
||||||
|
pub mod checkout;
|
||||||
|
pub mod home;
|
||||||
|
pub mod i18n;
|
||||||
|
pub mod media;
|
||||||
|
pub mod shop;
|
||||||
@@ -6,13 +6,11 @@ use loco_rs::prelude::*;
|
|||||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set};
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
pub mod models;
|
|
||||||
pub mod view;
|
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
i18n::current_lang,
|
controllers::i18n::current_lang,
|
||||||
shared::guard,
|
shared::guard,
|
||||||
shop::models::{categories, product_images, products},
|
models::{categories, product_images, products},
|
||||||
|
views::shop as view,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Shape a list of products into card rows, loading each one's primary image.
|
/// Shape a list of products into card rows, loading each one's primary image.
|
||||||
@@ -53,7 +51,7 @@ async fn category_sidebar(
|
|||||||
&v,
|
&v,
|
||||||
"shop/_sidebar.html",
|
"shop/_sidebar.html",
|
||||||
json!({
|
json!({
|
||||||
"category_tree": view::sidebar_rows(&categories::tree(&published)),
|
"category_groups": view::sidebar_groups(&published),
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
---
|
---
|
||||||
- id: 1
|
- id: 2
|
||||||
pid: 11111111-1111-1111-1111-111111111111
|
pid: 11111111-1111-1111-1111-111111111111
|
||||||
email: user1@example.com
|
email: user1@example.com
|
||||||
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
|
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
|
||||||
api_key: lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758
|
api_key: lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758
|
||||||
name: user1
|
name: user1
|
||||||
|
theme: light
|
||||||
created_at: "2023-11-12T12:34:56.789Z"
|
created_at: "2023-11-12T12:34:56.789Z"
|
||||||
updated_at: "2023-11-12T12:34:56.789Z"
|
updated_at: "2023-11-12T12:34:56.789Z"
|
||||||
- id: 2
|
- id: 3
|
||||||
pid: 22222222-2222-2222-2222-222222222222
|
pid: 22222222-2222-2222-2222-222222222222
|
||||||
email: user2@example.com
|
email: user2@example.com
|
||||||
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
|
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
|
||||||
api_key: lo-153561ca-fa84-4e1b-813a-c62526d0a77e
|
api_key: lo-153561ca-fa84-4e1b-813a-c62526d0a77e
|
||||||
name: user2
|
name: user2
|
||||||
|
theme: light
|
||||||
created_at: "2023-11-12T12:34:56.789Z"
|
created_at: "2023-11-12T12:34:56.789Z"
|
||||||
updated_at: "2023-11-12T12:34:56.789Z"
|
updated_at: "2023-11-12T12:34:56.789Z"
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
|
use loco_rs::hash;
|
||||||
|
use sea_orm::{ActiveModelTrait, IntoActiveModel, Set};
|
||||||
|
|
||||||
use crate::account::models::users::{self, RegisterParams};
|
use crate::models::users::{self, RegisterParams};
|
||||||
|
|
||||||
pub struct AdminSeeder;
|
pub struct AdminSeeder;
|
||||||
|
|
||||||
@@ -18,7 +20,19 @@ impl Initializer for AdminSeeder {
|
|||||||
|
|
||||||
if email.is_empty() || password.is_empty() {
|
if email.is_empty() || password.is_empty() {
|
||||||
tracing::warn!("ADMIN_EMAIL / ADMIN_PASSWORD not set in .env; admin not seeded");
|
tracing::warn!("ADMIN_EMAIL / ADMIN_PASSWORD not set in .env; admin not seeded");
|
||||||
} else if users::Model::find_by_email(&ctx.db, &email).await.is_err() {
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(user) = users::Model::find_by_email(&ctx.db, &email).await {
|
||||||
|
// User exists — update password so .env is always the source of truth.
|
||||||
|
let hash = hash::hash_password(&password)
|
||||||
|
.map_err(|e| Error::Message(e.to_string()))?;
|
||||||
|
let mut am = user.into_active_model();
|
||||||
|
am.password = Set(hash);
|
||||||
|
am.name = Set(name);
|
||||||
|
am.update(&ctx.db).await?;
|
||||||
|
tracing::info!(admin = %email, "admin password synced from .env");
|
||||||
|
} else {
|
||||||
users::Model::create_with_password(
|
users::Model::create_with_password(
|
||||||
&ctx.db,
|
&ctx.db,
|
||||||
&RegisterParams {
|
&RegisterParams {
|
||||||
|
|||||||
@@ -1,14 +1,20 @@
|
|||||||
|
//! Ensures the built-in carrier delivery options (Packeta, DPD) always exist.
|
||||||
|
//!
|
||||||
|
//! These are the only delivery options the shop offers; the admin can price and
|
||||||
|
//! enable/disable them but cannot add or remove options. We insert each one
|
||||||
|
//! only when its `code` is missing, so an admin's price/enabled changes are
|
||||||
|
//! never overwritten on the next boot.
|
||||||
|
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||||
|
|
||||||
use crate::models::_entities::shipping_methods;
|
use crate::models::shipping_methods;
|
||||||
|
|
||||||
/// (code, display name, price in cents, requires a pickup point)
|
/// `(code, name, carrier, requires_pickup_point, default_price_cents, position)`
|
||||||
const CARRIERS: [(&str, &str, i64, bool); 3] = [
|
const BUILTINS: [(&str, &str, &str, bool, i64, i32); 2] = [
|
||||||
("packeta", "Packeta", 300, true),
|
("packeta", "Packeta", "packeta", true, 290, 0),
|
||||||
("dpd", "DPD", 450, false),
|
("dpd", "DPD", "dpd", false, 450, 1),
|
||||||
("dhl", "DHL", 500, false),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
pub struct ShippingSeeder;
|
pub struct ShippingSeeder;
|
||||||
@@ -20,28 +26,28 @@ impl Initializer for ShippingSeeder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn before_run(&self, ctx: &AppContext) -> Result<()> {
|
async fn before_run(&self, ctx: &AppContext) -> Result<()> {
|
||||||
for (position, (code, name, price_cents, requires_pickup_point)) in
|
for (code, name, carrier, requires_pickup_point, price_cents, position) in BUILTINS {
|
||||||
CARRIERS.iter().enumerate()
|
|
||||||
{
|
|
||||||
let exists = shipping_methods::Entity::find()
|
let exists = shipping_methods::Entity::find()
|
||||||
.filter(shipping_methods::Column::Code.eq(*code))
|
.filter(shipping_methods::Column::Code.eq(code))
|
||||||
.one(&ctx.db)
|
.count(&ctx.db)
|
||||||
.await?
|
.await?
|
||||||
.is_some();
|
> 0;
|
||||||
if exists {
|
if exists {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
shipping_methods::ActiveModel {
|
shipping_methods::ActiveModel {
|
||||||
code: Set((*code).to_string()),
|
code: Set(code.to_string()),
|
||||||
name: Set((*name).to_string()),
|
name: Set(name.to_string()),
|
||||||
price_cents: Set(*price_cents),
|
carrier: Set(carrier.to_string()),
|
||||||
requires_pickup_point: Set(*requires_pickup_point),
|
requires_pickup_point: Set(requires_pickup_point),
|
||||||
|
price_cents: Set(price_cents),
|
||||||
enabled: Set(true),
|
enabled: Set(true),
|
||||||
position: Set(position as i32),
|
position: Set(position),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.insert(&ctx.db)
|
.insert(&ctx.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
tracing::info!(carrier = code, "seeded built-in delivery option");
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
35
src/integrations/dhl.rs
Normal file
35
src/integrations/dhl.rs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
//! DHL shipment creation. See `docs/integrations/dhl.md`.
|
||||||
|
//!
|
||||||
|
//! DHL has several APIs (Parcel DE Shipping, eCommerce, MyDHL Express) behind
|
||||||
|
//! one developer portal; which one applies depends on your contract and the
|
||||||
|
//! markets you ship to. As with DPD, the workflow is fully wired — only the
|
||||||
|
//! authenticated HTTP call is left as a marked TODO so we don't ship an
|
||||||
|
//! unverified payload.
|
||||||
|
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
|
||||||
|
use super::{ShipmentRequest, ShipmentResult};
|
||||||
|
use crate::shared::settings;
|
||||||
|
|
||||||
|
pub async fn create_shipment(ctx: &AppContext, _req: ShipmentRequest<'_>) -> Result<ShipmentResult> {
|
||||||
|
let _base = settings::get(ctx, "dhl_api_base").filter(|s| !s.is_empty());
|
||||||
|
let _key = settings::get(ctx, "dhl_api_key").filter(|s| !s.is_empty());
|
||||||
|
let _secret = settings::get(ctx, "dhl_api_secret").filter(|s| !s.is_empty());
|
||||||
|
let _account = settings::get(ctx, "dhl_account_number").filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
if _base.is_none() || _key.is_none() || _secret.is_none() || _account.is_none() {
|
||||||
|
return Err(Error::BadRequest(
|
||||||
|
"DHL is not configured: set settings.dhl_api_base / dhl_api_key / dhl_api_secret / dhl_account_number (see docs/integrations/dhl.md)".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(dhl): implement once the API subscription is known:
|
||||||
|
// 1. OAuth2 client-credentials -> Bearer token (cache until expiry).
|
||||||
|
// 2. POST the shipment: shipper = your account/EKP, consignee from
|
||||||
|
// `_req`, product code (domestic/express), weight, references; add
|
||||||
|
// customs data for non-EU destinations.
|
||||||
|
// 3. Parse tracking number + label, return ShipmentResult.
|
||||||
|
Err(Error::BadRequest(
|
||||||
|
"DHL shipment API not finalised yet — fill in the request in src/integrations/dhl.rs per your DHL subscription (docs/integrations/dhl.md)".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
38
src/integrations/dpd.rs
Normal file
38
src/integrations/dpd.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
//! DPD shipment creation. See `docs/integrations/dpd.md`.
|
||||||
|
//!
|
||||||
|
//! DPD's API (REST vs SOAP, base URL, exact field names) depends on your
|
||||||
|
//! country and contract, so the request body below must be finalised against
|
||||||
|
//! *your* DPD account before going live. The surrounding workflow — admin
|
||||||
|
//! trigger, tracking storage, status update — is fully wired; only the HTTP
|
||||||
|
//! call is left as a clearly-marked TODO so we don't ship an unverified payload
|
||||||
|
//! that silently produces broken shipments.
|
||||||
|
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
|
||||||
|
use super::{ShipmentRequest, ShipmentResult};
|
||||||
|
use crate::shared::settings;
|
||||||
|
|
||||||
|
pub async fn create_shipment(ctx: &AppContext, _req: ShipmentRequest<'_>) -> Result<ShipmentResult> {
|
||||||
|
// Required settings (add to config/*.yaml under `settings:` — see docs).
|
||||||
|
let _base = settings::get(ctx, "dpd_api_base").filter(|s| !s.is_empty());
|
||||||
|
let _login = settings::get(ctx, "dpd_login").filter(|s| !s.is_empty());
|
||||||
|
let _password = settings::get(ctx, "dpd_password").filter(|s| !s.is_empty());
|
||||||
|
let _customer = settings::get(ctx, "dpd_customer_number").filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
if _base.is_none() || _login.is_none() || _password.is_none() || _customer.is_none() {
|
||||||
|
return Err(Error::BadRequest(
|
||||||
|
"DPD is not configured: set settings.dpd_api_base / dpd_login / dpd_password / dpd_customer_number (see docs/integrations/dpd.md)".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO(dpd): implement once the contract's API variant is known:
|
||||||
|
// 1. POST login (delisId + password) -> auth token (cache it).
|
||||||
|
// 2. POST storeOrders with the token: recipient from `_req` (or
|
||||||
|
// parcelShopId = _req.pickup_point_id for DPD Pickup), weight,
|
||||||
|
// cod = _req.cod_cents, reference = _req.order_number.
|
||||||
|
// 3. Parse the returned parcel number (tracking) + label, then return:
|
||||||
|
// Ok(ShipmentResult { shipment_id, tracking_number, label_url })
|
||||||
|
Err(Error::BadRequest(
|
||||||
|
"DPD shipment API not finalised yet — fill in the request in src/integrations/dpd.rs per your DPD contract (docs/integrations/dpd.md)".to_string(),
|
||||||
|
))
|
||||||
|
}
|
||||||
66
src/integrations/mod.rs
Normal file
66
src/integrations/mod.rs
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
//! Outbound carrier integrations for creating shipments.
|
||||||
|
//!
|
||||||
|
//! Shipments are **never created automatically**. An admin reviews an order and,
|
||||||
|
//! once the goods are physically ready, explicitly triggers
|
||||||
|
//! [`create_shipment`] from the order page. Only then does the eshop call the
|
||||||
|
//! carrier's API. `orders::place` (checkout) does not touch any of this.
|
||||||
|
//!
|
||||||
|
//! Each delivery option (`shipping_methods.carrier`) maps to one carrier here.
|
||||||
|
//! "none" means the option is fulfilled manually and has no API.
|
||||||
|
|
||||||
|
pub mod dhl;
|
||||||
|
pub mod dpd;
|
||||||
|
pub mod packeta;
|
||||||
|
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
|
||||||
|
/// Everything a carrier needs to register a parcel, snapshotted from an order.
|
||||||
|
pub struct ShipmentRequest<'a> {
|
||||||
|
pub order_number: &'a str,
|
||||||
|
pub recipient_name: &'a str,
|
||||||
|
pub email: &'a str,
|
||||||
|
pub phone: Option<&'a str>,
|
||||||
|
pub address: Option<&'a str>,
|
||||||
|
pub city: Option<&'a str>,
|
||||||
|
pub zip: Option<&'a str>,
|
||||||
|
pub country: Option<&'a str>,
|
||||||
|
/// Carrier pickup-point / locker id, when the method requires one.
|
||||||
|
pub pickup_point_id: Option<&'a str>,
|
||||||
|
/// Cash-on-delivery amount in cents; `0` when payment is not COD.
|
||||||
|
pub cod_cents: i64,
|
||||||
|
pub currency: &'a str,
|
||||||
|
/// Total order value in cents (for insurance / customs declarations).
|
||||||
|
pub value_cents: i64,
|
||||||
|
pub weight_grams: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What a carrier returns once the parcel is registered.
|
||||||
|
pub struct ShipmentResult {
|
||||||
|
/// Carrier-internal shipment/packet id.
|
||||||
|
pub shipment_id: String,
|
||||||
|
/// Public tracking number / barcode shown to the customer.
|
||||||
|
pub tracking_number: String,
|
||||||
|
/// Direct link to the shipping label PDF, if the carrier returns one.
|
||||||
|
pub label_url: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatch to the carrier named by `shipping_methods.carrier`. Returns an error
|
||||||
|
/// for `"none"` (manual fulfilment) or an unknown carrier.
|
||||||
|
pub async fn create_shipment(
|
||||||
|
ctx: &AppContext,
|
||||||
|
carrier: &str,
|
||||||
|
req: ShipmentRequest<'_>,
|
||||||
|
) -> Result<ShipmentResult> {
|
||||||
|
match carrier {
|
||||||
|
"packeta" => packeta::create_shipment(ctx, req).await,
|
||||||
|
"dpd" => dpd::create_shipment(ctx, req).await,
|
||||||
|
"dhl" => dhl::create_shipment(ctx, req).await,
|
||||||
|
"none" | "" => Err(Error::BadRequest(
|
||||||
|
"this delivery option is fulfilled manually (no carrier API)".to_string(),
|
||||||
|
)),
|
||||||
|
other => Err(Error::BadRequest(format!("unknown carrier '{other}'"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The carrier values offered in the admin UI. `none` is the manual default.
|
||||||
|
pub const CARRIERS: [&str; 4] = ["none", "packeta", "dpd", "dhl"];
|
||||||
116
src/integrations/packeta.rs
Normal file
116
src/integrations/packeta.rs
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
//! Packeta (Zásilkovna) shipment creation via the REST `createPacket` call.
|
||||||
|
//!
|
||||||
|
//! See `docs/integrations/packeta.md`. Requires two settings:
|
||||||
|
//! - `packeta_api_password` — the secret REST API password (NOT the widget key)
|
||||||
|
//! - `packeta_sender_label` — your sender/eshop label configured in the portal
|
||||||
|
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
|
||||||
|
use super::{ShipmentRequest, ShipmentResult};
|
||||||
|
use crate::shared::settings;
|
||||||
|
|
||||||
|
const ENDPOINT: &str = "https://www.zasilkovna.cz/api/rest";
|
||||||
|
|
||||||
|
/// Minimal XML-entity escaping for values interpolated into the request body.
|
||||||
|
fn xml_escape(value: &str) -> String {
|
||||||
|
value
|
||||||
|
.replace('&', "&")
|
||||||
|
.replace('<', "<")
|
||||||
|
.replace('>', ">")
|
||||||
|
.replace('"', """)
|
||||||
|
.replace('\'', "'")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract the text inside the first `<tag>…</tag>` pair, if present.
|
||||||
|
fn extract(xml: &str, tag: &str) -> Option<String> {
|
||||||
|
let open = format!("<{tag}>");
|
||||||
|
let close = format!("</{tag}>");
|
||||||
|
let start = xml.find(&open)? + open.len();
|
||||||
|
let end = xml[start..].find(&close)? + start;
|
||||||
|
Some(xml[start..end].trim().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_shipment(ctx: &AppContext, req: ShipmentRequest<'_>) -> Result<ShipmentResult> {
|
||||||
|
let api_password = settings::get(ctx, "packeta_api_password").filter(|s| !s.is_empty()).ok_or_else(|| {
|
||||||
|
Error::BadRequest(
|
||||||
|
"Packeta is not configured: set settings.packeta_api_password (see docs/integrations/packeta.md)".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let sender_label = settings::get(ctx, "packeta_sender_label").filter(|s| !s.is_empty()).ok_or_else(|| {
|
||||||
|
Error::BadRequest(
|
||||||
|
"Packeta is not configured: set settings.packeta_sender_label (see docs/integrations/packeta.md)".to_string(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// The scaffolded checkout flow delivers to a Packeta pickup point, so an
|
||||||
|
// address id is required. (Home delivery uses a different routing flow.)
|
||||||
|
let address_id = req.pickup_point_id.filter(|s| !s.is_empty()).ok_or_else(|| {
|
||||||
|
Error::BadRequest("this order has no Packeta pickup point selected".to_string())
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let value = req.value_cents as f64 / 100.0;
|
||||||
|
let cod = req.cod_cents as f64 / 100.0;
|
||||||
|
let weight_kg = f64::from(req.weight_grams) / 1000.0;
|
||||||
|
|
||||||
|
let body = format!(
|
||||||
|
"<createPacket>\
|
||||||
|
<apiPassword>{}</apiPassword>\
|
||||||
|
<packetAttributes>\
|
||||||
|
<number>{}</number>\
|
||||||
|
<name>{}</name>\
|
||||||
|
<surname>-</surname>\
|
||||||
|
<email>{}</email>\
|
||||||
|
<phone>{}</phone>\
|
||||||
|
<addressId>{}</addressId>\
|
||||||
|
<value>{:.2}</value>\
|
||||||
|
<cod>{:.2}</cod>\
|
||||||
|
<currency>{}</currency>\
|
||||||
|
<weight>{:.3}</weight>\
|
||||||
|
<eshop>{}</eshop>\
|
||||||
|
</packetAttributes>\
|
||||||
|
</createPacket>",
|
||||||
|
xml_escape(api_password),
|
||||||
|
xml_escape(req.order_number),
|
||||||
|
xml_escape(req.recipient_name),
|
||||||
|
xml_escape(req.email),
|
||||||
|
xml_escape(req.phone.unwrap_or("")),
|
||||||
|
xml_escape(address_id),
|
||||||
|
value,
|
||||||
|
cod,
|
||||||
|
xml_escape(req.currency),
|
||||||
|
weight_kg,
|
||||||
|
xml_escape(sender_label),
|
||||||
|
);
|
||||||
|
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.post(ENDPOINT)
|
||||||
|
.header("Content-Type", "text/xml; charset=utf-8")
|
||||||
|
.body(body)
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::string(&format!("Packeta request failed: {e}")))?;
|
||||||
|
|
||||||
|
let text = resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| Error::string(&format!("Packeta response read failed: {e}")))?;
|
||||||
|
|
||||||
|
// A successful response is <response><status>ok</status><result>…</result>.
|
||||||
|
// A failure carries <status>fault</status> plus a <string>/<fault> message.
|
||||||
|
if extract(&text, "status").as_deref() != Some("ok") {
|
||||||
|
let message = extract(&text, "string")
|
||||||
|
.or_else(|| extract(&text, "fault"))
|
||||||
|
.unwrap_or_else(|| "unknown Packeta error".to_string());
|
||||||
|
return Err(Error::BadRequest(format!("Packeta rejected the shipment: {message}")));
|
||||||
|
}
|
||||||
|
|
||||||
|
let shipment_id = extract(&text, "id")
|
||||||
|
.ok_or_else(|| Error::string("Packeta response missing packet id"))?;
|
||||||
|
let tracking_number = extract(&text, "barcode").unwrap_or_else(|| shipment_id.clone());
|
||||||
|
|
||||||
|
Ok(ShipmentResult {
|
||||||
|
label_url: Some(format!("https://tracking.packeta.com/sk/?id={tracking_number}")),
|
||||||
|
shipment_id,
|
||||||
|
tracking_number,
|
||||||
|
})
|
||||||
|
}
|
||||||
22
src/lib.rs
22
src/lib.rs
@@ -1,22 +1,12 @@
|
|||||||
pub mod app;
|
pub mod app;
|
||||||
|
pub mod controllers;
|
||||||
pub mod data;
|
pub mod data;
|
||||||
pub mod initializers;
|
pub mod initializers;
|
||||||
|
pub mod integrations;
|
||||||
pub mod mailers;
|
pub mod mailers;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
pub mod tasks;
|
pub mod seed;
|
||||||
pub mod workers;
|
|
||||||
|
|
||||||
// Cross-cutting helpers shared by every feature.
|
|
||||||
pub mod shared;
|
pub mod shared;
|
||||||
|
pub mod tasks;
|
||||||
// Feature slices: each owns its routes, handlers, view-shaping and the model
|
pub mod views;
|
||||||
// methods/services specific to it. Generated sea-orm entities stay shared in
|
pub mod workers;
|
||||||
// `models::_entities`.
|
|
||||||
pub mod account;
|
|
||||||
pub mod admin;
|
|
||||||
pub mod cart;
|
|
||||||
pub mod checkout;
|
|
||||||
pub mod home;
|
|
||||||
pub mod i18n;
|
|
||||||
pub mod media;
|
|
||||||
pub mod shop;
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
|
|
||||||
use crate::account::models::users;
|
use crate::models::users;
|
||||||
|
|
||||||
static welcome: Dir<'_> = include_dir!("src/mailers/auth/welcome");
|
static welcome: Dir<'_> = include_dir!("src/mailers/auth/welcome");
|
||||||
static forgot: Dir<'_> = include_dir!("src/mailers/auth/forgot");
|
static forgot: Dir<'_> = include_dir!("src/mailers/auth/forgot");
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub struct Model {
|
|||||||
#[sea_orm(unique)]
|
#[sea_orm(unique)]
|
||||||
pub order_number: String,
|
pub order_number: String,
|
||||||
pub email: String,
|
pub email: String,
|
||||||
|
pub phone: Option<String>,
|
||||||
pub customer_name: Option<String>,
|
pub customer_name: Option<String>,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub total_cents: i64,
|
pub total_cents: i64,
|
||||||
@@ -29,6 +30,9 @@ pub struct Model {
|
|||||||
pub shipping_cents: i64,
|
pub shipping_cents: i64,
|
||||||
pub pickup_point_id: Option<String>,
|
pub pickup_point_id: Option<String>,
|
||||||
pub pickup_point_name: Option<String>,
|
pub pickup_point_name: Option<String>,
|
||||||
|
pub tracking_number: Option<String>,
|
||||||
|
pub shipment_id: Option<String>,
|
||||||
|
pub label_url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ pub struct Model {
|
|||||||
pub requires_pickup_point: bool,
|
pub requires_pickup_point: bool,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
pub position: i32,
|
pub position: i32,
|
||||||
|
pub carrier: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
|||||||
@@ -1,7 +1,18 @@
|
|||||||
//! Shared data layer: the sea-orm entities generated by `loco generate`.
|
//! Shared data layer: SeaORM entities and their hand-written model extensions.
|
||||||
//!
|
//!
|
||||||
//! These structs cross-reference each other (relations) and are regenerated as
|
//! `_entities/` contains auto-generated SeaORM code (regenerated as a unit).
|
||||||
//! a unit, so they live here centrally. The hand-written model methods,
|
//! The sibling files contain hand-written model impls: ActiveModelBehavior,
|
||||||
//! services and view-shaping that use them live in the feature slices
|
//! finder methods, business logic, and query helpers.
|
||||||
//! (`shop::models`, `checkout::models`, `account::models`, …).
|
|
||||||
pub mod _entities;
|
pub mod _entities;
|
||||||
|
|
||||||
|
pub mod audit_logs;
|
||||||
|
pub mod categories;
|
||||||
|
pub mod order_items;
|
||||||
|
pub mod orders;
|
||||||
|
pub mod product_images;
|
||||||
|
pub mod product_product_tags;
|
||||||
|
pub mod product_tags;
|
||||||
|
pub mod products;
|
||||||
|
pub mod shipping_methods;
|
||||||
|
pub mod users;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub type Orders = Entity;
|
|||||||
/// database inside [`place`] so the customer cannot influence what they pay.
|
/// database inside [`place`] so the customer cannot influence what they pay.
|
||||||
pub struct Checkout {
|
pub struct Checkout {
|
||||||
pub email: String,
|
pub email: String,
|
||||||
|
pub phone: String,
|
||||||
pub customer_name: Option<String>,
|
pub customer_name: Option<String>,
|
||||||
pub address: Option<String>,
|
pub address: Option<String>,
|
||||||
pub city: Option<String>,
|
pub city: Option<String>,
|
||||||
@@ -64,6 +65,7 @@ pub async fn place(ctx: &AppContext, items: &[(i32, i32)], details: Checkout) ->
|
|||||||
let order = ActiveModel {
|
let order = ActiveModel {
|
||||||
order_number: Set(generate_order_number()),
|
order_number: Set(generate_order_number()),
|
||||||
email: Set(details.email),
|
email: Set(details.email),
|
||||||
|
phone: Set(Some(details.phone)),
|
||||||
customer_name: Set(details.customer_name),
|
customer_name: Set(details.customer_name),
|
||||||
status: Set("pending".to_string()),
|
status: Set("pending".to_string()),
|
||||||
total_cents: Set(subtotal + details.method.price_cents),
|
total_cents: Set(subtotal + details.method.price_cents),
|
||||||
178
src/seed.rs
Normal file
178
src/seed.rs
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
//! Catalog seed data — run via `cargo loco seed`.
|
||||||
|
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
models::_entities::{categories, products},
|
||||||
|
shared::slug::slugify,
|
||||||
|
};
|
||||||
|
|
||||||
|
// -- Categories -----------------------------------------------------------
|
||||||
|
|
||||||
|
struct CategorySeed {
|
||||||
|
name: &'static str,
|
||||||
|
description: &'static str,
|
||||||
|
position: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
const CATEGORIES: &[CategorySeed] = &[
|
||||||
|
CategorySeed {
|
||||||
|
name: "Electronics",
|
||||||
|
description: "Audio, computing, and smart devices",
|
||||||
|
position: 0,
|
||||||
|
},
|
||||||
|
CategorySeed {
|
||||||
|
name: "Accessories",
|
||||||
|
description: "Cables, adapters, cases, and everyday essentials",
|
||||||
|
position: 1,
|
||||||
|
},
|
||||||
|
CategorySeed {
|
||||||
|
name: "Home & Office",
|
||||||
|
description: "Ergonomic furniture, lighting, and desk organization",
|
||||||
|
position: 2,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// -- Products -------------------------------------------------------------
|
||||||
|
|
||||||
|
struct ProductSeed {
|
||||||
|
name: &'static str,
|
||||||
|
description: &'static str,
|
||||||
|
price_cents: i64,
|
||||||
|
stock: i32,
|
||||||
|
category_slug: &'static str,
|
||||||
|
sku: Option<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
const PRODUCTS: &[ProductSeed] = &[
|
||||||
|
ProductSeed {
|
||||||
|
name: "Wireless Headphones",
|
||||||
|
description: "Over-ear Bluetooth headphones with active noise cancelling, 30-hour battery life, and plush memory-foam cushions.",
|
||||||
|
price_cents: 7_999,
|
||||||
|
stock: 25,
|
||||||
|
category_slug: "electronics",
|
||||||
|
sku: Some("WH-1000"),
|
||||||
|
},
|
||||||
|
ProductSeed {
|
||||||
|
name: "Mechanical Keyboard",
|
||||||
|
description: "Tenkeyless mechanical keyboard with hot-swappable switches, per-key RGB backlight, and a detachable USB-C cable.",
|
||||||
|
price_cents: 12_999,
|
||||||
|
stock: 15,
|
||||||
|
category_slug: "electronics",
|
||||||
|
sku: Some("MK-TKL-RGB"),
|
||||||
|
},
|
||||||
|
ProductSeed {
|
||||||
|
name: "USB-C Hub",
|
||||||
|
description: "7-in-1 USB-C hub with HDMI 4K output, 100 W power delivery pass-through, SD card reader, and three USB-A 3.2 ports.",
|
||||||
|
price_cents: 3_499,
|
||||||
|
stock: 40,
|
||||||
|
category_slug: "accessories",
|
||||||
|
sku: Some("USBC-HUB7"),
|
||||||
|
},
|
||||||
|
ProductSeed {
|
||||||
|
name: "Laptop Stand",
|
||||||
|
description: "Adjustable aluminium laptop stand with ventilated surface. Supports laptops from 10\u{201d} to 17\u{201d}.",
|
||||||
|
price_cents: 4_999,
|
||||||
|
stock: 30,
|
||||||
|
category_slug: "accessories",
|
||||||
|
sku: Some("LS-ALU-01"),
|
||||||
|
},
|
||||||
|
ProductSeed {
|
||||||
|
name: "Desk Lamp",
|
||||||
|
description: "LED desk lamp with 5 colour temperatures, stepless brightness control, and a flexible gooseneck arm.",
|
||||||
|
price_cents: 3_999,
|
||||||
|
stock: 20,
|
||||||
|
category_slug: "home-office",
|
||||||
|
sku: Some("DL-5CT"),
|
||||||
|
},
|
||||||
|
ProductSeed {
|
||||||
|
name: "Ergonomic Mouse",
|
||||||
|
description: "Vertical wireless ergonomic mouse with 6 buttons, adjustable DPI up to 4 000, and a sculpted thumb rest.",
|
||||||
|
price_cents: 5_999,
|
||||||
|
stock: 18,
|
||||||
|
category_slug: "electronics",
|
||||||
|
sku: Some("EM-VW-01"),
|
||||||
|
},
|
||||||
|
ProductSeed {
|
||||||
|
name: "Webcam Privacy Cover",
|
||||||
|
description: "Ultra-thin sliding webcam cover compatible with laptops, tablets, and external monitors. Pack of 3.",
|
||||||
|
price_cents: 599,
|
||||||
|
stock: 100,
|
||||||
|
category_slug: "accessories",
|
||||||
|
sku: Some("WPC-3PK"),
|
||||||
|
},
|
||||||
|
ProductSeed {
|
||||||
|
name: "Cable Organizer Set",
|
||||||
|
description: "Silicone cable management kit with 6 magnetic clips, 4 velcro straps, and an under-desk cable tray.",
|
||||||
|
price_cents: 1_299,
|
||||||
|
stock: 50,
|
||||||
|
category_slug: "home-office",
|
||||||
|
sku: Some("COS-MAG"),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// -- Public API -----------------------------------------------------------
|
||||||
|
|
||||||
|
/// Insert starter categories and products. Called from the `seed()` hook.
|
||||||
|
pub async fn seed_catalog(ctx: &AppContext) -> Result<()> {
|
||||||
|
for cat in CATEGORIES {
|
||||||
|
let slug = slugify(cat.name);
|
||||||
|
let exists = categories::Entity::find()
|
||||||
|
.filter(categories::Column::Slug.eq(&slug))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.is_some();
|
||||||
|
if exists {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
categories::ActiveModel {
|
||||||
|
name: Set(cat.name.to_string()),
|
||||||
|
slug: Set(slug),
|
||||||
|
description: Set(Some(cat.description.to_string())),
|
||||||
|
position: Set(cat.position),
|
||||||
|
published: Set(true),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for item in PRODUCTS {
|
||||||
|
let product_slug = slugify(item.name);
|
||||||
|
let exists = products::Entity::find()
|
||||||
|
.filter(products::Column::Slug.eq(&product_slug))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.is_some();
|
||||||
|
if exists {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
let cat_slug = slugify(item.category_slug);
|
||||||
|
let category = categories::Entity::find()
|
||||||
|
.filter(categories::Column::Slug.eq(&cat_slug))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
|
||||||
|
products::ActiveModel {
|
||||||
|
name: Set(item.name.to_string()),
|
||||||
|
slug: Set(product_slug),
|
||||||
|
description: Set(Some(item.description.to_string())),
|
||||||
|
price_cents: Set(item.price_cents),
|
||||||
|
currency: Set("EUR".to_string()),
|
||||||
|
sku: Set(item.sku.map(|s| s.to_string())),
|
||||||
|
stock: Set(item.stock),
|
||||||
|
published: Set(true),
|
||||||
|
published_at: Set(Some(now.into())),
|
||||||
|
category_id: Set(category.map(|c| c.id)),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -3,8 +3,8 @@
|
|||||||
use axum_extra::extract::cookie::CookieJar;
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
|
|
||||||
use crate::account::models::users;
|
use crate::models::users;
|
||||||
use crate::account::AUTH_COOKIE;
|
use crate::controllers::auth::AUTH_COOKIE;
|
||||||
use crate::shared::settings;
|
use crate::shared::settings;
|
||||||
|
|
||||||
/// Is `user` the configured admin (settings.admin_email)?
|
/// Is `user` the configured admin (settings.admin_email)?
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
pub mod categories;
|
|
||||||
pub mod product_images;
|
|
||||||
pub mod product_product_tags;
|
|
||||||
pub mod product_tags;
|
|
||||||
pub mod products;
|
|
||||||
@@ -28,6 +28,7 @@ pub fn detail(order: &orders::Model, bank_iban: &str, bank_account_name: &str) -
|
|||||||
"id": order.id,
|
"id": order.id,
|
||||||
"order_number": order.order_number,
|
"order_number": order.order_number,
|
||||||
"email": order.email,
|
"email": order.email,
|
||||||
|
"phone": order.phone,
|
||||||
"customer_name": order.customer_name,
|
"customer_name": order.customer_name,
|
||||||
"status": order.status,
|
"status": order.status,
|
||||||
"subtotal": format_price(order.total_cents - order.shipping_cents),
|
"subtotal": format_price(order.total_cents - order.shipping_cents),
|
||||||
@@ -42,6 +43,9 @@ pub fn detail(order: &orders::Model, bank_iban: &str, bank_account_name: &str) -
|
|||||||
"payment_method": order.payment_method,
|
"payment_method": order.payment_method,
|
||||||
"carrier_name": order.carrier_name,
|
"carrier_name": order.carrier_name,
|
||||||
"pickup_point_name": order.pickup_point_name,
|
"pickup_point_name": order.pickup_point_name,
|
||||||
|
"tracking_number": order.tracking_number,
|
||||||
|
"shipment_id": order.shipment_id,
|
||||||
|
"label_url": order.label_url,
|
||||||
// Numeric, sequential order id doubles as the bank variable symbol.
|
// Numeric, sequential order id doubles as the bank variable symbol.
|
||||||
"variable_symbol": order.id,
|
"variable_symbol": order.id,
|
||||||
"bank_iban": bank_iban,
|
"bank_iban": bank_iban,
|
||||||
5
src/views/mod.rs
Normal file
5
src/views/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
//! JSON view-shaping structs for API responses and templates.
|
||||||
|
|
||||||
|
pub mod auth;
|
||||||
|
pub mod checkout;
|
||||||
|
pub mod shop;
|
||||||
@@ -45,12 +45,24 @@ pub fn product_form(product: &products::Model, image: Option<String>) -> Value {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Depth-ordered `{ name, slug, depth }` rows for the storefront sidebar,
|
/// Two-level grouping for the storefront sidebar menu: each top-level category
|
||||||
/// rendered as an indented flat list.
|
/// as `{ name, slug, children: [{ name, slug }] }`, with its direct
|
||||||
pub fn sidebar_rows(tree: &[(categories::Model, usize)]) -> Vec<Value> {
|
/// subcategories nested under `children` (empty when the category has none).
|
||||||
tree.iter()
|
/// Siblings are ordered by position then name on both levels.
|
||||||
.map(|(category, depth)| {
|
pub fn sidebar_groups(categories: &[categories::Model]) -> Vec<Value> {
|
||||||
json!({ "name": category.name, "slug": category.slug, "depth": depth })
|
let mut top: Vec<&categories::Model> = categories
|
||||||
|
.iter()
|
||||||
|
.filter(|c| c.parent_id.is_none())
|
||||||
|
.collect();
|
||||||
|
top.sort_by(|a, b| a.position.cmp(&b.position).then_with(|| a.name.cmp(&b.name)));
|
||||||
|
|
||||||
|
top.into_iter()
|
||||||
|
.map(|category| {
|
||||||
|
let children: Vec<Value> = crate::models::categories::children_of(categories, category.id)
|
||||||
|
.into_iter()
|
||||||
|
.map(|child| json!({ "name": child.name, "slug": child.slug }))
|
||||||
|
.collect();
|
||||||
|
json!({ "name": category.name, "slug": category.slug, "children": children })
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
281
structure.md
Normal file
281
structure.md
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
# Project Structure & How to Scale It
|
||||||
|
|
||||||
|
This is a [Loco](https://loco.rs) app (Rust, on top of Axum + SeaORM). It uses
|
||||||
|
the **standard Loco layer layout**. This document explains *why* that layout
|
||||||
|
scales and *how* you add things as the shop grows, so you never have to guess
|
||||||
|
where a new piece of code belongs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. The mental model: layers, not features
|
||||||
|
|
||||||
|
Loco organizes code by **what kind of thing it is** (a layer), not by which
|
||||||
|
feature it belongs to. The top-level dirs under `src/` are the layers:
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── app.rs # The wiring hub: registers routes, workers, initializers, tasks
|
||||||
|
├── lib.rs # Declares which modules exist (pub mod ...)
|
||||||
|
├── bin/main.rs # Binary entrypoint (you rarely touch this)
|
||||||
|
│
|
||||||
|
├── controllers/ # HTTP layer: routes + request handlers
|
||||||
|
├── models/ # Data layer: DB entities + business logic
|
||||||
|
│ └── _entities/ # AUTO-GENERATED SeaORM structs — never hand-edit
|
||||||
|
├── views/ # Presentation layer: shapes data into JSON for templates
|
||||||
|
├── mailers/ # Email sending + email templates (.t files)
|
||||||
|
├── workers/ # Background jobs (async, off the request path)
|
||||||
|
├── tasks/ # CLI tasks (`cargo loco task ...`)
|
||||||
|
├── initializers/ # Runs once at boot (seeders, view engine setup, ...)
|
||||||
|
├── fixtures/ # Seed data (YAML) for `cargo loco db seed`
|
||||||
|
├── data/ # Misc static/loaded data
|
||||||
|
└── shared/ # Cross-cutting helpers used by many layers
|
||||||
|
```
|
||||||
|
|
||||||
|
Supporting dirs outside `src/`:
|
||||||
|
|
||||||
|
```
|
||||||
|
migration/ # SeaORM migrations (one file per schema change)
|
||||||
|
config/ # development.yaml / test.yaml / production.yaml
|
||||||
|
assets/ # Tera templates (views/), i18n (.ftl), static files, CSS
|
||||||
|
tests/ # requests/ models/ workers/ tasks/ + snapshot .snap files
|
||||||
|
```
|
||||||
|
|
||||||
|
### Why layers scale
|
||||||
|
|
||||||
|
The instinct is often "put everything for the shop in one folder." That feels
|
||||||
|
nice early, but it fights the framework: Loco's codegen, conventions, and docs
|
||||||
|
all assume layers. By staying with layers you get:
|
||||||
|
|
||||||
|
1. **`loco generate` just works.** Scaffolding lands in the right place; you
|
||||||
|
never hand-move files. (This is exactly why the project moved *back* to this
|
||||||
|
layout in the `loco straucture` commit.)
|
||||||
|
2. **Each layer has one reason to change.** A routing change touches only
|
||||||
|
`controllers/`. A schema change touches `migration/` + `models/_entities/`. A
|
||||||
|
"make the price display differently" change touches only `views/`. Bugs stay
|
||||||
|
contained.
|
||||||
|
3. **New contributors (and AI tools) navigate by convention**, not by reading
|
||||||
|
the whole tree.
|
||||||
|
|
||||||
|
The trade-off — "I have to open 3 dirs to see the whole shop feature" — is
|
||||||
|
solved below with naming, not folders.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Feature grouping without folders: the naming convention
|
||||||
|
|
||||||
|
You still get the "everything for X in one glance" benefit, via **filename
|
||||||
|
prefixes** inside the flat `controllers/` dir:
|
||||||
|
|
||||||
|
```
|
||||||
|
controllers/
|
||||||
|
├── home.rs ┐
|
||||||
|
├── shop.rs │ public storefront
|
||||||
|
├── cart.rs │
|
||||||
|
├── checkout.rs ┘
|
||||||
|
│
|
||||||
|
├── admin_dashboard.rs ┐
|
||||||
|
├── admin_products.rs │
|
||||||
|
├── admin_categories.rs│ admin area — `admin_` prefix groups them
|
||||||
|
├── admin_orders.rs │
|
||||||
|
├── admin_shipping.rs │
|
||||||
|
├── admin_login.rs │
|
||||||
|
├── admin_form.rs ┘
|
||||||
|
│
|
||||||
|
├── auth.rs ┐
|
||||||
|
├── i18n.rs │ cross-cutting
|
||||||
|
└── media.rs ┘
|
||||||
|
```
|
||||||
|
|
||||||
|
In your editor's file list, `admin_*` sorts together — you see the whole admin
|
||||||
|
surface at once, but `loco generate` and Loco conventions still see flat
|
||||||
|
controllers. Best of both.
|
||||||
|
|
||||||
|
**Rule of thumb:** prefix = the "feature area." Add `admin_returns.rs`, not a
|
||||||
|
`returns/` folder.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. How a request flows through the layers
|
||||||
|
|
||||||
|
Trace the shop index (`GET /shop`) to see how layers cooperate — this is the
|
||||||
|
pattern every feature follows:
|
||||||
|
|
||||||
|
```
|
||||||
|
Browser → app.rs routes() # 1. router dispatches /shop to shop::index
|
||||||
|
→ controllers/shop.rs # 2. handler: query DB, gather data
|
||||||
|
→ models/products.rs # 3. data layer: products::Entity::find()...
|
||||||
|
→ views/shop.rs # 4. shape Model → JSON (product_card)
|
||||||
|
→ shared/guard.rs # 5. cross-cutting: is admin logged in?
|
||||||
|
→ assets/views/shop/index.html # 6. Tera renders the JSON
|
||||||
|
→ HTML response
|
||||||
|
```
|
||||||
|
|
||||||
|
Concretely, from `controllers/shop.rs`:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use crate::{
|
||||||
|
models::{categories, product_images, products}, // data layer
|
||||||
|
views::shop as view, // presentation layer
|
||||||
|
shared::guard, // cross-cutting
|
||||||
|
controllers::i18n::current_lang,
|
||||||
|
};
|
||||||
|
|
||||||
|
async fn index(...) -> Result<Response> {
|
||||||
|
let list = products::Entity::find() // query (models)
|
||||||
|
.filter(products::Column::Published.eq(true))
|
||||||
|
.all(&ctx.db).await?;
|
||||||
|
|
||||||
|
format::view(&v, "shop/index.html", json!({ // render
|
||||||
|
"products": product_rows(&ctx, list).await?, // shaped by views::shop
|
||||||
|
"logged_in_admin": guard::logged_in(&ctx, &jar).await,
|
||||||
|
"lang": current_lang(&jar),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The controller is a thin coordinator. It does **not** contain business logic —
|
||||||
|
that lives in `models/`. It does **not** build HTML strings — that's `views/` +
|
||||||
|
templates. Keeping controllers thin is the single biggest factor in whether this
|
||||||
|
stays scalable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. The models layer: the one piece of Loco that surprises people
|
||||||
|
|
||||||
|
There are **two files per database table**, and they have different jobs:
|
||||||
|
|
||||||
|
```
|
||||||
|
models/
|
||||||
|
├── _entities/products.rs ← AUTO-GENERATED. The raw table struct
|
||||||
|
│ (columns, relations). Regenerated as a unit
|
||||||
|
│ whenever the schema changes. NEVER hand-edit.
|
||||||
|
│
|
||||||
|
└── products.rs ← HAND-WRITTEN. Re-exports the entity, then adds
|
||||||
|
your behavior on top of it.
|
||||||
|
```
|
||||||
|
|
||||||
|
Your `models/products.rs` shows the pattern exactly:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub use crate::models::_entities::products::{ActiveModel, Column, Entity, Model};
|
||||||
|
|
||||||
|
#[async_trait::async_trait]
|
||||||
|
impl ActiveModelBehavior for ActiveModel {
|
||||||
|
// lifecycle hooks, e.g. touch updated_at before save
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Model {} // read-oriented logic (your finders that return data)
|
||||||
|
impl ActiveModel {} // write-oriented logic (validation, mutation)
|
||||||
|
impl Entity {} // custom queries / selectors
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why two files:** the schema is machine-owned (so codegen can overwrite
|
||||||
|
`_entities/` safely), but your logic is human-owned (so it survives
|
||||||
|
regeneration). The `pub use` bridge means the rest of the app imports
|
||||||
|
`crate::models::products` and never has to know `_entities` exists.
|
||||||
|
|
||||||
|
**How to apply when scaling:** put domain logic on the model, not in the
|
||||||
|
controller. "Is this product low on stock?" → a method on `products::Model`.
|
||||||
|
"Recalculate order total" → a method on `orders`. As features pile up, this is
|
||||||
|
what keeps controllers from turning back into the 900-line god-files this
|
||||||
|
project deliberately escaped.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Where new code goes — a decision table
|
||||||
|
|
||||||
|
When you build the next thing, find the row and follow it:
|
||||||
|
|
||||||
|
| You want to add... | Touch these |
|
||||||
|
|---------------------------------------------|------------------------------------------------------------------------|
|
||||||
|
| A new page / endpoint | `controllers/<area>.rs` (+ register `routes()` in `app.rs`) |
|
||||||
|
| A new admin screen | `controllers/admin_<thing>.rs` (prefix!) + `assets/views/admin/...` |
|
||||||
|
| A new database table | `cargo loco generate model <name> ...` → migration + `_entities` + wrapper |
|
||||||
|
| A schema change to an existing table | `cargo loco generate migration <name> ...`, then rebuild & migrate |
|
||||||
|
| Business logic / a custom query | a method in `models/<entity>.rs` (not the controller) |
|
||||||
|
| Reshaping data for a template | `views/<area>.rs` |
|
||||||
|
| An HTML template / partial | `assets/views/<area>/...html` |
|
||||||
|
| A reusable helper (money, slugs, auth) | `shared/<helper>.rs` |
|
||||||
|
| Something slow (resize image, send batch) | `workers/<name>.rs` (+ register in `app.rs` `connect_workers`) |
|
||||||
|
| A transactional email | `mailers/<name>.rs` + `mailers/<name>/<event>/{subject,html,text}.t` |
|
||||||
|
| One-time-at-boot setup / seeding | `initializers/<name>.rs` (+ register in `app.rs` `initializers`) |
|
||||||
|
| A CLI maintenance command | `tasks/<name>.rs` (+ register in `app.rs` `register_tasks`) |
|
||||||
|
| A cross-cutting config value | `shared/settings.rs` + `config/*.yaml` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. `app.rs` is the wiring hub — the one file you revisit constantly
|
||||||
|
|
||||||
|
Every new route, worker, initializer, and task is *registered* here. It's the
|
||||||
|
table of contents for the whole backend:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
fn routes(_ctx: &AppContext) -> AppRoutes {
|
||||||
|
AppRoutes::with_default_routes()
|
||||||
|
.add_route(shop::routes()) // ← every new controller's routes()
|
||||||
|
.add_route(admin_products::routes())// gets one line here
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn initializers(...) -> Result<Vec<Box<dyn Initializer>>> {
|
||||||
|
Ok(vec![ /* AdminSeeder, ShippingSeeder, ViewEngine ... */ ])
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn connect_workers(...) { queue.register(DownloadWorker::build(ctx)).await?; }
|
||||||
|
fn register_tasks(tasks: &mut Tasks) { /* tasks-inject */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
If you add a controller and the route 404s, the usual cause is: **you forgot the
|
||||||
|
`add_route` line in `app.rs`.** Same shape for workers/initializers/tasks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Scaling checklist (the habits that keep this healthy)
|
||||||
|
|
||||||
|
As the shop grows, these are the things that decide whether the codebase stays
|
||||||
|
pleasant or rots:
|
||||||
|
|
||||||
|
1. **Keep controllers thin.** They query, gather, and render. Logic goes to
|
||||||
|
`models/`, shaping goes to `views/`. If a handler exceeds ~80 lines, extract.
|
||||||
|
2. **One controller file per feature area, prefix-grouped.** Don't let
|
||||||
|
`admin_products.rs` start handling orders. Split by area, not convenience.
|
||||||
|
3. **Never edit `models/_entities/`.** Change the schema via a migration and
|
||||||
|
regenerate. Your logic in the sibling wrapper survives.
|
||||||
|
4. **Push slow/optional work to `workers/`.** Image processing, bulk emails,
|
||||||
|
external API calls — off the request path so pages stay fast.
|
||||||
|
5. **Reuse via `shared/` and model methods**, not copy-paste. You already do
|
||||||
|
this well: `money` (integer cents everywhere), `guard` (one source of truth
|
||||||
|
for admin auth), `slug`.
|
||||||
|
6. **Every schema change is a migration file**, never a manual DB edit — so
|
||||||
|
`test`, `development`, and `production` stay reproducible from
|
||||||
|
`config/*.yaml` + `migration/`.
|
||||||
|
7. **Mirror new code with a test** in `tests/{models,requests,...}/`. The
|
||||||
|
snapshot tests (`.snap`) catch accidental output changes for free.
|
||||||
|
|
||||||
|
### When a feature genuinely outgrows a single file
|
||||||
|
|
||||||
|
If one area gets huge (say `shop` becomes 5+ concerns), you have two
|
||||||
|
Loco-friendly options — both keep the layout intact:
|
||||||
|
|
||||||
|
- **Split by sub-area with more prefixes:** `shop_catalog.rs`,
|
||||||
|
`shop_search.rs`, `shop_reviews.rs`.
|
||||||
|
- **Promote a layer file to a folder module:** turn `controllers/shop.rs` into
|
||||||
|
`controllers/shop/mod.rs` + `controllers/shop/{listing,detail,search}.rs`.
|
||||||
|
Loco doesn't care; `mod.rs` just re-exports a `routes()`.
|
||||||
|
|
||||||
|
What you should *not* do is recreate top-level vertical slices (a `src/shop/`
|
||||||
|
holding its own controllers+models+views). That's the layout this project
|
||||||
|
already tried and reverted — it breaks `loco generate` and fights the framework.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
- **Layers, not features.** `controllers/ models/ views/ ...` is deliberate and
|
||||||
|
is what makes `loco generate` and Loco conventions work for you.
|
||||||
|
- **Group features by filename prefix** (`admin_*`) inside the flat layers.
|
||||||
|
- **Controllers are thin coordinators**; logic lives on models, shaping in views.
|
||||||
|
- **`_entities/` is machine-owned; the sibling model file is yours.**
|
||||||
|
- **`app.rs` registers everything** — add a line there for each new route/worker/task.
|
||||||
|
- **Scale by adding files within layers**, splitting busy files into more
|
||||||
|
prefixes or `mod.rs` folders — never by going back to vertical slices.
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
use gitara_web::app::App;
|
use kompress_eshop::app::App;
|
||||||
use loco_rs::testing::prelude::*;
|
use loco_rs::testing::prelude::*;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use gitara_web::app::App;
|
use kompress_eshop::app::App;
|
||||||
use loco_rs::testing::prelude::*;
|
use loco_rs::testing::prelude::*;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use gitara_web::app::App;
|
use kompress_eshop::app::App;
|
||||||
use loco_rs::testing::prelude::*;
|
use loco_rs::testing::prelude::*;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use gitara_web::app::App;
|
use kompress_eshop::app::App;
|
||||||
use loco_rs::testing::prelude::*;
|
use loco_rs::testing::prelude::*;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use gitara_web::app::App;
|
use kompress_eshop::app::App;
|
||||||
use loco_rs::testing::prelude::*;
|
use loco_rs::testing::prelude::*;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use gitara_web::app::App;
|
use kompress_eshop::app::App;
|
||||||
use loco_rs::testing::prelude::*;
|
use loco_rs::testing::prelude::*;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use gitara_web::app::App;
|
use kompress_eshop::app::App;
|
||||||
use loco_rs::testing::prelude::*;
|
use loco_rs::testing::prelude::*;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ use insta::assert_debug_snapshot;
|
|||||||
use loco_rs::testing::prelude::*;
|
use loco_rs::testing::prelude::*;
|
||||||
use sea_orm::{ActiveModelTrait, ActiveValue, IntoActiveModel};
|
use sea_orm::{ActiveModelTrait, ActiveValue, IntoActiveModel};
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use gitara_web::{
|
use kompress_eshop::{
|
||||||
account::models::users::{self, Model, RegisterParams},
|
models::users::{self, Model, RegisterParams},
|
||||||
app::App,
|
app::App,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ use insta::{assert_debug_snapshot, with_settings};
|
|||||||
use loco_rs::testing::prelude::*;
|
use loco_rs::testing::prelude::*;
|
||||||
use rstest::rstest;
|
use rstest::rstest;
|
||||||
use serial_test::serial;
|
use serial_test::serial;
|
||||||
use gitara_web::{account::models::users, app::App};
|
use kompress_eshop::{models::users, app::App};
|
||||||
|
|
||||||
use super::prepare_data;
|
use super::prepare_data;
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
use axum::http::{HeaderName, HeaderValue};
|
use axum::http::{HeaderName, HeaderValue};
|
||||||
use loco_rs::{app::AppContext, TestServer};
|
use loco_rs::{app::AppContext, TestServer};
|
||||||
use gitara_web::{account::models::users, account::view::LoginResponse};
|
use kompress_eshop::{models::users, views::auth::LoginResponse};
|
||||||
|
|
||||||
const USER_EMAIL: &str = "test@loco.com";
|
const USER_EMAIL: &str = "test@loco.com";
|
||||||
const USER_PASSWORD: &str = "1234";
|
const USER_PASSWORD: &str = "1234";
|
||||||
|
|||||||
Reference in New Issue
Block a user