more webshop

This commit is contained in:
Priec
2026-06-16 19:27:29 +02:00
parent baf7522273
commit f0a6f97609
25 changed files with 583 additions and 33 deletions

View File

@@ -239,3 +239,23 @@ order-status-paid = Paid
order-status-shipped = Shipped
order-status-cancelled = Cancelled
order-update-status = Update status
# --- eshop: shipping & payment ---
checkout-carrier = Delivery
checkout-payment = Payment method
checkout-subtotal = Subtotal
checkout-shipping-cost = Shipping
checkout-pick-point = Choose pickup point
checkout-chosen-point = Chosen point
checkout-pickup-point = Pickup point
payment-cod = Cash on delivery
payment-bank = Bank transfer
payment-bank-instructions = Please transfer the amount to our account:
payment-cod-note = You will pay for the goods on delivery.
payment-bank-note = We will ship once the payment arrives.
bank-account-name = Account holder
bank-variable-symbol = Variable symbol
bank-amount = Amount
admin-shipping = Shipping
admin-shipping-desc = set carrier prices and availability.
shipping-enabled = Active

View File

@@ -239,3 +239,23 @@ order-status-paid = Zaplatené
order-status-shipped = Odoslané
order-status-cancelled = Zrušené
order-update-status = Zmeniť stav
# --- eshop: shipping & payment ---
checkout-carrier = Doprava
checkout-payment = Spôsob platby
checkout-subtotal = Medzisúčet
checkout-shipping-cost = Doprava
checkout-pick-point = Vybrať výdajné miesto
checkout-chosen-point = Vybrané miesto
checkout-pickup-point = Výdajné miesto
payment-cod = Dobierka (platba pri prevzatí)
payment-bank = Bankový prevod
payment-bank-instructions = Sumu uhraďte prevodom na náš účet:
payment-cod-note = Za tovar zaplatíte pri jeho prevzatí.
payment-bank-note = Po prijatí platby objednávku odošleme.
bank-account-name = Príjemca
bank-variable-symbol = Variabilný symbol
bank-amount = Suma
admin-shipping = Doprava
admin-shipping-desc = nastaviť cenu a dostupnosť dopravcov.
shipping-enabled = Aktívne

File diff suppressed because one or more lines are too long

View File

@@ -79,6 +79,10 @@
class="flex items-center gap-3 rounded-radius px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
{{ t(key="admin-orders", lang=lang | default(value='sk')) }}
</a>
<a href="/admin/shipping" data-nav="/admin/shipping"
class="flex items-center gap-3 rounded-radius px-3 py-2 text-sm font-medium text-on-surface transition hover:bg-surface hover:text-primary aria-[current=page]:bg-primary aria-[current=page]:text-on-primary dark:text-on-surface-dark dark:hover:bg-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:bg-primary-dark dark:aria-[current=page]:text-on-primary-dark">
{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}
</a>
</div>
<div class="border-t border-outline p-4 dark:border-outline-dark">

View File

@@ -50,6 +50,17 @@
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</p>
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.address }}<br>{{ order.zip }} {{ order.city }}<br>{{ order.country }}</p>
</div>
<div>
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-carrier", lang=lang | default(value='sk')) }}</p>
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.carrier_name }} — {{ order.shipping }} {{ order.currency }}</p>
{% if order.pickup_point_name %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.pickup_point_name }}</p>{% endif %}
</div>
<div>
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-payment", lang=lang | default(value='sk')) }}</p>
<p class="text-on-surface/80 dark:text-on-surface-dark/80">
{% if order.payment_method == "bank_transfer" %}{{ t(key="payment-bank", lang=lang | default(value='sk')) }} · VS {{ order.variable_symbol }}{% else %}{{ t(key="payment-cod", lang=lang | default(value='sk')) }}{% endif %}
</p>
</div>
{% if order.note %}
<div>
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-note", lang=lang | default(value='sk')) }}</p>

View File

