5 Commits

Author SHA1 Message Date
Priec
7601fc704d dialog to ask if remove item compltely
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-17 19:00:17 +02:00
Priec
7be1726f1b cart and buy page 2026-06-17 18:53:49 +02:00
Priec
d18bdeaf6e phone number + country 2026-06-17 18:02:46 +02:00
Priec
cd7a756a54 hardcode of dpd and packeta 2026-06-17 17:27:19 +02:00
Priec
e8c0362a54 shipping 2026-06-17 16:15:22 +02:00
36 changed files with 1556 additions and 99 deletions

87
Cargo.lock generated
View File

@@ -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]]
@@ -1755,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"
@@ -2110,6 +2128,7 @@ dependencies = [
"loco-rs",
"migration",
"regex",
"reqwest",
"rstest",
"sea-orm",
"serde",
@@ -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",
]

View File

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

View File

@@ -215,6 +215,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 +223,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 +269,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?

View File

@@ -215,6 +215,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 +223,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 +269,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?

View File

@@ -9,6 +9,12 @@
<a href="/admin/orders" class="inline-flex items-center rounded-radius border border-outline px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="admin-orders", lang=lang | default(value='sk')) }}</a>
</div>
{% if ship_error %}
<div class="mt-4 rounded-radius border border-danger/40 bg-danger/10 px-4 py-3 text-sm font-medium text-danger">
{{ ship_error }}
</div>
{% endif %}
<div class="mt-6 grid gap-6 lg:grid-cols-3">
<div class="space-y-6 lg:col-span-2">
<div class="overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
@@ -45,6 +51,7 @@
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="order-customer", lang=lang | default(value='sk')) }}</p>
<p class="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,6 +76,33 @@
{% 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 %}
<a href="{{ order.label_url }}" target="_blank" rel="noopener"
class="inline-flex items-center rounded-radius border border-outline px-3 py-1.5 text-xs font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="order-label", lang=lang | default(value='sk')) }}</a>
{% endif %}
{% elif carrier == "none" %}
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="order-manual-fulfillment", lang=lang | default(value='sk')) }}</p>
{% elif can_ship %}
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="order-send-hint", lang=lang | default(value='sk')) }}</p>
<form method="post" action="/admin/orders/{{ order.id }}/ship"
onsubmit="return confirm('{{ t(key="order-send-confirm", lang=lang | default(value='sk')) }}')">
<button type="submit"
class="inline-flex w-full items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
{{ t(key="order-send-to-carrier", lang=lang | default(value='sk')) }} {{ carrier | upper }}
</button>
</form>
{% endif %}
</div>
<form method="post" action="/admin/orders/{{ order.id }}/status" class="space-y-3 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
<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"

View File

@@ -15,7 +15,7 @@
class="flex flex-wrap items-end gap-4 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
<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>

View File

@@ -39,6 +39,14 @@
}
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);
}
</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>
@@ -85,7 +93,7 @@
<!-- 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(); window.addEventListener('htmx:afterSwap', 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">

View File

@@ -1,12 +1,29 @@
<a href="/shop/{{ product.slug }}"
<div
class="group flex flex-col overflow-hidden rounded-radius border border-outline bg-surface transition hover:border-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:hover:border-primary-dark">
<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">
<a href="/shop/{{ product.slug }}" class="flex flex-1 flex-col">
<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">
{% endif %}
</div>
<div class="flex flex-1 flex-col gap-1 p-4 pb-2">
<h3 class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
<p class="mt-auto pt-2 font-semibold text-primary dark:text-primary-dark">{{ product.price }} {{ product.currency }}</p>
</div>
</a>
<div class="flex flex-col gap-2 px-4 pb-4">
{% if product.stock > 0 %}
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}</p>
<form method="post" action="/cart/add" hx-boost="false">
<input type="hidden" name="product_id" value="{{ product.id }}">
<input type="hidden" name="quantity" value="1">
<button type="submit"
class="inline-flex w-full items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
{{ t(key="add-to-cart", lang=lang | default(value='sk')) }}
</button>
</form>
{% else %}
<p class="inline-flex justify-center rounded-radius bg-danger/10 px-3 py-2 text-xs font-medium text-danger">{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}</p>
{% endif %}
</div>
<div 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>
</div>

View File

