more webshop
This commit is contained in:
@@ -239,3 +239,23 @@ order-status-paid = Paid
|
|||||||
order-status-shipped = Shipped
|
order-status-shipped = Shipped
|
||||||
order-status-cancelled = Cancelled
|
order-status-cancelled = Cancelled
|
||||||
order-update-status = Update status
|
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
|
||||||
|
|||||||
@@ -239,3 +239,23 @@ order-status-paid = Zaplatené
|
|||||||
order-status-shipped = Odoslané
|
order-status-shipped = Odoslané
|
||||||
order-status-cancelled = Zrušené
|
order-status-cancelled = Zrušené
|
||||||
order-update-status = Zmeniť stav
|
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
@@ -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">
|
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')) }}
|
{{ t(key="admin-orders", lang=lang | default(value='sk')) }}
|
||||||
</a>
|
</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>
|
||||||
|
|
||||||
<div class="border-t border-outline p-4 dark:border-outline-dark">
|
<div class="border-t border-outline p-4 dark:border-outline-dark">
|
||||||
|
|||||||
@@ -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-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>
|
<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>
|
||||||
|
<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 %}
|
{% if order.note %}
|
||||||
<div>
|
<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>
|
<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>
|
||||||
|
|||||||
37
assets/views/admin/shipping/index.html
Normal file
37
assets/views/admin/shipping/index.html
Normal 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 %}
|
||||||
@@ -3,10 +3,34 @@
|
|||||||
{% block title %}{{ t(key="checkout-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
{% block title %}{{ t(key="checkout-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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>
|
<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"
|
||||||
<form method="post" action="/checkout" hx-boost="false" class="space-y-6 lg:col-span-2">
|
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">
|
<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>
|
<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">
|
<div class="space-y-1.5">
|
||||||
@@ -21,6 +45,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</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">
|
<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>
|
<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">
|
<div class="space-y-1.5">
|
||||||
@@ -41,23 +66,70 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<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>
|
<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">
|
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>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
</fieldset>
|
||||||
<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"
|
<!-- carrier -->
|
||||||
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>
|
<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>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<button type="submit"
|
<!-- payment -->
|
||||||
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">
|
<fieldset class="space-y-3 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
{{ t(key="checkout-place-order", lang=lang | default(value='sk')) }}
|
<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>
|
||||||
</button>
|
<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">
|
||||||
</form>
|
<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>
|
||||||
|
</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">
|
<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>
|
<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">
|
<ul class="space-y-2 text-sm">
|
||||||
@@ -68,10 +140,24 @@
|
|||||||
</li>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</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">
|
<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>{{ 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>
|
</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>
|
</aside>
|
||||||
</div>
|
</form>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -3,18 +3,18 @@
|
|||||||
{% block title %}{{ t(key="order-confirmed-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
{% block title %}{{ t(key="order-confirmed-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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="mx-auto flex size-14 items-center justify-center rounded-full bg-success/15 text-success">
|
<div class="text-center">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-7">
|
<div class="mx-auto flex size-14 items-center justify-center rounded-full bg-success/15 text-success">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-7">
|
||||||
</svg>
|
<path stroke-linecap="round" stroke-linejoin="round" d="m4.5 12.75 6 6 9-13.5" />
|
||||||
</div>
|
</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>
|
<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>
|
||||||
|
|
||||||
<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">
|
<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="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>
|
<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>
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</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">
|
<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>{{ 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>
|
<span class="tabular-nums text-primary dark:text-primary-dark">{{ order.total }} {{ order.currency }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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>
|
{% 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>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -105,3 +105,9 @@ auth:
|
|||||||
settings:
|
settings:
|
||||||
admin_email: {{ get_env(name="ADMIN_EMAIL", default="admin@example.com") }}
|
admin_email: {{ get_env(name="ADMIN_EMAIL", default="admin@example.com") }}
|
||||||
uploads_root: {{ get_env(name="UPLOADS_ROOT", default="uploads") }}
|
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.") }}
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ mod m20260616_130610_orders;
|
|||||||
mod m20260616_130628_order_items;
|
mod m20260616_130628_order_items;
|
||||||
mod m20260616_131000_drop_audio_tables;
|
mod m20260616_131000_drop_audio_tables;
|
||||||
mod m20260616_132000_drop_blog_and_pages;
|
mod m20260616_132000_drop_blog_and_pages;
|
||||||
|
mod m20260616_150755_shipping_methods;
|
||||||
|
mod m20260616_150812_add_shipping_fields_to_orders;
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -52,6 +54,8 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260616_130628_order_items::Migration),
|
Box::new(m20260616_130628_order_items::Migration),
|
||||||
Box::new(m20260616_131000_drop_audio_tables::Migration),
|
Box::new(m20260616_131000_drop_audio_tables::Migration),
|
||||||
Box::new(m20260616_132000_drop_blog_and_pages::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)
|
// inject-above (do not remove this comment)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
30
migration/src/m20260616_150755_shipping_methods.rs
Normal file
30
migration/src/m20260616_150755_shipping_methods.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -53,6 +53,7 @@ impl Hooks for App {
|
|||||||
Ok(vec![
|
Ok(vec![
|
||||||
Box::new(initializers::view_engine::ViewEngineInitializer),
|
Box::new(initializers::view_engine::ViewEngineInitializer),
|
||||||
Box::new(initializers::admin_seeder::AdminSeeder),
|
Box::new(initializers::admin_seeder::AdminSeeder),
|
||||||
|
Box::new(initializers::shipping_seeder::ShippingSeeder),
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
/// Parse a price typed in major units ("12", "12.5", "12.34") into integer
|
||||||
/// minor units (cents). Rejects negatives and more than two decimals.
|
/// 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 value = value.trim().replace(',', ".");
|
||||||
let invalid = || Error::BadRequest("invalid price".to_string());
|
let invalid = || Error::BadRequest("invalid price".to_string());
|
||||||
let (whole, frac) = match value.split_once('.') {
|
let (whole, frac) = match value.split_once('.') {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ use crate::{
|
|||||||
catalog::format_price,
|
catalog::format_price,
|
||||||
i18n::current_lang,
|
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 axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
@@ -18,6 +18,7 @@ use time::Duration as TimeDuration;
|
|||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
const ORDER_STATUSES: [&str; 4] = ["pending", "paid", "shipped", "cancelled"];
|
const ORDER_STATUSES: [&str; 4] = ["pending", "paid", "shipped", "cancelled"];
|
||||||
|
const PAYMENT_METHODS: [&str; 2] = ["cod", "bank_transfer"];
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct CheckoutForm {
|
struct CheckoutForm {
|
||||||
@@ -28,6 +29,26 @@ struct CheckoutForm {
|
|||||||
zip: String,
|
zip: String,
|
||||||
country: String,
|
country: String,
|
||||||
note: Option<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)]
|
#[derive(Debug, Deserialize)]
|
||||||
@@ -59,7 +80,7 @@ async fn checkout_page(
|
|||||||
ViewEngine(v): ViewEngine<TeraView>,
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let (lines, _valid, total) = resolve_cart(&ctx, &jar).await?;
|
let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar).await?;
|
||||||
if lines.is_empty() {
|
if lines.is_empty() {
|
||||||
return format::redirect("/cart");
|
return format::redirect("/cart");
|
||||||
}
|
}
|
||||||
@@ -69,13 +90,30 @@ async fn checkout_page(
|
|||||||
.unwrap_or("EUR")
|
.unwrap_or("EUR")
|
||||||
.to_string();
|
.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(
|
format::view(
|
||||||
&v,
|
&v,
|
||||||
"shop/checkout.html",
|
"shop/checkout.html",
|
||||||
json!({
|
json!({
|
||||||
"items": lines,
|
"items": lines,
|
||||||
"total": format_price(total),
|
"subtotal": format_price(subtotal),
|
||||||
|
"subtotal_cents": subtotal,
|
||||||
"currency": currency,
|
"currency": currency,
|
||||||
|
"shipping_methods": methods,
|
||||||
|
"packeta_api_key": setting(&ctx, "packeta_api_key").unwrap_or(""),
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -94,11 +132,35 @@ async fn place_order(
|
|||||||
let email = trimmed(&form.email)
|
let email = trimmed(&form.email)
|
||||||
.ok_or_else(|| Error::BadRequest("email is required".to_string()))?;
|
.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?;
|
let txn = ctx.db.begin().await?;
|
||||||
|
|
||||||
// Snapshot prices/names and decrement stock atomically. Re-checking stock
|
// Snapshot prices/names and decrement stock atomically. Re-checking stock
|
||||||
// inside the transaction guards against it selling out between cart and pay.
|
// 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 currency = "EUR".to_string();
|
||||||
let mut snapshots = Vec::new();
|
let mut snapshots = Vec::new();
|
||||||
for (product_id, qty) in &valid {
|
for (product_id, qty) in &valid {
|
||||||
@@ -115,7 +177,7 @@ async fn place_order(
|
|||||||
}
|
}
|
||||||
currency = product.currency.clone();
|
currency = product.currency.clone();
|
||||||
let line_total = product.price_cents * i64::from(*qty);
|
let line_total = product.price_cents * i64::from(*qty);
|
||||||
total += line_total;
|
subtotal += line_total;
|
||||||
|
|
||||||
let mut active = product.clone().into_active_model();
|
let mut active = product.clone().into_active_model();
|
||||||
active.stock = Set(product.stock - *qty);
|
active.stock = Set(product.stock - *qty);
|
||||||
@@ -129,13 +191,19 @@ async fn place_order(
|
|||||||
email: Set(email),
|
email: Set(email),
|
||||||
customer_name: Set(trimmed(&form.customer_name)),
|
customer_name: Set(trimmed(&form.customer_name)),
|
||||||
status: Set("pending".to_string()),
|
status: Set("pending".to_string()),
|
||||||
total_cents: Set(total),
|
total_cents: Set(subtotal + method.price_cents),
|
||||||
currency: Set(currency),
|
currency: Set(currency),
|
||||||
address: Set(trimmed(&form.address)),
|
address: Set(trimmed(&form.address)),
|
||||||
city: Set(trimmed(&form.city)),
|
city: Set(trimmed(&form.city)),
|
||||||
zip: Set(trimmed(&form.zip)),
|
zip: Set(trimmed(&form.zip)),
|
||||||
country: Set(trimmed(&form.country)),
|
country: Set(trimmed(&form.country)),
|
||||||
note: Set(form.note.as_deref().and_then(trimmed)),
|
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()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.insert(&txn)
|
.insert(&txn)
|
||||||
@@ -186,6 +254,8 @@ async fn order_with_items(
|
|||||||
"email": order.email,
|
"email": order.email,
|
||||||
"customer_name": order.customer_name,
|
"customer_name": order.customer_name,
|
||||||
"status": order.status,
|
"status": order.status,
|
||||||
|
"subtotal": format_price(order.total_cents - order.shipping_cents),
|
||||||
|
"shipping": format_price(order.shipping_cents),
|
||||||
"total": format_price(order.total_cents),
|
"total": format_price(order.total_cents),
|
||||||
"currency": order.currency,
|
"currency": order.currency,
|
||||||
"address": order.address,
|
"address": order.address,
|
||||||
@@ -193,6 +263,13 @@ async fn order_with_items(
|
|||||||
"zip": order.zip,
|
"zip": order.zip,
|
||||||
"country": order.country,
|
"country": order.country,
|
||||||
"note": order.note,
|
"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(),
|
"created_at": order.created_at.to_rfc3339(),
|
||||||
});
|
});
|
||||||
Ok((order_json, items_json))
|
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]
|
#[debug_handler]
|
||||||
async fn admin_order_status(
|
async fn admin_order_status(
|
||||||
auth: auth::JWT,
|
auth: auth::JWT,
|
||||||
@@ -313,4 +450,6 @@ pub fn routes() -> Routes {
|
|||||||
.add("/admin/orders", get(admin_orders))
|
.add("/admin/orders", get(admin_orders))
|
||||||
.add("/admin/orders/{id}", get(admin_order_show))
|
.add("/admin/orders/{id}", get(admin_order_show))
|
||||||
.add("/admin/orders/{id}/status", post(admin_order_status))
|
.add("/admin/orders/{id}/status", post(admin_order_status))
|
||||||
|
.add("/admin/shipping", get(admin_shipping))
|
||||||
|
.add("/admin/shipping/{id}", post(admin_shipping_update))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
pub mod admin_seeder;
|
pub mod admin_seeder;
|
||||||
|
pub mod shipping_seeder;
|
||||||
pub mod view_engine;
|
pub mod view_engine;
|
||||||
|
|||||||
48
src/initializers/shipping_seeder.rs
Normal file
48
src/initializers/shipping_seeder.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,4 +10,5 @@ pub mod product_images;
|
|||||||
pub mod product_product_tags;
|
pub mod product_product_tags;
|
||||||
pub mod product_tags;
|
pub mod product_tags;
|
||||||
pub mod products;
|
pub mod products;
|
||||||
|
pub mod shipping_methods;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ pub struct Model {
|
|||||||
pub country: Option<String>,
|
pub country: Option<String>,
|
||||||
#[sea_orm(column_type = "Text", nullable)]
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
pub note: Option<String>,
|
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)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
|||||||
@@ -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_product_tags::Entity as ProductProductTags;
|
||||||
pub use super::product_tags::Entity as ProductTags;
|
pub use super::product_tags::Entity as ProductTags;
|
||||||
pub use super::products::Entity as Products;
|
pub use super::products::Entity as Products;
|
||||||
|
pub use super::shipping_methods::Entity as ShippingMethods;
|
||||||
pub use super::users::Entity as Users;
|
pub use super::users::Entity as Users;
|
||||||
|
|||||||
23
src/models/_entities/shipping_methods.rs
Normal file
23
src/models/_entities/shipping_methods.rs
Normal 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 {}
|
||||||
@@ -8,3 +8,4 @@ pub mod product_tags;
|
|||||||
pub mod product_product_tags;
|
pub mod product_product_tags;
|
||||||
pub mod orders;
|
pub mod orders;
|
||||||
pub mod order_items;
|
pub mod order_items;
|
||||||
|
pub mod shipping_methods;
|
||||||
|
|||||||
28
src/models/shipping_methods.rs
Normal file
28
src/models/shipping_methods.rs
Normal 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 {}
|
||||||
@@ -5,4 +5,5 @@ mod products;
|
|||||||
mod product_images;
|
mod product_images;
|
||||||
mod product_tags;
|
mod product_tags;
|
||||||
mod orders;
|
mod orders;
|
||||||
mod order_items;
|
mod order_items;
|
||||||
|
mod shipping_methods;
|
||||||
31
tests/models/shipping_methods.rs
Normal file
31
tests/models/shipping_methods.rs
Normal 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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user