Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a619517b6 | ||
|
|
1538d870b9 | ||
|
|
ed2eb036ae | ||
|
|
ae99ec079f | ||
|
|
0754e014a3 | ||
|
|
1d747d9960 | ||
|
|
126b1eeb7e | ||
|
|
c401acb1cc | ||
|
|
67fd364761 | ||
|
|
7601fc704d | ||
|
|
7be1726f1b | ||
|
|
d18bdeaf6e | ||
|
|
cd7a756a54 | ||
|
|
e8c0362a54 | ||
|
|
43562e964a | ||
|
|
f54fd3d717 | ||
|
|
e4f63b3de9 | ||
|
|
95f195a204 | ||
|
|
b88c990873 |
@@ -2,7 +2,7 @@ CONTAINER_NAME=universal-web
|
||||
REVERSE_PROXY_NETWORK=
|
||||
UPLOADS_VOLUME_NAME=universal_web_uploads
|
||||
|
||||
APP_HOST=https://gitara.farmeris.sk
|
||||
APP_HOST=https://eshop.example.com
|
||||
PORT=5150
|
||||
SERVER_BINDING=0.0.0.0
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
gitara.farmeris.sk {
|
||||
eshop.example.com {
|
||||
encode gzip
|
||||
|
||||
@static path /static/*
|
||||
@@ -6,9 +6,9 @@ gitara.farmeris.sk {
|
||||
|
||||
rewrite /favicon.ico /static/favicon/favicon.ico
|
||||
|
||||
reverse_proxy gitara-web:5150
|
||||
reverse_proxy kompress:5150
|
||||
}
|
||||
|
||||
www.gitara.farmeris.sk {
|
||||
redir https://gitara.farmeris.sk{uri} permanent
|
||||
eshop.example.com {
|
||||
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"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1523,36 +1525,6 @@ dependencies = [
|
||||
"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]]
|
||||
name = "glob"
|
||||
version = "0.3.3"
|
||||
@@ -1785,6 +1757,22 @@ dependencies = [
|
||||
"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]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.20"
|
||||
@@ -2124,6 +2112,37 @@ dependencies = [
|
||||
"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]]
|
||||
name = "kqueue"
|
||||
version = "1.1.1"
|
||||
@@ -2332,6 +2351,12 @@ version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "mac"
|
||||
version = "0.1.1"
|
||||
@@ -3090,6 +3115,61 @@ dependencies = [
|
||||
"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]]
|
||||
name = "quote"
|
||||
version = "1.0.45"
|
||||
@@ -3297,16 +3377,21 @@ dependencies = [
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-rustls",
|
||||
"hyper-util",
|
||||
"js-sys",
|
||||
"log",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"quinn",
|
||||
"rustls",
|
||||
"rustls-pki-types",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_urlencoded",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-rustls",
|
||||
"tokio-util",
|
||||
"tower 0.5.3",
|
||||
"tower-http",
|
||||
@@ -3316,6 +3401,7 @@ dependencies = [
|
||||
"wasm-bindgen-futures",
|
||||
"wasm-streams",
|
||||
"web-sys",
|
||||
"webpki-roots 1.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3520,6 +3606,7 @@ version = "1.14.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9"
|
||||
dependencies = [
|
||||
"web-time",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
[workspace]
|
||||
|
||||
[package]
|
||||
name = "gitara_web"
|
||||
name = "kompress_eshop"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
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
|
||||
|
||||
@@ -37,6 +37,8 @@ dotenvy = { version = "0.15" }
|
||||
validator = { version = "0.20" }
|
||||
uuid = { version = "1.6", features = ["v4"] }
|
||||
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
|
||||
fluent-templates = { version = "0.13", features = ["tera"] }
|
||||
unic-langid = { version = "0.9" }
|
||||
@@ -45,7 +47,7 @@ axum-extra = { version = "0.10", features = ["form"] }
|
||||
bytes = { version = "1" }
|
||||
|
||||
[[bin]]
|
||||
name = "gitara_web-cli"
|
||||
name = "kompress-eshop-cli"
|
||||
path = "src/bin/main.rs"
|
||||
required-features = []
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ WORKDIR /usr/src
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN cargo build --release --bin gitara_web-cli
|
||||
RUN cargo build --release --bin kompress-cli
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
|
||||
@@ -17,9 +17,9 @@ WORKDIR /usr/app
|
||||
|
||||
COPY --from=builder /usr/src/assets assets
|
||||
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
|
||||
EXPOSE 5150
|
||||
ENTRYPOINT ["/usr/app/gitara_web-cli"]
|
||||
ENTRYPOINT ["/usr/app/kompress-cli"]
|
||||
CMD ["start"]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
brand = My guitar
|
||||
brand = Kompress eshop
|
||||
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-about = About
|
||||
nav-blog = Blog
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
brand = My guitar
|
||||
brand = Kompress eshop
|
||||
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-about = About
|
||||
nav-blog = Blog
|
||||
@@ -202,6 +202,7 @@ parent-category = Parent category
|
||||
no-parent = — None (top level) —
|
||||
quantity = Quantity
|
||||
add-to-cart = Add to cart
|
||||
cart-added = Added to cart
|
||||
in-stock = In stock
|
||||
out-of-stock = Out of stock
|
||||
confirm-delete = Delete this for good?
|
||||
@@ -215,6 +216,7 @@ cart-empty = Your cart is empty.
|
||||
cart-total = Total
|
||||
cart-checkout = Proceed to checkout
|
||||
cart-remove = Remove
|
||||
cart-remove-confirm = Remove this item from the cart?
|
||||
cart-update = Update
|
||||
cart-continue = Continue shopping
|
||||
checkout-title = Checkout
|
||||
@@ -222,10 +224,17 @@ checkout-contact = Contact details
|
||||
checkout-shipping = Shipping address
|
||||
checkout-email = Email
|
||||
checkout-name = Full name
|
||||
checkout-phone = Phone
|
||||
checkout-address = Address
|
||||
checkout-city = City
|
||||
checkout-zip = Postal code
|
||||
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-place-order = Place order
|
||||
checkout-summary = Order summary
|
||||
@@ -261,5 +270,21 @@ bank-account-name = Account holder
|
||||
bank-variable-symbol = Variable symbol
|
||||
bank-amount = Amount
|
||||
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-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!
|
||||
meta-description = Osobná stránka gitaristu. Novinky, blog, albumy a skladby na jednom mieste.
|
||||
meta-description = Kompress eshop
|
||||
nav-home = Domov
|
||||
nav-about = O mne
|
||||
nav-blog = Blog
|
||||
@@ -202,6 +202,7 @@ parent-category = Nadradená kategória
|
||||
no-parent = — Žiadna (najvyššia úroveň) —
|
||||
quantity = Množstvo
|
||||
add-to-cart = Pridať do košíka
|
||||
cart-added = Pridané do košíka
|
||||
in-stock = Na sklade
|
||||
out-of-stock = Vypredané
|
||||
confirm-delete = Naozaj zmazať?
|
||||
@@ -215,6 +216,7 @@ cart-empty = Váš košík je prázdny.
|
||||
cart-total = Spolu
|
||||
cart-checkout = Pokračovať k pokladni
|
||||
cart-remove = Odstrániť
|
||||
cart-remove-confirm = Odstrániť túto položku z košíka?
|
||||
cart-update = Aktualizovať
|
||||
cart-continue = Pokračovať v nákupe
|
||||
checkout-title = Pokladňa
|
||||
@@ -222,10 +224,17 @@ checkout-contact = Kontaktné údaje
|
||||
checkout-shipping = Dodacia adresa
|
||||
checkout-email = E-mail
|
||||
checkout-name = Meno a priezvisko
|
||||
checkout-phone = Telefón
|
||||
checkout-address = Adresa
|
||||
checkout-city = Mesto
|
||||
checkout-zip = PSČ
|
||||
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-place-order = Odoslať objednávku
|
||||
checkout-summary = Súhrn objednávky
|
||||
@@ -261,5 +270,21 @@ bank-account-name = Príjemca
|
||||
bank-variable-symbol = Variabilný symbol
|
||||
bank-amount = Suma
|
||||
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-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?
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,3 +1,4 @@
|
||||
{% import "macros/ui.html" as ui %}
|
||||
<!doctype html>
|
||||
<html lang="{{ lang | default(value='sk') }}" data-theme="dark">
|
||||
<head>
|
||||
@@ -57,6 +58,11 @@
|
||||
x-bind:class="showSidebar ? 'translate-x-0' : '-translate-x-60'"
|
||||
class="fixed inset-y-0 left-0 z-40 flex w-60 flex-col border-r border-outline bg-surface-alt transition-transform duration-300 md:translate-x-0 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
|
||||
{# Sidebar nav links — adapted from the vendored Penguin UI component
|
||||
assets/views/penguinui/sidebar/simple-sidebar.html: Penguin's link
|
||||
treatment (hover:bg-primary/5, focus-visible:underline) with the active
|
||||
state (bg-primary/10 + text-on-surface-strong) mapped onto our
|
||||
data-nav / aria-current so markActiveNav() keeps driving it. #}
|
||||
<a href="/admin/dashboard"
|
||||
class="flex h-16 items-center gap-2 border-b border-outline px-6 text-lg font-bold tracking-tight text-on-surface-strong dark:border-outline-dark dark:text-on-surface-dark-strong">
|
||||
{{ t(key="admin-title", lang=lang | default(value='sk')) }}
|
||||
@@ -64,33 +70,33 @@
|
||||
|
||||
<div class="flex flex-1 flex-col gap-1 overflow-y-auto p-4">
|
||||
<a href="/admin/dashboard" data-nav="/admin/dashboard"
|
||||
class="flex items-center gap-3 rounded-radius px-3 py-2 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-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||
{{ t(key="admin-dashboard", lang=lang | default(value='sk')) }}
|
||||
</a>
|
||||
<a href="/admin/catalog/products" data-nav="/admin/catalog/products"
|
||||
class="flex items-center gap-3 rounded-radius px-3 py-2 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-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||
{{ t(key="admin-products", lang=lang | default(value='sk')) }}
|
||||
</a>
|
||||
<a href="/admin/catalog/categories" data-nav="/admin/catalog/categories"
|
||||
class="flex items-center gap-3 rounded-radius px-3 py-2 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-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||
{{ t(key="admin-categories", lang=lang | default(value='sk')) }}
|
||||
</a>
|
||||
<a href="/admin/orders" data-nav="/admin/orders"
|
||||
class="flex items-center gap-3 rounded-radius px-3 py-2 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-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||
{{ t(key="admin-orders", lang=lang | default(value='sk')) }}
|
||||
</a>
|
||||
<a href="/admin/shipping" data-nav="/admin/shipping"
|
||||
class="flex items-center gap-3 rounded-radius px-3 py-2 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-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||
{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-outline p-4 dark:border-outline-dark">
|
||||
<a href="/" class="flex items-center gap-3 rounded-radius px-3 py-2 text-sm font-medium text-info transition hover:bg-surface dark:hover:bg-surface-dark">
|
||||
<a href="/" class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-info underline-offset-2 transition hover:bg-info/5 focus:outline-hidden focus-visible:underline">
|
||||
{{ t(key="admin-exit", lang=lang | default(value='sk')) }}
|
||||
</a>
|
||||
<form method="post" action="/admin/logout">
|
||||
<button type="submit" class="flex w-full items-center gap-3 rounded-radius px-3 py-2 text-left text-sm font-medium text-danger transition hover:bg-surface dark:hover:bg-surface-dark">
|
||||
<button type="submit" class="flex w-full items-center gap-2 rounded-radius px-2 py-1.5 text-left text-sm font-medium text-danger underline-offset-2 transition hover:bg-danger/5 focus:outline-hidden focus-visible:underline">
|
||||
{{ t(key="logout", lang=lang | default(value='sk')) }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -100,14 +106,7 @@
|
||||
<!-- content column -->
|
||||
<div class="flex min-h-screen flex-col md:ml-60">
|
||||
<header class="sticky top-0 z-20 flex h-16 items-center gap-4 border-b border-outline bg-surface/95 px-4 backdrop-blur dark:border-outline-dark dark:bg-surface-dark/95">
|
||||
<button type="button" @click="showSidebar = !showSidebar" :aria-expanded="showSidebar"
|
||||
aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}"
|
||||
class="inline-flex size-9 items-center justify-center rounded-radius text-on-surface transition hover:bg-surface-alt md:hidden dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
{{ ui::icon_button(aria_label=t(key='menu', lang=lang | default(value='sk')), attrs='@click="showSidebar = !showSidebar" :aria-expanded="showSidebar"', extra="md:hidden", icon='<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /></svg>') }}
|
||||
|
||||
<span class="text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{% block crumb %}{{ t(key="admin-title", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||
@@ -115,54 +114,7 @@
|
||||
|
||||
<!-- settings (language + theme) dropdown -->
|
||||
<div x-data="{ open: false }" @keydown.escape="open = false" class="relative ml-auto">
|
||||
<button type="button" @click="open = !open" :aria-expanded="open"
|
||||
aria-label="{{ t(key='settings', lang=lang | default(value='sk')) }}"
|
||||
class="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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="size-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div x-show="open" x-cloak @click.outside="open = false" x-transition.origin.top.right
|
||||
class="absolute right-0 mt-2 w-56 rounded-radius border border-outline bg-surface p-2 shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
<form method="post" action="/lang" hx-boost="false">
|
||||
<p class="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
||||
{{ t(key="settings-language", lang=lang | default(value='sk')) }}
|
||||
</p>
|
||||
<button type="submit" name="lang" value="en"
|
||||
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
|
||||
<span>English</span>
|
||||
{% if lang | default(value='sk') == "en" %}<span class="text-primary dark:text-primary-dark">✓</span>{% endif %}
|
||||
</button>
|
||||
<button type="submit" name="lang" value="sk"
|
||||
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
|
||||
<span>Slovenčina</span>
|
||||
{% if lang | default(value='sk') == "sk" %}<span class="text-primary dark:text-primary-dark">✓</span>{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-1 px-2 py-1 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
||||
{{ t(key="settings-theme", lang=lang | default(value='sk')) }}
|
||||
</p>
|
||||
<div x-data="{ theme: currentTheme() }" @theme:changed.document="theme = $event.detail">
|
||||
<button type="button" @click="setTheme('system')"
|
||||
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
|
||||
<span>{{ t(key="theme-system", lang=lang | default(value='sk')) }}</span>
|
||||
<span x-show="theme === 'system'" class="text-primary dark:text-primary-dark">✓</span>
|
||||
</button>
|
||||
<button type="button" @click="setTheme('light')"
|
||||
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
|
||||
<span>{{ t(key="theme-light", lang=lang | default(value='sk')) }}</span>
|
||||
<span x-show="theme === 'light'" class="text-primary dark:text-primary-dark">✓</span>
|
||||
</button>
|
||||
<button type="button" @click="setTheme('dark')"
|
||||
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
|
||||
<span>{{ t(key="theme-dark", lang=lang | default(value='sk')) }}</span>
|
||||
<span x-show="theme === 'dark'" class="text-primary dark:text-primary-dark">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% include "partials/settings_dropdown.html" %}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{{ t(key="admin-categories", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||
{% block crumb %}{{ t(key="admin-categories", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||
@@ -9,10 +10,7 @@
|
||||
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-categories", lang=lang | default(value='sk')) }}</h1>
|
||||
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-categories-desc", lang=lang | default(value='sk')) }}</p>
|
||||
</div>
|
||||
<a href="/admin/catalog/categories/new"
|
||||
class="inline-flex 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="new-category", lang=lang | default(value='sk')) }}
|
||||
</a>
|
||||
{{ ui::button(label=t(key="new-category", lang=lang | default(value='sk')), href="/admin/catalog/categories/new") }}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
|
||||
@@ -38,18 +36,17 @@
|
||||
<td class="px-4 py-3 tabular-nums">{{ row.product_count }}</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if row.category.published %}
|
||||
<span class="inline-flex rounded-full bg-success/15 px-2 py-0.5 text-xs font-medium text-success">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
|
||||
{{ ui::badge(label=t(key="published", lang=lang | default(value='sk')), variant="success") }}
|
||||
{% else %}
|
||||
<span class="inline-flex rounded-full bg-surface-alt px-2 py-0.5 text-xs font-medium text-on-surface/70 dark:bg-surface-dark-alt dark:text-on-surface-dark/70">{{ t(key="draft", lang=lang | default(value='sk')) }}</span>
|
||||
{{ ui::badge(label=t(key="draft", lang=lang | default(value='sk')), variant="neutral") }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<a href="/admin/catalog/categories/{{ row.category.id }}/edit"
|
||||
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="edit", lang=lang | default(value='sk')) }}</a>
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="edit", lang=lang | default(value='sk')), href="/admin/catalog/categories/" ~ row.category.id ~ "/edit", size="px-3 py-1.5 text-xs") }}
|
||||
<form method="post" action="/admin/catalog/categories/{{ row.category.id }}/delete"
|
||||
onsubmit="return confirm('{{ t(key="confirm-delete", lang=lang | default(value='sk')) }}')">
|
||||
<button type="submit" class="inline-flex items-center rounded-radius border border-outline px-3 py-1.5 text-xs font-medium text-danger transition hover:bg-danger/10 dark:border-outline-dark">{{ t(key="delete", lang=lang | default(value='sk')) }}</button>
|
||||
{{ ui::button(variant="outline-danger", label=t(key="delete", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
@@ -60,10 +57,7 @@
|
||||
{% else %}
|
||||
<div class="flex flex-col items-center gap-3 px-4 py-16 text-center">
|
||||
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-no-categories", lang=lang | default(value='sk')) }}</p>
|
||||
<a href="/admin/catalog/categories/new"
|
||||
class="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="new-category", lang=lang | default(value='sk')) }}
|
||||
</a>
|
||||
{{ ui::button(label=t(key="new-category", lang=lang | default(value='sk')), href="/admin/catalog/categories/new") }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,83 +1,77 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% set editing = category %}
|
||||
{% 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 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 crumb %}{{ t(key="admin-categories", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{% if 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>
|
||||
<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>
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/categories", size="px-3 py-2 text-sm") }}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
|
||||
{% if category %}
|
||||
{% set v_name = category.name %}{% set v_slug = category.slug %}{% set v_pos = category.position %}{% set v_desc = category.description | default(value="") %}{% set v_pub = category.published %}
|
||||
{% else %}
|
||||
{% set v_name = "" %}{% set v_slug = "" %}{% set v_pos = 0 %}{% set v_desc = "" %}{% set v_pub = false %}
|
||||
{% endif %}
|
||||
|
||||
<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>
|
||||
<input id="name" name="name" type="text" required value="{% if editing %}{{ 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">
|
||||
{{ ui::input(name="name", id="name", required=true, value=v_name) }}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<div class="space-y-1.5">
|
||||
<label for="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 %}"
|
||||
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">
|
||||
{{ ui::input(name="slug", id="slug", value=v_slug, placeholder=t(key='slug-auto', lang=lang | default(value='sk'))) }}
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label for="position" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="position", lang=lang | default(value='sk')) }}</label>
|
||||
<input id="position" name="position" type="number" value="{% if editing %}{{ 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">
|
||||
{{ ui::input(name="position", id="position", type="number", value=v_pos) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="parent_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="parent-category", lang=lang | default(value='sk')) }}</label>
|
||||
<select id="parent_id" name="parent_id"
|
||||
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>
|
||||
{% for parent in parents %}
|
||||
<option value="{{ parent.id }}" {% if editing and category.parent_id == parent.id %}selected{% endif %}>
|
||||
{% if parent.depth > 0 %}{% for _ in range(end=parent.depth) %}— {% endfor %}{% endif %}{{ parent.name }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="relative">
|
||||
<select id="parent_id" name="parent_id"
|
||||
class="w-full appearance-none rounded-radius border border-outline bg-surface-alt px-4 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark">
|
||||
<option value="">{{ t(key="no-parent", lang=lang | default(value='sk')) }}</option>
|
||||
{% for parent in parents %}
|
||||
<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 }}
|
||||
</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-on-surface/60 dark:text-on-surface-dark/60"><path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="description" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="description", lang=lang | default(value='sk')) }}</label>
|
||||
<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>
|
||||
{{ ui::textarea(name="description", id="description", rows="4", value=v_desc) }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="image" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="image", lang=lang | default(value='sk')) }}</label>
|
||||
{% if 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">
|
||||
{% endif %}
|
||||
<input id="image" name="image" type="file" accept="image/*"
|
||||
class="block w-full text-sm text-on-surface file:mr-3 file:rounded-radius file:border-0 file:bg-primary file:px-3 file:py-2 file:text-sm file:font-medium file:text-on-primary dark:text-on-surface-dark dark:file:bg-primary-dark dark:file:text-on-primary-dark">
|
||||
{{ ui::file_input(name="image", id="image", accept="image/*") }}
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" name="published" value="on" {% if editing and category.published %}checked{% endif %}
|
||||
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>
|
||||
</label>
|
||||
{{ ui::checkbox(name="published", id="published", label=t(key="published", lang=lang | default(value='sk')), checked=v_pub) }}
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button type="submit"
|
||||
class="inline-flex 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="save", lang=lang | default(value='sk')) }}
|
||||
</button>
|
||||
<a href="/admin/catalog/categories"
|
||||
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="cancel", lang=lang | default(value='sk')) }}</a>
|
||||
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit") }}
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/categories") }}
|
||||
</div>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
|
||||
@@ -1,101 +1,91 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% set editing = product %}
|
||||
{% 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 title %}{% if product %}{{ t(key="edit-product", lang=lang | default(value='sk')) }}{% else %}{{ t(key="new-product", lang=lang | default(value='sk')) }}{% endif %}{% endblock title %}
|
||||
{% block crumb %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{% if 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>
|
||||
<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>
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products", size="px-3 py-2 text-sm") }}
|
||||
</div>
|
||||
|
||||
<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">
|
||||
|
||||
{% if product %}
|
||||
{% set v_name = product.name %}{% set v_price = product.price %}{% set v_currency = product.currency %}{% set v_stock = product.stock %}{% set v_sku = product.sku | default(value="") %}{% set v_slug = product.slug %}{% set v_desc = product.description | default(value="") %}{% set v_pub = product.published %}
|
||||
{% else %}
|
||||
{% set v_name = "" %}{% set v_price = "" %}{% set v_currency = "EUR" %}{% set v_stock = 0 %}{% set v_sku = "" %}{% set v_slug = "" %}{% set v_desc = "" %}{% set v_pub = false %}
|
||||
{% endif %}
|
||||
|
||||
<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>
|
||||
<input id="name" name="name" type="text" required value="{% if editing %}{{ 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">
|
||||
{{ ui::input(name="name", id="name", required=true, value=v_name) }}
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<div class="space-y-1.5">
|
||||
<label for="price" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
|
||||
<input id="price" name="price" type="text" inputmode="decimal" required value="{% if editing %}{{ product.price }}{% endif %}"
|
||||
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">
|
||||
{{ ui::input(name="price", id="price", required=true, value=v_price, placeholder="0.00", attrs='inputmode="decimal"') }}
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label for="currency" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="currency", lang=lang | default(value='sk')) }}</label>
|
||||
<input id="currency" name="currency" type="text" maxlength="3" value="{% if editing %}{{ 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">
|
||||
{{ ui::input(name="currency", id="currency", value=v_currency, attrs='maxlength="3"', extra="uppercase") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-5 sm:grid-cols-2">
|
||||
<div class="space-y-1.5">
|
||||
<label for="stock" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="stock", lang=lang | default(value='sk')) }}</label>
|
||||
<input id="stock" name="stock" type="number" min="0" value="{% if editing %}{{ 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">
|
||||
{{ ui::input(name="stock", id="stock", type="number", value=v_stock, attrs='min="0"') }}
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label for="sku" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="sku", lang=lang | default(value='sk')) }}</label>
|
||||
<input id="sku" name="sku" type="text" value="{% if editing 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">
|
||||
{{ ui::input(name="sku", id="sku", value=v_sku) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="category_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="category", lang=lang | default(value='sk')) }}</label>
|
||||
<select id="category_id" name="category_id"
|
||||
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>
|
||||
{% for category in categories %}
|
||||
<option value="{{ category.id }}" {% if editing and product.category_id == category.id %}selected{% endif %}>{{ category.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<div class="relative">
|
||||
<select id="category_id" name="category_id"
|
||||
class="w-full appearance-none rounded-radius border border-outline bg-surface-alt px-4 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark">
|
||||
<option value="">{{ t(key="no-category", lang=lang | default(value='sk')) }}</option>
|
||||
{% for category in categories %}
|
||||
<option value="{{ category.id }}" {% if product and product.category_id == category.id %}selected{% endif %}>{{ category.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-on-surface/60 dark:text-on-surface-dark/60"><path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" /></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<input id="slug" name="slug" type="text" value="{% if editing %}{{ product.slug }}{% endif %}"
|
||||
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">
|
||||
{{ ui::input(name="slug", id="slug", value=v_slug, placeholder=t(key='slug-auto', lang=lang | default(value='sk'))) }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="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"
|
||||
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>
|
||||
{{ ui::textarea(name="description", id="description", rows="5", value=v_desc) }}
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="image" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="image", lang=lang | default(value='sk')) }}</label>
|
||||
{% if editing and product.image %}
|
||||
{% if product and product.image %}
|
||||
<img src="/images/{{ product.image }}" alt="" class="size-24 rounded-radius object-cover">
|
||||
{% endif %}
|
||||
<input id="image" name="image" type="file" accept="image/*"
|
||||
class="block w-full text-sm text-on-surface file:mr-3 file:rounded-radius file:border-0 file:bg-primary file:px-3 file:py-2 file:text-sm file:font-medium file:text-on-primary dark:text-on-surface-dark dark:file:bg-primary-dark dark:file:text-on-primary-dark">
|
||||
{{ ui::file_input(name="image", id="image", accept="image/*") }}
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-2">
|
||||
<input type="checkbox" name="published" value="on" {% if editing and product.published %}checked{% endif %}
|
||||
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>
|
||||
</label>
|
||||
{{ ui::checkbox(name="published", id="published", label=t(key="published", lang=lang | default(value='sk')), checked=v_pub) }}
|
||||
|
||||
<div class="flex gap-3 pt-2">
|
||||
<button type="submit"
|
||||
class="inline-flex 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="save", lang=lang | default(value='sk')) }}
|
||||
</button>
|
||||
<a href="/admin/catalog/products"
|
||||
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="cancel", lang=lang | default(value='sk')) }}</a>
|
||||
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit") }}
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products") }}
|
||||
</div>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||
{% block crumb %}{{ t(key="admin-products", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||
@@ -9,10 +10,7 @@
|
||||
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-products", lang=lang | default(value='sk')) }}</h1>
|
||||
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-products-desc", lang=lang | default(value='sk')) }}</p>
|
||||
</div>
|
||||
<a href="/admin/catalog/products/new"
|
||||
class="inline-flex 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="new-product", lang=lang | default(value='sk')) }}
|
||||
</a>
|
||||
{{ ui::button(label=t(key="new-product", lang=lang | default(value='sk')), href="/admin/catalog/products/new") }}
|
||||
</div>
|
||||
|
||||
<div class="mt-6 overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
|
||||
@@ -47,20 +45,18 @@
|
||||
<td class="px-4 py-3 tabular-nums">{{ product.stock }}</td>
|
||||
<td class="px-4 py-3">
|
||||
{% if product.published %}
|
||||
<span class="inline-flex rounded-full bg-success/15 px-2 py-0.5 text-xs font-medium text-success">{{ t(key="published", lang=lang | default(value='sk')) }}</span>
|
||||
{{ ui::badge(label=t(key="published", lang=lang | default(value='sk')), variant="success") }}
|
||||
{% else %}
|
||||
<span class="inline-flex rounded-full bg-surface-alt px-2 py-0.5 text-xs font-medium text-on-surface/70 dark:bg-surface-dark-alt dark:text-on-surface-dark/70">{{ t(key="draft", lang=lang | default(value='sk')) }}</span>
|
||||
{{ ui::badge(label=t(key="draft", lang=lang | default(value='sk')), variant="neutral") }}
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-4 py-3">
|
||||
<div class="flex flex-wrap justify-end gap-2">
|
||||
<a href="/admin/catalog/products/{{ product.id }}/edit"
|
||||
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="edit", lang=lang | default(value='sk')) }}</a>
|
||||
<a href="/shop/{{ product.slug }}"
|
||||
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="view", lang=lang | default(value='sk')) }}</a>
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="edit", lang=lang | default(value='sk')), href="/admin/catalog/products/" ~ product.id ~ "/edit", size="px-3 py-1.5 text-xs") }}
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="view", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, size="px-3 py-1.5 text-xs") }}
|
||||
<form method="post" action="/admin/catalog/products/{{ product.id }}/delete"
|
||||
onsubmit="return confirm('{{ t(key="confirm-delete", lang=lang | default(value='sk')) }}')">
|
||||
<button type="submit" class="inline-flex items-center rounded-radius border border-outline px-3 py-1.5 text-xs font-medium text-danger transition hover:bg-danger/10 dark:border-outline-dark">{{ t(key="delete", lang=lang | default(value='sk')) }}</button>
|
||||
{{ ui::button(variant="outline-danger", label=t(key="delete", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
@@ -71,10 +67,7 @@
|
||||
{% else %}
|
||||
<div class="flex flex-col items-center gap-3 px-4 py-16 text-center">
|
||||
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-no-products", lang=lang | default(value='sk')) }}</p>
|
||||
<a href="/admin/catalog/products/new"
|
||||
class="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="new-product", lang=lang | default(value='sk')) }}
|
||||
</a>
|
||||
{{ ui::button(label=t(key="new-product", lang=lang | default(value='sk')), href="/admin/catalog/products/new") }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{{ t(key="login-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||
|
||||
@@ -11,10 +12,7 @@
|
||||
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{{ t(key="nav-admin", lang=lang | default(value='sk')) }}
|
||||
</span>
|
||||
<span
|
||||
class="rounded-radius border border-danger/40 px-2 py-0.5 text-xs font-medium text-danger">
|
||||
{{ t(key="auth", lang=lang | default(value='sk')) }}
|
||||
</span>
|
||||
{{ ui::badge(label=t(key="auth", lang=lang | default(value='sk')), variant="danger") }}
|
||||
</div>
|
||||
|
||||
<div class="p-5">
|
||||
@@ -23,11 +21,7 @@
|
||||
</h1>
|
||||
|
||||
{% if error %}
|
||||
<div
|
||||
class="mt-3 rounded-radius border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger"
|
||||
role="alert">
|
||||
✗ {{ t(key="login-error", lang=lang | default(value='sk')) }}
|
||||
</div>
|
||||
{{ ui::alert_danger(message=t(key="login-error", lang=lang | default(value='sk')), extra="mt-3") }}
|
||||
{% endif %}
|
||||
|
||||
<form method="post" action="/admin/login" hx-boost="false" class="mt-4 flex flex-col gap-4">
|
||||
@@ -36,9 +30,7 @@
|
||||
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{{ t(key="login-email", lang=lang | default(value='sk')) }}
|
||||
</label>
|
||||
<input type="email" id="email" name="email" required autofocus
|
||||
autocomplete="email"
|
||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-none focus:ring-2 focus:ring-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark dark:focus:ring-primary-dark">
|
||||
{{ ui::input(name="email", id="email", type="email", required=true, autocomplete="email", attrs="autofocus") }}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-1">
|
||||
@@ -46,15 +38,10 @@
|
||||
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{{ t(key="login-password", lang=lang | default(value='sk')) }}
|
||||
</label>
|
||||
<input type="password" id="password" name="password" required
|
||||
autocomplete="current-password"
|
||||
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-none focus:ring-2 focus:ring-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark dark:focus:ring-primary-dark">
|
||||
{{ ui::input(name="password", id="password", type="password", required=true, autocomplete="current-password") }}
|
||||
</div>
|
||||
|
||||
<button type="submit"
|
||||
class="mt-1 w-full rounded-radius bg-primary px-4 py-2 text-sm font-semibold text-on-primary transition hover:opacity-90 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2 focus:ring-offset-surface-alt dark:bg-primary-dark dark:text-on-primary-dark dark:focus:ring-primary-dark dark:focus:ring-offset-surface-dark-alt">
|
||||
{{ t(key="login-auth", lang=lang | default(value='sk')) }}
|
||||
</button>
|
||||
{{ ui::button(label=t(key="login-auth", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full") }}
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{{ t(key="admin-orders", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||
{% block crumb %}{{ t(key="admin-orders", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||
@@ -24,11 +25,11 @@
|
||||
<td class="px-4 py-3 font-mono font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.order_number }}</td>
|
||||
<td class="px-4 py-3">{{ order.email }}</td>
|
||||
<td class="px-4 py-3">
|
||||
<span class="inline-flex rounded-full bg-surface-alt px-2 py-0.5 text-xs font-medium text-on-surface/80 dark:bg-surface-dark-alt dark:text-on-surface-dark/80">{{ t(key="order-status-" ~ order.status, lang=lang | default(value='sk')) }}</span>
|
||||
{{ ui::badge(label=t(key="order-status-" ~ order.status, lang=lang | default(value='sk')), variant="neutral") }}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right tabular-nums">{{ order.total }} {{ order.currency }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<a href="/admin/orders/{{ order.id }}" 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="view", lang=lang | default(value='sk')) }}</a>
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="view", lang=lang | default(value='sk')), href="/admin/orders/" ~ order.id, size="px-3 py-1.5 text-xs") }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{{ order.order_number }}{% endblock title %}
|
||||
{% block crumb %}{{ t(key="admin-orders", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||
@@ -6,9 +7,13 @@
|
||||
{% block content %}
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="font-mono text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.order_number }}</h1>
|
||||
<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>
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="admin-orders", lang=lang | default(value='sk')), href="/admin/orders", size="px-3 py-2 text-sm") }}
|
||||
</div>
|
||||
|
||||
{% if ship_error %}
|
||||
{{ ui::alert_danger(message=ship_error, extra="mt-4") }}
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-6 grid gap-6 lg:grid-cols-3">
|
||||
<div class="space-y-6 lg:col-span-2">
|
||||
<div class="overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
|
||||
@@ -45,6 +50,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="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>
|
||||
{% if order.phone %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.phone }}</p>{% endif %}
|
||||
</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>
|
||||
@@ -69,15 +75,43 @@
|
||||
{% endif %}
|
||||
</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 %}
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="order-label", lang=lang | default(value='sk')), href=order.label_url, size="px-3 py-1.5 text-xs", attrs='target="_blank" rel="noopener"') }}
|
||||
{% 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')) }}')">
|
||||
{% set carrier_up = carrier | upper %}
|
||||
{% set ship_label = t(key="order-send-to-carrier", lang=lang | default(value='sk')) ~ " " ~ carrier_up %}
|
||||
{{ ui::button(label=ship_label, type="submit", extra="w-full") }}
|
||||
</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">
|
||||
<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"
|
||||
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">
|
||||
{% for status in statuses %}
|
||||
<option value="{{ status }}" {% if order.status == status %}selected{% endif %}>{{ t(key="order-status-" ~ status, lang=lang | default(value='sk')) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<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-update-status", lang=lang | default(value='sk')) }}</button>
|
||||
<div class="relative">
|
||||
<select id="status" name="status"
|
||||
class="w-full appearance-none rounded-radius border border-outline bg-surface-alt px-4 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark">
|
||||
{% for status in statuses %}
|
||||
<option value="{{ status }}" {% if order.status == status %}selected{% endif %}>{{ t(key="order-status-" ~ status, lang=lang | default(value='sk')) }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-on-surface/60 dark:text-on-surface-dark/60"><path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" /></svg>
|
||||
</div>
|
||||
{{ ui::button(label=t(key="order-update-status", lang=lang | default(value='sk')), type="submit", extra="w-full") }}
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "admin/base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||
{% block crumb %}{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||
@@ -15,22 +16,14 @@
|
||||
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">
|
||||
<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 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>
|
||||
<input id="price-{{ method.id }}" name="price" type="text" inputmode="decimal" value="{{ method.price }}"
|
||||
class="w-28 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">
|
||||
{{ ui::input(name="price", id="price-" ~ method.id, value=method.price, width="w-28", attrs='inputmode="decimal"') }}
|
||||
</div>
|
||||
<label class="flex items-center gap-2 pb-2">
|
||||
<input type="checkbox" name="enabled" value="on" {% if method.enabled %}checked{% endif %}
|
||||
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="shipping-enabled", lang=lang | default(value='sk')) }}</span>
|
||||
</label>
|
||||
<button type="submit"
|
||||
class="ml-auto 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="save", lang=lang | default(value='sk')) }}
|
||||
</button>
|
||||
<div class="pb-2">{{ ui::checkbox(name="enabled", label=t(key="shipping-enabled", lang=lang | default(value='sk')), checked=method.enabled) }}</div>
|
||||
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", extra="ml-auto") }}
|
||||
</form>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
{% import "macros/ui.html" as ui %}
|
||||
<!doctype html>
|
||||
<html lang="{{ lang | default(value='sk') }}" data-theme="dark">
|
||||
<head>
|
||||
@@ -39,6 +40,20 @@
|
||||
}
|
||||
document.addEventListener('DOMContentLoaded', 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);
|
||||
}
|
||||
// Show a floating toast notification. Usage: toast('Saved').
|
||||
// Bridges to the vendored Penguin UI toast component, which listens for a
|
||||
// `notify` event with { variant, title, message }.
|
||||
function toast(message) {
|
||||
window.dispatchEvent(new CustomEvent('notify', { detail: { variant: 'success', message: message } }));
|
||||
}
|
||||
</script>
|
||||
<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>
|
||||
@@ -52,13 +67,7 @@
|
||||
class="sticky top-0 z-30 border-b border-outline bg-surface/95 backdrop-blur dark:border-outline-dark dark:bg-surface-dark/95">
|
||||
<nav x-data="{ mobile: false }" class="mx-auto flex max-w-7xl items-center gap-4 px-4 py-3">
|
||||
<!-- category sidebar toggle (mobile only) -->
|
||||
<button type="button" @click="cats = !cats" :aria-expanded="cats"
|
||||
aria-label="{{ t(key='categories', lang=lang | default(value='sk')) }}"
|
||||
class="inline-flex size-9 items-center justify-center rounded-radius text-on-surface transition hover:bg-surface-alt lg:hidden dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
{{ ui::icon_button(aria_label=t(key='categories', lang=lang | default(value='sk')), attrs='@click="cats = !cats" :aria-expanded="cats"', extra="lg:hidden", icon='<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /></svg>') }}
|
||||
<a href="/"
|
||||
class="text-lg font-bold tracking-tight text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
{{ t(key="brand", lang=lang | default(value='sk')) }}
|
||||
@@ -85,10 +94,10 @@
|
||||
<!-- cart with live item-count badge read from the `cart` cookie -->
|
||||
<a href="/cart" data-nav="/cart"
|
||||
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(); ['htmx:afterSwap', 'htmx:afterRequest'].forEach(function (e) { window.addEventListener(e, function () { count = cartCount() }) })"
|
||||
aria-label="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
|
||||
title="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
|
||||
class="relative inline-flex size-9 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 shrink-0 items-center justify-center rounded-radius bg-transparent text-secondary transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-secondary active:opacity-100 active:outline-offset-0 dark:text-secondary-dark dark:focus-visible:outline-secondary-dark">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M2.25 3h1.386c.51 0 .955.343 1.087.835l.383 1.437M7.5 14.25a3 3 0 0 0-3 3h15.75m-12.75-3h11.218c1.121-2.3 2.1-4.684 2.924-7.138a60.114 60.114 0 0 0-16.536-1.84M7.5 14.25 5.106 5.272M6 20.25a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Zm12.75 0a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0Z" />
|
||||
</svg>
|
||||
@@ -97,67 +106,11 @@
|
||||
</a>
|
||||
<!-- settings (language + theme) dropdown -->
|
||||
<div x-data="{ open: false }" @keydown.escape="open = false" class="relative">
|
||||
<button type="button" @click="open = !open" :aria-expanded="open"
|
||||
aria-label="{{ t(key='settings', lang=lang | default(value='sk')) }}"
|
||||
title="{{ t(key='settings', lang=lang | default(value='sk')) }}"
|
||||
class="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">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="size-5">
|
||||
<path stroke-linecap="round" stroke-linejoin="round"
|
||||
d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
|
||||
</svg>
|
||||
</button>
|
||||
<div x-show="open" x-cloak @click.outside="open = false"
|
||||
x-transition.origin.top.right
|
||||
class="absolute right-0 mt-2 w-56 rounded-radius border border-outline bg-surface p-2 shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
<form method="post" action="/lang" hx-boost="false">
|
||||
<p class="px-2 py-1 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
||||
{{ t(key="settings-language", lang=lang | default(value='sk')) }}
|
||||
</p>
|
||||
<button type="submit" name="lang" value="en"
|
||||
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
|
||||
<span>English</span>
|
||||
{% if lang | default(value='sk') == "en" %}<span class="text-primary dark:text-primary-dark">✓</span>{% endif %}
|
||||
</button>
|
||||
<button type="submit" name="lang" value="sk"
|
||||
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
|
||||
<span>Slovenčina</span>
|
||||
{% if lang | default(value='sk') == "sk" %}<span class="text-primary dark:text-primary-dark">✓</span>{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-1 px-2 py-1 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
||||
{{ t(key="settings-theme", lang=lang | default(value='sk')) }}
|
||||
</p>
|
||||
<div x-data="{ theme: currentTheme() }" @theme:changed.document="theme = $event.detail">
|
||||
<button type="button" @click="setTheme('system')"
|
||||
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
|
||||
<span>{{ t(key="theme-system", lang=lang | default(value='sk')) }}</span>
|
||||
<span x-show="theme === 'system'" class="text-primary dark:text-primary-dark">✓</span>
|
||||
</button>
|
||||
<button type="button" @click="setTheme('light')"
|
||||
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
|
||||
<span>{{ t(key="theme-light", lang=lang | default(value='sk')) }}</span>
|
||||
<span x-show="theme === 'light'" class="text-primary dark:text-primary-dark">✓</span>
|
||||
</button>
|
||||
<button type="button" @click="setTheme('dark')"
|
||||
class="flex w-full items-center justify-between rounded-radius px-2 py-1.5 text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark">
|
||||
<span>{{ t(key="theme-dark", lang=lang | default(value='sk')) }}</span>
|
||||
<span x-show="theme === 'dark'" class="text-primary dark:text-primary-dark">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% include "partials/settings_dropdown.html" %}
|
||||
</div>
|
||||
|
||||
<!-- mobile hamburger -->
|
||||
<button type="button" @click="mobile = !mobile" :aria-expanded="mobile"
|
||||
aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}"
|
||||
class="inline-flex size-9 items-center justify-center rounded-radius text-on-surface transition hover:bg-surface-alt md:hidden dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5"
|
||||
stroke="currentColor" class="size-6">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" />
|
||||
</svg>
|
||||
</button>
|
||||
{{ ui::icon_button(aria_label=t(key='menu', lang=lang | default(value='sk')), attrs='@click="mobile = !mobile" :aria-expanded="mobile"', extra="md:hidden", icon='<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-6"><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /></svg>') }}
|
||||
</div>
|
||||
|
||||
<!-- mobile menu panel -->
|
||||
@@ -197,5 +150,151 @@
|
||||
{% block content %}{% endblock content %}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<!-- toast notifications: fire from anywhere with toast('message').
|
||||
Adapted from the vendored Penguin UI component
|
||||
(assets/views/penguinui/toast-notification/stacking-toast-notification.html):
|
||||
the docs-only demo trigger buttons are omitted and the malformed quotes on
|
||||
the upstream dismiss-button <svg> tags are fixed. -->
|
||||
<div x-data="{
|
||||
notifications: [],
|
||||
displayDuration: 8000,
|
||||
soundEffect: false,
|
||||
addNotification({ variant = 'info', sender = null, title = null, message = null}) {
|
||||
const id = Date.now()
|
||||
const notification = { id, variant, sender, title, message }
|
||||
if (this.notifications.length >= 20) {
|
||||
this.notifications.splice(0, this.notifications.length - 19)
|
||||
}
|
||||
this.notifications.push(notification)
|
||||
},
|
||||
removeNotification(id) {
|
||||
setTimeout(() => {
|
||||
this.notifications = this.notifications.filter(
|
||||
(notification) => notification.id !== id,
|
||||
)
|
||||
}, 400);
|
||||
},
|
||||
}" x-on:notify.window="addNotification({
|
||||
variant: $event.detail.variant,
|
||||
sender: $event.detail.sender,
|
||||
title: $event.detail.title,
|
||||
message: $event.detail.message,
|
||||
})">
|
||||
|
||||
<div x-on:mouseenter="$dispatch('pause-auto-dismiss')" x-on:mouseleave="$dispatch('resume-auto-dismiss')" class="group pointer-events-none fixed inset-x-8 top-0 z-99 flex max-w-full flex-col gap-2 bg-transparent px-6 py-6 md:bottom-0 md:left-[unset] md:right-0 md:top-[unset] md:max-w-sm">
|
||||
<template x-for="(notification, index) in notifications" x-bind:key="notification.id">
|
||||
<div>
|
||||
<!-- Info Notification -->
|
||||
<template x-if="notification.variant === 'info'">
|
||||
<div x-data="{ isVisible: false, timeout: null }" x-cloak x-show="isVisible" class="pointer-events-auto relative rounded-radius border border-info bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark" role="alert" x-on:pause-auto-dismiss.window="clearTimeout(timeout)" x-on:resume-auto-dismiss.window=" timeout = setTimeout(() => {(isVisible = false), removeNotification(notification.id) }, displayDuration)" x-init="$nextTick(() => { isVisible = true }), (timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id)}, displayDuration))" x-transition:enter="transition duration-300 ease-out" x-transition:enter-end="translate-y-0" x-transition:enter-start="translate-y-8" x-transition:leave="transition duration-300 ease-in" x-transition:leave-end="-translate-x-24 opacity-0 md:translate-x-24" x-transition:leave-start="translate-x-0 opacity-100">
|
||||
<div class="flex w-full items-center gap-2.5 bg-info/10 rounded-radius p-4 transition-all duration-300">
|
||||
<div class="rounded-full bg-info/15 p-0.5 text-info" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 x-cloak x-show="notification.title" class="text-sm font-semibold text-info" x-text="notification.title"></h3>
|
||||
<p x-cloak x-show="notification.message" class="text-pretty text-sm" x-text="notification.message"></p>
|
||||
</div>
|
||||
<button type="button" class="ml-auto" aria-label="dismiss notification" x-on:click="(isVisible = false), removeNotification(notification.id)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Success Notification -->
|
||||
<template x-if="notification.variant === 'success'">
|
||||
<div x-data="{ isVisible: false, timeout: null }" x-cloak x-show="isVisible" class="pointer-events-auto relative rounded-radius border border-success bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark" role="alert" x-on:pause-auto-dismiss.window="clearTimeout(timeout)" x-on:resume-auto-dismiss.window=" timeout = setTimeout(() => {(isVisible = false), removeNotification(notification.id) }, displayDuration)" x-init="$nextTick(() => { isVisible = true }), (timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id)}, displayDuration))" x-transition:enter="transition duration-300 ease-out" x-transition:enter-end="translate-y-0" x-transition:enter-start="translate-y-8" x-transition:leave="transition duration-300 ease-in" x-transition:leave-end="-translate-x-24 opacity-0 md:translate-x-24" x-transition:leave-start="translate-x-0 opacity-100">
|
||||
<div class="flex w-full items-center gap-2.5 bg-success/10 rounded-radius p-4 transition-all duration-300">
|
||||
<div class="rounded-full bg-success/15 p-0.5 text-success" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 x-cloak x-show="notification.title" class="text-sm font-semibold text-success" x-text="notification.title"></h3>
|
||||
<p x-cloak x-show="notification.message" class="text-pretty text-sm" x-text="notification.message"></p>
|
||||
</div>
|
||||
<button type="button" class="ml-auto" aria-label="dismiss notification" x-on:click="(isVisible = false), removeNotification(notification.id)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Warning Notification -->
|
||||
<template x-if="notification.variant === 'warning'">
|
||||
<div x-data="{ isVisible: false, timeout: null }" x-cloak x-show="isVisible" class="pointer-events-auto relative rounded-radius border border-warning bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark" role="alert" x-on:pause-auto-dismiss.window="clearTimeout(timeout)" x-on:resume-auto-dismiss.window=" timeout = setTimeout(() => {(isVisible = false), removeNotification(notification.id) }, displayDuration)" x-init="$nextTick(() => { isVisible = true }), (timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id)}, displayDuration))" x-transition:enter="transition duration-300 ease-out" x-transition:enter-end="translate-y-0" x-transition:enter-start="translate-y-8" x-transition:leave="transition duration-300 ease-in" x-transition:leave-end="-translate-x-24 opacity-0 md:translate-x-24" x-transition:leave-start="translate-x-0 opacity-100">
|
||||
<div class="flex w-full items-center gap-2.5 bg-warning/10 rounded-radius p-4 transition-all duration-300">
|
||||
<div class="rounded-full bg-warning/15 p-0.5 text-warning" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 x-cloak x-show="notification.title" class="text-sm font-semibold text-warning" x-text="notification.title"></h3>
|
||||
<p x-cloak x-show="notification.message" class="text-pretty text-sm" x-text="notification.message"></p>
|
||||
</div>
|
||||
<button type="button" class="ml-auto" aria-label="dismiss notification" x-on:click="(isVisible = false), removeNotification(notification.id)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Danger Notification -->
|
||||
<template x-if="notification.variant === 'danger'">
|
||||
<div x-data="{ isVisible: false, timeout: null }" x-cloak x-show="isVisible" class="pointer-events-auto relative rounded-radius border border-danger bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark" role="alert" x-on:pause-auto-dismiss.window="clearTimeout(timeout)" x-on:resume-auto-dismiss.window=" timeout = setTimeout(() => {(isVisible = false), removeNotification(notification.id) }, displayDuration)" x-init="$nextTick(() => { isVisible = true }), (timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id)}, displayDuration))" x-transition:enter="transition duration-300 ease-out" x-transition:enter-end="translate-y-0" x-transition:enter-start="translate-y-8" x-transition:leave="transition duration-300 ease-in" x-transition:leave-end="-translate-x-24 opacity-0 md:translate-x-24" x-transition:leave-start="translate-x-0 opacity-100">
|
||||
<div class="flex w-full items-center gap-2.5 bg-danger/10 rounded-radius p-4 transition-all duration-300">
|
||||
<div class="rounded-full bg-danger/15 p-0.5 text-danger" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 x-cloak x-show="notification.title" class="text-sm font-semibold text-danger" x-text="notification.title"></h3>
|
||||
<p x-cloak x-show="notification.message" class="text-pretty text-sm" x-text="notification.message"></p>
|
||||
</div>
|
||||
<button type="button" class="ml-auto" aria-label="dismiss notification" x-on:click="(isVisible = false), removeNotification(notification.id)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- Message Notification -->
|
||||
<template x-if="notification.variant === 'message'">
|
||||
<div x-data="{ isVisible: false, timeout: null }" x-cloak x-show="isVisible" class="pointer-events-auto relative rounded-radius border border-outline bg-surface text-on-surface dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark" role="alert" x-on:pause-auto-dismiss.window="clearTimeout(timeout)" x-on:resume-auto-dismiss.window="timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id) }, displayDuration)" x-init="$nextTick(() => { isVisible = true }), (timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id) }, displayDuration))" x-transition:enter="transition duration-300 ease-out" x-transition:enter-end="translate-y-0" x-transition:enter-start="translate-y-8" x-transition:leave="transition duration-300 ease-in" x-transition:leave-end="-translate-x-24 opacity-0 md:translate-x-24" x-transition:leave-start="translate-x-0 opacity-100">
|
||||
<div class="flex w-full rounded-radius items-center gap-2.5 bg-surface-alt p-4 transition-all duration-300 dark:bg-surface-dark-alt">
|
||||
<div class="flex w-full items-center gap-2.5">
|
||||
<img x-cloak x-show="notification.sender.avatar" class="mr-2 size-12 rounded-full" alt="avatar" aria-hidden="true" x-bind:src="notification.sender.avatar"/>
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<h3 x-cloak x-show="notification.sender.name" class="text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong" x-text="notification.sender.name"></h3>
|
||||
<p x-cloak x-show="notification.message" class="text-pretty text-sm" x-text="notification.message"></p>
|
||||
<div class="flex items-center gap-4">
|
||||
<button type="button" class="whitespace-nowrap bg-transparent text-center text-sm font-bold tracking-wide text-primary transition hover:opacity-75 active:opacity-100 dark:text-primary-dark">Reply</button>
|
||||
<button type="button" class="whitespace-nowrap bg-transparent text-center text-sm font-bold tracking-wide text-on-surface transition hover:opacity-75 active:opacity-100 dark:text-on-surface-dark" x-on:click=" (isVisible = false), setTimeout(() => { removeNotification(notification.id) }, 400)">Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="ml-auto" aria-label="dismiss notification" x-on:click="(isVisible = false), removeNotification(notification.id)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{{ t(key="brand", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||
|
||||
|
||||
122
assets/views/macros/ui.html
Normal file
122
assets/views/macros/ui.html
Normal file
@@ -0,0 +1,122 @@
|
||||
{# Reusable UI macros adapted from vendored Penguin UI components.
|
||||
These are OUR adaptation layer; the byte-for-byte upstream sources live under
|
||||
assets/views/penguinui/. Tailwind sees the full literal class strings here
|
||||
(assets/css/app.css has @source "../views"), so every branch must spell its
|
||||
classes out in full — never build class names by concatenation.
|
||||
|
||||
Usage:
|
||||
{% import "macros/ui.html" as ui %}
|
||||
{{ ui::button(label=t(key="save", lang=lang)) }} {# default primary #}
|
||||
{{ ui::button(label="Add", attrs='hx-post="/x"' | safe) }}
|
||||
{{ ui::button(label="Cancel", variant="outline-secondary", href="/back") }}
|
||||
{{ ui::button(label="Send", size="px-6 py-2.5 text-sm") }} {# keep a non-default size #}
|
||||
{{ ui::badge(label="Published", variant="success") }}
|
||||
|
||||
Notes:
|
||||
- Macros can't see template context vars (e.g. `lang`); pass already-translated
|
||||
strings as `label`.
|
||||
- `attrs` is injected raw (caller must pass it through `| safe`); use it for
|
||||
htmx / name / value / @click / :disabled etc. For buttons whose attrs carry
|
||||
nested quotes (e.g. hx-on with toast(...)), keep them inline instead.
|
||||
- `pad` is the size (default Penguin "px-4 py-2"); override to preserve an
|
||||
existing size rather than normalizing it.
|
||||
- The button class strings are the **verbatim** Penguin variants from
|
||||
penguinui/buttons/{default,outline,ghost}-button.html (only `inline-flex
|
||||
items-center justify-center` is added so <a> and w-full render correctly,
|
||||
and the upstream `text-onDanger`/`text-onSuccess`… token typos are fixed to
|
||||
our real `text-on-*` tokens). `variant` selects a Penguin variant:
|
||||
solid : primary (default) | secondary | danger | success | warning | info
|
||||
outline : outline-primary | outline-secondary | outline-alternate | outline-danger
|
||||
ghost : ghost-primary | ghost-secondary | ghost-danger #}
|
||||
|
||||
{% macro button(label, variant="primary", type="button", href="", attrs="", extra="", icon="", size="px-4 py-2 text-sm") -%}
|
||||
{%- if variant == "secondary" -%}{% set cls = "border border-secondary bg-secondary text-on-secondary focus-visible:outline-secondary dark:border-secondary-dark dark:bg-secondary-dark dark:text-on-secondary-dark dark:focus-visible:outline-secondary-dark" -%}
|
||||
{%- elif variant == "danger" -%}{% set cls = "border border-danger bg-danger text-on-danger focus-visible:outline-danger dark:bg-danger dark:border-danger dark:text-on-danger dark:focus-visible:outline-danger" -%}
|
||||
{%- elif variant == "success" -%}{% set cls = "border border-success bg-success text-on-success focus-visible:outline-success dark:bg-success dark:border-success dark:text-on-success dark:focus-visible:outline-success" -%}
|
||||
{%- elif variant == "warning" -%}{% set cls = "border border-warning bg-warning text-on-warning focus-visible:outline-warning dark:bg-warning dark:border-warning dark:text-on-warning dark:focus-visible:outline-warning" -%}
|
||||
{%- elif variant == "info" -%}{% set cls = "border border-info bg-info text-on-info focus-visible:outline-info dark:bg-info dark:border-info dark:text-on-info dark:focus-visible:outline-info" -%}
|
||||
{%- elif variant == "outline-primary" -%}{% set cls = "border border-primary bg-transparent text-primary focus-visible:outline-primary dark:border-primary-dark dark:text-primary-dark dark:focus-visible:outline-primary-dark" -%}
|
||||
{%- elif variant == "outline-secondary" -%}{% set cls = "border border-secondary bg-transparent text-secondary focus-visible:outline-secondary dark:border-secondary-dark dark:text-secondary-dark dark:focus-visible:outline-secondary-dark" -%}
|
||||
{%- elif variant == "outline-alternate" -%}{% set cls = "border border-outline bg-transparent text-outline focus-visible:outline-outline dark:border-outline-dark dark:text-outline-dark dark:focus-visible:outline-outline-dark" -%}
|
||||
{%- elif variant == "outline-danger" -%}{% set cls = "border border-danger bg-transparent text-danger focus-visible:outline-danger dark:border-danger dark:text-danger dark:focus-visible:outline-danger" -%}
|
||||
{%- elif variant == "ghost-primary" -%}{% set cls = "bg-transparent text-primary focus-visible:outline-primary dark:text-primary-dark dark:focus-visible:outline-primary-dark" -%}
|
||||
{%- elif variant == "ghost-secondary" -%}{% set cls = "bg-transparent text-secondary focus-visible:outline-secondary dark:text-secondary-dark dark:focus-visible:outline-secondary-dark" -%}
|
||||
{%- elif variant == "ghost-danger" -%}{% set cls = "bg-transparent text-danger focus-visible:outline-danger dark:text-danger dark:focus-visible:outline-danger" -%}
|
||||
{%- else -%}{% set cls = "border border-primary bg-primary text-on-primary focus-visible:outline-primary dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark dark:focus-visible:outline-primary-dark" -%}
|
||||
{%- endif -%}
|
||||
{% if href %}<a href="{{ href }}"{% else %}<button type="{{ type }}"{% endif %} class="inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-radius {{ size }} text-center font-medium tracking-wide transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75 {{ cls }} {{ extra }}" {{ attrs | safe }}>{{ icon | safe }}{{ label }}</{% if href %}a{% else %}button{% endif %}>
|
||||
{%- endmacro button %}
|
||||
|
||||
{# Icon-only button (square). Penguin ghost treatment (bg-transparent,
|
||||
hover:opacity-75); pass the raw <svg> as `icon`, an accessible name via
|
||||
`aria_label`/`sr`, and any Alpine/htmx via `attrs` (raw). variant ∈
|
||||
ghost-secondary (default) | ghost-primary | ghost-danger | ghost-alternate. #}
|
||||
{% macro icon_button(icon, variant="ghost-secondary", type="button", href="", attrs="", extra="", aria_label="", sr="", size="size-9") -%}
|
||||
{%- if variant == "ghost-primary" -%}{% set cls = "text-primary focus-visible:outline-primary dark:text-primary-dark dark:focus-visible:outline-primary-dark" -%}
|
||||
{%- elif variant == "ghost-danger" -%}{% set cls = "text-danger focus-visible:outline-danger dark:text-danger dark:focus-visible:outline-danger" -%}
|
||||
{%- elif variant == "ghost-alternate" -%}{% set cls = "text-outline focus-visible:outline-outline dark:text-outline-dark dark:focus-visible:outline-outline-dark" -%}
|
||||
{%- else -%}{% set cls = "text-secondary focus-visible:outline-secondary dark:text-secondary-dark dark:focus-visible:outline-secondary-dark" -%}
|
||||
{%- endif -%}
|
||||
{% if href %}<a href="{{ href }}"{% else %}<button type="{{ type }}"{% endif %}{% if aria_label %} aria-label="{{ aria_label }}" title="{{ aria_label }}"{% endif %} class="inline-flex shrink-0 items-center justify-center rounded-radius bg-transparent {{ size }} transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75 {{ cls }} {{ extra }}" {{ attrs | safe }}>{{ icon | safe }}{% if sr %}<span class="sr-only">{{ sr }}</span>{% endif %}</{% if href %}a{% else %}button{% endif %}>
|
||||
{%- endmacro icon_button %}
|
||||
|
||||
{# Compact danger alert (form/inline errors). Adapted from
|
||||
penguinui/alert/default-alert.html (danger variant), trimmed to a single line
|
||||
with the danger icon. #}
|
||||
{% macro alert_danger(message, extra="") -%}
|
||||
<div class="flex w-full items-center gap-2 overflow-hidden rounded-radius border border-danger bg-danger/10 px-3 py-2 text-sm text-danger {{ extra }}" role="alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16ZM8.28 7.22a.75.75 0 0 0-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 1 0 1.06 1.06L10 11.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L11.06 10l1.72-1.72a.75.75 0 0 0-1.06-1.06L10 8.94 8.28 7.22Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<span>{{ message }}</span>
|
||||
</div>
|
||||
{%- endmacro alert_danger %}
|
||||
|
||||
{# Soft-color badge. variant ∈ success | danger | warning | info | primary | neutral #}
|
||||
{% macro badge(label, variant="neutral") -%}
|
||||
{% if variant == "success" -%}
|
||||
<span class="inline-flex w-fit overflow-hidden rounded-radius border border-success bg-surface text-xs font-medium text-success dark:bg-surface-dark"><span class="bg-success/10 px-2 py-1">{{ label }}</span></span>
|
||||
{%- elif variant == "danger" -%}
|
||||
<span class="inline-flex w-fit overflow-hidden rounded-radius border border-danger bg-surface text-xs font-medium text-danger dark:bg-surface-dark"><span class="bg-danger/10 px-2 py-1">{{ label }}</span></span>
|
||||
{%- elif variant == "warning" -%}
|
||||
<span class="inline-flex w-fit overflow-hidden rounded-radius border border-warning bg-surface text-xs font-medium text-warning dark:bg-surface-dark"><span class="bg-warning/10 px-2 py-1">{{ label }}</span></span>
|
||||
{%- elif variant == "info" -%}
|
||||
<span class="inline-flex w-fit overflow-hidden rounded-radius border border-info bg-surface text-xs font-medium text-info dark:bg-surface-dark"><span class="bg-info/10 px-2 py-1">{{ label }}</span></span>
|
||||
{%- elif variant == "primary" -%}
|
||||
<span class="inline-flex w-fit overflow-hidden rounded-radius border border-primary bg-surface text-xs font-medium text-primary dark:border-primary-dark dark:bg-surface-dark dark:text-primary-dark"><span class="bg-primary/10 px-2 py-1 dark:bg-primary-dark/10">{{ label }}</span></span>
|
||||
{%- else -%}
|
||||
<span class="inline-flex w-fit overflow-hidden rounded-radius border border-outline bg-surface text-xs font-medium text-on-surface dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark"><span class="bg-surface-alt/40 px-2 py-1 dark:bg-surface-dark-alt/40">{{ label }}</span></span>
|
||||
{%- endif %}
|
||||
{%- endmacro badge %}
|
||||
|
||||
{# ---- Form controls. Verbatim Penguin classes from
|
||||
penguinui/{text-input,text-area,select,checkbox,file-input}/default-*.html.
|
||||
These macros emit only the control (callers keep their own <label>/layout), so
|
||||
text-color utilities are added here (upstream sets them on the wrapper div). #}
|
||||
|
||||
{# Text/email/number/password input. #}
|
||||
{% macro input(name, type="text", id="", value="", placeholder="", required=false, autocomplete="", attrs="", extra="", width="w-full") -%}
|
||||
<input {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" type="{{ type }}"{% if value != "" %} value="{{ value }}"{% endif %}{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if required %} required{% endif %}{% if autocomplete %} autocomplete="{{ autocomplete }}"{% endif %} class="{{ width }} rounded-radius border border-outline bg-surface-alt px-2 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}/>
|
||||
{%- endmacro input %}
|
||||
|
||||
{% macro textarea(name, id="", value="", rows="3", placeholder="", required=false, attrs="", extra="") -%}
|
||||
<textarea {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" rows="{{ rows }}"{% if placeholder %} placeholder="{{ placeholder }}"{% endif %}{% if required %} required{% endif %} class="w-full rounded-radius border border-outline bg-surface-alt px-2.5 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}>{{ value }}</textarea>
|
||||
{%- endmacro textarea %}
|
||||
|
||||
{# File input. #}
|
||||
{% macro file_input(name, id="", accept="", attrs="", extra="") -%}
|
||||
<input {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" type="file"{% if accept %} accept="{{ accept }}"{% endif %} class="w-full overflow-clip rounded-radius border border-outline bg-surface-alt/50 text-sm text-on-surface file:mr-4 file:border-none file:bg-surface-alt file:px-4 file:py-2 file:font-medium file:text-on-surface-strong focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:file:bg-surface-dark-alt dark:file:text-on-surface-dark-strong dark:focus-visible:outline-primary-dark {{ extra }}" {{ attrs | safe }}/>
|
||||
{%- endmacro file_input %}
|
||||
|
||||
{# Checkbox (full Penguin control: custom box + check icon + label text). #}
|
||||
{% macro checkbox(name, label, id="", value="on", checked=false, attrs="", extra="") -%}
|
||||
<label {% if id %}for="{{ id }}" {% endif %}class="flex items-center gap-2 text-sm font-medium text-on-surface dark:text-on-surface-dark has-checked:text-on-surface-strong dark:has-checked:text-on-surface-dark-strong has-disabled:cursor-not-allowed has-disabled:opacity-75 {{ extra }}">
|
||||
<span class="relative flex items-center">
|
||||
<input {% if id %}id="{{ id }}" {% endif %}name="{{ name }}" value="{{ value }}" type="checkbox"{% if checked %} checked{% endif %} class="before:content[''] peer relative size-4 appearance-none overflow-hidden rounded-sm border border-outline bg-surface-alt before:absolute before:inset-0 checked:border-primary checked:before:bg-primary focus:outline-2 focus:outline-offset-2 focus:outline-outline-strong checked:focus:outline-primary active:outline-offset-0 disabled:cursor-not-allowed dark:border-outline-dark dark:bg-surface-dark-alt dark:checked:border-primary-dark dark:checked:before:bg-primary-dark dark:focus:outline-outline-dark-strong dark:checked:focus:outline-primary-dark" {{ attrs | safe }}/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" stroke="currentColor" fill="none" stroke-width="4" class="pointer-events-none invisible absolute left-1/2 top-1/2 size-3 -translate-x-1/2 -translate-y-1/2 text-on-primary peer-checked:visible dark:text-on-primary-dark">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>{{ label }}</span>
|
||||
</label>
|
||||
{%- endmacro checkbox %}
|
||||
53
assets/views/partials/settings_dropdown.html
Normal file
53
assets/views/partials/settings_dropdown.html
Normal file
@@ -0,0 +1,53 @@
|
||||
{# Settings dropdown (language + theme). Shared by base.html and admin/base.html
|
||||
to kill the former ~100-line copy-paste duplication.
|
||||
|
||||
Adapted from the vendored Penguin UI component
|
||||
assets/views/penguinui/dropdowns/dropdown-with-click.html: Penguin's dropdown
|
||||
menu container + item treatment. Deviations: kept our gear icon-only trigger
|
||||
and our core-Alpine open / @click.outside toggle (upstream's x-trap / $focus
|
||||
need the Alpine Focus plugin, which we don't bundle); item hover uses
|
||||
bg-primary/5 to stay consistent with the rest of our Penguin-ified UI.
|
||||
|
||||
The host template provides the wrapper
|
||||
<div x-data="{ open: false }" @keydown.escape="open = false" class="relative ...">
|
||||
so it controls its own positioning (e.g. ml-auto in admin). #}
|
||||
{{ ui::icon_button(aria_label=t(key='settings', lang=lang | default(value='sk')), attrs='@click="open = !open" :aria-expanded="open"', icon='<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>') }}
|
||||
<div x-show="open" x-cloak @click.outside="open = false" x-transition.origin.top.right
|
||||
class="absolute right-0 mt-2 flex w-56 flex-col overflow-hidden rounded-radius border border-outline bg-surface-alt py-1 shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt"
|
||||
role="menu">
|
||||
<form method="post" action="/lang" hx-boost="false">
|
||||
<p class="px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
||||
{{ t(key="settings-language", lang=lang | default(value='sk')) }}
|
||||
</p>
|
||||
<button type="submit" name="lang" value="en" role="menuitem"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-sm text-on-surface transition hover:bg-primary/5 hover:text-on-surface-strong focus-visible:bg-primary/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
|
||||
<span>English</span>
|
||||
{% if lang | default(value='sk') == "en" %}<span class="text-primary dark:text-primary-dark">✓</span>{% endif %}
|
||||
</button>
|
||||
<button type="submit" name="lang" value="sk" role="menuitem"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-sm text-on-surface transition hover:bg-primary/5 hover:text-on-surface-strong focus-visible:bg-primary/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
|
||||
<span>Slovenčina</span>
|
||||
{% if lang | default(value='sk') == "sk" %}<span class="text-primary dark:text-primary-dark">✓</span>{% endif %}
|
||||
</button>
|
||||
</form>
|
||||
<p class="mt-1 px-4 py-1.5 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
||||
{{ t(key="settings-theme", lang=lang | default(value='sk')) }}
|
||||
</p>
|
||||
<div x-data="{ theme: currentTheme() }" @theme:changed.document="theme = $event.detail">
|
||||
<button type="button" @click="setTheme('system')" role="menuitem"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-sm text-on-surface transition hover:bg-primary/5 hover:text-on-surface-strong focus-visible:bg-primary/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
|
||||
<span>{{ t(key="theme-system", lang=lang | default(value='sk')) }}</span>
|
||||
<span x-show="theme === 'system'" class="text-primary dark:text-primary-dark">✓</span>
|
||||
</button>
|
||||
<button type="button" @click="setTheme('light')" role="menuitem"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-sm text-on-surface transition hover:bg-primary/5 hover:text-on-surface-strong focus-visible:bg-primary/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
|
||||
<span>{{ t(key="theme-light", lang=lang | default(value='sk')) }}</span>
|
||||
<span x-show="theme === 'light'" class="text-primary dark:text-primary-dark">✓</span>
|
||||
</button>
|
||||
<button type="button" @click="setTheme('dark')" role="menuitem"
|
||||
class="flex w-full items-center justify-between px-4 py-2 text-sm text-on-surface transition hover:bg-primary/5 hover:text-on-surface-strong focus-visible:bg-primary/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
|
||||
<span>{{ t(key="theme-dark", lang=lang | default(value='sk')) }}</span>
|
||||
<span x-show="theme === 'dark'" class="text-primary dark:text-primary-dark">✓</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
101
assets/views/penguinui/alert/default-alert.html
Normal file
101
assets/views/penguinui/alert/default-alert.html
Normal file
@@ -0,0 +1,101 @@
|
||||
<!-- info Alert -->
|
||||
<div class="relative w-full overflow-hidden rounded-sm border border-sky-500 bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark"
|
||||
role="alert">
|
||||
<div class="flex w-full items-center gap-2 bg-info/10 p-4">
|
||||
<div class="bg-sky-500/15 text-sky-500 rounded-full p-1" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-6"
|
||||
aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-2">
|
||||
<h3 class="text-sm font-semibold text-info">Update Available</h3>
|
||||
<p class="text-xs font-medium sm:text-sm">A new version is available. Please update to the latest version.
|
||||
</p>
|
||||
</div>
|
||||
<button class="ml-auto" aria-label="dismiss alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" stroke="currentColor"
|
||||
fill="none" stroke-width="2.5" class="size-4 shrink-0">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- success Alert -->
|
||||
<div class="relative w-full overflow-hidden rounded-sm border border-green-500 bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark"
|
||||
role="alert">
|
||||
<div class="flex w-full items-center gap-2 bg-success/10 p-4">
|
||||
<div class="bg-green-500/15 text-green-500 rounded-full p-1" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-6"
|
||||
aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-2">
|
||||
<h3 class="text-sm font-semibold text-success">Successfully Subscribed</h3>
|
||||
<p class="text-xs font-medium sm:text-sm">Success! You've subscribed to our newsletter. Welcome aboard!</p>
|
||||
</div>
|
||||
<button class="ml-auto" aria-label="dismiss alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" stroke="currentColor"
|
||||
fill="none" stroke-width="2.5" class="size-4 shrink-0">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- warning Alert -->
|
||||
<div class="relative w-full overflow-hidden rounded-sm border border-amber-500 bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark"
|
||||
role="alert">
|
||||
<div class="flex w-full items-center gap-2 bg-warning/10 p-4">
|
||||
<div class="bg-amber-500/15 text-amber-500 rounded-full p-1" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-6"
|
||||
aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-2">
|
||||
<h3 class="text-sm font-semibold text-warning">Credit Card Expires Soon</h3>
|
||||
<p class="text-xs font-medium sm:text-sm">Your credit card expires soon. Please update your payment
|
||||
information.</p>
|
||||
</div>
|
||||
<button class="ml-auto" aria-label="dismiss alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" stroke="currentColor"
|
||||
fill="none" stroke-width="2.5" class="size-4 shrink-0">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- danger Alert -->
|
||||
<div class="relative w-full overflow-hidden rounded-sm border border-red-500 bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark"
|
||||
role="alert">
|
||||
<div class="flex w-full items-center gap-2 bg-danger/10 p-4">
|
||||
<div class="bg-red-500/15 text-red-500 rounded-full p-1" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-6"
|
||||
aria-hidden="true">
|
||||
<path fill-rule="evenodd"
|
||||
d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16ZM8.28 7.22a.75.75 0 0 0-1.06 1.06L8.94 10l-1.72 1.72a.75.75 0 1 0 1.06 1.06L10 11.06l1.72 1.72a.75.75 0 1 0 1.06-1.06L11.06 10l1.72-1.72a.75.75 0 0 0-1.06-1.06L10 8.94 8.28 7.22Z"
|
||||
clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ml-2">
|
||||
<h3 class="text-sm font-semibold text-danger">Invalid Email Address</h3>
|
||||
<p class="text-xs font-medium sm:text-sm">The email address you entered is invalid. Please try again.</p>
|
||||
</div>
|
||||
<button class="ml-auto" aria-label="dismiss alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" stroke="currentColor"
|
||||
fill="none" stroke-width="2.5" class="size-4 shrink-0">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
40
assets/views/penguinui/badge/soft-color-badge.html
Normal file
40
assets/views/penguinui/badge/soft-color-badge.html
Normal file
@@ -0,0 +1,40 @@
|
||||
<!-- default Badge -->
|
||||
<span class="w-fit inline-flex overflow-hidden rounded-radius border border-outline bg-surface text-xs font-medium text-on-surface dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||
<span class="px-2 py-1 bg-surface-alt/10 dark:bg-surface-dark-alt/10">Bagde</span>
|
||||
</span>
|
||||
|
||||
<!-- inverse Badge -->
|
||||
<span class="w-fit inline-flex overflow-hidden rounded-radius border border-outline-dark bg-surface text-xs font-medium text-on-surface dark:border-outline dark:bg-surface-dark dark:text-on-surface-dark">
|
||||
<span class="px-2 py-1 bg-surface-dark-alt/10 dark:bg-surface-alt/10">Bagde</span>
|
||||
</span>
|
||||
|
||||
<!-- primary Badge -->
|
||||
<span class="w-fit inline-flex overflow-hidden rounded-radius border border-primary bg-surface text-xs font-medium text-primary dark:border-primary-dark dark:bg-surface-dark dark:text-primary-dark">
|
||||
<span class="px-2 py-1 bg-primary/10 dark:bg-primary-dark/10">Bagde</span>
|
||||
</span>
|
||||
|
||||
<!-- secondary Badge -->
|
||||
<span class="w-fit inline-flex overflow-hidden rounded-radius border border-secondary bg-surface text-xs font-medium text-secondary dark:border-secondary-dark dark:bg-surface-dark dark:text-secondary-dark">
|
||||
<span class="px-2 py-1 bg-secondary/10 dark:bg-secondary-dark/10">Bagde</span>
|
||||
</span>
|
||||
|
||||
<!-- info Badge -->
|
||||
<span class="w-fit inline-flex overflow-hidden rounded-radius border border-info bg-surface text-xs font-medium text-info dark:border-info dark:bg-surface-dark dark:text-info">
|
||||
<span class="px-2 py-1 bg-info/10 dark:bg-info/10">Bagde</span>
|
||||
</span>
|
||||
|
||||
<!-- success Badge -->
|
||||
<span class="w-fit inline-flex overflow-hidden rounded-radius border border-success bg-surface text-xs font-medium text-success dark:border-success dark:bg-surface-dark dark:text-success">
|
||||
<span class="px-2 py-1 bg-success/10 dark:bg-success/10">Bagde</span>
|
||||
</span>
|
||||
|
||||
<!-- warning Badge -->
|
||||
<span class="w-fit inline-flex overflow-hidden rounded-radius border border-warning bg-surface text-xs font-medium text-warning dark:border-warning dark:bg-surface-dark dark:text-warning">
|
||||
<span class="px-2 py-1 bg-warning/10 dark:bg-warning/10">Bagde</span>
|
||||
</span>
|
||||
|
||||
<!-- danger Badge -->
|
||||
<span class="w-fit inline-flex overflow-hidden rounded-radius border border-danger bg-surface text-xs font-medium text-danger dark:border-danger dark:bg-surface-dark dark:text-danger">
|
||||
<span class="px-2 py-1 bg-danger/10 dark:bg-danger/10">Bagde</span>
|
||||
</span>
|
||||
|
||||
64
assets/views/penguinui/buttons/button-with-icon.html
Normal file
64
assets/views/penguinui/buttons/button-with-icon.html
Normal file
@@ -0,0 +1,64 @@
|
||||
<!-- primary Button with Icon -->
|
||||
<button type="button" class="inline-flex justify-center items-center gap-2 whitespace-nowrap rounded-radius bg-primary border border-primary dark:border-primary-dark px-4 py-2 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 text-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:bg-primary-dark dark:text-on-primary-dark dark:focus-visible:outline-primary-dark">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="size-5 fill-on-primary dark:fill-on-primary-dark" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Create
|
||||
</button>
|
||||
|
||||
<!-- secondary Button with Icon -->
|
||||
<button type="button" class="inline-flex justify-center items-center gap-2 whitespace-nowrap rounded-radius bg-secondary border border-secondary dark:border-secondary-dark px-4 py-2 text-sm font-medium tracking-wide text-on-secondary transition hover:opacity-75 text-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-secondary active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:bg-secondary-dark dark:text-on-secondary-dark dark:focus-visible:outline-secondary-dark">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="size-5 fill-on-secondary dark:fill-on-secondary-dark" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Create
|
||||
</button>
|
||||
|
||||
<!-- alternate Button with Icon -->
|
||||
<button type="button" class="inline-flex justify-center items-center gap-2 whitespace-nowrap rounded-radius bg-surface-alt border border-surface-alt dark:border-surface-dark-alt px-4 py-2 text-sm font-medium tracking-wide text-on-surface-strong transition hover:opacity-75 text-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-surface-alt active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:bg-surface-dark-alt dark:text-on-surface-dark-strong dark:focus-visible:outline-surface-dark-alt">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="size-5 fill-on-surface-strong dark:fill-on-surface-dark-strong" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Create
|
||||
</button>
|
||||
|
||||
<!-- inverse Button with Icon -->
|
||||
<button type="button" class="inline-flex justify-center items-center gap-2 whitespace-nowrap rounded-radius bg-surface-dark border border-surface-dark dark:border-surface px-4 py-2 text-sm font-medium tracking-wide text-on-surface-dark transition hover:opacity-75 text-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-surface-dark active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:bg-surface dark:text-on-surface dark:focus-visible:outline-surface">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="size-5 fill-on-surface-dark dark:fill-on-surface" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Create
|
||||
</button>
|
||||
|
||||
<!-- info Button with Icon -->
|
||||
<button type="button" class="inline-flex justify-center items-center gap-2 whitespace-nowrap rounded-radius bg-info border border-info dark:border-info px-4 py-2 text-sm font-medium tracking-wide text-on-info transition hover:opacity-75 text-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:bg-info dark:text-on-info dark:focus-visible:outline-info">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="size-5 fill-on-info dark:fill-on-info" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Create
|
||||
</button>
|
||||
|
||||
<!-- danger Button with Icon -->
|
||||
<button type="button" class="inline-flex justify-center items-center gap-2 whitespace-nowrap rounded-radius bg-danger border border-danger dark:border-danger px-4 py-2 text-sm font-medium tracking-wide text-on-danger transition hover:opacity-75 text-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:bg-danger dark:text-on-danger dark:focus-visible:outline-danger">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="size-5 fill-on-danger dark:fill-on-danger" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Create
|
||||
</button>
|
||||
|
||||
<!-- warning Button with Icon -->
|
||||
<button type="button" class="inline-flex justify-center items-center gap-2 whitespace-nowrap rounded-radius bg-warning border border-warning dark:border-warning px-4 py-2 text-sm font-medium tracking-wide text-on-warning transition hover:opacity-75 text-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-warning active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:bg-warning dark:text-on-warning dark:focus-visible:outline-warning">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="size-5 fill-on-warning dark:fill-on-warning" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Create
|
||||
</button>
|
||||
|
||||
<!-- success Button with Icon -->
|
||||
<button type="button" class="inline-flex justify-center items-center gap-2 whitespace-nowrap rounded-radius bg-success border border-success dark:border-success px-4 py-2 text-sm font-medium tracking-wide text-on-success transition hover:opacity-75 text-center focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-success active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:bg-success dark:text-on-success dark:focus-visible:outline-success">
|
||||
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="size-5 fill-on-success dark:fill-on-success" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M12 3.75a.75.75 0 01.75.75v6.75h6.75a.75.75 0 010 1.5h-6.75v6.75a.75.75 0 01-1.5 0v-6.75H4.5a.75.75 0 010-1.5h6.75V4.5a.75.75 0 01.75-.75z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Create
|
||||
</button>
|
||||
|
||||
24
assets/views/penguinui/buttons/default-button.html
Normal file
24
assets/views/penguinui/buttons/default-button.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!-- primary Button -->
|
||||
<button type="button" class="whitespace-nowrap rounded-radius bg-primary border border-primary px-4 py-2 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 text-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:bg-primary-dark dark:border-primary-dark dark:text-on-primary-dark dark:focus-visible:outline-primary-dark">Primary</button>
|
||||
|
||||
<!-- secondary Button -->
|
||||
<button type="button" class="whitespace-nowrap rounded-radius bg-secondary border border-secondary px-4 py-2 text-sm font-medium tracking-wide text-on-secondary transition hover:opacity-75 text-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-secondary active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:bg-secondary-dark dark:border-secondary-dark dark:text-on-secondary-dark dark:focus-visible:outline-secondary-dark">Secondary</button>
|
||||
|
||||
<!-- alternate Button -->
|
||||
<button type="button" class="whitespace-nowrap rounded-radius bg-surface-alt border border-surface-alt px-4 py-2 text-sm font-medium tracking-wide text-on-surface-strong transition hover:opacity-75 text-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-surface-alt active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:bg-surface-dark-alt dark:border-surface-dark-alt dark:text-on-surface-dark-strong dark:focus-visible:outline-surface-dark-alt">Alternate</button>
|
||||
|
||||
<!-- inverse Button -->
|
||||
<button type="button" class="whitespace-nowrap rounded-radius bg-surface-dark border border-surface-dark px-4 py-2 text-sm font-medium tracking-wide text-on-surface-dark transition hover:opacity-75 text-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-surface-dark active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:bg-surface dark:border-surface dark:text-on-surface dark:focus-visible:outline-surface">Inverse</button>
|
||||
|
||||
<!-- info Button -->
|
||||
<button type="button" class="whitespace-nowrap rounded-radius bg-info border border-info px-4 py-2 text-sm font-medium tracking-wide text-onInfo transition hover:opacity-75 text-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:bg-info dark:border-info dark:text-onInfo dark:focus-visible:outline-info">Info</button>
|
||||
|
||||
<!-- danger Button -->
|
||||
<button type="button" class="whitespace-nowrap rounded-radius bg-danger border border-danger px-4 py-2 text-sm font-medium tracking-wide text-onDanger transition hover:opacity-75 text-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:bg-danger dark:border-danger dark:text-onDanger dark:focus-visible:outline-danger">Danger</button>
|
||||
|
||||
<!-- warning Button -->
|
||||
<button type="button" class="whitespace-nowrap rounded-radius bg-warning border border-warning px-4 py-2 text-sm font-medium tracking-wide text-onWarning transition hover:opacity-75 text-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-warning active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:bg-warning dark:border-warning dark:text-onWarning dark:focus-visible:outline-warning">Warning</button>
|
||||
|
||||
<!-- success Button -->
|
||||
<button type="button" class="whitespace-nowrap rounded-radius bg-success border border-success px-4 py-2 text-sm font-medium tracking-wide text-onSuccess transition hover:opacity-75 text-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-success active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:bg-success dark:border-success dark:text-onSuccess dark:focus-visible:outline-success">Success</button>
|
||||
|
||||
24
assets/views/penguinui/buttons/ghost-button.html
Normal file
24
assets/views/penguinui/buttons/ghost-button.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!-- primary Ghost Button -->
|
||||
<button type="button" class="bg-transparent rounded-radius px-4 py-2 text-sm font-medium tracking-wide text-primary transition hover:opacity-75 text-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:text-primary-dark dark:focus-visible:outline-primary-dark">Primary</button>
|
||||
|
||||
<!-- secondary Ghost Button -->
|
||||
<button type="button" class="bg-transparent rounded-radius px-4 py-2 text-sm font-medium tracking-wide text-secondary transition hover:opacity-75 text-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-secondary active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:text-secondary-dark dark:focus-visible:outline-secondary-dark">Secondary</button>
|
||||
|
||||
<!-- alternate Ghost Button -->
|
||||
<button type="button" class="bg-transparent rounded-radius px-4 py-2 text-sm font-medium tracking-wide text-outline transition hover:opacity-75 text-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-outline active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:text-outline-dark dark:focus-visible:outline-outline-dark">Alternate</button>
|
||||
|
||||
<!-- inverse Ghost Button -->
|
||||
<button type="button" class="bg-transparent rounded-radius px-4 py-2 text-sm font-medium tracking-wide text-surface-dark transition hover:opacity-75 text-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-surface-dark active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:text-surface dark:focus-visible:outline-surface">Inverse</button>
|
||||
|
||||
<!-- info Ghost Button -->
|
||||
<button type="button" class="bg-transparent rounded-radius px-4 py-2 text-sm font-medium tracking-wide text-info transition hover:opacity-75 text-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:text-info dark:focus-visible:outline-info">Info</button>
|
||||
|
||||
<!-- danger Ghost Button -->
|
||||
<button type="button" class="bg-transparent rounded-radius px-4 py-2 text-sm font-medium tracking-wide text-danger transition hover:opacity-75 text-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:text-danger dark:focus-visible:outline-danger">Danger</button>
|
||||
|
||||
<!-- warning Ghost Button -->
|
||||
<button type="button" class="bg-transparent rounded-radius px-4 py-2 text-sm font-medium tracking-wide text-warning transition hover:opacity-75 text-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-warning active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:text-warning dark:focus-visible:outline-warning">Warning</button>
|
||||
|
||||
<!-- success Ghost Button -->
|
||||
<button type="button" class="bg-transparent rounded-radius px-4 py-2 text-sm font-medium tracking-wide text-success transition hover:opacity-75 text-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-success active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:text-success dark:focus-visible:outline-success">Success</button>
|
||||
|
||||
24
assets/views/penguinui/buttons/outline-button.html
Normal file
24
assets/views/penguinui/buttons/outline-button.html
Normal file
@@ -0,0 +1,24 @@
|
||||
<!-- primary Outline Button -->
|
||||
<button type="button" class="whitespace-nowrap bg-transparent rounded-radius border border-primary px-4 py-2 text-sm font-medium tracking-wide text-primary transition hover:opacity-75 text-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:border-primary-dark dark:text-primary-dark dark:focus-visible:outline-primary-dark">Primary</button>
|
||||
|
||||
<!-- secondary Outline Button -->
|
||||
<button type="button" class="whitespace-nowrap bg-transparent rounded-radius border border-secondary px-4 py-2 text-sm font-medium tracking-wide text-secondary transition hover:opacity-75 text-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-secondary active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:border-secondary-dark dark:text-secondary-dark dark:focus-visible:outline-secondary-dark">Secondary</button>
|
||||
|
||||
<!-- alternate Outline Button -->
|
||||
<button type="button" class="whitespace-nowrap bg-transparent rounded-radius border border-outline px-4 py-2 text-sm font-medium tracking-wide text-outline transition hover:opacity-75 text-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-outline active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:border-outline-dark dark:text-outline-dark dark:focus-visible:outline-outline-dark">Alternate</button>
|
||||
|
||||
<!-- inverse Outline Button -->
|
||||
<button type="button" class="whitespace-nowrap bg-transparent rounded-radius border border-surface-dark px-4 py-2 text-sm font-medium tracking-wide text-surface-dark transition hover:opacity-75 text-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-surface-dark active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:border-surface dark:text-surface dark:focus-visible:outline-surface">Inverse</button>
|
||||
|
||||
<!-- info Outline Button -->
|
||||
<button type="button" class="whitespace-nowrap bg-transparent rounded-radius border border-info px-4 py-2 text-sm font-medium tracking-wide text-info transition hover:opacity-75 text-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:border-info dark:text-info dark:focus-visible:outline-info">Info</button>
|
||||
|
||||
<!-- danger Outline Button -->
|
||||
<button type="button" class="whitespace-nowrap bg-transparent rounded-radius border border-danger px-4 py-2 text-sm font-medium tracking-wide text-danger transition hover:opacity-75 text-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:border-danger dark:text-danger dark:focus-visible:outline-danger">Danger</button>
|
||||
|
||||
<!-- warning Outline Button -->
|
||||
<button type="button" class="whitespace-nowrap bg-transparent rounded-radius border border-warning px-4 py-2 text-sm font-medium tracking-wide text-warning transition hover:opacity-75 text-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-warning active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:border-warning dark:text-warning dark:focus-visible:outline-warning">Warning</button>
|
||||
|
||||
<!-- success Outline Button -->
|
||||
<button type="button" class="whitespace-nowrap bg-transparent rounded-radius border border-success px-4 py-2 text-sm font-medium tracking-wide text-success transition hover:opacity-75 text-center focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-success active:opacity-100 active:outline-offset-0 disabled:opacity-75 disabled:cursor-not-allowed dark:border-success dark:text-success dark:focus-visible:outline-success">Success</button>
|
||||
|
||||
47
assets/views/penguinui/card/ecommerce-product-card.html
Normal file
47
assets/views/penguinui/card/ecommerce-product-card.html
Normal file
@@ -0,0 +1,47 @@
|
||||
<article class="group flex rounded-radius max-w-sm flex-col overflow-hidden border border-outline bg-surface-alt text-on-surface dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark">
|
||||
<!-- Image -->
|
||||
<div class="h-44 md:h-64 overflow-hidden">
|
||||
<img src="https://penguinui.s3.amazonaws.com/component-assets/card-img-3.webp" class="object-cover transition duration-700 ease-out group-hover:scale-105" alt="CASIO G-SHOCK GA2100, Black face, black bands" />
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="flex flex-col gap-4 p-6">
|
||||
<!-- Header -->
|
||||
<div class="flex flex-col md:flex-row gap-4 md:gap-12 justify-between">
|
||||
<!-- Title & Rating -->
|
||||
<div class="flex flex-col">
|
||||
<h3 class="text-lg lg:text-xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong" aria-describedby="productDescription">CASIO G-SHOCK GA2100</h3>
|
||||
<!-- Rating -->
|
||||
<div class="flex items-center gap-1">
|
||||
<span class="sr-only">Rated 3 stars</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4 text-amber-500" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4 text-amber-500" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4 text-amber-500" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4 text-on-surface/50 dark:text-on-surface-dark/50" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="size-4 text-on-surface/50 dark:text-on-surface-dark/50" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xl"><span class="sr-only">Price</span>$99.99</span>
|
||||
</div>
|
||||
<p id="productDescription" class="mb-2 text-pretty text-sm">
|
||||
The Casio G-Shock GA2100 is simply designed for easy
|
||||
timekeeping, featuring a sleek profile and clear display.
|
||||
</p>
|
||||
<!-- Button -->
|
||||
<button type="button" class="flex items-center justify-center gap-2 whitespace-nowrap bg-primary px-4 py-2 text-center text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary active:opacity-100 active:outline-offset-0 dark:bg-primary-dark dark:text-on-primary-dark dark:focus-visible:outline-primary-dark rounded-radius">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" class="size-3.5">
|
||||
<path fill-rule="evenodd" d="M5 4a3 3 0 0 1 6 0v1h.643a1.5 1.5 0 0 1 1.492 1.35l.7 7A1.5 1.5 0 0 1 12.342 15H3.657a1.5 1.5 0 0 1-1.492-1.65l.7-7A1.5 1.5 0 0 1 4.357 5H5V4Zm4.5 0v1h-3V4a1.5 1.5 0 0 1 3 0Zm-3 3.75a.75.75 0 0 0-1.5 0v1a3 3 0 1 0 6 0v-1a.75.75 0 0 0-1.5 0v1a1.5 1.5 0 1 1-3 0v-1Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
Add to Cart
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
9
assets/views/penguinui/checkbox/default-checkbox.html
Normal file
9
assets/views/penguinui/checkbox/default-checkbox.html
Normal file
@@ -0,0 +1,9 @@
|
||||
<label for="checkboxDefault" class="flex items-center gap-2 text-sm font-medium text-on-surface dark:text-on-surface-dark has-checked:text-on-surface-strong dark:has-checked:text-on-surface-dark-strong has-disabled:cursor-not-allowed has-disabled:opacity-75">
|
||||
<span class="relative flex items-center">
|
||||
<input id="checkboxDefault" type="checkbox" class="before:content[''] peer relative size-4 appearance-none overflow-hidden rounded-sm border border-outline bg-surface-alt before:absolute before:inset-0 checked:border-primary checked:before:bg-primary focus:outline-2 focus:outline-offset-2 focus:outline-outline-strong checked:focus:outline-primary active:outline-offset-0 disabled:cursor-not-allowed dark:border-outline-dark dark:bg-surface-dark-alt dark:checked:border-primary-dark dark:checked:before:bg-primary-dark dark:focus:outline-outline-dark-strong dark:checked:focus:outline-primary-dark" checked/>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" stroke="currentColor" fill="none" stroke-width="4" class="pointer-events-none invisible absolute left-1/2 top-1/2 size-3 -translate-x-1/2 -translate-y-1/2 text-on-primary peer-checked:visible dark:text-on-primary-dark">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>Notifications</span>
|
||||
</label>
|
||||
16
assets/views/penguinui/dropdowns/dropdown-with-click.html
Normal file
16
assets/views/penguinui/dropdowns/dropdown-with-click.html
Normal file
@@ -0,0 +1,16 @@
|
||||
<div x-data="{ isOpen: false, openedWithKeyboard: false }" class="relative w-fit" x-on:keydown.esc.window="isOpen = false, openedWithKeyboard = false">
|
||||
<!-- Toggle Button -->
|
||||
<button type="button" x-on:click="isOpen = ! isOpen" class="inline-flex items-center gap-2 whitespace-nowrap rounded-radius border border-outline bg-surface-alt px-4 py-2 text-sm font-medium tracking-wide transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-outline-strong dark:border-outline-dark dark:bg-surface-dark-alt dark:focus-visible:outline-outline-dark-strong" aria-haspopup="true" x-on:keydown.space.prevent="openedWithKeyboard = true" x-on:keydown.enter.prevent="openedWithKeyboard = true" x-on:keydown.down.prevent="openedWithKeyboard = true" x-bind:class="isOpen || openedWithKeyboard ? 'text-on-surface-strong dark:text-on-surface-dark-strong' : 'text-on-surface dark:text-on-surface-dark'" x-bind:aria-expanded="isOpen || openedWithKeyboard">
|
||||
Actions Menu
|
||||
<svg aria-hidden="true" fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4 rotate-0">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Dropdown Menu -->
|
||||
<div x-cloak x-show="isOpen || openedWithKeyboard" x-transition x-trap="openedWithKeyboard" x-on:click.outside="isOpen = false, openedWithKeyboard = false" x-on:keydown.down.prevent="$focus.wrap().next()" x-on:keydown.up.prevent="$focus.wrap().previous()" class="absolute top-11 left-0 flex w-fit min-w-48 flex-col overflow-hidden rounded-radius border border-outline bg-surface-alt dark:border-outline-dark dark:bg-surface-dark-alt" role="menu">
|
||||
<a href="#" class="bg-surface-alt px-4 py-2 text-sm text-on-surface hover:bg-surface-dark-alt/5 hover:text-on-surface-strong focus-visible:bg-surface-dark-alt/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:bg-surface-alt/5 dark:hover:text-on-surface-dark-strong dark:focus-visible:bg-surface-alt/10 dark:focus-visible:text-on-surface-dark-strong" role="menuitem">Dashboard</a>
|
||||
<a href="#" class="bg-surface-alt px-4 py-2 text-sm text-on-surface hover:bg-surface-dark-alt/5 hover:text-on-surface-strong focus-visible:bg-surface-dark-alt/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:bg-surface-alt/5 dark:hover:text-on-surface-dark-strong dark:focus-visible:bg-surface-alt/10 dark:focus-visible:text-on-surface-dark-strong" role="menuitem">Subscription</a>
|
||||
<a href="#" class="bg-surface-alt px-4 py-2 text-sm text-on-surface hover:bg-surface-dark-alt/5 hover:text-on-surface-strong focus-visible:bg-surface-dark-alt/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:bg-surface-alt/5 dark:hover:text-on-surface-dark-strong dark:focus-visible:bg-surface-alt/10 dark:focus-visible:text-on-surface-dark-strong" role="menuitem">Settings</a>
|
||||
<a href="#" class="bg-surface-alt px-4 py-2 text-sm text-on-surface hover:bg-surface-dark-alt/5 hover:text-on-surface-strong focus-visible:bg-surface-dark-alt/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:bg-surface-alt/5 dark:hover:text-on-surface-dark-strong dark:focus-visible:bg-surface-alt/10 dark:focus-visible:text-on-surface-dark-strong" role="menuitem">Sign Out</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
<div class="relative flex w-full max-w-sm flex-col gap-1">
|
||||
<label class="w-fit pl-0.5 text-sm text-on-surface dark:text-on-surface-dark" for="fileInput">Upload File</label>
|
||||
<input id="fileInput" type="file" class="w-full overflow-clip rounded-radius border border-outline bg-surface-alt/50 text-sm text-on-surface file:mr-4 file:border-none file:bg-surface-alt file:px-4 file:py-2 file:font-medium file:text-on-surface-strong focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:file:bg-surface-dark-alt dark:file:text-on-surface-dark-strong dark:focus-visible:outline-primary-dark" />
|
||||
</div>
|
||||
12
assets/views/penguinui/select/default-select.html
Normal file
12
assets/views/penguinui/select/default-select.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<div class="relative flex w-full max-w-xs flex-col gap-1 text-on-surface dark:text-on-surface-dark">
|
||||
<label for="os" class="w-fit pl-0.5 text-sm">Operating System</label>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="absolute pointer-events-none right-4 top-8 size-5">
|
||||
<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<select id="os" name="os" class="w-full appearance-none rounded-radius border border-outline bg-surface-alt px-4 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:focus-visible:outline-primary-dark">
|
||||
<option selected>Please Select</option>
|
||||
<option value="mac">Mac</option>
|
||||
<option value="windows">Windows</option>
|
||||
<option value="linux">Linux</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -0,0 +1,155 @@
|
||||
<div x-data="{ showSidebar: false }" class="relative flex w-full flex-col md:flex-row">
|
||||
<!-- This allows screen readers to skip the sidebar and go directly to the main content. -->
|
||||
<a class="sr-only" href="#main-content">skip to the main content</a>
|
||||
|
||||
<!-- dark overlay for when the sidebar is open on smaller screens -->
|
||||
<div x-cloak x-show="showSidebar" class="fixed inset-0 z-10 bg-surface-dark/10 backdrop-blur-xs md:hidden" aria-hidden="true" x-on:click="showSidebar = false" x-transition.opacity></div>
|
||||
|
||||
<nav x-cloak class="fixed left-0 z-20 flex h-svh w-60 shrink-0 flex-col border-r border-outline bg-surface-alt p-4 transition-transform duration-300 md:w-64 md:translate-x-0 md:relative dark:border-outline-dark dark:bg-surface-dark-alt" x-bind:class="showSidebar ? 'translate-x-0' : '-translate-x-60'" aria-label="sidebar navigation">
|
||||
<!-- logo -->
|
||||
<a href="#" class="ml-2 w-fit text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
<span class="sr-only">homepage</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 376 155" fill="none" class="w-24" aria-hidden="true">
|
||||
<path d="M54.009 101.344C54.137 103.733 54.777 105.867 55.929 107.744C57.081 109.621 58.745 111.093 60.921 112.16C63.1397 113.227 65.785 113.76 68.857 113.76C71.6303 113.76 74.0623 113.419 76.153 112.736C78.2863 112.053 80.0783 111.221 81.529 110.24C83.0223 109.216 84.1317 108.235 84.857 107.296L91.577 117.28C90.3823 118.773 88.7823 120.16 86.777 121.44C84.8143 122.677 82.297 123.659 79.225 124.384C76.1957 125.152 72.3983 125.536 67.833 125.536C62.073 125.536 57.017 124.405 52.665 122.144C48.313 119.883 44.921 116.619 42.489 112.352C40.057 108.085 38.841 103.008 38.841 97.12C38.841 92 39.929 87.392 42.105 83.296C44.281 79.1573 47.4383 75.8933 51.577 73.504C55.7583 71.1147 60.7717 69.92 66.617 69.92C72.121 69.92 76.8783 70.9867 80.889 73.12C84.9423 75.2107 88.0783 78.2827 90.297 82.336C92.5157 86.3893 93.625 91.3387 93.625 97.184C93.625 97.5253 93.6037 98.2293 93.561 99.296C93.561 100.32 93.5183 101.003 93.433 101.344H54.009ZM78.393 91.296C78.3503 89.9307 77.9237 88.4587 77.113 86.88C76.345 85.3013 75.129 83.9573 73.465 82.848C71.801 81.7387 69.5823 81.184 66.809 81.184C64.0357 81.184 61.753 81.7173 59.961 82.784C58.2117 83.8507 56.889 85.1733 55.993 86.752C55.097 88.288 54.585 89.8027 54.457 91.296H78.393ZM134.234 69.92C137.818 69.92 141.317 70.6667 144.73 72.16C148.143 73.6107 150.938 75.936 153.114 79.136C155.333 82.336 156.442 86.5173 156.442 91.68V124H140.122V94.496C140.122 90.1867 139.098 86.9867 137.05 84.896C135.045 82.8053 132.399 81.76 129.114 81.76C126.981 81.76 124.933 82.3573 122.97 83.552C121.05 84.704 119.471 86.3253 118.234 88.416C117.039 90.464 116.442 92.8107 116.442 95.456V124H100.186V71.456H116.442V79.84C116.911 78.3893 117.978 76.896 119.642 75.36C121.306 73.824 123.418 72.544 125.978 71.52C128.538 70.4533 131.29 69.92 134.234 69.92ZM189.297 152.096C185.457 152.096 181.766 151.733 178.225 151.008C174.726 150.283 171.548 149.152 168.689 147.616C165.83 146.08 163.441 144.075 161.521 141.6L171.377 131.616C172.273 132.768 173.425 133.92 174.833 135.072C176.284 136.267 178.118 137.248 180.337 138.016C182.556 138.827 185.329 139.232 188.657 139.232C191.814 139.232 194.524 138.613 196.785 137.376C199.089 136.181 200.86 134.475 202.097 132.256C203.377 130.037 204.017 127.456 204.017 124.512V123.04H219.953V125.472C219.953 131.275 218.588 136.16 215.857 140.128C213.126 144.096 209.457 147.083 204.849 149.088C200.241 151.093 195.057 152.096 189.297 152.096ZM204.017 124V114.848C203.633 115.787 202.652 117.109 201.073 118.816C199.494 120.523 197.361 122.08 194.673 123.488C192.028 124.853 188.913 125.536 185.329 125.536C180.294 125.536 175.836 124.341 171.953 121.952C168.07 119.52 165.02 116.213 162.801 112.032C160.625 107.808 159.537 103.051 159.537 97.76C159.537 92.4693 160.625 87.7333 162.801 83.552C165.02 79.328 168.07 76 171.953 73.568C175.836 71.136 180.294 69.92 185.329 69.92C188.828 69.92 191.857 70.5173 194.417 71.712C197.02 72.864 199.11 74.2293 200.689 75.808C202.31 77.344 203.356 78.7307 203.825 79.968V71.456H219.953V124H204.017ZM175.473 97.76C175.473 100.704 176.134 103.307 177.457 105.568C178.78 107.787 180.529 109.515 182.705 110.752C184.881 111.989 187.249 112.608 189.809 112.608C192.497 112.608 194.886 111.989 196.977 110.752C199.068 109.472 200.71 107.723 201.905 105.504C203.142 103.243 203.761 100.661 203.761 97.76C203.761 94.8587 203.142 92.2987 201.905 90.08C200.71 87.8187 199.068 86.048 196.977 84.768C194.886 83.488 192.497 82.848 189.809 82.848C187.249 82.848 184.881 83.488 182.705 84.768C180.529 86.0053 178.78 87.7547 177.457 90.016C176.134 92.2347 175.473 94.816 175.473 97.76ZM346.854 69.92C350.438 69.92 353.937 70.6667 357.35 72.16C360.763 73.6107 363.558 75.936 365.734 79.136C367.953 82.336 369.062 86.5173 369.062 91.68V124H352.742V94.496C352.742 90.1867 351.718 86.9867 349.67 84.896C347.665 82.8053 345.019 81.76 341.734 81.76C339.601 81.76 337.553 82.3573 335.59 83.552C333.67 84.704 332.091 86.3253 330.854 88.416C329.659 90.464 329.062 92.8107 329.062 95.456V124H312.806V71.456H329.062V79.84C329.531 78.3893 330.598 76.896 332.262 75.36C333.926 73.824 336.038 72.544 338.598 71.52C341.158 70.4533 343.91 69.92 346.854 69.92Z" class="fill-on-surface-strong dark:fill-on-surface-dark-strong"></path>
|
||||
<path d="M242.508 96.608C242.508 101.301 243.447 105.056 245.324 107.872C247.201 110.645 250.231 112.032 254.412 112.032C258.636 112.032 261.687 110.645 263.564 107.872C265.441 105.056 266.38 101.301 266.38 96.608V71.456H282.38V98.464C282.38 103.883 281.292 108.64 279.116 112.736C276.94 116.789 273.761 119.947 269.58 122.208C265.441 124.427 260.385 125.536 254.412 125.536C248.481 125.536 243.425 124.427 239.244 122.208C235.105 119.947 231.948 116.789 229.772 112.736C227.596 108.64 226.508 103.883 226.508 98.464V71.456H242.508V96.608ZM288.739 124V71.456H304.931V124H288.739ZM297.059 57.888C294.371 57.888 292.088 56.9493 290.211 55.072C288.376 53.1947 287.459 50.9547 287.459 48.352C287.459 45.7493 288.398 43.488 290.275 41.568C292.152 39.648 294.414 38.688 297.059 38.688C298.808 38.688 300.408 39.136 301.859 40.032C303.31 40.8853 304.483 42.0373 305.379 43.488C306.275 44.9387 306.723 46.56 306.723 48.352C306.723 50.9547 305.763 53.1947 303.843 55.072C301.966 56.9493 299.704 57.888 297.059 57.888Z" class="fill-primary dark:fill-primary-dark"></path>
|
||||
<g>
|
||||
<path d="M36.9195 49.9344C37.7242 49.9344 38.3765 49.2951 38.3765 48.5065C38.3765 47.7179 37.7242 47.0786 36.9195 47.0786C36.1147 47.0786 35.4624 47.7179 35.4624 48.5065C35.4624 49.2951 36.1147 49.9344 36.9195 49.9344Z" class="fill-on-surface-strong dark:fill-on-surface-dark-strong"></path>
|
||||
<path d="M68.6288 43.4241C65.7147 38.8016 61.4918 35.244 55.9846 32.7512C50.4528 30.2585 43.7849 29.0242 35.981 29.0242H4V126.024H19.6324C17.8049 112.471 18.2742 101.048 19.8547 91.7063C14.7427 95.7238 12.446 101.46 12.446 101.46C8.24767 90.6415 17.1135 80.0896 24.1024 75.6607C24.7445 73.9908 25.4112 72.4419 26.0533 71.014C29.6589 62.7854 27.1646 56.9528 23.4109 53.0806C18.9903 48.5307 12.8411 46.643 12.8411 46.643C12.8411 46.643 15.9034 41.1734 26.1027 46.8124C27.1152 47.369 28.3747 47.3448 29.3626 46.7398C30.0787 46.3041 30.8443 45.9411 31.6346 45.6023C37.5369 43.0611 44.2541 43.3999 49.3661 46.6672C55.1203 50.3458 62.2079 56.8076 58.3554 69.2715C57.7874 69.5619 56.2563 70.0701 55.7377 70.3363C48.5265 74.1844 40.4263 82.1709 42.2538 92.2146C42.2538 92.2146 47.6621 83.6956 56.7255 82.1225C56.7255 82.1225 70.9749 78.4197 72.8024 63.1969C72.9012 62.1078 73 61.0429 73 59.9054C73 53.5162 71.5429 48.0225 68.6041 43.4241H68.6288Z" class="fill-on-surface-strong dark:fill-on-surface-dark-strong"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- search -->
|
||||
<div class="relative my-4 flex w-full max-w-xs flex-col gap-1 text-on-surface dark:text-on-surface-dark">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2" class="absolute left-2 top-1/2 size-5 -translate-y-1/2 text-on-surface/50 dark:text-on-surface-dark/50" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/>
|
||||
</svg>
|
||||
<input type="search" class="w-full border border-outline rounded-radius bg-surface px-2 py-1.5 pl-9 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark/50 dark:focus-visible:outline-primary-dark" name="search" aria-label="Search" placeholder="Search"/>
|
||||
</div>
|
||||
|
||||
<!-- sidebar links -->
|
||||
<div class="flex flex-col gap-2 overflow-y-auto pb-6">
|
||||
|
||||
<a href="#" class="flex items-center rounded-radius gap-2 px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 hover:bg-primary/5 hover:text-on-surface-strong focus-visible:underline focus:outline-hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path d="M15.5 2A1.5 1.5 0 0 0 14 3.5v13a1.5 1.5 0 0 0 1.5 1.5h1a1.5 1.5 0 0 0 1.5-1.5v-13A1.5 1.5 0 0 0 16.5 2h-1ZM9.5 6A1.5 1.5 0 0 0 8 7.5v9A1.5 1.5 0 0 0 9.5 18h1a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 10.5 6h-1ZM3.5 10A1.5 1.5 0 0 0 2 11.5v5A1.5 1.5 0 0 0 3.5 18h1A1.5 1.5 0 0 0 6 16.5v-5A1.5 1.5 0 0 0 4.5 10h-1Z"/>
|
||||
</svg>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
|
||||
<!-- collapsible item -->
|
||||
<div x-data="{ isExpanded: false }" class="flex flex-col">
|
||||
<button type="button" x-on:click="isExpanded = ! isExpanded" id="user-management-btn" aria-controls="user-management" x-bind:aria-expanded="isExpanded ? 'true' : 'false'" class="flex items-center justify-between rounded-radius gap-2 px-2 py-1.5 text-sm font-medium underline-offset-2 focus:outline-hidden focus-visible:underline" x-bind:class="isExpanded ? 'text-on-surface-strong bg-primary/10 dark:text-on-surface-dark-strong dark:bg-primary-dark/10' : 'text-on-surface hover:bg-primary/5 hover:text-on-surface-strong dark:text-on-surface-dark dark:hover:text-on-surface-dark-strong dark:hover:bg-primary-dark/5'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path d="M7 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM14.5 9a2.5 2.5 0 1 0 0-5 2.5 2.5 0 0 0 0 5ZM1.615 16.428a1.224 1.224 0 0 1-.569-1.175 6.002 6.002 0 0 1 11.908 0c.058.467-.172.92-.57 1.174A9.953 9.953 0 0 1 7 18a9.953 9.953 0 0 1-5.385-1.572ZM14.5 16h-.106c.07-.297.088-.611.048-.933a7.47 7.47 0 0 0-1.588-3.755 4.502 4.502 0 0 1 5.874 2.636.818.818 0 0 1-.36.98A7.465 7.465 0 0 1 14.5 16Z"/>
|
||||
</svg>
|
||||
<span class="mr-auto text-left">User Management</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5 transition-transform rotate-0 shrink-0" x-bind:class="isExpanded ? 'rotate-180' : 'rotate-0'" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<ul x-cloak x-collapse x-show="isExpanded" aria-labelledby="user-management-btn" id="user-management">
|
||||
<li class="px-1 py-0.5 first:mt-2">
|
||||
<a href="#" class="flex items-center rounded-radius gap-2 px-2 py-1.5 text-sm text-on-surface underline-offset-2 hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">Users</a>
|
||||
</li>
|
||||
<li class="px-1 py-0.5 first:mt-2">
|
||||
<a href="#" class="flex items-center rounded-radius gap-2 px-2 py-1.5 text-sm text-on-surface underline-offset-2 hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">Permissions</a>
|
||||
</li>
|
||||
<li class="px-1 py-0.5 first:mt-2">
|
||||
<a href="#" class="flex items-center rounded-radius gap-2 px-2 py-1.5 text-sm text-on-surface underline-offset-2 hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">Activity Log</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- collapsible item -->
|
||||
<div x-data="{ isExpanded: false }" class="flex flex-col">
|
||||
<button type="button" x-on:click="isExpanded = ! isExpanded" id="products-btn" aria-controls="products" x-bind:aria-expanded="isExpanded ? 'true' : 'false'" class="flex items-center justify-between rounded-radius gap-2 px-2 py-1.5 text-sm font-medium underline-offset-2 focus:outline-hidden focus-visible:underline" x-bind:class="isExpanded ? 'text-on-surface-strong bg-primary/10 dark:text-on-surface-dark-strong dark:bg-primary-dark/10' : 'text-on-surface hover:bg-primary/5 hover:text-on-surface-strong dark:text-on-surface-dark dark:hover:text-on-surface-dark-strong dark:hover:bg-primary-dark/5'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path d="M10.362 1.093a.75.75 0 0 0-.724 0L2.523 5.018 10 9.143l7.477-4.125-7.115-3.925ZM18 6.443l-7.25 4v8.25l6.862-3.786A.75.75 0 0 0 18 14.25V6.443ZM9.25 18.693v-8.25l-7.25-4v7.807a.75.75 0 0 0 .388.657l6.862 3.786Z"/>
|
||||
</svg>
|
||||
<span class="mr-auto text-left">Products</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5 transition-transform rotate-0 shrink-0" x-bind:class="isExpanded ? 'rotate-180' : 'rotate-0'" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<ul x-cloak x-collapse x-show="isExpanded" aria-labelledby="products-btn" id="products">
|
||||
<li class="px-1 py-0.5 first:mt-2">
|
||||
<a href="#" class="flex items-center rounded-radius gap-2 px-2 py-1.5 text-sm text-on-surface underline-offset-2 hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">All Products</a>
|
||||
</li>
|
||||
<li class="px-1 py-0.5 first:mt-2">
|
||||
<a href="#" class="flex items-center rounded-radius gap-2 px-2 py-1.5 text-sm text-on-surface underline-offset-2 hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">Inventory</a>
|
||||
</li>
|
||||
<li class="px-1 py-0.5 first:mt-2">
|
||||
<a href="#" class="flex items-center rounded-radius gap-2 px-2 py-1.5 text-sm text-on-surface underline-offset-2 hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">Reviews</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- collapsible item -->
|
||||
<div x-data="{ isExpanded: false }" class="flex flex-col">
|
||||
<button type="button" x-on:click="isExpanded = ! isExpanded" id="orders-btn" aria-controls="orders" x-bind:aria-expanded="isExpanded ? 'true' : 'false'" class="flex items-center justify-between rounded-radius gap-2 px-2 py-1.5 text-sm font-medium underline-offset-2 focus:outline-hidden focus-visible:underline" x-bind:class="isExpanded ? 'text-on-surface-strong bg-primary/10 dark:text-on-surface-dark-strong dark:bg-primary-dark/10' : 'text-on-surface hover:bg-primary/5 hover:text-on-surface-strong dark:text-on-surface-dark dark:hover:text-on-surface-dark-strong dark:hover:bg-primary-dark/5'">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path d="M6.5 3c-1.051 0-2.093.04-3.125.117A1.49 1.49 0 0 0 2 4.607V10.5h9V4.606c0-.771-.59-1.43-1.375-1.489A41.568 41.568 0 0 0 6.5 3ZM2 12v2.5A1.5 1.5 0 0 0 3.5 16h.041a3 3 0 0 1 5.918 0h.791a.75.75 0 0 0 .75-.75V12H2Z"/>
|
||||
<path d="M6.5 18a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3ZM13.25 5a.75.75 0 0 0-.75.75v8.514a3.001 3.001 0 0 1 4.893 1.44c.37-.275.61-.719.595-1.227a24.905 24.905 0 0 0-1.784-8.549A1.486 1.486 0 0 0 14.823 5H13.25ZM14.5 18a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3Z"/>
|
||||
</svg>
|
||||
<span class="mr-auto text-left">Orders</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5 transition-transform rotate-0 shrink-0" x-bind:class="isExpanded ? 'rotate-180' : 'rotate-0'" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<ul x-cloak x-collapse x-show="isExpanded" aria-labelledby="orders-btn" id="orders">
|
||||
<li class="px-1 py-0.5 first:mt-2">
|
||||
<a href="#" class="flex items-center rounded-radius gap-2 px-2 py-1.5 text-sm text-on-surface underline-offset-2 hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
|
||||
<span>Pending</span>
|
||||
<span class="ml-auto font-bold">3</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="px-1 py-0.5 first:mt-2">
|
||||
<a href="#" class="flex items-center rounded-radius gap-2 px-2 py-1.5 text-sm text-on-surface underline-offset-2 hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
|
||||
<span>Shipped</span>
|
||||
<span class="ml-auto font-bold">12</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="px-1 py-0.5 first:mt-2">
|
||||
<a href="#" class="flex items-center rounded-radius gap-2 px-2 py-1.5 text-sm text-on-surface underline-offset-2 hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
|
||||
<span>Completed</span>
|
||||
<span class="ml-auto font-bold">38</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="px-1 py-0.5 first:mt-2">
|
||||
<a href="#" class="flex items-center rounded-radius gap-2 px-2 py-1.5 text-sm text-on-surface underline-offset-2 hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
|
||||
<span>Returns</span>
|
||||
<span class="ml-auto font-bold">2</span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<a href="#" class="flex items-center rounded-radius gap-2 px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 hover:bg-primary/5 hover:text-on-surface-strong focus-visible:underline focus:outline-hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M7.84 1.804A1 1 0 0 1 8.82 1h2.36a1 1 0 0 1 .98.804l.331 1.652a6.993 6.993 0 0 1 1.929 1.115l1.598-.54a1 1 0 0 1 1.186.447l1.18 2.044a1 1 0 0 1-.205 1.251l-1.267 1.113a7.047 7.047 0 0 1 0 2.228l1.267 1.113a1 1 0 0 1 .206 1.25l-1.18 2.045a1 1 0 0 1-1.187.447l-1.598-.54a6.993 6.993 0 0 1-1.929 1.115l-.33 1.652a1 1 0 0 1-.98.804H8.82a1 1 0 0 1-.98-.804l-.331-1.652a6.993 6.993 0 0 1-1.929-1.115l-1.598.54a1 1 0 0 1-1.186-.447l-1.18-2.044a1 1 0 0 1 .205-1.251l1.267-1.114a7.05 7.05 0 0 1 0-2.227L1.821 7.773a1 1 0 0 1-.206-1.25l1.18-2.045a1 1 0 0 1 1.187-.447l1.598.54A6.992 6.992 0 0 1 7.51 3.456l.33-1.652ZM10 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- main content -->
|
||||
<div id="main-content" class="h-svh w-full overflow-y-auto p-4 bg-white dark:bg-neutral-950">
|
||||
<!-- Add main content here -->
|
||||
</div>
|
||||
|
||||
<!-- toggle button for small screen -->
|
||||
<button class="fixed right-4 top-4 z-20 rounded-full bg-primary p-4 md:hidden text-on-primary dark:bg-primary-dark dark:text-on-primary-dark" x-on:click="showSidebar = ! showSidebar">
|
||||
<svg x-show="showSidebar" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="size-5" aria-hidden="true">
|
||||
<path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8z"/>
|
||||
</svg>
|
||||
<svg x-show="! showSidebar" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="size-5" aria-hidden="true">
|
||||
<path d="M0 3a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zm5-1v12h9a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1zM4 2H2a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h2z"/>
|
||||
</svg>
|
||||
<span class="sr-only">sidebar toggle</span>
|
||||
</button>
|
||||
</div>
|
||||
86
assets/views/penguinui/sidebar/simple-sidebar.html
Normal file
86
assets/views/penguinui/sidebar/simple-sidebar.html
Normal file
@@ -0,0 +1,86 @@
|
||||
<div x-data="{ showSidebar: false }" class="relative flex w-full flex-col md:flex-row">
|
||||
<!-- This allows screen readers to skip the sidebar and go directly to the main content. -->
|
||||
<a class="sr-only" href="#main-content">skip to the main content</a>
|
||||
|
||||
<!-- dark overlay for when the sidebar is open on smaller screens -->
|
||||
<div x-cloak x-show="showSidebar" class="fixed inset-0 z-10 bg-surface-dark/10 backdrop-blur-xs md:hidden" aria-hidden="true" x-on:click="showSidebar = false" x-transition.opacity ></div>
|
||||
|
||||
<nav x-cloak class="fixed left-0 z-20 flex h-svh w-60 shrink-0 flex-col border-r border-outline bg-surface-alt p-4 transition-transform duration-300 md:w-64 md:translate-x-0 md:relative dark:border-outline-dark dark:bg-surface-dark-alt" x-bind:class="showSidebar ? 'translate-x-0' : '-translate-x-60'" aria-label="sidebar navigation">
|
||||
<!-- logo -->
|
||||
<a href="#" class="ml-2 w-fit text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">
|
||||
<span class="sr-only">homepage</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 376 155" fill="none" class="w-24" aria-hidden="true">
|
||||
<path d="M54.009 101.344C54.137 103.733 54.777 105.867 55.929 107.744C57.081 109.621 58.745 111.093 60.921 112.16C63.1397 113.227 65.785 113.76 68.857 113.76C71.6303 113.76 74.0623 113.419 76.153 112.736C78.2863 112.053 80.0783 111.221 81.529 110.24C83.0223 109.216 84.1317 108.235 84.857 107.296L91.577 117.28C90.3823 118.773 88.7823 120.16 86.777 121.44C84.8143 122.677 82.297 123.659 79.225 124.384C76.1957 125.152 72.3983 125.536 67.833 125.536C62.073 125.536 57.017 124.405 52.665 122.144C48.313 119.883 44.921 116.619 42.489 112.352C40.057 108.085 38.841 103.008 38.841 97.12C38.841 92 39.929 87.392 42.105 83.296C44.281 79.1573 47.4383 75.8933 51.577 73.504C55.7583 71.1147 60.7717 69.92 66.617 69.92C72.121 69.92 76.8783 70.9867 80.889 73.12C84.9423 75.2107 88.0783 78.2827 90.297 82.336C92.5157 86.3893 93.625 91.3387 93.625 97.184C93.625 97.5253 93.6037 98.2293 93.561 99.296C93.561 100.32 93.5183 101.003 93.433 101.344H54.009ZM78.393 91.296C78.3503 89.9307 77.9237 88.4587 77.113 86.88C76.345 85.3013 75.129 83.9573 73.465 82.848C71.801 81.7387 69.5823 81.184 66.809 81.184C64.0357 81.184 61.753 81.7173 59.961 82.784C58.2117 83.8507 56.889 85.1733 55.993 86.752C55.097 88.288 54.585 89.8027 54.457 91.296H78.393ZM134.234 69.92C137.818 69.92 141.317 70.6667 144.73 72.16C148.143 73.6107 150.938 75.936 153.114 79.136C155.333 82.336 156.442 86.5173 156.442 91.68V124H140.122V94.496C140.122 90.1867 139.098 86.9867 137.05 84.896C135.045 82.8053 132.399 81.76 129.114 81.76C126.981 81.76 124.933 82.3573 122.97 83.552C121.05 84.704 119.471 86.3253 118.234 88.416C117.039 90.464 116.442 92.8107 116.442 95.456V124H100.186V71.456H116.442V79.84C116.911 78.3893 117.978 76.896 119.642 75.36C121.306 73.824 123.418 72.544 125.978 71.52C128.538 70.4533 131.29 69.92 134.234 69.92ZM189.297 152.096C185.457 152.096 181.766 151.733 178.225 151.008C174.726 150.283 171.548 149.152 168.689 147.616C165.83 146.08 163.441 144.075 161.521 141.6L171.377 131.616C172.273 132.768 173.425 133.92 174.833 135.072C176.284 136.267 178.118 137.248 180.337 138.016C182.556 138.827 185.329 139.232 188.657 139.232C191.814 139.232 194.524 138.613 196.785 137.376C199.089 136.181 200.86 134.475 202.097 132.256C203.377 130.037 204.017 127.456 204.017 124.512V123.04H219.953V125.472C219.953 131.275 218.588 136.16 215.857 140.128C213.126 144.096 209.457 147.083 204.849 149.088C200.241 151.093 195.057 152.096 189.297 152.096ZM204.017 124V114.848C203.633 115.787 202.652 117.109 201.073 118.816C199.494 120.523 197.361 122.08 194.673 123.488C192.028 124.853 188.913 125.536 185.329 125.536C180.294 125.536 175.836 124.341 171.953 121.952C168.07 119.52 165.02 116.213 162.801 112.032C160.625 107.808 159.537 103.051 159.537 97.76C159.537 92.4693 160.625 87.7333 162.801 83.552C165.02 79.328 168.07 76 171.953 73.568C175.836 71.136 180.294 69.92 185.329 69.92C188.828 69.92 191.857 70.5173 194.417 71.712C197.02 72.864 199.11 74.2293 200.689 75.808C202.31 77.344 203.356 78.7307 203.825 79.968V71.456H219.953V124H204.017ZM175.473 97.76C175.473 100.704 176.134 103.307 177.457 105.568C178.78 107.787 180.529 109.515 182.705 110.752C184.881 111.989 187.249 112.608 189.809 112.608C192.497 112.608 194.886 111.989 196.977 110.752C199.068 109.472 200.71 107.723 201.905 105.504C203.142 103.243 203.761 100.661 203.761 97.76C203.761 94.8587 203.142 92.2987 201.905 90.08C200.71 87.8187 199.068 86.048 196.977 84.768C194.886 83.488 192.497 82.848 189.809 82.848C187.249 82.848 184.881 83.488 182.705 84.768C180.529 86.0053 178.78 87.7547 177.457 90.016C176.134 92.2347 175.473 94.816 175.473 97.76ZM346.854 69.92C350.438 69.92 353.937 70.6667 357.35 72.16C360.763 73.6107 363.558 75.936 365.734 79.136C367.953 82.336 369.062 86.5173 369.062 91.68V124H352.742V94.496C352.742 90.1867 351.718 86.9867 349.67 84.896C347.665 82.8053 345.019 81.76 341.734 81.76C339.601 81.76 337.553 82.3573 335.59 83.552C333.67 84.704 332.091 86.3253 330.854 88.416C329.659 90.464 329.062 92.8107 329.062 95.456V124H312.806V71.456H329.062V79.84C329.531 78.3893 330.598 76.896 332.262 75.36C333.926 73.824 336.038 72.544 338.598 71.52C341.158 70.4533 343.91 69.92 346.854 69.92Z" class="fill-on-surface-strong dark:fill-on-surface-dark-strong"></path>
|
||||
<path d="M242.508 96.608C242.508 101.301 243.447 105.056 245.324 107.872C247.201 110.645 250.231 112.032 254.412 112.032C258.636 112.032 261.687 110.645 263.564 107.872C265.441 105.056 266.38 101.301 266.38 96.608V71.456H282.38V98.464C282.38 103.883 281.292 108.64 279.116 112.736C276.94 116.789 273.761 119.947 269.58 122.208C265.441 124.427 260.385 125.536 254.412 125.536C248.481 125.536 243.425 124.427 239.244 122.208C235.105 119.947 231.948 116.789 229.772 112.736C227.596 108.64 226.508 103.883 226.508 98.464V71.456H242.508V96.608ZM288.739 124V71.456H304.931V124H288.739ZM297.059 57.888C294.371 57.888 292.088 56.9493 290.211 55.072C288.376 53.1947 287.459 50.9547 287.459 48.352C287.459 45.7493 288.398 43.488 290.275 41.568C292.152 39.648 294.414 38.688 297.059 38.688C298.808 38.688 300.408 39.136 301.859 40.032C303.31 40.8853 304.483 42.0373 305.379 43.488C306.275 44.9387 306.723 46.56 306.723 48.352C306.723 50.9547 305.763 53.1947 303.843 55.072C301.966 56.9493 299.704 57.888 297.059 57.888Z" class="fill-primary dark:fill-primary-dark"></path>
|
||||
<g>
|
||||
<path d="M36.9195 49.9344C37.7242 49.9344 38.3765 49.2951 38.3765 48.5065C38.3765 47.7179 37.7242 47.0786 36.9195 47.0786C36.1147 47.0786 35.4624 47.7179 35.4624 48.5065C35.4624 49.2951 36.1147 49.9344 36.9195 49.9344Z" class="fill-on-surface-strong dark:fill-on-surface-dark-strong"></path>
|
||||
<path d="M68.6288 43.4241C65.7147 38.8016 61.4918 35.244 55.9846 32.7512C50.4528 30.2585 43.7849 29.0242 35.981 29.0242H4V126.024H19.6324C17.8049 112.471 18.2742 101.048 19.8547 91.7063C14.7427 95.7238 12.446 101.46 12.446 101.46C8.24767 90.6415 17.1135 80.0896 24.1024 75.6607C24.7445 73.9908 25.4112 72.4419 26.0533 71.014C29.6589 62.7854 27.1646 56.9528 23.4109 53.0806C18.9903 48.5307 12.8411 46.643 12.8411 46.643C12.8411 46.643 15.9034 41.1734 26.1027 46.8124C27.1152 47.369 28.3747 47.3448 29.3626 46.7398C30.0787 46.3041 30.8443 45.9411 31.6346 45.6023C37.5369 43.0611 44.2541 43.3999 49.3661 46.6672C55.1203 50.3458 62.2079 56.8076 58.3554 69.2715C57.7874 69.5619 56.2563 70.0701 55.7377 70.3363C48.5265 74.1844 40.4263 82.1709 42.2538 92.2146C42.2538 92.2146 47.6621 83.6956 56.7255 82.1225C56.7255 82.1225 70.9749 78.4197 72.8024 63.1969C72.9012 62.1078 73 61.0429 73 59.9054C73 53.5162 71.5429 48.0225 68.6041 43.4241H68.6288Z" class="fill-on-surface-strong dark:fill-on-surface-dark-strong"></path>
|
||||
</g>
|
||||
</svg>
|
||||
</a>
|
||||
|
||||
<!-- search -->
|
||||
<div class="relative my-4 flex w-full max-w-xs flex-col gap-1 text-on-surface dark:text-on-surface-dark">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" stroke="currentColor" fill="none" stroke-width="2" class="absolute left-2 top-1/2 size-5 -translate-y-1/2 text-on-surface/50 dark:text-on-surface-dark/50" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z"/>
|
||||
</svg>
|
||||
<input type="search" class="w-full border border-outline rounded-radius bg-surface px-2 py-1.5 pl-9 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark/50 dark:focus-visible:outline-primary-dark" name="search" aria-label="Search" placeholder="Search"/>
|
||||
</div>
|
||||
|
||||
<!-- sidebar links -->
|
||||
<div class="flex flex-col gap-2 overflow-y-auto pb-6">
|
||||
|
||||
<a href="#" class="flex items-center rounded-radius gap-2 px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 hover:bg-primary/5 hover:text-on-surface-strong focus-visible:underline focus:outline-hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path d="M15.5 2A1.5 1.5 0 0 0 14 3.5v13a1.5 1.5 0 0 0 1.5 1.5h1a1.5 1.5 0 0 0 1.5-1.5v-13A1.5 1.5 0 0 0 16.5 2h-1ZM9.5 6A1.5 1.5 0 0 0 8 7.5v9A1.5 1.5 0 0 0 9.5 18h1a1.5 1.5 0 0 0 1.5-1.5v-9A1.5 1.5 0 0 0 10.5 6h-1ZM3.5 10A1.5 1.5 0 0 0 2 11.5v5A1.5 1.5 0 0 0 3.5 18h1A1.5 1.5 0 0 0 6 16.5v-5A1.5 1.5 0 0 0 4.5 10h-1Z"/>
|
||||
</svg>
|
||||
<span>Dashboard</span>
|
||||
</a>
|
||||
|
||||
<a href="#" class="flex items-center rounded-radius gap-2 bg-primary/10 px-2 py-1.5 text-sm font-medium text-on-surface-strong underline-offset-2 focus-visible:underline focus:outline-hidden dark:bg-primary-dark/10 dark:text-on-surface-dark-strong">
|
||||
<svg xmlns="http://www.w3.org/2000/svg viewBox="0 0 20 20" fill="currentColor" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path d="M10 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM3.465 14.493a1.23 1.23 0 0 0 .41 1.412A9.957 9.957 0 0 0 10 18c2.31 0 4.438-.784 6.131-2.1.43-.333.604-.903.408-1.41a7.002 7.002 0 0 0-13.074.003Z"/>
|
||||
</svg>
|
||||
<span>Profile</span>
|
||||
<span class="sr-only">active</span>
|
||||
</a>
|
||||
|
||||
<a href="#" class="flex items-center rounded-radius gap-2 px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 hover:bg-primary/5 hover:text-on-surface-strong focus-visible:underline focus:outline-hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10 2c-2.236 0-4.43.18-6.57.524C1.993 2.755 1 4.014 1 5.426v5.148c0 1.413.993 2.67 2.43 2.902 1.168.188 2.352.327 3.55.414.28.02.521.18.642.413l1.713 3.293a.75.75 0 0 0 1.33 0l1.713-3.293a.783.783 0 0 1 .642-.413 41.102 41.102 0 0 0 3.55-.414c1.437-.231 2.43-1.49 2.43-2.902V5.426c0-1.413-.993-2.67-2.43-2.902A41.289 41.289 0 0 0 10 2ZM6.75 6a.75.75 0 0 0 0 1.5h6.5a.75.75 0 0 0 0-1.5h-6.5Zm0 2.5a.75.75 0 0 0 0 1.5h3.5a.75.75 0 0 0 0-1.5h-3.5Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span>Inbox</span>
|
||||
</a>
|
||||
|
||||
<a href="#" class="flex items-center rounded-radius gap-2 px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 hover:bg-primary/5 hover:text-on-surface-strong focus-visible:underline focus:outline-hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10 2a6 6 0 0 0-6 6c0 1.887-.454 3.665-1.257 5.234a.75.75 0 0 0 .515 1.076 32.91 32.91 0 0 0 3.256.508 3.5 3.5 0 0 0 6.972 0 32.903 32.903 0 0 0 3.256-.508.75.75 0 0 0 .515-1.076A11.448 11.448 0 0 1 16 8a6 6 0 0 0-6-6ZM8.05 14.943a33.54 33.54 0 0 0 3.9 0 2 2 0 0 1-3.9 0Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span>Notifications</span>
|
||||
</a>
|
||||
|
||||
<a href="#" class="flex items-center rounded-radius gap-2 px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 hover:bg-primary/5 hover:text-on-surface-strong focus-visible:underline focus:outline-hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path d="M7.84 1.804A1 1 0 0 1 8.82 1h2.36a1 1 0 0 1 .98.804l.331 1.652a6.993 6.993 0 0 1 1.929 1.115l1.598-.54a1 1 0 0 1 1.186.447l1.18 2.044a1 1 0 0 1-.205 1.251l-1.267 1.113a7.047 7.047 0 0 1 0 2.228l1.267 1.113a1 1 0 0 1 .206 1.25l-1.18 2.045a1 1 0 0 1-1.187.447l-1.598-.54a6.993 6.993 0 0 1-1.929 1.115l-.33 1.652a1 1 0 0 1-.98.804H8.82a1 1 0 0 1-.98-.804l-.331-1.652a6.993 6.993 0 0 1-1.929-1.115l-1.598.54a1 1 0 0 1-1.186-.447l-1.18-2.044a1 1 0 0 1 .205-1.251l1.267-1.114a7.05 7.05 0 0 1 0-2.227L1.821 7.773a1 1 0 0 1-.206-1.25l1.18-2.045a1 1 0 0 1 1.187-.447l1.598.54A6.992 6.992 0 0 1 7.51 3.456l.33-1.652ZM10 13a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z" clip-rule="evenodd"/>
|
||||
</svg>
|
||||
<span>Settings</span>
|
||||
</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- main content -->
|
||||
<div id="main-content" class="h-svh w-full overflow-y-auto p-4 bg-surface dark:bg-surface-dark">
|
||||
<!-- Add main content here -->
|
||||
</div>
|
||||
|
||||
<!-- toggle button for small screen -->
|
||||
<button class="fixed right-4 top-4 z-20 rounded-full bg-primary p-4 md:hidden text-on-primary dark:bg-primary-dark dark:text-on-primary-dark" x-on:click="showSidebar = ! showSidebar">
|
||||
<svg x-show="showSidebar" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="size-5" aria-hidden="true">
|
||||
<path d="M2.146 2.854a.5.5 0 1 1 .708-.708L8 7.293l5.146-5.147a.5.5 0 0 1 .708.708L8.707 8l5.147 5.146a.5.5 0 0 1-.708.708L8 8.707l-5.146 5.147a.5.5 0 0 1-.708-.708L7.293 8z"/>
|
||||
</svg>
|
||||
<svg x-show="! showSidebar" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" class="size-5" aria-hidden="true">
|
||||
<path d="M0 3a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v10a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2zm5-1v12h9a1 1 0 0 0 1-1V3a1 1 0 0 0-1-1zM4 2H2a1 1 0 0 0-1 1v10a1 1 0 0 0 1 1h2z"/>
|
||||
</svg>
|
||||
<span class="sr-only">sidebar toggle</span>
|
||||
</button>
|
||||
</div>
|
||||
4
assets/views/penguinui/text-area/default-textarea.html
Normal file
4
assets/views/penguinui/text-area/default-textarea.html
Normal file
@@ -0,0 +1,4 @@
|
||||
<div class="flex w-full max-w-md flex-col gap-1 text-on-surface dark:text-on-surface-dark">
|
||||
<label for="textArea" class="w-fit pl-0.5 text-sm">Comment</label>
|
||||
<textarea id="textArea" class="w-full rounded-radius border border-outline bg-surface-alt px-2.5 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:focus-visible:outline-primary-dark" rows="3" placeholder="We'd love to hear from you..."></textarea>
|
||||
</div>
|
||||
@@ -0,0 +1,4 @@
|
||||
<div class="flex w-full max-w-xs flex-col gap-1 text-on-surface dark:text-on-surface-dark">
|
||||
<label for="textInputDefault" class="w-fit pl-0.5 text-sm">Name</label>
|
||||
<input id="textInputDefault" type="text" class="w-full rounded-radius border border-outline bg-surface-alt px-2 py-2 text-sm focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:focus-visible:outline-primary-dark" name="name" placeholder="Enter your name" autocomplete="name"/>
|
||||
</div>
|
||||
@@ -0,0 +1,204 @@
|
||||
<!-- Triggers -->
|
||||
|
||||
<!-- Message Trigger -->
|
||||
<button x-on:click="$dispatch('notify', { variant: 'message', sender:{name:'Jack Ellis', avatar:'https://penguinui.s3.amazonaws.com/component-assets/avatar-2.webp'}, message: 'Hey, can you review the PR I just submitted? Let me know if you spot any issues!' })" type="button" class="whitespace-nowrap rounded-radius bg-primary px-4 py-2 text-center text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark dark:focus-visible:outline-primary-dark">Message</button>
|
||||
<!-- Info Trigger -->
|
||||
<button x-on:click="$dispatch('notify', { variant: 'info', title: 'Update Available', message: 'A new version of the app is ready for you. Update now to enjoy the latest features!' })" type="button" class="whitespace-nowrap rounded-radius bg-info px-4 py-2 text-center text-sm font-medium tracking-wide text-on-info transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-info active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75">Info</button>
|
||||
<!-- Success Trigger -->
|
||||
<button x-on:click="$dispatch('notify', { variant: 'success', title: 'Success!', message: 'Your changes have been saved. Keep up the great work!' })" type="button" class="whitespace-nowrap rounded-radius bg-success px-4 py-2 text-center text-sm font-medium tracking-wide text-on-success transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-success active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75">Success</button>
|
||||
<!-- Danger Trigger -->
|
||||
<button x-on:click="$dispatch('notify', { variant: 'danger', title: 'Oops!', message: 'Something went wrong. Please try again. If the problem persists, we’re here to help!' })" type="button" class="whitespace-nowrap rounded-radius bg-danger px-4 py-2 text-center text-sm font-medium tracking-wide text-on-danger transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-danger active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75">Danger</button>
|
||||
<!-- Warning Trigger -->
|
||||
<button x-on:click="$dispatch('notify', { variant: 'warning', title: 'Action Needed', message: 'Your storage is getting low. Consider upgrading your plan.' })" type="button" class="whitespace-nowrap rounded-radius bg-warning px-4 py-2 text-center text-sm font-medium tracking-wide text-on-warning transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-warning active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75">Warning</button>
|
||||
|
||||
<!-- Notifications -->
|
||||
<div x-data="{
|
||||
notifications: [],
|
||||
displayDuration: 8000,
|
||||
soundEffect: false,
|
||||
|
||||
addNotification({ variant = 'info', sender = null, title = null, message = null}) {
|
||||
const id = Date.now()
|
||||
const notification = { id, variant, sender, title, message }
|
||||
|
||||
// Keep only the most recent 20 notifications
|
||||
if (this.notifications.length >= 20) {
|
||||
this.notifications.splice(0, this.notifications.length - 19)
|
||||
}
|
||||
|
||||
// Add the new notification to the notifications stack
|
||||
this.notifications.push(notification)
|
||||
|
||||
if (this.soundEffect) {
|
||||
// Play the notification sound
|
||||
const notificationSound = new Audio('https://res.cloudinary.com/ds8pgw1pf/video/upload/v1728571480/penguinui/component-assets/sounds/ding.mp3')
|
||||
notificationSound.play().catch((error) => {
|
||||
console.error('Error playing the sound:', error)
|
||||
})
|
||||
}
|
||||
},
|
||||
removeNotification(id) {
|
||||
setTimeout(() => {
|
||||
this.notifications = this.notifications.filter(
|
||||
(notification) => notification.id !== id,
|
||||
)
|
||||
}, 400);
|
||||
},
|
||||
}" x-on:notify.window="addNotification({
|
||||
variant: $event.detail.variant,
|
||||
sender: $event.detail.sender,
|
||||
title: $event.detail.title,
|
||||
message: $event.detail.message,
|
||||
})">
|
||||
|
||||
<div x-on:mouseenter="$dispatch('pause-auto-dismiss')" x-on:mouseleave="$dispatch('resume-auto-dismiss')" class="group pointer-events-none fixed inset-x-8 top-0 z-99 flex max-w-full flex-col gap-2 bg-transparent px-6 py-6 md:bottom-0 md:left-[unset] md:right-0 md:top-[unset] md:max-w-sm">
|
||||
<template x-for="(notification, index) in notifications" x-bind:key="notification.id">
|
||||
<!-- root div holds all of the notifications -->
|
||||
<div>
|
||||
<!-- Info Notification -->
|
||||
<template x-if="notification.variant === 'info'">
|
||||
<div x-data="{ isVisible: false, timeout: null }" x-cloak x-show="isVisible" class="pointer-events-auto relative rounded-radius border border-info bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark" role="alert" x-on:pause-auto-dismiss.window="clearTimeout(timeout)" x-on:resume-auto-dismiss.window=" timeout = setTimeout(() => {(isVisible = false), removeNotification(notification.id) }, displayDuration)" x-init="$nextTick(() => { isVisible = true }), (timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id)}, displayDuration))" x-transition:enter="transition duration-300 ease-out" x-transition:enter-end="translate-y-0" x-transition:enter-start="translate-y-8" x-transition:leave="transition duration-300 ease-in" x-transition:leave-end="-translate-x-24 opacity-0 md:translate-x-24" x-transition:leave-start="translate-x-0 opacity-100">
|
||||
<div class="flex w-full items-center gap-2.5 bg-info/10 rounded-radius p-4 transition-all duration-300">
|
||||
|
||||
<!-- Icon -->
|
||||
<div class="rounded-full bg-info/15 p-0.5 text-info" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-7-4a1 1 0 1 1-2 0 1 1 0 0 1 2 0ZM9 9a.75.75 0 0 0 0 1.5h.253a.25.25 0 0 1 .244.304l-.459 2.066A1.75 1.75 0 0 0 10.747 15H11a.75.75 0 0 0 0-1.5h-.253a.25.25 0 0 1-.244-.304l.459-2.066A1.75 1.75 0 0 0 9.253 9H9Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Title & Message -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 x-cloak x-show="notification.title" class="text-sm font-semibold text-info" x-text="notification.title"></h3>
|
||||
<p x-cloak x-show="notification.message" class="text-pretty text-sm" x-text="notification.message"></p>
|
||||
</div>
|
||||
|
||||
<!--Dismiss Button -->
|
||||
<button type="button" class="ml-auto" aria-label="dismiss notification" x-on:click="(isVisible = false), removeNotification(notification.id)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg viewBox="0 0 24 24 stroke="currentColor" fill="none" stroke-width="2" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Success Notification -->
|
||||
<template x-if="notification.variant === 'success'">
|
||||
<div x-data="{ isVisible: false, timeout: null }" x-cloak x-show="isVisible" class="pointer-events-auto relative rounded-radius border border-success bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark" role="alert" x-on:pause-auto-dismiss.window="clearTimeout(timeout)" x-on:resume-auto-dismiss.window=" timeout = setTimeout(() => {(isVisible = false), removeNotification(notification.id) }, displayDuration)" x-init="$nextTick(() => { isVisible = true }), (timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id)}, displayDuration))" x-transition:enter="transition duration-300 ease-out" x-transition:enter-end="translate-y-0" x-transition:enter-start="translate-y-8" x-transition:leave="transition duration-300 ease-in" x-transition:leave-end="-translate-x-24 opacity-0 md:translate-x-24" x-transition:leave-start="translate-x-0 opacity-100">
|
||||
<div class="flex w-full items-center gap-2.5 bg-success/10 rounded-radius p-4 transition-all duration-300">
|
||||
|
||||
<!-- Icon -->
|
||||
<div class="rounded-full bg-success/15 p-0.5 text-success" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 1 0 0-16 8 8 0 0 0 0 16Zm3.857-9.809a.75.75 0 0 0-1.214-.882l-3.483 4.79-1.88-1.88a.75.75 0 1 0-1.06 1.061l2.5 2.5a.75.75 0 0 0 1.137-.089l4-5.5Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Title & Message -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 x-cloak x-show="notification.title" class="text-sm font-semibold text-success" x-text="notification.title"></h3>
|
||||
<p x-cloak x-show="notification.message" class="text-pretty text-sm" x-text="notification.message"></p>
|
||||
</div>
|
||||
|
||||
<!--Dismiss Button -->
|
||||
<button type="button" class="ml-auto" aria-label="dismiss notification" x-on:click="(isVisible = false), removeNotification(notification.id)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg viewBox="0 0 24 24 stroke="currentColor" fill="none" stroke-width="2" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Warning Notification -->
|
||||
<template x-if="notification.variant === 'warning'">
|
||||
<div x-data="{ isVisible: false, timeout: null }" x-cloak x-show="isVisible" class="pointer-events-auto relative rounded-radius border border-warning bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark" role="alert" x-on:pause-auto-dismiss.window="clearTimeout(timeout)" x-on:resume-auto-dismiss.window=" timeout = setTimeout(() => {(isVisible = false), removeNotification(notification.id) }, displayDuration)" x-init="$nextTick(() => { isVisible = true }), (timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id)}, displayDuration))" x-transition:enter="transition duration-300 ease-out" x-transition:enter-end="translate-y-0" x-transition:enter-start="translate-y-8" x-transition:leave="transition duration-300 ease-in" x-transition:leave-end="-translate-x-24 opacity-0 md:translate-x-24" x-transition:leave-start="translate-x-0 opacity-100">
|
||||
<div class="flex w-full items-center gap-2.5 bg-warning/10 rounded-radius p-4 transition-all duration-300">
|
||||
|
||||
<!-- Icon -->
|
||||
<div class="rounded-full bg-warning/15 p-0.5 text-warning" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Title & Message -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 x-cloak x-show="notification.title" class="text-sm font-semibold text-warning" x-text="notification.title"></h3>
|
||||
<p x-cloak x-show="notification.message" class="text-pretty text-sm" x-text="notification.message"></p>
|
||||
</div>
|
||||
|
||||
<!--Dismiss Button -->
|
||||
<button type="button" class="ml-auto" aria-label="dismiss notification" x-on:click="(isVisible = false), removeNotification(notification.id)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg viewBox="0 0 24 24 stroke="currentColor" fill="none" stroke-width="2" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Danger Notification -->
|
||||
<template x-if="notification.variant === 'danger'">
|
||||
<div x-data="{ isVisible: false, timeout: null }" x-cloak x-show="isVisible" class="pointer-events-auto relative rounded-radius border border-danger bg-surface text-on-surface dark:bg-surface-dark dark:text-on-surface-dark" role="alert" x-on:pause-auto-dismiss.window="clearTimeout(timeout)" x-on:resume-auto-dismiss.window=" timeout = setTimeout(() => {(isVisible = false), removeNotification(notification.id) }, displayDuration)" x-init="$nextTick(() => { isVisible = true }), (timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id)}, displayDuration))" x-transition:enter="transition duration-300 ease-out" x-transition:enter-end="translate-y-0" x-transition:enter-start="translate-y-8" x-transition:leave="transition duration-300 ease-in" x-transition:leave-end="-translate-x-24 opacity-0 md:translate-x-24" x-transition:leave-start="translate-x-0 opacity-100">
|
||||
<div class="flex w-full items-center gap-2.5 bg-danger/10 rounded-radius p-4 transition-all duration-300">
|
||||
|
||||
<!-- Icon -->
|
||||
<div class="rounded-full bg-danger/15 p-0.5 text-danger" aria-hidden="true">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 1 1-16 0 8 8 0 0 1 16 0Zm-8-5a.75.75 0 0 1 .75.75v4.5a.75.75 0 0 1-1.5 0v-4.5A.75.75 0 0 1 10 5Zm0 10a1 1 0 1 0 0-2 1 1 0 0 0 0 2Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Title & Message -->
|
||||
<div class="flex flex-col gap-2">
|
||||
<h3 x-cloak x-show="notification.title" class="text-sm font-semibold text-danger" x-text="notification.title"></h3>
|
||||
<p x-cloak x-show="notification.message" class="text-pretty text-sm" x-text="notification.message"></p>
|
||||
</div>
|
||||
|
||||
<!--Dismiss Button -->
|
||||
<button type="button" class="ml-auto" aria-label="dismiss notification" x-on:click="(isVisible = false), removeNotification(notification.id)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg viewBox="0 0 24 24 stroke="currentColor" fill="none" stroke-width="2" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Message Notification -->
|
||||
<template x-if="notification.variant === 'message'">
|
||||
<div x-data="{ isVisible: false, timeout: null }" x-cloak x-show="isVisible" class="pointer-events-auto relative rounded-radius border border-outline bg-surface text-on-surface dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark" role="alert" x-on:pause-auto-dismiss.window="clearTimeout(timeout)" x-on:resume-auto-dismiss.window="timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id) }, displayDuration)" x-init="$nextTick(() => { isVisible = true }), (timeout = setTimeout(() => { isVisible = false, removeNotification(notification.id) }, displayDuration))" x-transition:enter="transition duration-300 ease-out" x-transition:enter-end="translate-y-0" x-transition:enter-start="translate-y-8" x-transition:leave="transition duration-300 ease-in" x-transition:leave-end="-translate-x-24 opacity-0 md:translate-x-24" x-transition:leave-start="translate-x-0 opacity-100">
|
||||
<div class="flex w-full rounded-radius items-center gap-2.5 bg-surface-alt p-4 transition-all duration-300 dark:bg-surface-dark-alt">
|
||||
<div class="flex w-full items-center gap-2.5">
|
||||
|
||||
<!-- Avatar -->
|
||||
<img x-cloak x-show="notification.sender.avatar" class="mr-2 size-12 rounded-full" alt="avatar" aria-hidden="true" x-bind:src="notification.sender.avatar"/>
|
||||
<div class="flex flex-col items-start gap-2">
|
||||
<!-- Title & Message -->
|
||||
<h3 x-cloak x-show="notification.sender.name" class="text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong" x-text="notification.sender.name"></h3>
|
||||
<p x-cloak x-show="notification.message" class="text-pretty text-sm" x-text="notification.message"></p>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="flex items-center gap-4">
|
||||
<button type="button" class="whitespace-nowrap bg-transparent text-center text-sm font-bold tracking-wide text-primary transition hover:opacity-75 active:opacity-100 dark:text-primary-dark">Reply</button>
|
||||
<button type="button" class="whitespace-nowrap bg-transparent text-center text-sm font-bold tracking-wide text-on-surface transition hover:opacity-75 active:opacity-100 dark:text-on-surface-dark" x-on:click=" (isVisible = false), setTimeout(() => { removeNotification(notification.id) }, 400)">Dismiss</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Dismiss Button -->
|
||||
<button type="button" class="ml-auto" aria-label="dismiss notification" x-on:click="(isVisible = false), removeNotification(notification.id)">
|
||||
<svg xmlns="http://www.w3.org/2000/svg viewBox="0 0 24 24 stroke="currentColor" fill="none" stroke-width="2" class="size-5 shrink-0" aria-hidden="true">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,12 +1,37 @@
|
||||
<a href="/shop/{{ product.slug }}"
|
||||
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">
|
||||
<div class="aspect-square overflow-hidden bg-surface-alt dark:bg-surface-dark">
|
||||
{% if product.image %}
|
||||
<img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover transition group-hover:scale-105">
|
||||
{# Adapted from the vendored Penguin UI component
|
||||
(assets/views/penguinui/card/ecommerce-product-card.html):
|
||||
wired to our product data + i18n + htmx add-to-cart + toast. The demo rating
|
||||
stars, hardcoded title/price/description/image and the `max-w-sm` (which fights
|
||||
the shop grid) are dropped; the whole card links to the product page. #}
|
||||
<article
|
||||
class="group flex flex-col overflow-hidden rounded-radius border border-outline bg-surface-alt text-on-surface transition hover:border-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:border-primary-dark">
|
||||
<a href="/shop/{{ product.slug }}" class="flex flex-1 flex-col">
|
||||
<!-- Image -->
|
||||
<div class="h-44 overflow-hidden bg-surface-alt md:h-64 dark:bg-surface-dark">
|
||||
{% if product.image %}
|
||||
<img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover transition duration-700 ease-out group-hover:scale-105">
|
||||
{% endif %}
|
||||
</div>
|
||||
<!-- Content -->
|
||||
<div class="flex flex-1 flex-col gap-1 p-6 pb-2">
|
||||
<!-- Header: Title & Price -->
|
||||
<div class="flex justify-between gap-4">
|
||||
<h3 class="text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
|
||||
<span class="whitespace-nowrap text-xl"><span class="sr-only">Price</span>{{ product.price }} {{ product.currency }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="flex flex-col gap-2 p-6 pt-0">
|
||||
{% if product.stock > 0 %}
|
||||
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}</p>
|
||||
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none"
|
||||
hx-on::after-request="if (event.detail.successful) toast('{{ t(key='cart-added', lang=lang | default(value='sk')) }}')">
|
||||
<input type="hidden" name="product_id" value="{{ product.id }}">
|
||||
<input type="hidden" name="quantity" value="1">
|
||||
{{ ui::button(label=t(key="add-to-cart", lang=lang | default(value='sk')), type="submit", extra="w-full", icon='<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true" class="size-3.5"><path fill-rule="evenodd" d="M5 4a3 3 0 0 1 6 0v1h.643a1.5 1.5 0 0 1 1.492 1.35l.7 7A1.5 1.5 0 0 1 12.342 15H3.657a1.5 1.5 0 0 1-1.492-1.65l.7-7A1.5 1.5 0 0 1 4.357 5H5V4Zm4.5 0v1h-3V4a1.5 1.5 0 0 1 3 0Zm-3 3.75a.75.75 0 0 0-1.5 0v1a3 3 0 1 0 6 0v-1a.75.75 0 0 0-1.5 0v1a1.5 1.5 0 1 1-3 0v-1Z" clip-rule="evenodd" /></svg>') }}
|
||||
</form>
|
||||
{% 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 class="flex flex-1 flex-col gap-1 p-4">
|
||||
<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>
|
||||
</div>
|
||||
</a>
|
||||
</article>
|
||||
|
||||
72
assets/views/shop/_cart_body.html
Normal file
72
assets/views/shop/_cart_body.html
Normal file
@@ -0,0 +1,72 @@
|
||||
{# 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. #}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
{% 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-alt px-2 py-1 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-75 dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark">
|
||||
</form>
|
||||
</td>
|
||||
<td class="px-4 py-3 text-right font-medium tabular-nums">{{ item.line_total }} {{ item.currency }}</td>
|
||||
<td class="px-4 py-3 text-right">
|
||||
<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 }}">
|
||||
{{ ui::button(variant="ghost-danger", label=t(key="cart-remove", lang=lang | default(value='sk')), type="submit", size="px-2 py-1 text-xs") }}
|
||||
</form>
|
||||
</td>
|
||||
</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">
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="cart-continue", lang=lang | default(value='sk')), href="/shop") }}
|
||||
{{ ui::button(label=t(key="cart-checkout", lang=lang | default(value='sk')), href="/checkout", size="px-5 py-2 text-sm") }}
|
||||
</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>
|
||||
{{ ui::button(label=t(key="cart-continue", lang=lang | default(value='sk')), href="/shop", extra="mt-4") }}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -1,27 +1,62 @@
|
||||
{# Site-wide category sidebar contents, served as an htmx partial and swapped
|
||||
into the <aside> in base.html. `category_tree` is a depth-ordered flat list
|
||||
of { name, slug, depth }; nesting is shown via left indentation. Active state
|
||||
is set client-side by markActiveNav() via data-nav + aria-current. #}
|
||||
<p class="px-3 pb-2 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
||||
{# Site-wide category menu, served as an htmx partial and swapped into the
|
||||
<aside> in base.html. `category_groups` is a two-level list of top-level
|
||||
categories, each `{ name, slug, children: [{ name, slug }] }`. A category
|
||||
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.
|
||||
|
||||
Adapted from the vendored Penguin UI component
|
||||
assets/views/penguinui/sidebar/sidebar-with-collapsible-menus.html: Penguin's
|
||||
link treatment + active state + chevron-down rotation. Deviations: the group
|
||||
row keeps our link + toggle split (categories are navigable, not just
|
||||
expandable), and we use x-show/x-transition instead of upstream's x-collapse
|
||||
(that Alpine plugin isn't bundled in our build). #}
|
||||
<p class="px-2 pb-2 text-xs font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">
|
||||
{{ t(key="categories", lang=lang | default(value='sk')) }}
|
||||
</p>
|
||||
<ul class="flex flex-col gap-0.5">
|
||||
<li>
|
||||
<a href="/shop" data-nav="/shop"
|
||||
class="block 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">
|
||||
{{ t(key="all-products", lang=lang | default(value='sk')) }}
|
||||
</a>
|
||||
</li>
|
||||
{% for item in category_tree %}
|
||||
<li>
|
||||
<a href="/category/{{ item.slug }}" data-nav="/category/{{ item.slug }}" style="padding-left: {{ 12 + item.depth * 16 }}px"
|
||||
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">
|
||||
{% if item.depth > 0 %}<span class="text-on-surface/40 dark:text-on-surface-dark/40">↳</span>{% endif %}
|
||||
<span class="truncate">{{ item.name }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<div class="flex flex-col gap-1">
|
||||
<a href="/shop" data-nav="/shop"
|
||||
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||
{{ t(key="all-products", lang=lang | default(value='sk')) }}
|
||||
</a>
|
||||
{% for group in category_groups %}
|
||||
{% if group.children | length > 0 %}
|
||||
<div x-data="{ open: false }" class="flex flex-col"
|
||||
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 flex-1 items-center gap-2 truncate rounded-l-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||
{{ group.name }}
|
||||
</a>
|
||||
<button type="button" x-on:click="open = ! open" x-bind:aria-expanded="open ? 'true' : 'false'"
|
||||
aria-label="{{ group.name }}"
|
||||
class="inline-flex w-8 shrink-0 items-center justify-center rounded-r-radius text-on-surface/60 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark/60 dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
|
||||
class="size-5 shrink-0 transition-transform rotate-0" x-bind:class="open ? 'rotate-180' : 'rotate-0'" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<ul x-show="open" x-cloak x-transition class="ml-3 mt-0.5 flex flex-col gap-0.5 border-l border-outline pl-1 dark:border-outline-dark">
|
||||
{% for child in group.children %}
|
||||
<li>
|
||||
<a href="/category/{{ child.slug }}" data-nav="/category/{{ child.slug }}"
|
||||
class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||
{{ child.name }}
|
||||
</a>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% else %}
|
||||
<a href="/category/{{ group.slug }}" data-nav="/category/{{ group.slug }}"
|
||||
class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||
{{ group.name }}
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</ul>
|
||||
{% if category_tree | 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>
|
||||
</div>
|
||||
{% if category_groups | length == 0 %}
|
||||
<p class="px-2 py-2 text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="shop-empty", lang=lang | default(value='sk')) }}</p>
|
||||
{% endif %}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{{ t(key="cart-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||
|
||||
@@ -6,62 +7,8 @@
|
||||
<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>
|
||||
|
||||
{% 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">
|
||||
<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 id="cart-body">
|
||||
{% include "shop/_cart_body.html" %}
|
||||
</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>
|
||||
{% endblock content %}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{{ category.name }}{% endblock title %}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{{ t(key="checkout-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||
|
||||
@@ -35,13 +36,43 @@
|
||||
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-contact", lang=lang | default(value='sk')) }}</legend>
|
||||
<div class="space-y-1.5">
|
||||
<label for="email" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-email", lang=lang | default(value='sk')) }}</label>
|
||||
<input id="email" name="email" type="email" 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">
|
||||
{{ ui::input(name="email", id="email", type="email", required=true, autocomplete="email") }}
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label for="customer_name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-name", lang=lang | default(value='sk')) }}</label>
|
||||
<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">
|
||||
{{ ui::input(name="customer_name", id="customer_name", required=true, autocomplete="name") }}
|
||||
</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>
|
||||
{{ ui::input(name="phone", id="phone", type="tel", required=true, autocomplete="tel", placeholder="900 000 000", attrs='inputmode="tel"') }}
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -50,24 +81,45 @@
|
||||
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</legend>
|
||||
<div class="space-y-1.5">
|
||||
<label for="address" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-address", lang=lang | default(value='sk')) }}</label>
|
||||
<input id="address" name="address" 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">
|
||||
{{ ui::input(name="address", id="address", required=true, autocomplete="street-address") }}
|
||||
</div>
|
||||
<div class="grid gap-4 sm:grid-cols-3">
|
||||
<div class="space-y-1.5">
|
||||
<label for="city" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-city", lang=lang | default(value='sk')) }}</label>
|
||||
<input id="city" name="city" 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">
|
||||
{{ ui::input(name="city", id="city", required=true, autocomplete="address-level2") }}
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label for="zip" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-zip", lang=lang | default(value='sk')) }}</label>
|
||||
<input id="zip" name="zip" 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">
|
||||
{{ ui::input(name="zip", id="zip", required=true, autocomplete="postal-code") }}
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label for="country" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-country", lang=lang | default(value='sk')) }}</label>
|
||||
<input id="country" name="country" type="text" required value="Slovensko"
|
||||
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 class="relative" @click.outside="countryOpen = false"
|
||||
x-data="{ countryOpen: false, country: '{{ t(key='country-sk', lang=lang | default(value='sk')) }}', opts: [
|
||||
{ v: '{{ t(key='country-sk', lang=lang | default(value='sk')) }}', l: '🇸🇰 {{ t(key='country-sk', lang=lang | default(value='sk')) }}' },
|
||||
{ v: '{{ t(key='country-cz', lang=lang | default(value='sk')) }}', l: '🇨🇿 {{ t(key='country-cz', lang=lang | default(value='sk')) }}' },
|
||||
{ v: '{{ t(key='country-at', lang=lang | default(value='sk')) }}', l: '🇦🇹 {{ t(key='country-at', lang=lang | default(value='sk')) }}' },
|
||||
{ v: '{{ t(key='country-de', lang=lang | default(value='sk')) }}', l: '🇩🇪 {{ t(key='country-de', lang=lang | default(value='sk')) }}' },
|
||||
{ v: '{{ t(key='country-pl', lang=lang | default(value='sk')) }}', l: '🇵🇱 {{ t(key='country-pl', lang=lang | default(value='sk')) }}' },
|
||||
{ v: '{{ t(key='country-hu', lang=lang | default(value='sk')) }}', l: '🇭🇺 {{ t(key='country-hu', lang=lang | default(value='sk')) }}' }
|
||||
], get filtered() { return this.opts.filter(o => !this.country || o.v.toLowerCase().includes(this.country.toLowerCase())) } }">
|
||||
<input id="country" name="country" type="text" x-model="country" required @focus="countryOpen = true" @input="countryOpen = true"
|
||||
class="w-full rounded-radius border border-outline bg-surface py-2 pl-3 pr-8 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||
<button type="button" tabindex="-1" @click="countryOpen = !countryOpen"
|
||||
class="absolute inset-y-0 right-0 flex w-8 items-center justify-center text-on-surface/60 dark:text-on-surface-dark/60">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"
|
||||
class="size-4 transition-transform" :class="countryOpen && 'rotate-180'">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||
</svg>
|
||||
</button>
|
||||
<ul x-show="countryOpen" x-cloak x-transition
|
||||
class="absolute z-20 mt-1 max-h-56 w-full overflow-auto rounded-radius border border-outline bg-surface p-1 shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
<template x-for="o in filtered" :key="o.v">
|
||||
<li><button type="button" @click="country = o.v; countryOpen = false" x-text="o.l"
|
||||
class="block w-full rounded-radius px-3 py-1.5 text-left text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark"></button></li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
@@ -124,8 +176,7 @@
|
||||
|
||||
<div class="space-y-1.5">
|
||||
<label for="note" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-note", lang=lang | default(value='sk')) }}</label>
|
||||
<textarea id="note" name="note" rows="3"
|
||||
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"></textarea>
|
||||
{{ ui::textarea(name="note", id="note", rows="3") }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -154,10 +205,7 @@
|
||||
<span>{{ t(key="cart-total", lang=lang | default(value='sk')) }}</span>
|
||||
<span class="tabular-nums text-primary dark:text-primary-dark" x-text="fmt(subtotal + carrierPrice) + ' {{ currency }}'"></span>
|
||||
</div>
|
||||
<button type="submit" :disabled="!canSubmit"
|
||||
class="inline-flex w-full items-center justify-center rounded-radius bg-primary px-6 py-2.5 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-primary-dark dark:text-on-primary-dark">
|
||||
{{ t(key="checkout-place-order", lang=lang | default(value='sk')) }}
|
||||
</button>
|
||||
{{ ui::button(label=t(key="checkout-place-order", lang=lang | default(value='sk')), type="submit", attrs=':disabled="!canSubmit"', extra="w-full", size="px-6 py-2.5 text-sm") }}
|
||||
</aside>
|
||||
</form>
|
||||
{% endblock content %}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{{ t(key="nav-shop", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{{ t(key="order-confirmed-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||
|
||||
@@ -55,7 +56,7 @@
|
||||
{% endif %}
|
||||
|
||||
<div class="text-center">
|
||||
<a href="/shop" class="inline-flex items-center justify-center rounded-radius border border-outline px-5 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>
|
||||
{{ ui::button(variant="outline-secondary", label=t(key="cart-continue", lang=lang | default(value='sk')), href="/shop", size="px-5 py-2 text-sm") }}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends "base.html" %}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
|
||||
{% block title %}{{ product.name }}{% endblock title %}
|
||||
|
||||
@@ -39,17 +40,14 @@
|
||||
{% endif %}
|
||||
|
||||
{% if product.stock > 0 %}
|
||||
<form method="post" action="/cart/add" hx-boost="false" class="flex flex-wrap items-end gap-3">
|
||||
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none" class="flex flex-wrap items-end gap-3"
|
||||
hx-on::after-request="if (event.detail.successful) toast('{{ t(key='cart-added', lang=lang | default(value='sk')) }}')">
|
||||
<input type="hidden" name="product_id" value="{{ product.id }}">
|
||||
<div class="space-y-1.5">
|
||||
<label for="quantity" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="quantity", lang=lang | default(value='sk')) }}</label>
|
||||
<input id="quantity" name="quantity" type="number" min="1" max="{{ product.stock }}" value="1"
|
||||
class="w-24 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">
|
||||
{{ ui::input(name="quantity", id="quantity", type="number", value="1", width="w-24", attrs='min="1" max="' ~ product.stock ~ '"') }}
|
||||
</div>
|
||||
<button type="submit"
|
||||
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="add-to-cart", lang=lang | default(value='sk')) }}
|
||||
</button>
|
||||
{{ ui::button(label=t(key="add-to-cart", lang=lang | default(value='sk')), type="submit", size="px-5 py-2 text-sm") }}
|
||||
</form>
|
||||
<p class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}</p>
|
||||
{% else %}
|
||||
|
||||
@@ -71,7 +71,7 @@ mailer:
|
||||
# Database Configuration
|
||||
database:
|
||||
# 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.
|
||||
enable_logging: false
|
||||
# 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.
|
||||
# Empty falls back to a plain text field for the pickup point.
|
||||
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_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.") }}
|
||||
|
||||
@@ -68,7 +68,7 @@ mailer:
|
||||
# Database Configuration
|
||||
database:
|
||||
# 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.
|
||||
enable_logging: false
|
||||
# Set the timeout duration when acquiring a connection.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
services:
|
||||
gitara-web:
|
||||
container_name: gitara-web
|
||||
kompress:
|
||||
container_name: kompress
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
@@ -9,9 +9,9 @@ services:
|
||||
env_file:
|
||||
- .env.production
|
||||
volumes:
|
||||
- gitara_web_data:/usr/app/data
|
||||
- kompress_eshop_data:/usr/app/data
|
||||
networks:
|
||||
- gitara-net
|
||||
- kompress_eshop-net
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -fsS http://localhost:5150/_ping"]
|
||||
@@ -21,10 +21,10 @@ services:
|
||||
start_period: 20s
|
||||
|
||||
networks:
|
||||
gitara-net:
|
||||
kompress_eshop-net:
|
||||
external: true
|
||||
|
||||
volumes:
|
||||
gitara_web_data:
|
||||
kompress_eshop_data:
|
||||
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)]
|
||||
use loco_rs::{cli::playground, prelude::*};
|
||||
use gitara_web::app::App;
|
||||
use kompress_eshop::app::App;
|
||||
|
||||
#[tokio::main]
|
||||
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 = {
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||
@@ -41,8 +41,8 @@
|
||||
cargo = pkgs.rust-bin.stable.latest.minimal;
|
||||
rustc = pkgs.rust-bin.stable.latest.minimal;
|
||||
};
|
||||
gitara-web = rustPlatform.buildRustPackage {
|
||||
pname = "gitara_web";
|
||||
kompress = rustPlatform.buildRustPackage {
|
||||
pname = "kompress_eshop";
|
||||
inherit version;
|
||||
src = ./.;
|
||||
cargoLock.lockFile = ./Cargo.lock;
|
||||
@@ -57,7 +57,7 @@
|
||||
];
|
||||
|
||||
# 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.
|
||||
doCheck = false;
|
||||
|
||||
@@ -66,8 +66,8 @@
|
||||
};
|
||||
in
|
||||
{
|
||||
gitara-web = gitara-web;
|
||||
default = gitara-web;
|
||||
kompress = kompress;
|
||||
default = kompress;
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
480
hardcoded-inventory.md
Normal file
480
hardcoded-inventory.md
Normal file
@@ -0,0 +1,480 @@
|
||||
# Handcoded UI Components — Penguin UI Replacement Index
|
||||
|
||||
> **Scope**: Every handcoded UI component.
|
||||
> Each item maps to a [Penguin UI](https://github.com/SalarHoushvand/penguinui-components/tree/main) component that duplicates the same purpose with fewer lines and better accessibility.
|
||||
|
||||
## Vendoring convention
|
||||
|
||||
When a Penguin UI component can replace a handcoded one, we vendor its source
|
||||
and then use it (instead of hand-rolling):
|
||||
|
||||
1. Copy the component's source **byte-for-byte** from the [Penguin UI repo](https://github.com/SalarHoushvand/penguinui-components/tree/main)
|
||||
into `assets/views/penguinui/`, **mirroring the upstream repo hierarchy**
|
||||
(e.g. `toast-notification/stacking-toast-notification.html`). This directory
|
||||
is reserved exclusively for vendored Penguin UI components and is kept an
|
||||
**exact, unmodified mirror** of upstream — demo triggers, bugs and all. It's
|
||||
a reference, not the rendered markup.
|
||||
2. Adapt it where it's actually used (strip docs-only demo triggers, fix obvious
|
||||
upstream bugs, wire data bindings). Note the deviations in a comment next to
|
||||
the adapted copy.
|
||||
3. Rebuild Tailwind (`make css`) so any new utility classes get compiled.
|
||||
4. Mark the section below as ✅ **DONE**.
|
||||
|
||||
---
|
||||
|
||||
## 0. Toast — ✅ DONE
|
||||
|
||||
**Penguin UI: `toast-notification/stacking-toast-notification.html`**
|
||||
|
||||
- Exact upstream mirror at `assets/views/penguinui/toast-notification/stacking-toast-notification.html` (reference only)
|
||||
- Adapted/rendered copy lives inline in `assets/views/base.html` (demo triggers
|
||||
removed; the upstream dismiss-button `<svg>` quote bugs fixed)
|
||||
- The global `toast('message')` JS helper now dispatches the component's
|
||||
`notify` event (`{ variant: 'success', message }`), so existing callsites
|
||||
(`shop/show.html`, `shop/_card.html`) keep working unchanged.
|
||||
|
||||
---
|
||||
|
||||
## 1. Navbar
|
||||
**Penguin UI: `navbar/`**
|
||||
|
||||
| # | Location | What it is | Size |
|
||||
|---|----------|------------|------|
|
||||
| 1 | `assets/views/base.html:63-191` | Full site navbar: brand, desktop nav links, cart icon+badge, settings dropdown, mobile hamburger → mobile panel | ~130 lines |
|
||||
| 2 | `assets/views/admin/base.html:102-114` | Admin top bar: hamburger toggle + breadcrumb text | ~13 lines |
|
||||
|
||||
**Details for #1 (site navbar):**
|
||||
- **Brand/logo**: `base.html:74-77` — plain `<a>` with text
|
||||
- **Desktop nav links**: `base.html:80-92` — `<ul>` with 4–5 items, manual `aria-current` routing
|
||||
- **Cart icon + badge**: `base.html:96-109` — hand-rolled SVG cart icon + an Alpine `x-data` badge that reads `document.cookie` directly
|
||||
- **Settings dropdown**: `base.html:110-162` — gear-icon trigger + language-switcher `<form>` + theme tristate (system/light/dark)
|
||||
- **Mobile hamburger**: `base.html:164-172` — hamburger SVG button
|
||||
- **Mobile menu panel**: `base.html:175-190` — dropdown `<ul>` with duplicated nav links
|
||||
|
||||
**Penguin navbar variants:** `default-navbar.html`, `with-call-to-action.html`, `with-search.html`, `with-user-profile.html`
|
||||
|
||||
---
|
||||
|
||||
## 2. Sidebar (Admin) — ✅ DONE
|
||||
**Penguin UI: `sidebar/simple-sidebar.html`**
|
||||
|
||||
- Exact upstream mirror at `assets/views/penguinui/sidebar/simple-sidebar.html` (reference only)
|
||||
- Adapted at use-site in `assets/views/admin/base.html`: the nav links + bottom
|
||||
exit/logout now use Penguin's link treatment (`hover:bg-primary/5`,
|
||||
`underline-offset-2 focus-visible:underline focus:outline-hidden`) and the
|
||||
subtle active state (`bg-primary/10` + `text-on-surface-strong`) mapped onto
|
||||
our `data-nav`/`aria-current` so `markActiveNav()` still drives it.
|
||||
- The fixed-rail translate-X show/hide mechanics + mobile overlay (#4) are layout
|
||||
scaffolding, kept as-is. Icons were intentionally not added (no verified icon
|
||||
set yet) — possible follow-up.
|
||||
|
||||
---
|
||||
|
||||
## 3. Sidebar (Category Accordion) — ✅ DONE
|
||||
**Penguin UI: `sidebar/sidebar-with-collapsible-menus.html`**
|
||||
|
||||
- Exact upstream mirror at `assets/views/penguinui/sidebar/sidebar-with-collapsible-menus.html` (reference only)
|
||||
- Adapted at use-site in `assets/views/shop/_sidebar.html`: Penguin link treatment +
|
||||
active state + chevron-down rotation (`rotate-180`); child items now sit in a
|
||||
bordered/indented list instead of the old `padding-left:28px` + `↳`. Kept our
|
||||
htmx partial, data-driven `category_groups`, auto-expand `x-init`, and
|
||||
`data-nav`/`markActiveNav()` active routing.
|
||||
- Deviations: group row keeps our link + chevron-toggle split (categories are
|
||||
navigable, not just expandable); uses `x-show`/`x-transition` instead of
|
||||
upstream's `x-collapse` (that Alpine plugin isn't bundled in our build).
|
||||
- The `<aside>` drawer + mobile overlay (#6) in `base.html` are layout
|
||||
scaffolding, kept as-is.
|
||||
|
||||
---
|
||||
|
||||
## 4. Dropdown (Settings) — ✅ DONE
|
||||
**Penguin UI: `dropdowns/dropdown-with-click.html`**
|
||||
|
||||
- Exact upstream mirror at `assets/views/penguinui/dropdowns/dropdown-with-click.html` (reference only)
|
||||
- **De-duplicated**: the ~103-line copy-paste is now one shared partial
|
||||
`assets/views/partials/settings_dropdown.html`, included by both `base.html`
|
||||
and `admin/base.html` (each host keeps its own positioning wrapper
|
||||
`<div x-data="{ open:false }" class="relative [ml-auto]">`).
|
||||
- Adopts Penguin's dropdown menu container + item treatment. Deviations: kept our
|
||||
gear icon-only trigger and core-Alpine open/@click.outside toggle (upstream's
|
||||
`x-trap`/`$focus` need the Alpine Focus plugin we don't bundle); item hover
|
||||
uses `bg-primary/5` for consistency with the rest of the UI.
|
||||
|
||||
---
|
||||
|
||||
## 5. Country / Phone Combobox
|
||||
**Penguin UI: `text-input/` + custom dropdown list**
|
||||
|
||||
| # | Location | What it is | Size |
|
||||
|---|----------|------------|------|
|
||||
| 9 | `assets/views/shop/checkout.html:49-74` | Phone prefix combobox (`+421`, `+420`, …, `+33`) | ~25 lines |
|
||||
| 10 | `assets/views/shop/checkout.html:102-127` | Country combobox (SK, CZ, AT, DE, PL, HU) | ~26 lines |
|
||||
|
||||
**Details for #9:**
|
||||
- Alpine `x-data` with `prefix`, `prefixOpen`, `opts` array of `{ v, l }` (9 country codes)
|
||||
- Manual `filtered` computed property
|
||||
- Inline chevron SVG that rotates via `:class="prefixOpen && 'rotate-180'"`
|
||||
- Dropdown list with `<template x-for>` and `@click` selection
|
||||
|
||||
**Details for #10:**
|
||||
- Same pattern as #9 but with translate-able country names (6 countries)
|
||||
- Includes `+421` prefix shortcut
|
||||
|
||||
---
|
||||
|
||||
## 6. Product Card — ✅ DONE
|
||||
**Penguin UI: `card/ecommerce-product-card.html`**
|
||||
|
||||
- Exact upstream mirror at `assets/views/penguinui/card/ecommerce-product-card.html` (reference only)
|
||||
- Adapted/rendered copy is `assets/views/shop/_card.html`: `<article>` shell + Penguin
|
||||
image/title/price layout and the cart-icon add-to-cart button, wired to our product
|
||||
data + i18n + htmx `hx-post` add-to-cart + `toast()`. Demo-only rating stars,
|
||||
hardcoded content and `max-w-sm` (fights the shop grid) were dropped; whole card
|
||||
links to the product page; out-of-stock badge kept.
|
||||
|
||||
---
|
||||
|
||||
## 7. Product Image Gallery
|
||||
**Penguin UI: `carousel/` (3 variants)**
|
||||
|
||||
| # | Location | What it is | Size |
|
||||
|---|----------|------------|------|
|
||||
| 12 | `assets/views/shop/show.html:8-26` | Image gallery with main image + thumbnail strip, Alpine `x-data="{ active: 0 }"` | ~19 lines |
|
||||
|
||||
**Details:**
|
||||
- Main image: `x-show="active === {{ loop.index0 }}"` with `object-cover`
|
||||
- Thumbnail buttons: border changes to indicate active state
|
||||
- No transition/animation between images — just x-show toggling
|
||||
|
||||
---
|
||||
|
||||
## 8. Radio-Button Groups
|
||||
**Penguin UI: radio (part of form inputs)**
|
||||
|
||||
| # | Location | What it is | Size |
|
||||
|---|----------|------------|------|
|
||||
| 13 | `assets/views/shop/checkout.html:133-165` | Carrier selection radio group (each option shows name + price) | ~33 lines |
|
||||
| 14 | `assets/views/shop/checkout.html:167-180` | Payment method radio group (COD + bank transfer) | ~14 lines |
|
||||
|
||||
**Details for #13:**
|
||||
- `{% for m in shipping_methods %}` loop
|
||||
- Each `<label>` is a styled card with `has-[:checked]:border-primary` border highlight
|
||||
- Radio input triggers `@change` to update Alpine state (carrier, carrierPrice, requiresPoint)
|
||||
- Pickup-point sub-panel shown via `x-show="requiresPoint"`
|
||||
|
||||
**Details for #14:**
|
||||
- Two hardcoded radio options: COD and bank_transfer
|
||||
- `x-model="paymentMethod"` binding
|
||||
|
||||
---
|
||||
|
||||
## 9. Checkbox — ✅ DONE
|
||||
**Penguin UI: `checkbox/default-checkbox.html`**
|
||||
|
||||
- Exact upstream mirror at `assets/views/penguinui/checkbox/default-checkbox.html` (reference only)
|
||||
- `ui::checkbox(name, label, id, value="on", checked, attrs)` macro in `macros/ui.html`
|
||||
(full Penguin control: custom box + check-icon + label, `has-checked:`/`peer` variants).
|
||||
- Adopted: product/category "Published" + shipping "Enabled".
|
||||
|
||||
## 10. Text Input — ✅ DONE
|
||||
**Penguin UI: `text-input/default-text-input.html`**
|
||||
|
||||
- Exact upstream mirror at `assets/views/penguinui/text-input/default-text-input.html` (reference only)
|
||||
- `ui::input(name, type, id, value, placeholder, required, autocomplete, attrs, extra, width="w-full")`
|
||||
macro — **verbatim** Penguin classes (`bg-surface-alt`, `focus-visible:outline-*`).
|
||||
Adopted at every text/email/number/password input: login (2), checkout (email,
|
||||
name, phone, address, city, zip), product form (6), category form (3), product
|
||||
detail quantity, shipping price (`width="w-28"`).
|
||||
- The cart-body quantity input keeps its complex `@change` handler **inline** with
|
||||
the same Penguin classes (mixed single/double quotes can't pass through a macro arg).
|
||||
- Note: padding is Penguin's `px-2 py-2` (was `px-3`) and bg is `bg-surface-alt` (was
|
||||
`bg-surface`) — the real Penguin look.
|
||||
|
||||
## 11. Textarea — ✅ DONE
|
||||
**Penguin UI: `text-area/default-textarea.html`**
|
||||
|
||||
- Exact upstream mirror at `assets/views/penguinui/text-area/default-textarea.html` (reference only)
|
||||
- `ui::textarea(name, id, value, rows, placeholder, required, attrs, extra)` macro.
|
||||
- Adopted: checkout note, product & category description.
|
||||
|
||||
## 12. Select/Dropdown (Native) — ✅ DONE
|
||||
**Penguin UI: `select/default-select.html`**
|
||||
|
||||
- Exact upstream mirror at `assets/views/penguinui/select/default-select.html` (reference only)
|
||||
- Adopted inline (3 sites: product category, category parent, order status) — Penguin
|
||||
`appearance-none` select on `bg-surface-alt` wrapped in `relative` with the chevron
|
||||
SVG. Inline rather than a macro because the `<option>` set is caller-specific.
|
||||
|
||||
## 13. File Input — ✅ DONE
|
||||
**Penguin UI: `file-input/default-file-input.html`**
|
||||
|
||||
- Exact upstream mirror at `assets/views/penguinui/file-input/default-file-input.html` (reference only)
|
||||
- `ui::file_input(name, id, accept, attrs, extra)` macro (verbatim Penguin `file:` styling).
|
||||
- Adopted: product & category image upload.
|
||||
|
||||
---
|
||||
|
||||
## 14. Table
|
||||
**Penguin UI: `table/` (7 variants)**
|
||||
|
||||
| # | Location | What it is | Size |
|
||||
|---|----------|------------|------|
|
||||
| 34 | `assets/views/admin/orders/index.html:11-36` | Orders table: number, customer, status pill, total, "View" link | ~26 lines |
|
||||
| 35 | `assets/views/admin/orders/show.html:20-44` | Order items table: product, quantity, line total + tfoot summary | ~25 lines |
|
||||
| 36 | `assets/views/admin/catalog/products.html:20-70` | Products table: image+name+category, price, stock, status pill, edit/view/delete actions | ~51 lines |
|
||||
| 37 | `assets/views/admin/catalog/categories.html:20-59` | Categories table: tree-indented name, product count, status pill, edit/delete | ~40 lines |
|
||||
| 38 | `assets/views/shop/_cart_body.html:6-59` | Cart table: product link, price, quantity input, line total, remove button + tfoot total | ~54 lines |
|
||||
|
||||
**Pattern:** Every table uses the same class structure:
|
||||
```
|
||||
<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">
|
||||
<tbody class="divide-y divide-outline">
|
||||
<tr class="hover:bg-surface-alt">
|
||||
```
|
||||
This is copy-pasted 5 times.
|
||||
|
||||
---
|
||||
|
||||
## 15. Alert / Error Banner — ✅ DONE
|
||||
**Penguin UI: `alert/default-alert.html`**
|
||||
|
||||
- Exact upstream mirror at `assets/views/penguinui/alert/default-alert.html` (reference only)
|
||||
- Adapted into the `ui::alert_danger(message, extra="")` macro in
|
||||
`assets/views/macros/ui.html` (compact one-line danger alert + danger icon).
|
||||
- Adopted at both sites: `admin/login.html` (login error) and
|
||||
`admin/orders/show.html` (ship error).
|
||||
|
||||
---
|
||||
|
||||
## 16. Badge / Status Pill — ✅ DONE
|
||||
**Penguin UI: `badge/soft-color-badge.html`**
|
||||
|
||||
- Exact upstream mirror at `assets/views/penguinui/badge/soft-color-badge.html` (reference only)
|
||||
- Adapted into the `ui::badge(label, variant)` macro in `assets/views/macros/ui.html`
|
||||
(variants: success | danger | warning | info | primary | neutral).
|
||||
- Adopted at the status-pill sites: "Auth" badge (`admin/login.html`), order status
|
||||
(`orders/index.html`, neutral), Published/Draft pills (`products.html` +
|
||||
`categories.html`, success/neutral).
|
||||
- Intentionally left inline (not soft-color pills): the cart item-count **notification**
|
||||
badge in `base.html` (count bubble, a different Penguin badge type) and the
|
||||
block-style "out of stock" notice in `_card.html`.
|
||||
|
||||
---
|
||||
|
||||
## 17. Buttons — ✅ DONE
|
||||
**Penguin UI: `buttons/default-button.html`, `outline-button.html`, `ghost-button.html`, `button-with-icon.html`**
|
||||
|
||||
- Exact upstream mirrors at `assets/views/penguinui/buttons/*.html` (reference only).
|
||||
- Macros in `assets/views/macros/ui.html`:
|
||||
`ui::button(label, variant="primary", type, href, attrs, extra, icon, size="px-4 py-2 text-sm")`
|
||||
and `ui::icon_button(icon, variant="ghost-secondary", aria_label, attrs, …)`.
|
||||
The per-variant class strings are the **verbatim** Penguin variants (solid
|
||||
`primary|secondary|danger|success|warning|info`, `outline-*`, `ghost-*`) — only
|
||||
`inline-flex items-center justify-center gap-2` is added so `<a>`/`w-full`/`icon`
|
||||
render, and upstream's `text-onDanger`/`text-onSuccess`… token typos are fixed to
|
||||
our real `text-on-*` tokens. `href` → `<a>` else `<button>`; `attrs` is raw
|
||||
(htmx / `:disabled` / name / value); `icon` is a raw `<svg>` rendered before the
|
||||
label (Penguin button-with-icon).
|
||||
- **Sizes are NOT normalized**: `size` defaults to Penguin's `px-4 py-2 text-sm`
|
||||
but each call site that differed keeps it (`px-3 py-2` form-header cancels &
|
||||
order back, `px-5 py-2` add-to-cart / cart-checkout / order-confirmed continue,
|
||||
`px-6 py-2.5` checkout place-order, `px-3 py-1.5 text-xs` table actions).
|
||||
- Adopted across every standard filled/outline/submit button: login, product &
|
||||
category forms (save / cancel = `outline-secondary`), products/categories "new" +
|
||||
empty-state CTAs, orders detail (back/ship/status), shipping save, cart
|
||||
(continue/checkout/empty), checkout place-order (`:disabled` via `attrs`),
|
||||
product detail add-to-cart, order-confirmed continue.
|
||||
- Icon-only buttons now use `ui::icon_button(icon, variant="ghost-secondary",
|
||||
aria_label, attrs, …)` — Penguin ghost treatment, square. Converted: settings
|
||||
gear, both hamburgers (site + admin), admin sidebar toggle, mobile category
|
||||
toggle. The cart link (live `x-init` badge) and the category-accordion chevron
|
||||
keep the same Penguin ghost classes **inline** only because their markup mixes
|
||||
single+double quotes that can't be passed through a Tera macro arg — visually
|
||||
identical to `icon_button`.
|
||||
- Table row-actions (`edit`/`view`/`delete`/`View`/`label`) → `ui::button`
|
||||
`outline-secondary` / `outline-danger` at `size="px-3 py-1.5 text-xs"`; cart
|
||||
"Remove" → `ghost-danger`; card add-to-cart → `ui::button` with the cart `icon`.
|
||||
- Still genuinely not this component (tracked elsewhere): toast dismiss/Reply
|
||||
buttons (part of the vendored toast mirror, already Penguin), settings dropdown
|
||||
menu items (Penguin dropdown items), gallery thumbnail buttons (carousel),
|
||||
sidebar logout/exit (Penguin sidebar link treatment), and navbar nav-menu
|
||||
links/logout (belong to §1 Navbar). The file-input button is §13.
|
||||
|
||||
> Gotcha for future macro use: Tera renders `{% include %}` in the **includer's**
|
||||
> macro scope, so a template that includes a partial which calls `ui::` must also
|
||||
> `{% import "macros/ui.html" as ui %}` itself (see `shop/cart.html` →
|
||||
> `shop/_cart_body.html`). In an `{% extends %}` child the import must sit
|
||||
> directly after `{% extends %}` with no comment/content before it.
|
||||
|
||||
---
|
||||
|
||||
## 18. Toggle / Switch
|
||||
**Penguin UI: `toggle/` (2 variants)**
|
||||
|
||||
| # | Location | What it is | Size |
|
||||
|---|----------|------------|------|
|
||||
| 53 | `assets/views/base.html:13-30` | Theme toggle (dark/light/system) — inline `<script>` JavaScript | ~18 lines |
|
||||
| 54 | `assets/views/admin/base.html:13-30` | **Exact duplicate** of the theme toggle JS | ~18 lines |
|
||||
|
||||
**Details:**
|
||||
- `applyTheme()`, `setTheme()`, `currentTheme()` — reads/writes `localStorage`
|
||||
- `matchMedia('prefers-color-scheme: dark')` listener
|
||||
- All hand-written vanilla JS, duplicated twice (36 lines total)
|
||||
|
||||
---
|
||||
|
||||
## 19. Inline SVG Icons
|
||||
**Penguin UI: none (Penguin uses Heroicons-equivalent inline SVGs)**
|
||||
|
||||
| # | Location | Icon | Occurrences |
|
||||
|---|----------|------|-------------|
|
||||
| 55 | `base.html:70-72,168-170` | Hamburger (3-line menu) | 2 |
|
||||
| 56 | `base.html:104-105` | Shopping cart | 1 |
|
||||
| 57 | `base.html:116-121` | Gear/cog (settings) | 1 |
|
||||
| 58 | ~~`base.html:220-221`~~ | Checkmark (toast success) | ✅ removed — now in vendored toast component |
|
||||
| 59 | `checkout.html:62-64,115-117` | Chevron-down (dropdown arrow) | 2 |
|
||||
| 60 | `_sidebar.html:30-33` | Chevron-right (accordion expand) | 1 |
|
||||
| 61 | `admin/base.html:106-108` | Hamburger (admin sidebar toggle) | 1 |
|
||||
| 62 | `admin/base.html:121-125` | Gear/cog (admin settings) | 1 |
|
||||
|
||||
All are raw inline `<svg>` with hardcoded `<path d="...">` — no icon library, no partials.
|
||||
|
||||
---
|
||||
|
||||
## 20. Empty State
|
||||
**Penguin UI: no direct component, but table empty states exist in Penguin tables**
|
||||
|
||||
| # | Location | What it is | Size |
|
||||
|---|----------|------------|------|
|
||||
| 63 | `assets/views/admin/orders/index.html:38-39` | "No orders" message | ~2 lines |
|
||||
| 64 | `assets/views/admin/catalog/products.html:72-78` | "No products" with CTA button | ~7 lines |
|
||||
| 65 | `assets/views/admin/catalog/categories.html:61-67` | "No categories" with CTA button | ~7 lines |
|
||||
| 66 | `assets/views/shop/_cart_body.html:67-70` | "Cart empty" with CTA button | ~4 lines |
|
||||
| 67 | `assets/views/shop/_sidebar.html:58-59` | "No categories" message | ~2 lines |
|
||||
|
||||
---
|
||||
|
||||
## 21. Dashboard Navigation Cards
|
||||
**Penguin UI: `card/`**
|
||||
|
||||
| # | Location | What it is | Size |
|
||||
|---|----------|------------|------|
|
||||
| 68 | `assets/views/admin/index.html:12-27` | 3 dashboard link cards (Products, Categories, Orders) | ~16 lines |
|
||||
|
||||
**Details:**
|
||||
- Each card is an `<a>` styled with border, hover effect, and nested title+description
|
||||
- Same hover pattern: `hover:border-primary`
|
||||
|
||||
---
|
||||
|
||||
## 22. Checkout Order Summary
|
||||
**Penguin UI: `card/` (ecommerce-summary style)**
|
||||
|
||||
| # | Location | What it is | Size |
|
||||
|---|----------|------------|------|
|
||||
| 69 | `assets/views/shop/checkout.html:190-218` | Cart summary aside: item list, subtotal, shipping, total, place-order button | ~29 lines |
|
||||
|
||||
**Details:**
|
||||
- Item list with name × quantity + line total
|
||||
- Subtotal + shipping + total with `tabular-nums`
|
||||
- Dynamic shipping price from Alpine `carrierPrice`
|
||||
- Disabled submit button when `!canSubmit`
|
||||
|
||||
---
|
||||
|
||||
## 23. Login Card
|
||||
**Penguin UI: `card/`**
|
||||
|
||||
| # | Location | What it is | Size |
|
||||
|---|----------|------------|------|
|
||||
| 70 | `assets/views/admin/login.html:6-61` | Full login form: header with auth badge, email + password inputs, error alert, submit button | ~56 lines |
|
||||
|
||||
---
|
||||
|
||||
## 24. Checkout Fieldset Cards
|
||||
**Penguin UI: `card/`**
|
||||
|
||||
| # | Location | What it is | Size |
|
||||
|---|----------|------------|------|
|
||||
| 71 | `assets/views/shop/checkout.html:34-79` | Contact info fieldset (email, name, phone+prefix) | ~46 lines |
|
||||
| 72 | `assets/views/shop/checkout.html:82-130` | Shipping address fieldset (address, city, zip, country) | ~49 lines |
|
||||
| 73 | `assets/views/shop/checkout.html:133-165` | Carrier selection fieldset | ~33 lines |
|
||||
| 74 | `assets/views/shop/checkout.html:167-180` | Payment method fieldset | ~14 lines |
|
||||
|
||||
Each fieldset uses `<fieldset>` + `<legend>` with the same `rounded-radius border border-outline bg-surface p-6` styling.
|
||||
|
||||
---
|
||||
|
||||
## 25. Order Detail Info Panel
|
||||
**Penguin UI: `card/`**
|
||||
|
||||
| # | Location | What it is | Size |
|
||||
|---|----------|------------|------|
|
||||
| 75 | `assets/views/admin/orders/show.html:49-77` | Customer + shipping + payment info panel | ~29 lines |
|
||||
| 76 | `assets/views/admin/orders/show.html:79-103` | Fulfillment panel (tracking, label link, ship button) | ~25 lines |
|
||||
| 77 | `assets/views/admin/orders/show.html:106-115` | Status update form panel | ~10 lines |
|
||||
|
||||
---
|
||||
|
||||
## 26. Shipping Method Settings Row
|
||||
**Penguin UI: `card/`**
|
||||
|
||||
| # | Location | What it is | Size |
|
||||
|---|----------|------------|------|
|
||||
| 78 | `assets/views/admin/shipping/index.html:14-34` | Per-carrier settings: name label, price input, enabled checkbox, save button | ~21 lines |
|
||||
|
||||
---
|
||||
|
||||
## 27. Product/Category Form Wrapper
|
||||
**Penguin UI: `card/`**
|
||||
|
||||
| # | Location | What it is | Size |
|
||||
|---|----------|------------|------|
|
||||
| 79 | `assets/views/admin/catalog/product_form.html:15-99` | Full product edit/create form with all fields | ~84 lines |
|
||||
| 80 | `assets/views/admin/catalog/category_form.html:15-81` | Full category edit/create form with all fields | ~66 lines |
|
||||
|
||||
Both are wrapped in a single card-style `<form>`.
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| # | Component | Penguin UI Directory | Handcoded Instances | Total Lines |
|
||||
|---|-----------|---------------------|--------------------|-------------|
|
||||
| 1 | Navbar | `navbar/` | 2 | ~143 |
|
||||
| 2 | Sidebar (admin) | `sidebar/` | 2 | ~46 |
|
||||
| 3 | Sidebar (category accordion) | `sidebar/` | 2 | ~62 |
|
||||
| 4 | Dropdown (settings) | `dropdown-menu/` | **2 duplicates** | ~103 |
|
||||
| 5 | Country/Phone combobox | `text-input/` | 2 | ~51 |
|
||||
| 6 | Product card | `card/` | 1 | ~30 |
|
||||
| 7 | Image gallery | `carousel/` | 1 | ~19 |
|
||||
| 8 | Radio groups | (form inputs) | 2 | ~47 |
|
||||
| 9 | Checkbox | `checkbox/` | 3 | ~15 |
|
||||
| 10 | Text input | `text-input/` | 8 | ~146 |
|
||||
| 11 | Textarea | `textarea/` | 3 | ~10 |
|
||||
| 12 | Select | `select/` | 3 | ~23 |
|
||||
| 13 | File input | `file-input/` | 2 | ~12 |
|
||||
| 14 | Table | `table/` | 5 | ~196 |
|
||||
| 15 | Alert/Error | `alert/` | 2 | ~9 |
|
||||
| 16 | Badge/Pill | `badge/` | 6 | ~17 |
|
||||
| 17 | Button | `buttons/` | 50+ occurrences | ~200+ |
|
||||
| 18 | Toggle (theme) | `toggle/` | **2 duplicates** | ~36 |
|
||||
| 19 | Inline SVG icons | N/A | 8 distinct icons | ~50 |
|
||||
| 20 | Empty state | (table variants) | 5 | ~22 |
|
||||
| 21 | Dashboard cards | `card/` | 1 | ~16 |
|
||||
| 22 | Checkout summary | `card/` | 1 | ~29 |
|
||||
| 23 | Login card | `card/` | 1 | ~56 |
|
||||
| 24 | Checkout fieldsets | `card/` | 4 | ~142 |
|
||||
| 25 | Order info panels | `card/` | 3 | ~64 |
|
||||
| 26 | Shipping settings row | `card/` | 1 | ~21 |
|
||||
| 27 | Form wrappers | `card/` | 2 | ~150 |
|
||||
|
||||
**Grand total: ~27 distinct handcoded UI component types across ~80 instances, representing approximately 1,600+ lines of handcoded HTML/Tailwind/Alpine that could be replaced by Penguin UI components.**
|
||||
|
||||
### Duplication hotspots:
|
||||
- **Settings dropdown** (`base.html:110-162` and `admin/base.html:117-166`) — 100% copy-paste
|
||||
- **Theme toggle JS** (`base.html:13-30` and `admin/base.html:13-30`) — 100% copy-paste
|
||||
- **Text input class string** — same 80-character Tailwind string appears 15+ times
|
||||
- **Table class strings** (thead, tbody, tr) — copy-pasted 5 times
|
||||
- **Button variants** — inconsistent `hover:opacity-75` vs `hover:opacity-90`
|
||||
@@ -27,6 +27,9 @@ mod m20260616_132000_drop_blog_and_pages;
|
||||
mod m20260616_150755_shipping_methods;
|
||||
mod m20260616_150812_add_shipping_fields_to_orders;
|
||||
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;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -58,6 +61,9 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260616_150755_shipping_methods::Migration),
|
||||
Box::new(m20260616_150812_add_shipping_fields_to_orders::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)
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
use crate::{
|
||||
account, admin, cart, checkout, home, i18n, initializers, media,
|
||||
models::_entities::users, shop, tasks, workers::downloader::DownloadWorker,
|
||||
controllers::{
|
||||
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;
|
||||
@@ -66,16 +72,16 @@ impl Hooks for App {
|
||||
.add_route(cart::routes())
|
||||
.add_route(checkout::routes())
|
||||
// cross-cutting
|
||||
.add_route(account::routes())
|
||||
.add_route(auth::routes())
|
||||
.add_route(i18n::routes())
|
||||
.add_route(media::routes())
|
||||
// admin
|
||||
.add_route(admin::routes())
|
||||
.add_route(admin::login::routes())
|
||||
.add_route(admin::products::routes())
|
||||
.add_route(admin::categories::routes())
|
||||
.add_route(admin::orders::routes())
|
||||
.add_route(admin::shipping::routes())
|
||||
.add_route(admin_dashboard::routes())
|
||||
.add_route(admin_login::routes())
|
||||
.add_route(admin_products::routes())
|
||||
.add_route(admin_categories::routes())
|
||||
.add_route(admin_orders::routes())
|
||||
.add_route(admin_shipping::routes())
|
||||
}
|
||||
|
||||
async fn after_context(ctx: AppContext) -> Result<AppContext> {
|
||||
@@ -105,6 +111,7 @@ impl Hooks for App {
|
||||
async fn seed(ctx: &AppContext, base: &Path) -> Result<()> {
|
||||
db::seed::<users::ActiveModel>(&ctx.db, &base.join("users.yaml").display().to_string())
|
||||
.await?;
|
||||
crate::seed::seed_catalog(ctx).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use loco_rs::cli;
|
||||
use migration::Migrator;
|
||||
use gitara_web::app::App;
|
||||
use kompress_eshop::app::App;
|
||||
|
||||
#[tokio::main]
|
||||
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 crate::{
|
||||
admin::form::{read_multipart_form, store_image, MultipartForm},
|
||||
i18n::current_lang,
|
||||
media::IMAGE_MAX_BYTES,
|
||||
controllers::{
|
||||
admin_form::{read_multipart_form, store_image, MultipartForm},
|
||||
i18n::current_lang,
|
||||
media::IMAGE_MAX_BYTES,
|
||||
},
|
||||
shared::{
|
||||
guard,
|
||||
slug::{slugify, unique_slug},
|
||||
},
|
||||
shop::models::{categories, products},
|
||||
models::{categories, products},
|
||||
};
|
||||
|
||||
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
|
||||
//! 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;
|
||||
//! Admin dashboard (HTML home + JSON stats).
|
||||
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
@@ -15,7 +6,7 @@ use sea_orm::{EntityTrait, PaginatorTrait};
|
||||
use serde::Serialize;
|
||||
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)]
|
||||
struct DashboardResponse {
|
||||
@@ -9,7 +9,7 @@ use std::collections::HashMap;
|
||||
use axum::extract::Multipart;
|
||||
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> {
|
||||
value.and_then(|value| {
|
||||
@@ -6,8 +6,9 @@ use loco_rs::prelude::*;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
account::{self as auth_controller, models::users::{self, LoginParams}},
|
||||
i18n::current_lang,
|
||||
controllers::auth as auth_controller,
|
||||
models::users::{self, LoginParams},
|
||||
controllers::i18n::current_lang,
|
||||
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 crate::{
|
||||
admin::form::{read_multipart_form, store_image, MultipartForm},
|
||||
i18n::current_lang,
|
||||
media::IMAGE_MAX_BYTES,
|
||||
controllers::{
|
||||
admin_form::{read_multipart_form, store_image, MultipartForm},
|
||||
i18n::current_lang,
|
||||
media::IMAGE_MAX_BYTES,
|
||||
},
|
||||
shared::{
|
||||
guard,
|
||||
money::parse_price_to_cents,
|
||||
slug::{slugify, unique_slug},
|
||||
},
|
||||
shop::{
|
||||
models::{categories, product_images, products},
|
||||
view,
|
||||
},
|
||||
models::{categories, product_images, products},
|
||||
views::shop as view,
|
||||
};
|
||||
|
||||
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 loco_rs::prelude::*;
|
||||
@@ -7,8 +11,8 @@ use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
checkout::models::shipping_methods,
|
||||
i18n::current_lang,
|
||||
models::shipping_methods,
|
||||
controllers::i18n::current_lang,
|
||||
shared::{
|
||||
guard,
|
||||
money::{format_price, parse_price_to_cents},
|
||||
@@ -21,6 +25,10 @@ struct ShippingForm {
|
||||
enabled: Option<String>,
|
||||
}
|
||||
|
||||
fn is_checked(value: &Option<String>) -> bool {
|
||||
matches!(value.as_deref(), Some("on" | "true" | "1"))
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn index(
|
||||
auth: auth::JWT,
|
||||
@@ -41,6 +49,7 @@ async fn index(
|
||||
"code": m.code,
|
||||
"name": m.name,
|
||||
"price": format_price(m.price_cents),
|
||||
"carrier": m.carrier,
|
||||
"requires_pickup_point": m.requires_pickup_point,
|
||||
"enabled": m.enabled,
|
||||
})
|
||||
@@ -67,7 +76,7 @@ async fn update(
|
||||
.ok_or_else(|| Error::NotFound)?;
|
||||
let mut active = method.into_active_model();
|
||||
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?;
|
||||
format::redirect("/admin/shipping")
|
||||
}
|
||||
@@ -1,9 +1,6 @@
|
||||
pub mod models;
|
||||
pub mod view;
|
||||
|
||||
use crate::{
|
||||
account::models::users::{self, LoginParams, RegisterParams},
|
||||
account::view::{CurrentResponse, LoginResponse},
|
||||
models::users::{self, LoginParams, RegisterParams},
|
||||
views::auth::{CurrentResponse, LoginResponse},
|
||||
mailers::auth::AuthMailer,
|
||||
shared::guard::is_admin,
|
||||
};
|
||||
@@ -1,4 +1,8 @@
|
||||
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, StatusCode},
|
||||
response::Redirect,
|
||||
};
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
|
||||
@@ -72,6 +76,7 @@ async fn published_product(ctx: &AppContext, id: i32) -> Result<Option<products:
|
||||
async fn add(
|
||||
jar: CookieJar,
|
||||
State(ctx): State<AppContext>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<AddForm>,
|
||||
) -> Result<Response> {
|
||||
let Some(product) = published_product(&ctx, form.product_id).await? else {
|
||||
@@ -87,15 +92,29 @@ async fn add(
|
||||
}
|
||||
items.retain(|(_, qty)| *qty > 0);
|
||||
|
||||
format::render()
|
||||
.cookies(&[cart_cookie(serialize_cart(&items))])?
|
||||
.redirect("/cart")
|
||||
let jar = jar.add(cart_cookie(serialize_cart(&items)));
|
||||
|
||||
// Adding to the cart should never navigate away: htmx requests get an empty
|
||||
// 204 (the header cart badge updates client-side), and a no-JS submit goes
|
||||
// back to the page the customer was on rather than to the basket.
|
||||
if headers.contains_key("HX-Request") {
|
||||
Ok((jar, StatusCode::NO_CONTENT).into_response())
|
||||
} else {
|
||||
let back = headers
|
||||
.get("referer")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("/shop")
|
||||
.to_string();
|
||||
Ok((jar, Redirect::to(&back)).into_response())
|
||||
}
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn update(
|
||||
jar: CookieJar,
|
||||
State(ctx): State<AppContext>,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
headers: HeaderMap,
|
||||
Form(form): Form<UpdateForm>,
|
||||
) -> Result<Response> {
|
||||
let stock = published_product(&ctx, form.product_id)
|
||||
@@ -110,19 +129,57 @@ async fn update(
|
||||
}
|
||||
items.retain(|(_, qty)| *qty > 0);
|
||||
|
||||
format::render()
|
||||
.cookies(&[cart_cookie(serialize_cart(&items))])?
|
||||
.redirect("/cart")
|
||||
let jar = jar.add(cart_cookie(serialize_cart(&items)));
|
||||
cart_response(&ctx, &v, jar, &headers).await
|
||||
}
|
||||
|
||||
#[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);
|
||||
items.retain(|(id, _)| *id != form.product_id);
|
||||
|
||||
format::render()
|
||||
.cookies(&[cart_cookie(serialize_cart(&items))])?
|
||||
.redirect("/cart")
|
||||
let jar = jar.add(cart_cookie(serialize_cart(&items)));
|
||||
cart_response(&ctx, &v, jar, &headers).await
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -8,18 +8,12 @@ use serde::Deserialize;
|
||||
use serde_json::json;
|
||||
use time::Duration as TimeDuration;
|
||||
|
||||
pub mod models;
|
||||
pub mod view;
|
||||
|
||||
use crate::{
|
||||
cart::{resolve_cart, CART_COOKIE},
|
||||
checkout::models::{
|
||||
order_items,
|
||||
orders::{self, Checkout},
|
||||
shipping_methods,
|
||||
},
|
||||
i18n::current_lang,
|
||||
controllers::cart::{resolve_cart, CART_COOKIE},
|
||||
models::{order_items, orders, shipping_methods},
|
||||
controllers::i18n::current_lang,
|
||||
shared::{money::format_price, settings},
|
||||
views::checkout as view,
|
||||
};
|
||||
|
||||
const PAYMENT_METHODS: [&str; 2] = ["cod", "bank_transfer"];
|
||||
@@ -27,6 +21,8 @@ const PAYMENT_METHODS: [&str; 2] = ["cod", "bank_transfer"];
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CheckoutForm {
|
||||
email: String,
|
||||
phone_prefix: String,
|
||||
phone: String,
|
||||
customer_name: String,
|
||||
address: String,
|
||||
city: String,
|
||||
@@ -117,6 +113,25 @@ async fn place_order(
|
||||
}
|
||||
let email =
|
||||
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()) {
|
||||
return Err(Error::BadRequest("invalid payment method".to_string()));
|
||||
@@ -145,13 +160,14 @@ async fn place_order(
|
||||
let order = orders::place(
|
||||
&ctx,
|
||||
&valid,
|
||||
Checkout {
|
||||
orders::Checkout {
|
||||
email,
|
||||
customer_name: trimmed(&form.customer_name),
|
||||
address: trimmed(&form.address),
|
||||
city: trimmed(&form.city),
|
||||
zip: trimmed(&form.zip),
|
||||
country: trimmed(&form.country),
|
||||
phone,
|
||||
customer_name: Some(customer_name),
|
||||
address: Some(address),
|
||||
city: Some(city),
|
||||
zip: Some(zip),
|
||||
country: Some(country),
|
||||
note: form.note.as_deref().and_then(trimmed),
|
||||
payment_method: form.payment_method,
|
||||
method,
|
||||
@@ -4,7 +4,7 @@ use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::{i18n::current_lang, shared::guard, shop};
|
||||
use crate::{controllers::i18n::current_lang, shared::guard, controllers::shop};
|
||||
|
||||
#[debug_handler]
|
||||
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 serde_json::json;
|
||||
|
||||
pub mod models;
|
||||
pub mod view;
|
||||
|
||||
use crate::{
|
||||
i18n::current_lang,
|
||||
controllers::i18n::current_lang,
|
||||
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.
|
||||
@@ -53,7 +51,7 @@ async fn category_sidebar(
|
||||
&v,
|
||||
"shop/_sidebar.html",
|
||||
json!({
|
||||
"category_tree": view::sidebar_rows(&categories::tree(&published)),
|
||||
"category_groups": view::sidebar_groups(&published),
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
@@ -1,17 +1,19 @@
|
||||
---
|
||||
- id: 1
|
||||
- id: 2
|
||||
pid: 11111111-1111-1111-1111-111111111111
|
||||
email: user1@example.com
|
||||
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
|
||||
api_key: lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758
|
||||
name: user1
|
||||
theme: light
|
||||
created_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
|
||||
email: user2@example.com
|
||||
password: "$argon2id$v=19$m=19456,t=2,p=1$ETQBx4rTgNAZhSaeYZKOZg$eYTdH26CRT6nUJtacLDEboP0li6xUwUF/q5nSlQ8uuc"
|
||||
api_key: lo-153561ca-fa84-4e1b-813a-c62526d0a77e
|
||||
name: user2
|
||||
theme: light
|
||||
created_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 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;
|
||||
|
||||
@@ -18,7 +20,19 @@ impl Initializer for AdminSeeder {
|
||||
|
||||
if email.is_empty() || password.is_empty() {
|
||||
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(
|
||||
&ctx.db,
|
||||
&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 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)
|
||||
const CARRIERS: [(&str, &str, i64, bool); 3] = [
|
||||
("packeta", "Packeta", 300, true),
|
||||
("dpd", "DPD", 450, false),
|
||||
("dhl", "DHL", 500, false),
|
||||
/// `(code, name, carrier, requires_pickup_point, default_price_cents, position)`
|
||||
const BUILTINS: [(&str, &str, &str, bool, i64, i32); 2] = [
|
||||
("packeta", "Packeta", "packeta", true, 290, 0),
|
||||
("dpd", "DPD", "dpd", false, 450, 1),
|
||||
];
|
||||
|
||||
pub struct ShippingSeeder;
|
||||
@@ -20,28 +26,28 @@ impl Initializer for ShippingSeeder {
|
||||
}
|
||||
|
||||
async fn before_run(&self, ctx: &AppContext) -> Result<()> {
|
||||
for (position, (code, name, price_cents, requires_pickup_point)) in
|
||||
CARRIERS.iter().enumerate()
|
||||
{
|
||||
for (code, name, carrier, requires_pickup_point, price_cents, position) in BUILTINS {
|
||||
let exists = shipping_methods::Entity::find()
|
||||
.filter(shipping_methods::Column::Code.eq(*code))
|
||||
.one(&ctx.db)
|
||||
.filter(shipping_methods::Column::Code.eq(code))
|
||||
.count(&ctx.db)
|
||||
.await?
|
||||
.is_some();
|
||||
> 0;
|
||||
if exists {
|
||||
continue;
|
||||
}
|
||||
shipping_methods::ActiveModel {
|
||||
code: Set((*code).to_string()),
|
||||
name: Set((*name).to_string()),
|
||||
price_cents: Set(*price_cents),
|
||||
requires_pickup_point: Set(*requires_pickup_point),
|
||||
code: Set(code.to_string()),
|
||||
name: Set(name.to_string()),
|
||||
carrier: Set(carrier.to_string()),
|
||||
requires_pickup_point: Set(requires_pickup_point),
|
||||
price_cents: Set(price_cents),
|
||||
enabled: Set(true),
|
||||
position: Set(position as i32),
|
||||
position: Set(position),
|
||||
..Default::default()
|
||||
}
|
||||
.insert(&ctx.db)
|
||||
.await?;
|
||||
tracing::info!(carrier = code, "seeded built-in delivery option");
|
||||
}
|
||||
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 controllers;
|
||||
pub mod data;
|
||||
pub mod initializers;
|
||||
pub mod integrations;
|
||||
pub mod mailers;
|
||||
pub mod models;
|
||||
pub mod tasks;
|
||||
pub mod workers;
|
||||
|
||||
// Cross-cutting helpers shared by every feature.
|
||||
pub mod seed;
|
||||
pub mod shared;
|
||||
|
||||
// Feature slices: each owns its routes, handlers, view-shaping and the model
|
||||
// methods/services specific to it. Generated sea-orm entities stay shared in
|
||||
// `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;
|
||||
pub mod tasks;
|
||||
pub mod views;
|
||||
pub mod workers;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use loco_rs::prelude::*;
|
||||
use serde_json::json;
|
||||
|
||||
use crate::account::models::users;
|
||||
use crate::models::users;
|
||||
|
||||
static welcome: Dir<'_> = include_dir!("src/mailers/auth/welcome");
|
||||
static forgot: Dir<'_> = include_dir!("src/mailers/auth/forgot");
|
||||
|
||||
@@ -13,6 +13,7 @@ pub struct Model {
|
||||
#[sea_orm(unique)]
|
||||
pub order_number: String,
|
||||
pub email: String,
|
||||
pub phone: Option<String>,
|
||||
pub customer_name: Option<String>,
|
||||
pub status: String,
|
||||
pub total_cents: i64,
|
||||
@@ -29,6 +30,9 @@ pub struct Model {
|
||||
pub shipping_cents: i64,
|
||||
pub pickup_point_id: 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)]
|
||||
|
||||
@@ -17,6 +17,7 @@ pub struct Model {
|
||||
pub requires_pickup_point: bool,
|
||||
pub enabled: bool,
|
||||
pub position: i32,
|
||||
pub carrier: String,
|
||||
}
|
||||
|
||||
#[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
|
||||
//! a unit, so they live here centrally. The hand-written model methods,
|
||||
//! services and view-shaping that use them live in the feature slices
|
||||
//! (`shop::models`, `checkout::models`, `account::models`, …).
|
||||
//! `_entities/` contains auto-generated SeaORM code (regenerated as a unit).
|
||||
//! The sibling files contain hand-written model impls: ActiveModelBehavior,
|
||||
//! finder methods, business logic, and query helpers.
|
||||
|
||||
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.
|
||||
pub struct Checkout {
|
||||
pub email: String,
|
||||
pub phone: String,
|
||||
pub customer_name: Option<String>,
|
||||
pub address: Option<String>,
|
||||
pub city: Option<String>,
|
||||
@@ -64,6 +65,7 @@ pub async fn place(ctx: &AppContext, items: &[(i32, i32)], details: Checkout) ->
|
||||
let order = ActiveModel {
|
||||
order_number: Set(generate_order_number()),
|
||||
email: Set(details.email),
|
||||
phone: Set(Some(details.phone)),
|
||||
customer_name: Set(details.customer_name),
|
||||
status: Set("pending".to_string()),
|
||||
total_cents: Set(subtotal + details.method.price_cents),
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user