@@ -0,0 +1,71 @@
{# Cart contents, swapped in via htmx on quantity change / removal so the page
never does a full reload. Rendered inside <div id="cart-body"> in cart.html
and returned on its own by /cart/update and /cart/remove. #}
{% if items | length > 0 %}
<div class="overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
<table class="w-full text-left text-sm">
<thead class="border-b border-outline bg-surface-alt text-xs uppercase tracking-wide text-on-surface/70 dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark/70">
<tr>
<th class="px-4 py-3 font-semibold">{{ t(key="product", lang=lang | default(value='sk')) }}</th>
<th class="px-4 py-3 font-semibold">{{ t(key="price", lang=lang | default(value='sk')) }}</th>
<th class="px-4 py-3 font-semibold">{{ t(key="quantity", lang=lang | default(value='sk')) }}</th>
<th class="px-4 py-3 text-right font-semibold">{{ t(key="cart-total", lang=lang | default(value='sk')) }}</th>
<th class="px-4 py-3"></th>
</tr>
</thead>
<tbody class="divide-y divide-outline dark:divide-outline-dark">
{% for item in items %}
<tr>
<td class="px-4 py-3">
<a href="/shop/{{ item.slug }}" class="font-medium text-on-surface-strong hover:text-primary dark:text-on-surface-dark-strong dark:hover:text-primary-dark">{{ item.name }}</a>
</td>
<td class="px-4 py-3 tabular-nums">{{ item.price }} {{ item.currency }}</td>
<td class="px-4 py-3">
{# Changing the quantity posts via htmx (custom `cartchange` event) and
swaps only #cart-body. Dropping to 0 asks for confirmation first,
reverting to the previous quantity if the customer cancels. #}
<form method="post" action="/cart/update"
hx-post="/cart/update" hx-trigger="cartchange" hx-target="#cart-body" hx-swap="innerHTML">
<input type="hidden" name="product_id" value="{{ item.id }}">
<input type="number" name="quantity" min="0" max="{{ item.stock }}" value="{{ item.quantity }}"
@change="
if (parseInt($el.value || '0') <= 0 && !window.confirm('{{ t(key='cart-remove-confirm', lang=lang | default(value='sk')) }}')) {
$el.value = '{{ item.quantity }}';
} else {
$el.dispatchEvent(new Event('cartchange', { bubbles: true }));
}
"
class="w-20 rounded-radius border border-outline bg-surface px-2 py-1 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
</form>
</td>
<td class="px-4 py-3 text-right font-medium tabular-nums">{{ item.line_total }} {{ item.currency }}</td>
<td class="px-4 py-3 text-right">
<form method="post" action="/cart/remove"
hx-post="/cart/remove" hx-target="#cart-body" hx-swap="innerHTML">
<input type="hidden" name="product_id" value="{{ item.id }}">
<button type="submit" class="text-xs font-medium text-danger hover:underline">{{ t(key="cart-remove", lang=lang | default(value='sk')) }}</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
<tfoot class="border-t border-outline dark:border-outline-dark">
<tr>
<td colspan="3" class="px-4 py-3 text-right font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="cart-total", lang=lang | default(value='sk')) }}</td>
<td class="px-4 py-3 text-right text-lg font-bold tabular-nums text-primary dark:text-primary-dark">{{ total }} {{ currency }}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
<div class="mt-6 flex flex-wrap justify-between gap-3">
<a href="/shop" class="inline-flex items-center rounded-radius border border-outline px-4 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cart-continue", lang=lang | default(value='sk')) }}</a>
<a href="/checkout" class="inline-flex items-center justify-center rounded-radius bg-primary px-5 py-2 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="cart-checkout", lang=lang | default(value='sk')) }}</a>
</div>
{% else %}
<div class="rounded-radius border border-outline px-6 py-16 text-center dark:border-outline-dark">
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="cart-empty", lang=lang | default(value='sk')) }}</p>
<a href="/shop" class="mt-4 inline-flex items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="cart-continue", lang=lang | default(value='sk')) }}</a>
</div>
{% endif %}

View File

@@ -6,62 +6,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 %}

View File

@@ -43,6 +43,39 @@
<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">
</div>
<div class="space-y-1.5">
<label for="phone" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-phone", lang=lang | default(value='sk')) }}</label>
<div class="flex gap-2">
<!-- editable combobox: type freely or pick from the dropdown -->
<div class="relative w-28 shrink-0" @click.outside="prefixOpen = false"
x-data="{ prefixOpen: false, prefix: '+421', opts: [
{ v: '+421', l: '🇸🇰 +421' }, { v: '+420', l: '🇨🇿 +420' },
{ v: '+43', l: '🇦🇹 +43' }, { v: '+49', l: '🇩🇪 +49' },
{ v: '+48', l: '🇵🇱 +48' }, { v: '+36', l: '🇭🇺 +36' },
{ v: '+44', l: '🇬🇧 +44' }, { v: '+39', l: '🇮🇹 +39' }, { v: '+33', l: '🇫🇷 +33' }
], get filtered() { return this.opts.filter(o => !this.prefix || o.v.includes(this.prefix)) } }">
<input name="phone_prefix" type="text" x-model="prefix" required @focus="prefixOpen = true" @input="prefixOpen = true"
aria-label="{{ t(key='checkout-phone', lang=lang | default(value='sk')) }}" autocomplete="tel-country-code" inputmode="tel"
class="w-full rounded-radius border border-outline bg-surface py-2 pl-3 pr-7 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
<button type="button" tabindex="-1" @click="prefixOpen = !prefixOpen"
class="absolute inset-y-0 right-0 flex w-7 items-center justify-center text-on-surface/60 dark:text-on-surface-dark/60">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"
class="size-4 transition-transform" :class="prefixOpen && 'rotate-180'">
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</button>
<ul x-show="prefixOpen" x-cloak x-transition
class="absolute z-20 mt-1 max-h-56 w-full overflow-auto rounded-radius border border-outline bg-surface p-1 shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt">
<template x-for="o in filtered" :key="o.v">
<li><button type="button" @click="prefix = o.v; prefixOpen = false" x-text="o.l"
class="block w-full rounded-radius px-3 py-1.5 text-left text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark"></button></li>
</template>
</ul>
</div>
<input id="phone" name="phone" type="tel" required autocomplete="tel" inputmode="tel" placeholder="900 000 000"
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
</div>
</div>
</fieldset>
<!-- shipping address -->
@@ -66,8 +99,32 @@
</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>