@@ -0,0 +1,37 @@
{% extends "admin/base.html" %}
{% block title %}{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}{% endblock title %}
{% block crumb %}{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}{% endblock crumb %}
{% block content %}
<header class="space-y-1">
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}</h1>
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-shipping-desc", lang=lang | default(value='sk')) }}</p>
</header>
<div class="mt-6 space-y-4">
{% for method in methods %}
<form method="post" action="/admin/shipping/{{ method.id }}"
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>
</div>
<div class="space-y-1.5">
<label for="price-{{ method.id }}" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
<input id="price-{{ method.id }}" name="price" type="text" inputmode="decimal" value="{{ method.price }}"
class="w-28 rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
</div>
<label class="flex items-center gap-2 pb-2">
<input type="checkbox" name="enabled" value="on" {% if method.enabled %}checked{% endif %}
class="size-4 rounded border-outline text-primary focus:ring-primary dark:border-outline-dark">
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shipping-enabled", lang=lang | default(value='sk')) }}</span>
</label>
<button type="submit"
class="ml-auto inline-flex items-center justify-center rounded-radius bg-primary px-4 py-2 text-sm font-medium text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
{{ t(key="save", lang=lang | default(value='sk')) }}
</button>
</form>
{% endfor %}
</div>
{% endblock content %}

View File

@@ -3,10 +3,34 @@
{% block title %}{{ t(key="checkout-title", lang=lang | default(value='sk')) }}{% endblock title %}
{% block content %}
{% if packeta_api_key %}<script src="https://widget.packeta.com/v6/www/js/library.js"></script>{% endif %}
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-title", lang=lang | default(value='sk')) }}</h1>
<div class="mt-6 grid gap-8 lg:grid-cols-3">
<form method="post" action="/checkout" hx-boost="false" class="space-y-6 lg:col-span-2">
<form method="post" action="/checkout" hx-boost="false"
x-data="{
paymentMethod: '',
carrier: '',
carrierPrice: 0,
requiresPoint: false,
pointId: '',
pointName: '',
subtotal: {{ subtotal_cents }},
packetaKey: '{{ packeta_api_key }}',
fmt(c) { return (c / 100).toFixed(2) },
pickPoint() {
Packeta.Widget.pick(this.packetaKey, (point) => {
if (point) { this.pointId = String(point.id); this.pointName = point.formatedValue || point.name }
})
},
get canSubmit() {
return this.paymentMethod && this.carrier && (!this.requiresPoint || this.pointId)
}
}"
class="mt-6 grid gap-8 lg:grid-cols-3">
<div class="space-y-6 lg:col-span-2">
<!-- contact -->
<fieldset class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-contact", lang=lang | default(value='sk')) }}</legend>
<div class="space-y-1.5">
@@ -21,6 +45,7 @@
</div>
</fieldset>
<!-- shipping address -->
<fieldset class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</legend>
<div class="space-y-1.5">
@@ -41,23 +66,70 @@
</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
<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>
</div>
</fieldset>
<!-- carrier -->
<fieldset class="space-y-3 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-carrier", lang=lang | default(value='sk')) }}</legend>
{% for m in shipping_methods %}
<label class="flex cursor-pointer items-center justify-between gap-3 rounded-radius border border-outline px-4 py-3 transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark">
<span class="flex items-center gap-3">
<input type="radio" name="carrier_code" value="{{ m.code }}" required
@change="carrier='{{ m.code }}'; carrierPrice={{ m.price_cents }}; requiresPoint={{ m.requires_pickup_point }}; pointId=''; pointName=''"
class="size-4 border-outline text-primary focus:ring-primary dark:border-outline-dark">
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ m.name }}</span>
</span>
<span class="tabular-nums text-on-surface/80 dark:text-on-surface-dark/80">{{ m.price }} {{ currency }}</span>
</label>
{% endfor %}
<!-- pickup point (carriers that need one, e.g. Packeta) -->
<div x-show="requiresPoint" x-cloak class="space-y-2 pt-1">
<input type="hidden" name="pickup_point_id" x-model="pointId">
<input type="hidden" name="pickup_point_name" x-model="pointName">
{% if packeta_api_key %}
<button type="button" @click="pickPoint()"
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="checkout-pick-point", lang=lang | default(value='sk')) }}
</button>
<p x-show="pointName" x-cloak class="text-sm text-on-surface/80 dark:text-on-surface-dark/80">
<span class="font-medium">{{ t(key="checkout-chosen-point", lang=lang | default(value='sk')) }}:</span> <span x-text="pointName"></span>
</p>
{% else %}
<label class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-pickup-point", lang=lang | default(value='sk')) }}</label>
<input type="text" x-model="pointName" @input="pointId = pointName"
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">
{% endif %}
</div>
</fieldset>
<!-- payment -->
<fieldset class="space-y-3 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-payment", lang=lang | default(value='sk')) }}</legend>
<label class="flex cursor-pointer items-center gap-3 rounded-radius border border-outline px-4 py-3 transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark">
<input type="radio" name="payment_method" value="cod" required x-model="paymentMethod"
class="size-4 border-outline text-primary focus:ring-primary dark:border-outline-dark">
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-cod", lang=lang | default(value='sk')) }}</span>
</label>
<label class="flex cursor-pointer items-center gap-3 rounded-radius border border-outline px-4 py-3 transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark">
<input type="radio" name="payment_method" value="bank_transfer" required x-model="paymentMethod"
class="size-4 border-outline text-primary focus:ring-primary dark:border-outline-dark">
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-bank", lang=lang | default(value='sk')) }}</span>
</label>
</fieldset>
<div class="space-y-1.5">
<label for="note" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-note", lang=lang | default(value='sk')) }}</label>
<textarea id="note" name="note" rows="3"
class="w-full rounded-radius border border-outline bg-surface px-3 py-2 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark"></textarea>
</div>
</fieldset>
<button type="submit"
class="inline-flex items-center justify-center rounded-radius bg-primary px-6 py-2.5 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 dark:bg-primary-dark dark:text-on-primary-dark">
{{ t(key="checkout-place-order", lang=lang | default(value='sk')) }}
</button>
</form>
</div>
<!-- summary -->
<aside class="h-fit space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<h2 class="text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-summary", lang=lang | default(value='sk')) }}</h2>
<ul class="space-y-2 text-sm">
@@ -68,10 +140,24 @@
</li>
{% endfor %}
</ul>
<div class="space-y-1 border-t border-outline pt-3 text-sm dark:border-outline-dark">
<div class="flex justify-between">
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }}</span>
<span class="tabular-nums">{{ subtotal }} {{ currency }}</span>
</div>
<div class="flex justify-between">
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-shipping-cost", lang=lang | default(value='sk')) }}</span>
<span class="tabular-nums" x-text="fmt(carrierPrice) + ' {{ currency }}'"></span>
</div>
</div>
<div class="flex justify-between border-t border-outline pt-3 text-base font-bold dark:border-outline-dark">
<span>{{ t(key="cart-total", lang=lang | default(value='sk')) }}</span>
<span class="tabular-nums text-primary dark:text-primary-dark">{{ total }} {{ currency }}</span>
<span class="tabular-nums text-primary dark:text-primary-dark" x-text="fmt(subtotal + carrierPrice) + ' {{ currency }}'"></span>
</div>
<button type="submit" :disabled="!canSubmit"
class="inline-flex w-full items-center justify-center rounded-radius bg-primary px-6 py-2.5 text-sm font-medium tracking-wide text-on-primary transition hover:opacity-75 disabled:cursor-not-allowed disabled:opacity-40 dark:bg-primary-dark dark:text-on-primary-dark">
{{ t(key="checkout-place-order", lang=lang | default(value='sk')) }}
</button>
</aside>
</div>
</form>
{% endblock content %}

