This commit is contained in:
Priec
2026-06-17 16:15:22 +02:00
parent 43562e964a
commit e8c0362a54
9 changed files with 761 additions and 35 deletions

View File

@@ -261,5 +261,8 @@ bank-account-name = Account holder
bank-variable-symbol = Variable symbol bank-variable-symbol = Variable symbol
bank-amount = Amount bank-amount = Amount
admin-shipping = Shipping admin-shipping = Shipping
admin-shipping-desc = set carrier prices and availability. admin-shipping-desc = add, edit and remove delivery options.
shipping-enabled = Active shipping-enabled = Active
shipping-new = Add delivery option
shipping-add = Add
shipping-requires-pickup = Requires pickup point

View File

@@ -261,5 +261,8 @@ bank-account-name = Príjemca
bank-variable-symbol = Variabilný symbol bank-variable-symbol = Variabilný symbol
bank-amount = Suma bank-amount = Suma
admin-shipping = Doprava admin-shipping = Doprava
admin-shipping-desc = nastaviť cenu a dostupnosť dopravcov. admin-shipping-desc = pridať, upraviť a odstrániť možnosti dopravy.
shipping-enabled = Aktívne shipping-enabled = Aktívne
shipping-new = Pridať možnosť dopravy
shipping-add = Pridať
shipping-requires-pickup = Vyžaduje výdajné miesto

View File

@@ -11,27 +11,64 @@
<div class="mt-6 space-y-4"> <div class="mt-6 space-y-4">
{% for method in methods %} {% for method in methods %}
<form method="post" action="/admin/shipping/{{ method.id }}" <div class="flex flex-wrap items-end gap-4 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
class="flex flex-wrap items-end gap-4 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt"> <form method="post" action="/admin/shipping/{{ method.id }}" class="flex flex-1 flex-wrap items-end gap-4">
<div class="min-w-40"> <div class="min-w-40">
<p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ method.name }}</p> <p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ method.name }}</p>
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ method.code }}{% if method.requires_pickup_point %} · {{ t(key="checkout-pickup-point", lang=lang | default(value='sk')) }}{% endif %}</p> <p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ method.code }}{% if method.requires_pickup_point %} · {{ t(key="checkout-pickup-point", lang=lang | default(value='sk')) }}{% endif %}</p>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="price-{{ method.id }}" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="price", lang=lang | default(value='sk')) }}</label> <label for="price-{{ method.id }}" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
<input id="price-{{ method.id }}" name="price" type="text" inputmode="decimal" value="{{ method.price }}" <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"> 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">
</div> </div>
<label class="flex items-center gap-2 pb-2"> <label class="flex items-center gap-2 pb-2">
<input type="checkbox" name="enabled" value="on" {% if method.enabled %}checked{% endif %} <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"> 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> <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> </label>
<button type="submit" <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"> 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')) }} {{ t(key="save", lang=lang | default(value='sk')) }}
</button> </button>
</form> </form>
<form method="post" action="/admin/shipping/{{ method.id }}/delete"
onsubmit="return confirm('{{ t(key="confirm-delete", lang=lang | default(value='sk')) }}')">
<button type="submit"
class="inline-flex items-center justify-center rounded-radius border border-outline px-4 py-2 text-sm font-medium text-danger transition hover:bg-danger/10 dark:border-outline-dark">
{{ t(key="delete", lang=lang | default(value='sk')) }}
</button>
</form>
</div>
{% endfor %} {% endfor %}
</div> </div>
<form method="post" action="/admin/shipping"
class="mt-8 flex flex-wrap items-end gap-4 rounded-radius border border-dashed border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
<h2 class="w-full text-sm font-semibold uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="shipping-new", lang=lang | default(value='sk')) }}</h2>
<div class="space-y-1.5">
<label for="new-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="new-name" name="name" type="text" required
class="w-56 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="new-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="new-price" name="price" type="text" inputmode="decimal" value="0.00"
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">
</div>
<label class="flex items-center gap-2 pb-2">
<input type="checkbox" name="requires_pickup_point" value="on"
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-requires-pickup", lang=lang | default(value='sk')) }}</span>
</label>
<label class="flex items-center gap-2 pb-2">
<input type="checkbox" name="enabled" value="on" checked
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="shipping-add", lang=lang | default(value='sk')) }}
</button>
</form>
{% endblock content %} {% endblock content %}

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"> 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"> <a href="/shop/{{ product.slug }}" class="flex flex-1 flex-col">
{% if product.image %} <div class="aspect-square overflow-hidden bg-surface-alt dark:bg-surface-dark">
<img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover transition group-hover:scale-105"> {% 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 %} {% endif %}
</div> </div>
<div class="flex flex-1 flex-col gap-1 p-4"> </div>
<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>