View File

@@ -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.") }}

126
docs/integrations/README.md Normal file
View 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
View 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
View 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

View 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

View File

@@ -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)
]
}

View File

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

View 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(())
}
}

View 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
}
}

View File

@@ -60,6 +60,7 @@ impl Hooks for App {
Ok(vec![
Box::new(initializers::view_engine::ViewEngineInitializer),
Box::new(initializers::admin_seeder::AdminSeeder),
Box::new(initializers::shipping_seeder::ShippingSeeder),
])
}

View File

@@ -1,4 +1,4 @@
//! Admin order list, detail, and status updates.
//! Admin order list, detail, status updates, and manual carrier dispatch.
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
@@ -7,7 +7,8 @@ use serde::Deserialize;
use serde_json::json;
use crate::{
models::{order_items, orders},
integrations::{self, ShipmentRequest},
models::{order_items, orders, shipping_methods},
views::checkout as view,
controllers::i18n::current_lang,
shared::{guard, settings},
@@ -15,6 +16,9 @@ use crate::{
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,
@@ -40,15 +44,28 @@ async fn index(
)
}
#[debug_handler]
async fn show(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
/// 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> {
guard::current_admin(auth, &ctx).await?;
let order = orders::Entity::find_by_id(id)
.one(&ctx.db)
.await?
@@ -58,22 +75,42 @@ async fn show(
.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,
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(""),
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),
"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,
@@ -96,9 +133,83 @@ async fn update_status(
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))
}

View File

@@ -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::*;
@@ -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")
}

View File

@@ -1,4 +1,5 @@
use crate::{controllers::i18n::current_lang, shared::money::format_price, models::products};
use axum::{http::HeaderMap, response::Redirect};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use loco_rs::prelude::*;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter};
@@ -96,6 +97,8 @@ async fn add(
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 +113,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

View File

@@ -21,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,
@@ -111,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()));
@@ -141,11 +162,12 @@ async fn place_order(
&valid,
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,

View File

@@ -1,2 +1,3 @@
pub mod admin_seeder;
pub mod shipping_seeder;
pub mod view_engine;

View File

@@ -0,0 +1,54 @@
//! 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, PaginatorTrait, QueryFilter, Set};
use crate::models::shipping_methods;
/// `(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;
#[async_trait]
impl Initializer for ShippingSeeder {
fn name(&self) -> String {
"shipping-seeder".to_string()
}
async fn before_run(&self, ctx: &AppContext) -> Result<()> {
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))
.count(&ctx.db)
.await?
> 0;
if exists {
continue;
}
shipping_methods::ActiveModel {
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),
..Default::default()
}
.insert(&ctx.db)
.await?;
tracing::info!(carrier = code, "seeded built-in delivery option");
}
Ok(())
}
}

35
src/integrations/dhl.rs Normal file
View 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
View 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
View 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
View 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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
/// 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,
})
}

View File

@@ -2,6 +2,7 @@ pub mod app;
pub mod controllers;
pub mod data;
pub mod initializers;
pub mod integrations;
pub mod mailers;
pub mod models;
pub mod seed;

View File

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

View File

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

View File

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

View File

@@ -28,6 +28,7 @@ pub fn detail(order: &orders::Model, bank_iban: &str, bank_account_name: &str) -
"id": order.id,
"order_number": order.order_number,
"email": order.email,
"phone": order.phone,
"customer_name": order.customer_name,
"status": order.status,
"subtotal": format_price(order.total_cents - order.shipping_cents),
@@ -42,6 +43,9 @@ pub fn detail(order: &orders::Model, bank_iban: &str, bank_account_name: &str) -
"payment_method": order.payment_method,
"carrier_name": order.carrier_name,
"pickup_point_name": order.pickup_point_name,
"tracking_number": order.tracking_number,
"shipment_id": order.shipment_id,
"label_url": order.label_url,
// Numeric, sequential order id doubles as the bank variable symbol.
"variable_symbol": order.id,
"bank_iban": bank_iban,