View File

@@ -3,18 +3,18 @@
{% block title %}{{ t(key="order-confirmed-title", lang=lang | default(value='sk')) }}{% endblock title %}
{% block content %}
<div class="mx-auto max-w-2xl space-y-6 text-center">
<div class="mx-auto max-w-2xl space-y-6">
<div class="text-center">
<div class="mx-auto flex size-14 items-center justify-center rounded-full bg-success/15 text-success">
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-7">
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
</svg>
</div>
<div>
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="order-confirmed-title", lang=lang | default(value='sk')) }}</h1>
<h1 class="mt-3 text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="order-confirmed-title", lang=lang | default(value='sk')) }}</h1>
<p class="mt-1 text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="order-confirmed-sub", lang=lang | default(value='sk')) }}</p>
</div>
<div class="rounded-radius border border-outline bg-surface p-6 text-left dark:border-outline-dark dark:bg-surface-dark-alt">
<div class="rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<div class="flex flex-wrap justify-between gap-2 border-b border-outline pb-3 dark:border-outline-dark">
<span class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="order-number", lang=lang | default(value='sk')) }}</span>
<span class="font-mono font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.order_number }}</span>
@@ -27,12 +27,35 @@
</li>
{% endfor %}
</ul>
<div class="space-y-1 border-t border-outline py-3 text-sm dark:border-outline-dark">
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }}</span><span class="tabular-nums">{{ order.subtotal }} {{ order.currency }}</span></div>
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ order.carrier_name }}</span><span class="tabular-nums">{{ order.shipping }} {{ order.currency }}</span></div>
{% if order.pickup_point_name %}<div class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ order.pickup_point_name }}</div>{% endif %}
</div>
<div class="flex justify-between border-t border-outline pt-3 font-bold dark:border-outline-dark">
<span>{{ t(key="order-total", lang=lang | default(value='sk')) }}</span>
<span class="tabular-nums text-primary dark:text-primary-dark">{{ order.total }} {{ order.currency }}</span>
</div>
</div>
{% if order.payment_method == "bank_transfer" %}
<div class="space-y-2 rounded-radius border border-primary/40 bg-primary/5 p-6 text-sm dark:border-primary-dark/40">
<p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-bank-instructions", lang=lang | default(value='sk')) }}</p>
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-account-name", lang=lang | default(value='sk')) }}</span><span class="font-medium">{{ order.bank_account_name }}</span>
<span class="text-on-surface/70 dark:text-on-surface-dark/70">IBAN</span><span class="font-mono font-medium">{{ order.bank_iban }}</span>
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-variable-symbol", lang=lang | default(value='sk')) }}</span><span class="font-mono font-medium">{{ order.variable_symbol }}</span>
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-amount", lang=lang | default(value='sk')) }}</span><span class="font-medium tabular-nums">{{ order.total }} {{ order.currency }}</span>
</div>
</div>
{% else %}
<div class="rounded-radius border border-outline bg-surface p-4 text-sm text-on-surface/80 dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark/80">
{{ t(key="payment-cod-note", lang=lang | default(value='sk')) }}
</div>
{% endif %}
<div class="text-center">
<a href="/shop" class="inline-flex items-center justify-center rounded-radius border border-outline px-5 py-2 text-sm font-medium text-on-surface transition hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt">{{ t(key="cart-continue", lang=lang | default(value='sk')) }}</a>
</div>
</div>
{% endblock content %}