120
docs/integrations/README.md Normal file
View File

@@ -0,0 +1,120 @@
# 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 |
| Shipment-creation API client (any carrier) | — | ❌ not built |
| Tracking number on order | — | ❌ not built |
So **pickup-point selection for Packeta is already wired** — it just needs an
API key. Everything else (DPD/DHL widgets, and *all* shipment-creation API
calls) is new work, described per carrier.
## 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

@@ -1,8 +1,11 @@
//! Admin management of shipping methods (price + enabled toggle). //! Admin management of shipping methods: add, edit (price + enabled), remove.
use axum_extra::extract::cookie::CookieJar; use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*; use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, EntityTrait, QueryOrder, Set}; use sea_orm::{
ActiveModelTrait, ColumnTrait, EntityTrait, ModelTrait, PaginatorTrait, QueryFilter,
QueryOrder, Set,
};
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
@@ -12,6 +15,7 @@ use crate::{
shared::{ shared::{
guard, guard,
money::{format_price, parse_price_to_cents}, money::{format_price, parse_price_to_cents},
slug::{slugify, unique_slug},
}, },
}; };
@@ -21,6 +25,18 @@ struct ShippingForm {
enabled: Option<String>, enabled: Option<String>,
} }
#[derive(Debug, Deserialize)]
struct NewShippingForm {
name: String,
price: String,
requires_pickup_point: Option<String>,
enabled: Option<String>,
}
fn is_checked(value: &Option<String>) -> bool {
matches!(value.as_deref(), Some("on" | "true" | "1"))
}
#[debug_handler] #[debug_handler]
async fn index( async fn index(
auth: auth::JWT, auth: auth::JWT,
@@ -53,6 +69,48 @@ async fn index(
) )
} }
#[debug_handler]
async fn create(
auth: auth::JWT,
State(ctx): State<AppContext>,
Form(form): Form<NewShippingForm>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let name = form.name.trim().to_string();
if name.is_empty() {
return Err(Error::BadRequest("name is required".to_string()));
}
// Stable unique `code` derived from the name; it's what checkout submits and
// what an order stores, so it must not collide with an existing method.
let code = unique_slug(&slugify(&name), |candidate| {
let ctx = ctx.clone();
async move {
Ok(shipping_methods::Entity::find()
.filter(shipping_methods::Column::Code.eq(candidate))
.count(&ctx.db)
.await?
> 0)
}
})
.await?;
// Append after existing methods.
let position = shipping_methods::Entity::find().count(&ctx.db).await? as i32;
shipping_methods::ActiveModel {
code: Set(code),
name: Set(name),
price_cents: Set(parse_price_to_cents(&form.price)?),
requires_pickup_point: Set(is_checked(&form.requires_pickup_point)),
enabled: Set(is_checked(&form.enabled)),
position: Set(position),
..Default::default()
}
.insert(&ctx.db)
.await?;
format::redirect("/admin/shipping")
}
#[debug_handler] #[debug_handler]
async fn update( async fn update(
auth: auth::JWT, auth: auth::JWT,
@@ -67,13 +125,30 @@ async fn update(
.ok_or_else(|| Error::NotFound)?; .ok_or_else(|| Error::NotFound)?;
let mut active = method.into_active_model(); let mut active = method.into_active_model();
active.price_cents = Set(parse_price_to_cents(&form.price)?); active.price_cents = Set(parse_price_to_cents(&form.price)?);
active.enabled = Set(matches!(form.enabled.as_deref(), Some("on" | "true" | "1"))); active.enabled = Set(is_checked(&form.enabled));
active.update(&ctx.db).await?; active.update(&ctx.db).await?;
format::redirect("/admin/shipping") format::redirect("/admin/shipping")
} }
#[debug_handler]
async fn delete(
auth: auth::JWT,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let method = shipping_methods::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
method.delete(&ctx.db).await?;
format::redirect("/admin/shipping")
}
pub fn routes() -> Routes { pub fn routes() -> Routes {
Routes::new() Routes::new()
.add("/admin/shipping", get(index)) .add("/admin/shipping", get(index))
.add("/admin/shipping", post(create))
.add("/admin/shipping/{id}", post(update)) .add("/admin/shipping/{id}", post(update))
.add("/admin/shipping/{id}/delete", post(delete))
} }