shipping
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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
120
docs/integrations/README.md
Normal 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
150
docs/integrations/dhl.md
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
# DHL integration
|
||||||
|
|
||||||
|
DHL is best for **home delivery and international/express** shipments. Like DPD,
|
||||||
|
**nothing DHL-specific is scaffolded** here. DHL is mostly an **address (home)
|
||||||
|
delivery** carrier — pickup points exist (DHL ServicePoint / Packstation, mostly
|
||||||
|
DE) but most shops use DHL for door-to-door, so you can usually skip the pickup
|
||||||
|
widget entirely.
|
||||||
|
|
||||||
|
> DHL has **several separate APIs** behind one developer portal
|
||||||
|
> (<https://developer.dhl.com>). Pick the one that matches your service:
|
||||||
|
> - **DHL Parcel DE (Post & Parcel Germany) — Shipping API** for German domestic
|
||||||
|
> parcels / Packstation.
|
||||||
|
> - **DHL eCommerce (Parcel) APIs** for various countries.
|
||||||
|
> - **DHL Express — MyDHL API** for international express.
|
||||||
|
> Confirm which your contract covers before coding.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Get DHL API access
|
||||||
|
|
||||||
|
1. Create an account on the **DHL Developer Portal**: <https://developer.dhl.com>.
|
||||||
|
2. Create an **app** and subscribe it to the specific API you need (e.g.
|
||||||
|
"Shipping API" or "MyDHL API"). You receive an **API key (client id) +
|
||||||
|
secret**.
|
||||||
|
3. Separately you need a **DHL business/customer account** (EKP / account
|
||||||
|
number, billing number) — the developer key alone can't bill shipments. Link
|
||||||
|
your business account credentials to the app.
|
||||||
|
4. Most DHL APIs use **OAuth2 client-credentials**: you exchange key+secret for a
|
||||||
|
short-lived **Bearer token**, then call the shipping endpoints with it. (Some
|
||||||
|
older endpoints use Basic auth — check your API's docs.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Create the delivery option
|
||||||
|
|
||||||
|
At **`/admin/shipping`** → "Add delivery option":
|
||||||
|
- **Name**: e.g. `DHL` or `DHL Express (international)`
|
||||||
|
- **Price**: your fee
|
||||||
|
- **Requires pickup point**: ❌ off for normal home delivery
|
||||||
|
(turn ✅ on *only* if you specifically offer DHL Packstation/ServicePoint and
|
||||||
|
build a picker — see section 4)
|
||||||
|
- ✅ **Active**
|
||||||
|
|
||||||
|
With the option active, customers can already choose DHL and you can create the
|
||||||
|
label manually in DHL Business Customer Portal. The API (section 3) automates
|
||||||
|
that.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Create shipments via the DHL API
|
||||||
|
|
||||||
|
Do the [shared groundwork](README.md#shared-groundwork-do-this-once-before-any-carriers-api-step)
|
||||||
|
first. Set `shipping_methods.carrier = "dhl"` for your DHL options.
|
||||||
|
|
||||||
|
### Credentials
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DHL_API_KEY=your_client_id
|
||||||
|
DHL_API_SECRET=your_client_secret
|
||||||
|
DHL_ACCOUNT_NUMBER=your_ekp_or_billing_number
|
||||||
|
DHL_API_BASE=https://api-eu.dhl.com # depends on the specific API
|
||||||
|
```
|
||||||
|
Add matching lines under `settings:` in `config/*.yaml`:
|
||||||
|
```yaml
|
||||||
|
dhl_api_key: {{ get_env(name="DHL_API_KEY", default="") }}
|
||||||
|
dhl_api_secret: {{ get_env(name="DHL_API_SECRET", default="") }}
|
||||||
|
dhl_account_number: {{ get_env(name="DHL_ACCOUNT_NUMBER", default="") }}
|
||||||
|
dhl_api_base: {{ get_env(name="DHL_API_BASE", default="") }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flow (OAuth2 + create shipment)
|
||||||
|
|
||||||
|
1. **Token** → `POST {base}/.../token` with `grant_type=client_credentials` +
|
||||||
|
key/secret → `access_token` (Bearer; cache until it expires).
|
||||||
|
2. **Create shipment** → `POST` the shipment-orders endpoint with the Bearer
|
||||||
|
token: shipper (your account/EKP), consignee (recipient from the order),
|
||||||
|
product code (domestic vs international/express), weight, customs data for
|
||||||
|
non-EU, and references (`order_number`). COD is a value-added service if you
|
||||||
|
offer it.
|
||||||
|
3. **Label** → the response includes a **tracking/shipment number** and a
|
||||||
|
**label** (PDF/base64). Store/print it.
|
||||||
|
|
||||||
|
### Client sketch (`src/integrations/dhl.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use crate::shared::settings;
|
||||||
|
|
||||||
|
async fn bearer(ctx: &AppContext) -> Result<String> {
|
||||||
|
let base = settings::get(ctx, "dhl_api_base").unwrap_or_default();
|
||||||
|
let key = settings::get(ctx, "dhl_api_key").unwrap_or_default();
|
||||||
|
let secret = settings::get(ctx, "dhl_api_secret").unwrap_or_default();
|
||||||
|
// POST client_credentials → access_token; cache with expiry.
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_shipment(ctx: &AppContext, req: super::ShipmentRequest<'_>)
|
||||||
|
-> Result<super::ShipmentResult>
|
||||||
|
{
|
||||||
|
let token = bearer(ctx).await?;
|
||||||
|
let account = settings::get(ctx, "dhl_account_number").unwrap_or_default();
|
||||||
|
// Build shipment JSON:
|
||||||
|
// - shipper: your account address (account = EKP/billing number)
|
||||||
|
// - consignee: req.recipient_name / address / city / zip / country
|
||||||
|
// - details: weight, product code (domestic / express), currency
|
||||||
|
// - refs: req.order_number
|
||||||
|
// - for international: customs (HS codes, declared value, contents)
|
||||||
|
// POST {base}/.../shipments with Authorization: Bearer {token}
|
||||||
|
todo!("parse tracking number + label into ShipmentResult")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wire into the admin "Create shipment" action for `carrier == "dhl"` orders.
|
||||||
|
|
||||||
|
> 🌍 **International note:** for shipments outside the EU customs union you must
|
||||||
|
> send **customs/commodity data** (HS codes, declared value, item descriptions).
|
||||||
|
> Your `order_items` only store name + price today — if you ship internationally
|
||||||
|
> you'll likely add a customs description/HS-code field to products.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. (Optional) DHL pickup points
|
||||||
|
|
||||||
|
If you offer **Packstation / ServicePoint**, set "Requires pickup point" ✅ on
|
||||||
|
that delivery option and render DHL's **Location Finder** (a separate DHL API)
|
||||||
|
in the checkout pickup block (the `x-show="requiresPoint"` section of
|
||||||
|
`assets/views/shop/checkout.html`), writing the chosen locker id into the
|
||||||
|
existing hidden `pickup_point_id` / `pickup_point_name` fields. For Packstation
|
||||||
|
you also need the recipient's **DHL post number** — an extra field most shops
|
||||||
|
avoid unless targeting Germany.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Testing
|
||||||
|
|
||||||
|
- DHL provides a **sandbox** environment per API (separate base URL + test
|
||||||
|
credentials) on the developer portal. Get a token and create one test
|
||||||
|
shipment there before production.
|
||||||
|
- Validate the tracking number on <https://www.dhl.com/track>.
|
||||||
|
|
||||||
|
## 6. Go-live checklist
|
||||||
|
|
||||||
|
- [ ] DHL developer app created + subscribed to the right API
|
||||||
|
- [ ] DHL business account (EKP/billing number) linked
|
||||||
|
- [ ] `DHL_*` env vars set; matching `settings:` lines added to `config/production.yaml`
|
||||||
|
- [ ] Delivery option created in `/admin/shipping`; `carrier = "dhl"` set
|
||||||
|
- [ ] `src/integrations/dhl.rs` implemented; OAuth token caching working
|
||||||
|
- [ ] (International) customs data available on products/items
|
||||||
|
- [ ] Test shipment in DHL sandbox → tracking number stored on order
|
||||||
|
- [ ] Switched from sandbox to production base URL/credentials
|
||||||
147
docs/integrations/dpd.md
Normal file
147
docs/integrations/dpd.md
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
# DPD integration
|
||||||
|
|
||||||
|
DPD offers **home/business delivery** and **DPD Pickup parcelshops & lockers**.
|
||||||
|
Unlike Packeta, **nothing DPD-specific is scaffolded yet** in this repo, so this
|
||||||
|
is a full integration: an optional pickup widget plus the shipment-creation API.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Get a DPD account & API access
|
||||||
|
|
||||||
|
1. You need a **business contract** with DPD in your country (e.g. DPD SK:
|
||||||
|
<https://www.dpd.com/sk>). Ask your account manager for **API access**.
|
||||||
|
2. DPD exposes a few different APIs depending on country/era — confirm which one
|
||||||
|
your contract uses:
|
||||||
|
- **REST Shipping API** (modern; JSON) — most new integrations.
|
||||||
|
- **SOAP "Login/Shipment" web services** (older; still common in CEE).
|
||||||
|
- Some markets use the **DPD Geodata / Shop Finder API** for parcelshops.
|
||||||
|
3. You'll receive: an **API base URL**, a **delisId / login**, and a
|
||||||
|
**password** (the SOAP `login` call returns a short-lived **auth token** you
|
||||||
|
reuse on subsequent calls). REST variants use an API key/token directly.
|
||||||
|
4. Note your **sender address** and **DPD customer number** — required on every
|
||||||
|
shipment.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Decide which DPD services you offer
|
||||||
|
|
||||||
|
Create one delivery option per service at **`/admin/shipping`** → "Add delivery
|
||||||
|
option":
|
||||||
|
|
||||||
|
| Option | "Requires pickup point" | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `DPD Home` (classic) | ❌ off | delivered to the address on the order |
|
||||||
|
| `DPD Pickup` (parcelshop/locker) | ✅ on | customer must choose a shop/locker |
|
||||||
|
|
||||||
|
For `DPD Pickup` you need a **point picker** (section 3). For `DPD Home` you can
|
||||||
|
skip straight to the API (section 4).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. (Pickup only) Add the DPD parcelshop picker at checkout
|
||||||
|
|
||||||
|
The checkout already has the generic pickup machinery: when the selected method
|
||||||
|
has `requires_pickup_point = true`, the block with hidden `pickup_point_id` /
|
||||||
|
`pickup_point_name` shows (`assets/views/shop/checkout.html`, the `x-show=
|
||||||
|
"requiresPoint"` block). Today that block only renders the **Packeta** widget
|
||||||
|
(guarded by `{% if packeta_api_key %}`) or a text fallback.
|
||||||
|
|
||||||
|
To support DPD you make that block carrier-aware:
|
||||||
|
|
||||||
|
1. Pass a `dpd_enabled` / map-widget key flag into the checkout context from
|
||||||
|
`src/controllers/checkout.rs` (like `packeta_api_key` is passed today).
|
||||||
|
2. In the pickup block, branch on the chosen `carrier` (the Alpine `carrier`
|
||||||
|
variable already holds the method `code`) and render DPD's parcelshop map
|
||||||
|
widget when a DPD pickup method is selected. DPD provides an embeddable
|
||||||
|
**map/widget** (or you query their **Shop Finder API** and render your own
|
||||||
|
list); on selection, write the shop id into `pointId` and a human label into
|
||||||
|
`pointName` — exactly what the existing hidden inputs expect.
|
||||||
|
|
||||||
|
No new order fields are needed — `pickup_point_id` / `pickup_point_name` already
|
||||||
|
carry the DPD shop id + name.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Create shipments via the DPD API
|
||||||
|
|
||||||
|
Do the [shared groundwork](README.md#shared-groundwork-do-this-once-before-any-carriers-api-step)
|
||||||
|
first. Set `shipping_methods.carrier = "dpd"` for your DPD options.
|
||||||
|
|
||||||
|
### Auth (SOAP-style, common in CEE)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
DPD_API_BASE=https://api.dpd.sk # from your account manager
|
||||||
|
DPD_LOGIN=your_delis_login
|
||||||
|
DPD_PASSWORD=your_password
|
||||||
|
DPD_CUSTOMER_NUMBER=your_customer_no
|
||||||
|
```
|
||||||
|
Add matching lines under `settings:` in `config/*.yaml`:
|
||||||
|
```yaml
|
||||||
|
dpd_api_base: {{ get_env(name="DPD_API_BASE", default="") }}
|
||||||
|
dpd_login: {{ get_env(name="DPD_LOGIN", default="") }}
|
||||||
|
dpd_password: {{ get_env(name="DPD_PASSWORD", default="") }}
|
||||||
|
dpd_customer_number: {{ get_env(name="DPD_CUSTOMER_NUMBER", default="") }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flow
|
||||||
|
|
||||||
|
1. **Login** → `LoginService.getAuth(delisId, password)` returns an **auth
|
||||||
|
token** (valid for a while; cache it).
|
||||||
|
2. **Create shipment** → `ShipmentService.storeOrders(...)` with the auth token,
|
||||||
|
recipient address (or parcelshop id for Pickup), parcel weight, references
|
||||||
|
(your `order_number`), and COD amount if cash-on-delivery. Returns a
|
||||||
|
**parcel number (MPS id)** = your tracking number, plus label data.
|
||||||
|
3. **Label** → the same call (or `getParcelLabels`) returns a **PDF/ZPL label**;
|
||||||
|
store or print it.
|
||||||
|
|
||||||
|
### Client sketch (`src/integrations/dpd.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use crate::shared::settings;
|
||||||
|
|
||||||
|
async fn auth_token(ctx: &AppContext) -> Result<String> {
|
||||||
|
let base = settings::get(ctx, "dpd_api_base").unwrap_or_default();
|
||||||
|
let login = settings::get(ctx, "dpd_login").unwrap_or_default();
|
||||||
|
let pass = settings::get(ctx, "dpd_password").unwrap_or_default();
|
||||||
|
// POST login → parse token from response. Cache it (e.g. in-memory w/ expiry).
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_shipment(ctx: &AppContext, req: super::ShipmentRequest<'_>)
|
||||||
|
-> Result<super::ShipmentResult>
|
||||||
|
{
|
||||||
|
let token = auth_token(ctx).await?;
|
||||||
|
let customer = settings::get(ctx, "dpd_customer_number").unwrap_or_default();
|
||||||
|
// Build storeOrders payload:
|
||||||
|
// - product: "CL" (classic/home) or "Pickup" + parcelShopId = req.pickup_point_id
|
||||||
|
// - recipient: req.recipient_name / address / city / zip / country / phone
|
||||||
|
// - cod: req.cod_cents (set cash-on-delivery service if > 0)
|
||||||
|
// - reference: req.order_number
|
||||||
|
// POST to {base}/shipment ... with `token`.
|
||||||
|
todo!("parse parcel number + label into ShipmentResult")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Wire it into the admin "Create shipment" action for `carrier == "dpd"` orders.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Testing
|
||||||
|
|
||||||
|
- DPD provides a **test/integration environment** (separate base URL +
|
||||||
|
credentials) — get it from your account manager. Validate login + one
|
||||||
|
shipment there first.
|
||||||
|
- Confirm the returned parcel number tracks on
|
||||||
|
`https://tracking.dpd.de/...` / your local DPD tracking site.
|
||||||
|
|
||||||
|
## 6. Go-live checklist
|
||||||
|
|
||||||
|
- [ ] DPD business contract + API credentials obtained
|
||||||
|
- [ ] `DPD_*` env vars set; matching `settings:` lines added to `config/production.yaml`
|
||||||
|
- [ ] Delivery option(s) created in `/admin/shipping` (`DPD Home` and/or `DPD Pickup`)
|
||||||
|
- [ ] `carrier = "dpd"` set on those methods (via the shared `carrier` column)
|
||||||
|
- [ ] (Pickup) parcelshop picker rendered in checkout for DPD methods
|
||||||
|
- [ ] `src/integrations/dpd.rs` implemented; login token caching working
|
||||||
|
- [ ] Test shipment in DPD test env → tracking number stored on order
|
||||||
|
- [ ] Switched base URL/credentials from test to production
|
||||||
174
docs/integrations/packeta.md
Normal file
174
docs/integrations/packeta.md
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
# Packeta (Zásilkovna) integration
|
||||||
|
|
||||||
|
Packeta delivers mainly to **pickup points** and **Z-BOX lockers** (plus
|
||||||
|
home delivery in some regions). It's the most common choice for SK/CZ eshops.
|
||||||
|
This repo is already **scaffolded** for Packeta's pickup-point picker — you
|
||||||
|
mostly need an API key to switch it on. Shipment creation via API is extra,
|
||||||
|
optional work.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Get a Packeta account & keys
|
||||||
|
|
||||||
|
1. Register a client account at <https://client.packeta.com> (Zásilkovna /
|
||||||
|
Packeta). For SK: <https://www.packeta.sk>.
|
||||||
|
2. In the client portal open **Client support → API / Nastavenia API** (or
|
||||||
|
"Integrations"). You get **two different secrets** — don't mix them up:
|
||||||
|
- **Web/Widget API key** — public-ish key used by the browser pickup-point
|
||||||
|
widget (`Packeta.Widget.pick`). This is the one this repo already uses.
|
||||||
|
- **API password (REST/SOAP)** — secret server key used to *create packets*
|
||||||
|
(shipments). Never expose this to the browser.
|
||||||
|
3. (For real shipping) configure your **sender/pickup address and label
|
||||||
|
format** in the portal.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Activate the pickup-point picker (already built)
|
||||||
|
|
||||||
|
The checkout template already loads the widget and wires the chosen point into
|
||||||
|
the order **whenever `packeta_api_key` is non-empty**
|
||||||
|
(`assets/views/shop/checkout.html`):
|
||||||
|
|
||||||
|
- loads `https://widget.packeta.com/v6/www/js/library.js`
|
||||||
|
- `Packeta.Widget.pick(packetaKey, point => …)` fills hidden
|
||||||
|
`pickup_point_id` + `pickup_point_name`
|
||||||
|
- if the key is empty it falls back to a plain text field
|
||||||
|
|
||||||
|
So to turn it on:
|
||||||
|
|
||||||
|
### a) Set the Web/Widget API key
|
||||||
|
|
||||||
|
Set the env var (read by `config/development.yaml` / `production.yaml` →
|
||||||
|
`settings.packeta_api_key`, exposed via `src/shared/settings.rs`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .env (development) or your production environment
|
||||||
|
PACKETA_API_KEY=your_web_widget_api_key
|
||||||
|
```
|
||||||
|
|
||||||
|
`config/development.yaml` already contains:
|
||||||
|
```yaml
|
||||||
|
settings:
|
||||||
|
packeta_api_key: {{ get_env(name="PACKETA_API_KEY", default="") }}
|
||||||
|
```
|
||||||
|
For production, add the same line under `settings:` in `config/production.yaml`
|
||||||
|
(it isn't there yet).
|
||||||
|
|
||||||
|
### b) Create a Packeta delivery option in the admin
|
||||||
|
|
||||||
|
Go to **`/admin/shipping`** → "Add delivery option":
|
||||||
|
- **Name**: e.g. `Packeta – pickup point`
|
||||||
|
- **Price**: your fee (e.g. `2.90`)
|
||||||
|
- ✅ **Requires pickup point** ← this makes the picker appear at checkout
|
||||||
|
- ✅ **Active**
|
||||||
|
|
||||||
|
The auto-generated `code` will be `packeta-pickup-point` (or similar). Customers
|
||||||
|
now see the option, click "Choose pickup point", pick on the map, and the order
|
||||||
|
stores `pickup_point_id` + `pickup_point_name`.
|
||||||
|
|
||||||
|
**At this point you have a working Packeta flow** — you read the pickup point on
|
||||||
|
the order in `/admin/orders` and create the parcel manually in the Packeta
|
||||||
|
portal. Many small shops stop here.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. (Optional) Create shipments via API
|
||||||
|
|
||||||
|
Automate "register the parcel + get tracking + print label". Do the
|
||||||
|
[shared groundwork](README.md#shared-groundwork-do-this-once-before-any-carriers-api-step)
|
||||||
|
first (HTTP client, `integrations` module, `carrier` column, tracking columns).
|
||||||
|
|
||||||
|
### Endpoint & auth
|
||||||
|
|
||||||
|
- Packeta REST API base: `https://www.zasilkovna.cz/api/rest` (SOAP also
|
||||||
|
available at `http://www.zasilkovna.cz/api/soap.wsdl`).
|
||||||
|
- Auth = your **API password** (the server secret from step 1), sent in the
|
||||||
|
request body, **not** the widget key.
|
||||||
|
- Key operation: **`createPacket`**. You send sender id, recipient
|
||||||
|
name/email/phone, the chosen **pickup point id** (`addressId`), value, weight,
|
||||||
|
and COD amount; you receive a **packet id + barcode (tracking)**. A separate
|
||||||
|
**`packetLabelPdf`** call returns the label PDF.
|
||||||
|
|
||||||
|
### Store the secret
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PACKETA_API_PASSWORD=your_secret_api_password
|
||||||
|
```
|
||||||
|
Add to `config/*.yaml` under `settings:`:
|
||||||
|
```yaml
|
||||||
|
packeta_api_password: {{ get_env(name="PACKETA_API_PASSWORD", default="") }}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Client sketch (`src/integrations/packeta.rs`)
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use crate::shared::settings;
|
||||||
|
|
||||||
|
// createPacket accepts XML; serde_json works for the JSON REST variant.
|
||||||
|
pub async fn create_shipment(ctx: &AppContext, req: super::ShipmentRequest<'_>)
|
||||||
|
-> Result<super::ShipmentResult>
|
||||||
|
{
|
||||||
|
let api_password = settings::get(ctx, "packeta_api_password")
|
||||||
|
.ok_or_else(|| Error::string("packeta_api_password not configured"))?;
|
||||||
|
|
||||||
|
// Packeta's createPacket is XML/SOAP-ish; build the body per their docs.
|
||||||
|
// number = your order_number
|
||||||
|
// name/surname = recipient
|
||||||
|
// addressId = req.pickup_point_id (the chosen point)
|
||||||
|
// cod = req.cod_cents / 100 (0 if not COD)
|
||||||
|
// value = goods value
|
||||||
|
// eshop = your sender label/id from the portal
|
||||||
|
let body = format!(r#"<createPacket>
|
||||||
|
<apiPassword>{api_password}</apiPassword>
|
||||||
|
<packetAttributes>
|
||||||
|
<number>{}</number>
|
||||||
|
<name>{}</name>
|
||||||
|
<email>{}</email>
|
||||||
|
<addressId>{}</addressId>
|
||||||
|
<cod>{}</cod>
|
||||||
|
<value>{}</value>
|
||||||
|
<weight>{}</weight>
|
||||||
|
<eshop>YOUR_SENDER_LABEL</eshop>
|
||||||
|
</packetAttributes>
|
||||||
|
</createPacket>"#,
|
||||||
|
req.order_number, req.recipient_name, req.email,
|
||||||
|
req.pickup_point_id.unwrap_or(""),
|
||||||
|
req.cod_cents as f64 / 100.0,
|
||||||
|
req.cod_cents as f64 / 100.0,
|
||||||
|
req.weight_grams);
|
||||||
|
|
||||||
|
let resp = reqwest::Client::new()
|
||||||
|
.post("https://www.zasilkovna.cz/api/rest")
|
||||||
|
.body(body)
|
||||||
|
.send().await.map_err(|e| Error::string(&e.to_string()))?
|
||||||
|
.text().await.map_err(|e| Error::string(&e.to_string()))?;
|
||||||
|
|
||||||
|
// Parse <id> (packet id) and <barcode> (tracking) out of the XML response.
|
||||||
|
// Then optionally call packetLabelPdf with that id to fetch the label.
|
||||||
|
todo!("parse resp into ShipmentResult")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then call it from your admin "Create shipment" action for orders whose
|
||||||
|
`shipping_methods.carrier == "packeta"`, and save `tracking_number` /
|
||||||
|
`shipment_id` back on the order.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Testing
|
||||||
|
|
||||||
|
- Use the Packeta **sandbox/staging** portal if your account offers one, or a
|
||||||
|
test API password. Verify `createPacket` returns a packet id before going
|
||||||
|
live.
|
||||||
|
- Track the parcel at `https://tracking.packeta.com/...` using the returned
|
||||||
|
barcode.
|
||||||
|
|
||||||
|
## 5. Go-live checklist
|
||||||
|
|
||||||
|
- [ ] `PACKETA_API_KEY` (widget) set in production env
|
||||||
|
- [ ] `packeta_api_key` line added under `settings:` in `config/production.yaml`
|
||||||
|
- [ ] Packeta delivery option created in `/admin/shipping` with **Requires pickup point** ✅
|
||||||
|
- [ ] (If using API) `PACKETA_API_PASSWORD` set + `src/integrations/packeta.rs` implemented
|
||||||
|
- [ ] Sender address & label format configured in the Packeta portal
|
||||||
|
- [ ] Test order → pickup point saved on order → (API) tracking number stored
|
||||||
@@ -1,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))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user