View File

@@ -105,3 +105,9 @@ auth:
settings:
admin_email: {{ get_env(name="ADMIN_EMAIL", default="admin@example.com") }}
uploads_root: {{ get_env(name="UPLOADS_ROOT", default="uploads") }}
# 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="") }}
# 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.") }}

View File

@@ -24,6 +24,8 @@ mod m20260616_130610_orders;
mod m20260616_130628_order_items;
mod m20260616_131000_drop_audio_tables;
mod m20260616_132000_drop_blog_and_pages;
mod m20260616_150755_shipping_methods;
mod m20260616_150812_add_shipping_fields_to_orders;
pub struct Migrator;
#[async_trait::async_trait]
@@ -52,6 +54,8 @@ impl MigratorTrait for Migrator {
Box::new(m20260616_130628_order_items::Migration),
Box::new(m20260616_131000_drop_audio_tables::Migration),
Box::new(m20260616_132000_drop_blog_and_pages::Migration),
Box::new(m20260616_150755_shipping_methods::Migration),
Box::new(m20260616_150812_add_shipping_fields_to_orders::Migration),
// inject-above (do not remove this comment)
]
}

View File

@@ -0,0 +1,30 @@
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> {
create_table(m, "shipping_methods",
&[
("id", ColType::PkAuto),
("code", ColType::StringUniq),
("name", ColType::String),
("price_cents", ColType::BigIntegerWithDefault(0)),
("requires_pickup_point", ColType::BooleanWithDefault(false)),
("enabled", ColType::BooleanWithDefault(true)),
("position", ColType::IntegerWithDefault(0)),
],
&[
]
).await
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
drop_table(m, "shipping_methods").await
}
}

View File

@@ -0,0 +1,28 @@
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> {
add_column(m, "orders", "payment_method", ColType::StringNull).await?;
add_column(m, "orders", "carrier_code", ColType::StringNull).await?;
add_column(m, "orders", "carrier_name", ColType::StringNull).await?;
add_column(m, "orders", "shipping_cents", ColType::BigIntegerWithDefault(0)).await?;
add_column(m, "orders", "pickup_point_id", ColType::StringNull).await?;
add_column(m, "orders", "pickup_point_name", ColType::StringNull).await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
remove_column(m, "orders", "payment_method").await?;
remove_column(m, "orders", "carrier_code").await?;
remove_column(m, "orders", "carrier_name").await?;
remove_column(m, "orders", "shipping_cents").await?;
remove_column(m, "orders", "pickup_point_id").await?;
remove_column(m, "orders", "pickup_point_name").await?;
Ok(())
}
}

View File

@@ -53,6 +53,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

@@ -53,7 +53,7 @@ fn normalize_empty(value: Option<String>) -> Option<String> {
/// Parse a price typed in major units ("12", "12.5", "12.34") into integer
/// minor units (cents). Rejects negatives and more than two decimals.
fn parse_price_to_cents(value: &str) -> Result<i64> {
pub(crate) fn parse_price_to_cents(value: &str) -> Result<i64> {
let value = value.trim().replace(',', ".");
let invalid = || Error::BadRequest("invalid price".to_string());
let (whole, frac) = match value.split_once('.') {

View File

@@ -5,7 +5,7 @@ use crate::{
catalog::format_price,
i18n::current_lang,
},
models::_entities::{order_items, orders, products},
models::_entities::{order_items, orders, products, shipping_methods},
};
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
use loco_rs::prelude::*;
@@ -18,6 +18,7 @@ use time::Duration as TimeDuration;
use uuid::Uuid;
const ORDER_STATUSES: [&str; 4] = ["pending", "paid", "shipped", "cancelled"];
const PAYMENT_METHODS: [&str; 2] = ["cod", "bank_transfer"];
#[derive(Debug, Deserialize)]
struct CheckoutForm {
@@ -28,6 +29,26 @@ struct CheckoutForm {
zip: String,
country: String,
note: Option<String>,
payment_method: String,
carrier_code: String,
pickup_point_id: Option<String>,
pickup_point_name: Option<String>,
}
fn setting<'a>(ctx: &'a AppContext, key: &str) -> Option<&'a str> {
ctx.config
.settings
.as_ref()
.and_then(|settings| settings.get(key))
.and_then(|value| value.as_str())
}
async fn enabled_shipping_methods(ctx: &AppContext) -> Result<Vec<shipping_methods::Model>> {
Ok(shipping_methods::Entity::find()
.filter(shipping_methods::Column::Enabled.eq(true))
.order_by_asc(shipping_methods::Column::Position)
.all(&ctx.db)
.await?)
}
#[derive(Debug, Deserialize)]
@@ -59,7 +80,7 @@ async fn checkout_page(
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let (lines, _valid, total) = resolve_cart(&ctx, &jar).await?;
let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar).await?;
if lines.is_empty() {
return format::redirect("/cart");
}
@@ -69,13 +90,30 @@ async fn checkout_page(
.unwrap_or("EUR")
.to_string();
let methods: Vec<serde_json::Value> = enabled_shipping_methods(&ctx)
.await?
.iter()
.map(|m| {
json!({
"code": m.code,
"name": m.name,
"price_cents": m.price_cents,
"price": format_price(m.price_cents),
"requires_pickup_point": m.requires_pickup_point,
})
})
.collect();
format::view(
&v,
"shop/checkout.html",
json!({
"items": lines,
"total": format_price(total),
"subtotal": format_price(subtotal),
"subtotal_cents": subtotal,
"currency": currency,
"shipping_methods": methods,
"packeta_api_key": setting(&ctx, "packeta_api_key").unwrap_or(""),
"lang": current_lang(&jar),
}),
)
@@ -94,11 +132,35 @@ async fn place_order(
let email = trimmed(&form.email)
.ok_or_else(|| Error::BadRequest("email is required".to_string()))?;
if !PAYMENT_METHODS.contains(&form.payment_method.as_str()) {
return Err(Error::BadRequest("invalid payment method".to_string()));
}
// Resolve the chosen carrier from the enabled methods (price is taken from
// the DB, never the form, so the customer can't pick their own fee).
let method = shipping_methods::Entity::find()
.filter(shipping_methods::Column::Code.eq(&form.carrier_code))
.filter(shipping_methods::Column::Enabled.eq(true))
.one(&ctx.db)
.await?
.ok_or_else(|| Error::BadRequest("invalid shipping method".to_string()))?;
let (pickup_point_id, pickup_point_name) = if method.requires_pickup_point {
let id = form
.pickup_point_id
.as_deref()
.and_then(trimmed)
.ok_or_else(|| Error::BadRequest("a pickup point is required".to_string()))?;
(Some(id), form.pickup_point_name.as_deref().and_then(trimmed))
} else {
(None, None)
};
let txn = ctx.db.begin().await?;
// Snapshot prices/names and decrement stock atomically. Re-checking stock
// inside the transaction guards against it selling out between cart and pay.
let mut total: i64 = 0;
let mut subtotal: i64 = 0;
let mut currency = "EUR".to_string();
let mut snapshots = Vec::new();
for (product_id, qty) in &valid {
@@ -115,7 +177,7 @@ async fn place_order(
}
currency = product.currency.clone();
let line_total = product.price_cents * i64::from(*qty);
total += line_total;
subtotal += line_total;
let mut active = product.clone().into_active_model();
active.stock = Set(product.stock - *qty);
@@ -129,13 +191,19 @@ async fn place_order(
email: Set(email),
customer_name: Set(trimmed(&form.customer_name)),
status: Set("pending".to_string()),
total_cents: Set(total),
total_cents: Set(subtotal + method.price_cents),
currency: Set(currency),
address: Set(trimmed(&form.address)),
city: Set(trimmed(&form.city)),
zip: Set(trimmed(&form.zip)),
country: Set(trimmed(&form.country)),
note: Set(form.note.as_deref().and_then(trimmed)),
payment_method: Set(Some(form.payment_method.clone())),
carrier_code: Set(Some(method.code.clone())),
carrier_name: Set(Some(method.name.clone())),
shipping_cents: Set(method.price_cents),
pickup_point_id: Set(pickup_point_id),
pickup_point_name: Set(pickup_point_name),
..Default::default()
}
.insert(&txn)
@@ -186,6 +254,8 @@ async fn order_with_items(
"email": order.email,
"customer_name": order.customer_name,
"status": order.status,
"subtotal": format_price(order.total_cents - order.shipping_cents),
"shipping": format_price(order.shipping_cents),
"total": format_price(order.total_cents),
"currency": order.currency,
"address": order.address,
@@ -193,6 +263,13 @@ async fn order_with_items(
"zip": order.zip,
"country": order.country,
"note": order.note,
"payment_method": order.payment_method,
"carrier_name": order.carrier_name,
"pickup_point_name": order.pickup_point_name,
// Numeric, sequential order id doubles as the bank variable symbol.
"variable_symbol": order.id,
"bank_iban": setting(ctx, "bank_iban").unwrap_or(""),
"bank_account_name": setting(ctx, "bank_account_name").unwrap_or(""),
"created_at": order.created_at.to_rfc3339(),
});
Ok((order_json, items_json))
@@ -283,6 +360,66 @@ async fn admin_order_show(
)
}
#[debug_handler]
async fn admin_shipping(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let methods = shipping_methods::Entity::find()
.order_by_asc(shipping_methods::Column::Position)
.all(&ctx.db)
.await?;
let rows: Vec<serde_json::Value> = methods
.iter()
.map(|m| {
json!({
"id": m.id,
"code": m.code,
"name": m.name,
"price": format_price(m.price_cents),
"requires_pickup_point": m.requires_pickup_point,
"enabled": m.enabled,
})
})
.collect();
format::view(
&v,
"admin/shipping/index.html",
json!({ "methods": rows, "lang": current_lang(&jar) }),
)
}
#[derive(Debug, Deserialize)]
struct ShippingForm {
price: String,
enabled: Option<String>,
}
#[debug_handler]
async fn admin_shipping_update(
auth: auth::JWT,
Path(id): Path<i32>,
State(ctx): State<AppContext>,
Form(form): Form<ShippingForm>,
) -> Result<Response> {
admin::current_admin(auth, &ctx).await?;
let method = shipping_methods::Entity::find_by_id(id)
.one(&ctx.db)
.await?
.ok_or_else(|| Error::NotFound)?;
let mut active = method.into_active_model();
active.price_cents = Set(crate::controllers::catalog::parse_price_to_cents(&form.price)?);
active.enabled = Set(matches!(
form.enabled.as_deref(),
Some("on" | "true" | "1")
));
active.update(&ctx.db).await?;
format::redirect("/admin/shipping")
}
#[debug_handler]
async fn admin_order_status(
auth: auth::JWT,
@@ -313,4 +450,6 @@ pub fn routes() -> Routes {
.add("/admin/orders", get(admin_orders))
.add("/admin/orders/{id}", get(admin_order_show))
.add("/admin/orders/{id}/status", post(admin_order_status))
.add("/admin/shipping", get(admin_shipping))
.add("/admin/shipping/{id}", post(admin_shipping_update))
}

View File

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

View File

@@ -0,0 +1,48 @@
use async_trait::async_trait;
use loco_rs::prelude::*;
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, Set};
use crate::models::_entities::shipping_methods;
/// (code, display name, price in cents, requires a pickup point)
const CARRIERS: [(&str, &str, i64, bool); 3] = [
("packeta", "Packeta", 300, true),
("dpd", "DPD", 450, false),
("dhl", "DHL", 500, false),
];
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 (position, (code, name, price_cents, requires_pickup_point)) in
CARRIERS.iter().enumerate()
{
let exists = shipping_methods::Entity::find()
.filter(shipping_methods::Column::Code.eq(*code))
.one(&ctx.db)
.await?
.is_some();
if exists {
continue;
}
shipping_methods::ActiveModel {
code: Set((*code).to_string()),
name: Set((*name).to_string()),
price_cents: Set(*price_cents),
requires_pickup_point: Set(*requires_pickup_point),
enabled: Set(true),
position: Set(position as i32),
..Default::default()
}
.insert(&ctx.db)
.await?;
}
Ok(())
}
}

View File

@@ -10,4 +10,5 @@ pub mod product_images;
pub mod product_product_tags;
pub mod product_tags;
pub mod products;
pub mod shipping_methods;
pub mod users;

View File

@@ -23,6 +23,12 @@ pub struct Model {
pub country: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub note: Option<String>,
pub payment_method: Option<String>,
pub carrier_code: Option<String>,
pub carrier_name: Option<String>,
pub shipping_cents: i64,
pub pickup_point_id: Option<String>,
pub pickup_point_name: Option<String>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

View File

@@ -8,4 +8,5 @@ pub use super::product_images::Entity as ProductImages;
pub use super::product_product_tags::Entity as ProductProductTags;
pub use super::product_tags::Entity as ProductTags;
pub use super::products::Entity as Products;
pub use super::shipping_methods::Entity as ShippingMethods;
pub use super::users::Entity as Users;

View File

@@ -0,0 +1,23 @@
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.20
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
#[sea_orm(table_name = "shipping_methods")]
pub struct Model {
pub created_at: DateTimeWithTimeZone,
pub updated_at: DateTimeWithTimeZone,
#[sea_orm(primary_key)]
pub id: i32,
#[sea_orm(unique)]
pub code: String,
pub name: String,
pub price_cents: i64,
pub requires_pickup_point: bool,
pub enabled: bool,
pub position: i32,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {}

View File

@@ -8,3 +8,4 @@ pub mod product_tags;
pub mod product_product_tags;
pub mod orders;
pub mod order_items;
pub mod shipping_methods;

View File

@@ -0,0 +1,28 @@
use sea_orm::entity::prelude::*;
pub use super::_entities::shipping_methods::{ActiveModel, Model, Entity};
pub type ShippingMethods = Entity;
#[async_trait::async_trait]
impl ActiveModelBehavior for ActiveModel {
async fn before_save<C>(self, _db: &C, insert: bool) -> std::result::Result<Self, DbErr>
where
C: ConnectionTrait,
{
if !insert && self.updated_at.is_unchanged() {
let mut this = self;
this.updated_at = sea_orm::ActiveValue::Set(chrono::Utc::now().into());
Ok(this)
} else {
Ok(self)
}
}
}
// implement your read-oriented logic here
impl Model {}
// implement your write-oriented logic here
impl ActiveModel {}
// implement your custom finders, selectors oriented logic here
impl Entity {}

View File

@@ -6,3 +6,4 @@ mod product_images;
mod product_tags;
mod orders;
mod order_items;
mod shipping_methods;

View File

@@ -0,0 +1,31 @@
use gitara_web::app::App;
use loco_rs::testing::prelude::*;
use serial_test::serial;
macro_rules! configure_insta {
($($expr:expr),*) => {
let mut settings = insta::Settings::clone_current();
settings.set_prepend_module_to_snapshot(false);
let _guard = settings.bind_to_scope();
};
}
#[tokio::test]
#[serial]
async fn test_model() {
configure_insta!();
let boot = boot_test::<App>().await.unwrap();
seed::<App>(&boot.app_context).await.unwrap();
// query your model, e.g.:
//
// let item = models::posts::Model::find_by_pid(
// &boot.app_context.db,
// "11111111-1111-1111-1111-111111111111",
// )
// .await;
// snapshot the result:
// assert_debug_snapshot!(item);
}