Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1168da8f11 | ||
|
|
1bde553f79 | ||
|
|
e5c84e631f | ||
|
|
0f3189ca26 | ||
|
|
f4c66936c0 | ||
|
|
4a5e0404c7 | ||
|
|
80f3e7d48e | ||
|
|
97c4c23af1 | ||
|
|
269bb15e6f | ||
|
|
da2c487dc4 | ||
|
|
c549e2bc03 | ||
|
|
9bdf91e717 | ||
|
|
d1f9838890 | ||
|
|
e8d8aafd97 |
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2646,6 +2646,7 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"axum-casbin",
|
"axum-casbin",
|
||||||
"axum-extra 0.10.3",
|
"axum-extra 0.10.3",
|
||||||
|
"base64",
|
||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ hmac = { version = "0.12" }
|
|||||||
sha2 = { version = "0.10" }
|
sha2 = { version = "0.10" }
|
||||||
subtle = { version = "2.6" }
|
subtle = { version = "2.6" }
|
||||||
form_urlencoded = { version = "1" }
|
form_urlencoded = { version = "1" }
|
||||||
|
# base64: cookie-safe encoding of the multi-step checkout info JSON
|
||||||
|
base64 = { version = "0.22" }
|
||||||
multer = { version = "3" }
|
multer = { version = "3" }
|
||||||
futures-util = { version = "0.3" }
|
futures-util = { version = "0.3" }
|
||||||
|
|
||||||
|
|||||||
@@ -191,6 +191,8 @@
|
|||||||
[data-theme="dark"] .rich-content a { color: var(--color-primary-dark); }
|
[data-theme="dark"] .rich-content a { color: var(--color-primary-dark); }
|
||||||
.rich-content :first-child { margin-top: 0; }
|
.rich-content :first-child { margin-top: 0; }
|
||||||
.rich-content :last-child { margin-bottom: 0; }
|
.rich-content :last-child { margin-bottom: 0; }
|
||||||
|
.rich-summary :where(p) { display: inline; margin: 0; }
|
||||||
|
.rich-summary .product-more-link { margin-left: 0.25rem; }
|
||||||
|
|
||||||
/* Compact rich blurb for product cards: neutralize block spacing so the
|
/* Compact rich blurb for product cards: neutralize block spacing so the
|
||||||
* line-clamp truncation stays tidy regardless of the authored markup. */
|
* line-clamp truncation stays tidy regardless of the authored markup. */
|
||||||
|
|||||||
@@ -152,6 +152,7 @@ artist = Artist
|
|||||||
release-date = Release date
|
release-date = Release date
|
||||||
cover-image = Cover image
|
cover-image = Cover image
|
||||||
description = Description
|
description = Description
|
||||||
|
product-more = more
|
||||||
songs-in-album = Songs in this album
|
songs-in-album = Songs in this album
|
||||||
admin-new-album-desc = Fill in the details, then tick the songs to include.
|
admin-new-album-desc = Fill in the details, then tick the songs to include.
|
||||||
cover-help = Optional - png, jpg, webp or gif; shown on the album page.
|
cover-help = Optional - png, jpg, webp or gif; shown on the album page.
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ artist = Artist
|
|||||||
release-date = Release date
|
release-date = Release date
|
||||||
cover-image = Cover image
|
cover-image = Cover image
|
||||||
description = Description
|
description = Description
|
||||||
|
product-more = more
|
||||||
short-description = Short description
|
short-description = Short description
|
||||||
short-description-hint = Shown on product cards. Keep it short.
|
short-description-hint = Shown on product cards. Keep it short.
|
||||||
songs-in-album = Songs in this album
|
songs-in-album = Songs in this album
|
||||||
@@ -349,8 +350,14 @@ cart-remove-confirm = Remove this item from the cart?
|
|||||||
cart-update = Update
|
cart-update = Update
|
||||||
cart-continue = Continue shopping
|
cart-continue = Continue shopping
|
||||||
checkout-title = Checkout
|
checkout-title = Checkout
|
||||||
|
checkout-step-basket = Basket
|
||||||
|
checkout-step-info = Info
|
||||||
|
checkout-step-payment = Payment & transport
|
||||||
|
checkout-step-transport = Transport
|
||||||
checkout-contact = Contact details
|
checkout-contact = Contact details
|
||||||
checkout-shipping = Shipping address
|
checkout-shipping = Delivery address
|
||||||
|
checkout-residence-address = Residence address
|
||||||
|
checkout-delivery-same = Delivery address is the same as residence address
|
||||||
checkout-email = Email
|
checkout-email = Email
|
||||||
checkout-name = Full name
|
checkout-name = Full name
|
||||||
checkout-phone = Phone
|
checkout-phone = Phone
|
||||||
@@ -365,7 +372,8 @@ country-de = Germany
|
|||||||
country-pl = Poland
|
country-pl = Poland
|
||||||
country-hu = Hungary
|
country-hu = Hungary
|
||||||
checkout-note = Order note
|
checkout-note = Order note
|
||||||
checkout-save-profile = Save this address to my profile
|
checkout-save-profile = Save residence address to my profile
|
||||||
|
payment-none = No payment method is currently available.
|
||||||
account-type = Account type
|
account-type = Account type
|
||||||
account-personal = Individual
|
account-personal = Individual
|
||||||
account-company = Company
|
account-company = Company
|
||||||
@@ -376,6 +384,8 @@ company-dic = Tax ID (DIČ)
|
|||||||
company-icdph = VAT ID (IČ DPH)
|
company-icdph = VAT ID (IČ DPH)
|
||||||
field-optional = optional
|
field-optional = optional
|
||||||
checkout-place-order = Place order
|
checkout-place-order = Place order
|
||||||
|
checkout-continue-payment = Continue
|
||||||
|
checkout-back-info = Back to details
|
||||||
checkout-summary = Order summary
|
checkout-summary = Order summary
|
||||||
profile-title = My profile
|
profile-title = My profile
|
||||||
profile-intro = We'll use these details to prefill checkout.
|
profile-intro = We'll use these details to prefill checkout.
|
||||||
@@ -481,6 +491,12 @@ bank-variable-symbol = Variable symbol
|
|||||||
bank-amount = Amount
|
bank-amount = Amount
|
||||||
admin-shipping = Shipping
|
admin-shipping = Shipping
|
||||||
admin-shipping-desc = set the price and availability of each delivery option.
|
admin-shipping-desc = set the price and availability of each delivery option.
|
||||||
|
shipping-packeta-missing-settings = Packeta can be enabled after PACKETA_API_KEY, PACKETA_API_PASSWORD and PACKETA_SENDER_LABEL are configured.
|
||||||
|
admin-payments = Payments
|
||||||
|
admin-payments-desc = enable or disable payment methods and edit bank-transfer details.
|
||||||
|
payment-methods = Payment methods
|
||||||
|
payment-enabled = Active
|
||||||
|
payment-bank-settings = Bank transfer details
|
||||||
shipping-enabled = Active
|
shipping-enabled = Active
|
||||||
admin-currency = Exchange rate
|
admin-currency = Exchange rate
|
||||||
admin-currency-desc = set the exchange rate for the currencies customers can switch between. You always enter prices in EUR.
|
admin-currency-desc = set the exchange rate for the currencies customers can switch between. You always enter prices in EUR.
|
||||||
|
|||||||
@@ -172,6 +172,7 @@ artist = Interpret
|
|||||||
release-date = Dátum vydania
|
release-date = Dátum vydania
|
||||||
cover-image = Obrázok obalu
|
cover-image = Obrázok obalu
|
||||||
description = Popis
|
description = Popis
|
||||||
|
product-more = viac
|
||||||
short-description = Krátky popis
|
short-description = Krátky popis
|
||||||
short-description-hint = Zobrazuje sa na kartách produktov. Najlepšie krátke.
|
short-description-hint = Zobrazuje sa na kartách produktov. Najlepšie krátke.
|
||||||
songs-in-album = Skladby v albume
|
songs-in-album = Skladby v albume
|
||||||
@@ -349,8 +350,14 @@ cart-remove-confirm = Odstrániť túto položku z košíka?
|
|||||||
cart-update = Aktualizovať
|
cart-update = Aktualizovať
|
||||||
cart-continue = Pokračovať v nákupe
|
cart-continue = Pokračovať v nákupe
|
||||||
checkout-title = Pokladňa
|
checkout-title = Pokladňa
|
||||||
|
checkout-step-basket = Košík
|
||||||
|
checkout-step-info = Údaje
|
||||||
|
checkout-step-payment = Doprava a platba
|
||||||
|
checkout-step-transport = Doprava
|
||||||
checkout-contact = Kontaktné údaje
|
checkout-contact = Kontaktné údaje
|
||||||
checkout-shipping = Dodacia adresa
|
checkout-shipping = Dodacia adresa
|
||||||
|
checkout-residence-address = Adresa bydliska
|
||||||
|
checkout-delivery-same = Dodacia adresa je rovnaká ako adresa bydliska
|
||||||
checkout-email = E-mail
|
checkout-email = E-mail
|
||||||
checkout-name = Meno a priezvisko
|
checkout-name = Meno a priezvisko
|
||||||
checkout-phone = Telefón
|
checkout-phone = Telefón
|
||||||
@@ -365,7 +372,8 @@ country-de = Nemecko
|
|||||||
country-pl = Poľsko
|
country-pl = Poľsko
|
||||||
country-hu = Maďarsko
|
country-hu = Maďarsko
|
||||||
checkout-note = Poznámka k objednávke
|
checkout-note = Poznámka k objednávke
|
||||||
checkout-save-profile = Uložiť túto adresu do môjho profilu
|
checkout-save-profile = Uložiť adresu bydliska do môjho profilu
|
||||||
|
payment-none = Momentálne nie je dostupný žiadny spôsob platby.
|
||||||
account-type = Typ účtu
|
account-type = Typ účtu
|
||||||
account-personal = Súkromná osoba
|
account-personal = Súkromná osoba
|
||||||
account-company = Firma
|
account-company = Firma
|
||||||
@@ -376,6 +384,8 @@ company-dic = DIČ
|
|||||||
company-icdph = IČ DPH
|
company-icdph = IČ DPH
|
||||||
field-optional = nepovinné
|
field-optional = nepovinné
|
||||||
checkout-place-order = Odoslať objednávku
|
checkout-place-order = Odoslať objednávku
|
||||||
|
checkout-continue-payment = Pokračovať
|
||||||
|
checkout-back-info = Späť na údaje
|
||||||
checkout-summary = Súhrn objednávky
|
checkout-summary = Súhrn objednávky
|
||||||
profile-title = Môj profil
|
profile-title = Môj profil
|
||||||
profile-intro = Tieto údaje použijeme na predvyplnenie pokladne.
|
profile-intro = Tieto údaje použijeme na predvyplnenie pokladne.
|
||||||
@@ -481,6 +491,12 @@ bank-variable-symbol = Variabilný symbol
|
|||||||
bank-amount = Suma
|
bank-amount = Suma
|
||||||
admin-shipping = Doprava
|
admin-shipping = Doprava
|
||||||
admin-shipping-desc = nastaviť cenu a dostupnosť jednotlivých možností dopravy.
|
admin-shipping-desc = nastaviť cenu a dostupnosť jednotlivých možností dopravy.
|
||||||
|
shipping-packeta-missing-settings = Packeta sa dá zapnúť až po nastavení PACKETA_API_KEY, PACKETA_API_PASSWORD a PACKETA_SENDER_LABEL.
|
||||||
|
admin-payments = Platby
|
||||||
|
admin-payments-desc = zapnite alebo vypnite spôsoby platby a upravte údaje pre prevod na účet.
|
||||||
|
payment-methods = Spôsoby platby
|
||||||
|
payment-enabled = Aktívne
|
||||||
|
payment-bank-settings = Údaje pre prevod na účet
|
||||||
shipping-enabled = Aktívne
|
shipping-enabled = Aktívne
|
||||||
admin-currency = Kurz
|
admin-currency = Kurz
|
||||||
admin-currency-desc = nastaviť výmenný kurz pre meny, medzi ktorými môžu zákazníci prepínať. Ceny zadávate vždy v EUR.
|
admin-currency-desc = nastaviť výmenný kurz pre meny, medzi ktorými môžu zákazníci prepínať. Ceny zadávate vždy v EUR.
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -55,11 +55,22 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="rounded-radius border border-outline bg-surface p-6 text-sm dark:border-outline-dark dark:bg-surface-dark-alt">
|
<div class="rounded-radius border border-outline bg-surface p-6 text-sm dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<div class="space-y-4">
|
||||||
|
{% if order.residence_address %}
|
||||||
|
<div>
|
||||||
|
<h2 class="mb-2 font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-residence-address", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.residence_address }}</p>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.residence_zip }} {{ order.residence_city }}{% if order.residence_country %}, {{ order.residence_country }}{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div>
|
||||||
<h2 class="mb-2 font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</h2>
|
<h2 class="mb-2 font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</h2>
|
||||||
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.customer_name }}</p>
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.customer_name }}</p>
|
||||||
{% if order.address %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.address }}</p>{% endif %}
|
{% if order.address %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.address }}</p>{% endif %}
|
||||||
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.zip }} {{ order.city }}{% if order.country %}, {{ order.country }}{% endif %}</p>
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.zip }} {{ order.city }}{% if order.country %}, {{ order.country }}{% endif %}</p>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if order.payment_method == "bank_transfer" and order.status == "pending" %}
|
{% if order.payment_method == "bank_transfer" and order.status == "pending" %}
|
||||||
<div class="space-y-2 rounded-radius border border-primary/40 bg-primary/5 p-6 text-sm dark:border-primary-dark/40">
|
<div class="space-y-2 rounded-radius border border-primary/40 bg-primary/5 p-6 text-sm dark:border-primary-dark/40">
|
||||||
|
|||||||
@@ -107,7 +107,7 @@
|
|||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<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-residence-address", lang=lang | default(value='sk')) }}</legend>
|
||||||
{{ self::field(label=t(key="checkout-address", lang=lang | default(value='sk')), value=address) }}
|
{{ self::field(label=t(key="checkout-address", lang=lang | default(value='sk')), value=address) }}
|
||||||
<div class="grid gap-4 sm:grid-cols-3">
|
<div class="grid gap-4 sm:grid-cols-3">
|
||||||
{{ self::field(label=t(key="checkout-city", lang=lang | default(value='sk')), value=city) }}
|
{{ self::field(label=t(key="checkout-city", lang=lang | default(value='sk')), value=city) }}
|
||||||
@@ -211,9 +211,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<!-- default shipping address -->
|
<!-- residence 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-residence-address", lang=lang | default(value='sk')) }}</legend>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="address" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-address", lang=lang | default(value='sk')) }}</label>
|
<label for="address" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-address", lang=lang | default(value='sk')) }}</label>
|
||||||
{{ ui::input(name="address", id="address", value=address | default(value=''), autocomplete="street-address") }}
|
{{ ui::input(name="address", id="address", value=address | default(value=''), autocomplete="street-address") }}
|
||||||
|
|||||||
@@ -105,6 +105,10 @@
|
|||||||
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||||
{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}
|
{{ t(key="admin-shipping", lang=lang | default(value='sk')) }}
|
||||||
</a>
|
</a>
|
||||||
|
<a href="/admin/payments" data-nav="/admin/payments"
|
||||||
|
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||||
|
{{ t(key="admin-payments", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
<a href="/admin/currencies" data-nav="/admin/currencies"
|
<a href="/admin/currencies" data-nav="/admin/currencies"
|
||||||
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
||||||
{{ t(key="admin-currency", lang=lang | default(value='sk')) }}
|
{{ t(key="admin-currency", lang=lang | default(value='sk')) }}
|
||||||
|
|||||||
@@ -69,6 +69,10 @@
|
|||||||
{% if order.vat_id %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ t(key="company-icdph", lang=lang | default(value='sk')) }}: {{ order.vat_id }}</p>{% endif %}
|
{% if order.vat_id %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ t(key="company-icdph", lang=lang | default(value='sk')) }}: {{ order.vat_id }}</p>{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
<div>
|
||||||
|
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-residence-address", lang=lang | default(value='sk')) }}</p>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{% if order.residence_address %}{{ order.residence_address }}<br>{{ order.residence_zip }} {{ order.residence_city }}<br>{{ order.residence_country }}{% else %}{{ t(key="profile-not-set", lang=lang | default(value='sk')) }}{% endif %}</p>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-xs uppercase tracking-wide text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</p>
|
<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>
|
||||||
|
|||||||
47
assets/views/admin/payments/index.html
Normal file
47
assets/views/admin/payments/index.html
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{% extends "admin/base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="admin-payments", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
{% block crumb %}{{ t(key="admin-payments", 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-payments", lang=lang | default(value='sk')) }}</h1>
|
||||||
|
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="admin-payments-desc", lang=lang | default(value='sk')) }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="mt-6 space-y-4">
|
||||||
|
<h2 class="text-lg font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-methods", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
{% for method in methods %}
|
||||||
|
<form method="post" action="/admin/payments/methods/{{ method.id }}"
|
||||||
|
class="flex flex-wrap items-center gap-4 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<div class="min-w-40">
|
||||||
|
<p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key=method.label_key, lang=lang | default(value='sk')) }}</p>
|
||||||
|
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ method.code }}</p>
|
||||||
|
</div>
|
||||||
|
<div class="pb-1">{{ ui::checkbox(name="enabled", label=t(key="payment-enabled", lang=lang | default(value='sk')), checked=method.enabled) }}</div>
|
||||||
|
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", extra="ml-auto") }}
|
||||||
|
</form>
|
||||||
|
{% endfor %}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="mt-8 space-y-4">
|
||||||
|
<h2 class="text-lg font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-bank-settings", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
<form method="post" action="/admin/payments/bank"
|
||||||
|
class="space-y-4 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="bank_account_name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="bank-account-name", lang=lang | default(value='sk')) }}</label>
|
||||||
|
{{ ui::input(name="bank_account_name", id="bank_account_name", value=bank_account_name) }}
|
||||||
|
</div>
|
||||||
|
<div class="space-y-1.5">
|
||||||
|
<label for="bank_iban" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">IBAN</label>
|
||||||
|
{{ ui::input(name="bank_iban", id="bank_iban", value=bank_iban) }}
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end">
|
||||||
|
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit") }}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
{% endblock content %}
|
||||||
@@ -18,12 +18,21 @@
|
|||||||
<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.carrier | upper }}{% if method.requires_pickup_point %} · {{ t(key="checkout-pickup-point", lang=lang | default(value='sk')) }}{% endif %}</p>
|
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ method.carrier | upper }}{% if method.requires_pickup_point %} · {{ t(key="checkout-pickup-point", lang=lang | default(value='sk')) }}{% endif %}</p>
|
||||||
|
{% if method.packeta_not_ready %}
|
||||||
|
<p class="mt-1 text-xs text-warning">{{ t(key=method.lock_reason, lang=lang | default(value='sk')) }}</p>
|
||||||
|
{% endif %}
|
||||||
</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>
|
||||||
{{ ui::input(name="price", id="price-" ~ method.id, value=method.price, width="w-28", attrs='inputmode="decimal"') }}
|
{{ ui::input(name="price", id="price-" ~ method.id, value=method.price, width="w-28", attrs='inputmode="decimal"') }}
|
||||||
</div>
|
</div>
|
||||||
<div class="pb-2">{{ ui::checkbox(name="enabled", label=t(key="shipping-enabled", lang=lang | default(value='sk')), checked=method.enabled) }}</div>
|
<div class="pb-2">
|
||||||
|
{% if method.locked %}
|
||||||
|
{{ ui::checkbox(name="enabled", label=t(key="shipping-enabled", lang=lang | default(value='sk')), checked=method.enabled, attrs='disabled') }}
|
||||||
|
{% else %}
|
||||||
|
{{ ui::checkbox(name="enabled", label=t(key="shipping-enabled", lang=lang | default(value='sk')), checked=method.enabled) }}
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", extra="ml-auto") }}
|
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", extra="ml-auto") }}
|
||||||
</form>
|
</form>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|||||||
@@ -103,21 +103,21 @@
|
|||||||
<img src="/static/img/logo.jpg" alt="{{ t(key='brand', lang=lang | default(value='sk')) }}" width="260" height="52" class="h-8 w-auto dark:rounded-radius dark:bg-white dark:px-1.5 dark:py-0.5" />
|
<img src="/static/img/logo.jpg" alt="{{ t(key='brand', lang=lang | default(value='sk')) }}" width="260" height="52" class="h-8 w-auto dark:rounded-radius dark:bg-white dark:px-1.5 dark:py-0.5" />
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- in-header search → existing GET /search (q param). Only on the home
|
<!-- in-header search → existing GET /search (q param). Hidden on small
|
||||||
page; elsewhere the shop's own toolbar carries the search box. Hidden
|
screens; the shop page keeps its compact mobile search row there. -->
|
||||||
on small screens (a compact copy lives in the mobile menu below). -->
|
<form action="/search" method="get" role="search" class="hidden min-w-0 flex-1 md:flex md:max-w-sm lg:max-w-md">
|
||||||
{% if on_home | default(value=false) %}
|
{% if selected_category and selected_category != "all" %}
|
||||||
<form action="/search" method="get" role="search" class="hidden min-w-0 flex-1 md:flex md:max-w-xl">
|
<input type="hidden" name="category" value="{{ selected_category }}" />
|
||||||
|
{% endif %}
|
||||||
<div class="flex min-w-0 flex-1 overflow-hidden rounded-radius border border-outline transition focus-within:border-primary dark:border-outline-dark dark:focus-within:border-primary-dark">
|
<div class="flex min-w-0 flex-1 overflow-hidden rounded-radius border border-outline transition focus-within:border-primary dark:border-outline-dark dark:focus-within:border-primary-dark">
|
||||||
<span class="pointer-events-none flex items-center bg-surface-alt pl-3.5 text-on-surface/40 dark:bg-surface-dark-alt dark:text-on-surface-dark/40">{{ ui::icon(name="search", size="size-[18px]") }}</span>
|
<span class="pointer-events-none flex items-center bg-surface-alt pl-3.5 text-on-surface/40 dark:bg-surface-dark-alt dark:text-on-surface-dark/40">{{ ui::icon(name="search", size="size-[18px]") }}</span>
|
||||||
<input type="search" name="q" autocomplete="off"
|
<input type="search" name="q" value="{{ query | default(value='') }}" autocomplete="off"
|
||||||
placeholder="{{ t(key='search-placeholder', lang=lang | default(value='sk')) }}"
|
placeholder="{{ t(key='search-placeholder', lang=lang | default(value='sk')) }}"
|
||||||
aria-label="{{ t(key='search-placeholder', lang=lang | default(value='sk')) }}"
|
aria-label="{{ t(key='search-placeholder', lang=lang | default(value='sk')) }}"
|
||||||
class="min-w-0 flex-1 border-0 bg-surface-alt px-2.5 py-2.5 text-sm text-on-surface placeholder:text-on-surface/50 focus:outline-none dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50" />
|
class="min-w-0 flex-1 border-0 bg-surface-alt px-2.5 py-2.5 text-sm text-on-surface placeholder:text-on-surface/50 focus:outline-none dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50" />
|
||||||
<button type="submit" class="shrink-0 bg-cta px-5 text-sm font-bold text-on-cta transition hover:opacity-90 dark:bg-cta-dark dark:text-on-cta-dark">{{ t(key="search-button", lang=lang | default(value='sk')) }}</button>
|
<button type="submit" class="shrink-0 bg-cta px-5 text-sm font-bold text-on-cta transition hover:opacity-90 dark:bg-cta-dark dark:text-on-cta-dark">{{ t(key="search-button", lang=lang | default(value='sk')) }}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<!-- right side: kurz + account + cart + settings + mobile toggle -->
|
<!-- right side: kurz + account + cart + settings + mobile toggle -->
|
||||||
<div class="ml-auto flex items-center gap-2 sm:gap-3">
|
<div class="ml-auto flex items-center gap-2 sm:gap-3">
|
||||||
@@ -196,6 +196,22 @@
|
|||||||
{% include "partials/settings_dropdown.html" %}
|
{% include "partials/settings_dropdown.html" %}
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
{% set mobile_search_category = selected_category | default(value="") %}
|
||||||
|
{% if on_home | default(value=false) or mobile_search_category %}
|
||||||
|
<form action="/search" method="get" role="search" class="px-4 pb-3 md:hidden">
|
||||||
|
{% if mobile_search_category and mobile_search_category != "all" %}
|
||||||
|
<input type="hidden" name="category" value="{{ mobile_search_category }}" />
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex min-w-0 overflow-hidden rounded-radius border border-outline transition focus-within:border-primary dark:border-outline-dark dark:focus-within:border-primary-dark">
|
||||||
|
<span class="pointer-events-none flex items-center bg-surface-alt pl-3.5 text-on-surface/40 dark:bg-surface-dark-alt dark:text-on-surface-dark/40">{{ ui::icon(name="search", size="size-[18px]") }}</span>
|
||||||
|
<input type="search" name="q" value="{{ query | default(value='') }}" autocomplete="off"
|
||||||
|
placeholder="{{ t(key='search-placeholder', lang=lang | default(value='sk')) }}"
|
||||||
|
aria-label="{{ t(key='search-placeholder', lang=lang | default(value='sk')) }}"
|
||||||
|
class="min-w-0 flex-1 border-0 bg-surface-alt px-2.5 py-2.5 text-sm text-on-surface placeholder:text-on-surface/50 focus:outline-none dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50" />
|
||||||
|
<button type="submit" class="shrink-0 bg-cta px-4 text-sm font-bold text-on-cta transition hover:opacity-90 dark:bg-cta-dark dark:text-on-cta-dark">{{ t(key="search-button", lang=lang | default(value='sk')) }}</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- dark overlay behind the category drawer on small screens -->
|
<!-- dark overlay behind the category drawer on small screens -->
|
||||||
@@ -240,19 +256,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- site footer (Kompress design): brand blurb + Informácie / Účet / Kontakt
|
<!-- site footer (Kompress design): Informácie / Účet / Kontakt link columns
|
||||||
link columns + copyright bar. Static links; reuses the nav i18n keys. -->
|
+ copyright bar. Static links; reuses the nav i18n keys. -->
|
||||||
<footer class="border-t border-outline bg-surface dark:border-outline-dark dark:bg-surface-dark">
|
<footer class="border-t border-outline bg-surface dark:border-outline-dark dark:bg-surface-dark">
|
||||||
<div class="mx-auto grid max-w-7xl grid-cols-2 gap-8 px-4 py-10 md:grid-cols-4 md:px-8">
|
<div class="mx-auto grid max-w-7xl grid-cols-1 gap-8 px-4 py-10 sm:w-fit sm:grid-cols-3 sm:gap-x-32 md:gap-x-36 md:px-8 lg:gap-x-40">
|
||||||
<div class="col-span-2 md:col-span-1">
|
|
||||||
<div class="flex items-center gap-2.5">
|
|
||||||
<span class="inline-flex size-8 items-center justify-center rounded-radius bg-primary text-on-primary dark:bg-primary-dark dark:text-on-primary-dark">
|
|
||||||
<svg width="17" height="17" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><rect x="10" y="3" width="4" height="18" rx="1.5"></rect><rect x="3" y="10" width="18" height="4" rx="1.5"></rect></svg>
|
|
||||||
</span>
|
|
||||||
<span class="text-lg font-extrabold tracking-tight text-primary dark:text-primary-dark">{{ t(key="brand", lang=lang | default(value='sk')) }}</span>
|
|
||||||
</div>
|
|
||||||
<p class="mt-3 max-w-xs text-sm leading-relaxed text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="footer-tagline", lang=lang | default(value='sk')) }}</p>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col gap-2.5">
|
<div class="flex flex-col gap-2.5">
|
||||||
<div class="text-xs font-bold uppercase tracking-wider text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="footer-info", lang=lang | default(value='sk')) }}</div>
|
<div class="text-xs font-bold uppercase tracking-wider text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="footer-info", lang=lang | default(value='sk')) }}</div>
|
||||||
<a href="/obchodne-podmienky" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="footer-terms", lang=lang | default(value='sk')) }}</a>
|
<a href="/obchodne-podmienky" class="text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">{{ t(key="footer-terms", lang=lang | default(value='sk')) }}</a>
|
||||||
|
|||||||
@@ -39,7 +39,7 @@
|
|||||||
</span>
|
</span>
|
||||||
<span class="flex min-w-0 flex-col gap-0.5">
|
<span class="flex min-w-0 flex-col gap-0.5">
|
||||||
<span class="line-clamp-2 text-[13px] font-semibold leading-tight text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</span>
|
<span class="line-clamp-2 text-[13px] font-semibold leading-tight text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</span>
|
||||||
<span class="text-sm font-extrabold text-primary dark:text-primary-dark">{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ currency_symbol }}</span>
|
<span class="text-sm font-extrabold text-on-surface-strong dark:text-on-surface-dark-strong">{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ currency_symbol }}</span>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -96,19 +96,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- contact CTA (static, brand blue) -->
|
|
||||||
<section class="overflow-hidden rounded-radius bg-cta text-on-cta dark:bg-cta-dark dark:text-on-cta-dark">
|
|
||||||
<div class="p-5">
|
|
||||||
<div class="text-xs font-bold uppercase tracking-wider opacity-80">{{ t(key="home-contact-title", lang=L) }}</div>
|
|
||||||
<p class="mt-2.5 text-sm leading-relaxed opacity-90">{{ t(key="home-contact-text", lang=L) }}</p>
|
|
||||||
<a href="tel:+421903410476" class="mt-3.5 flex items-center gap-2.5 text-xl font-extrabold tracking-tight">
|
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M5 4h4l2 5-3 2a12 12 0 0 0 5 5l2-3 5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 3 6a2 2 0 0 1 2-2Z"></path></svg>
|
|
||||||
+421 903 410 476
|
|
||||||
</a>
|
|
||||||
<a href="tel:+421903410476" class="mt-3.5 block w-full rounded-radius bg-surface px-4 py-3 text-center text-sm font-bold text-cta transition hover:opacity-90 dark:bg-surface-dark dark:text-cta-dark">{{ t(key="home-contact-cta", lang=L) }}</a>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -292,6 +292,25 @@ border-t border-outline dark:border-outline-dark
|
|||||||
<li class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong" aria-current="page">{{ label }}</li>
|
<li class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong" aria-current="page">{{ label }}</li>
|
||||||
{%- endmacro crumb_current %}
|
{%- endmacro crumb_current %}
|
||||||
|
|
||||||
|
{# Checkout step indicator: Basket › Info › Payment & transport, with chevrons
|
||||||
|
only *between* steps (no dangling trailing chevron) and the active step bold.
|
||||||
|
`active` is one of "info" | "payment"; the basket is always a link to /cart. #}
|
||||||
|
{% macro checkout_steps(active, lang) -%}
|
||||||
|
<nav aria-label="breadcrumb" class="mb-5 text-sm">
|
||||||
|
<ol class="flex flex-wrap items-center gap-1.5 text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
|
<li><a href="/cart" class="transition hover:text-primary dark:hover:text-primary-dark">{{ t(key="checkout-step-basket", lang=lang) }}</a></li>
|
||||||
|
<li class="flex items-center gap-1.5"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-3.5 shrink-0 text-on-surface/30 dark:text-on-surface-dark/30" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /></svg>
|
||||||
|
{% if active == "info" %}<span class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong" aria-current="page">{{ t(key="checkout-step-info", lang=lang) }}</span>
|
||||||
|
{% else %}<a href="/checkout/info" class="transition hover:text-primary dark:hover:text-primary-dark">{{ t(key="checkout-step-info", lang=lang) }}</a>{% endif %}
|
||||||
|
</li>
|
||||||
|
<li class="flex items-center gap-1.5"><svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-3.5 shrink-0 text-on-surface/30 dark:text-on-surface-dark/30" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="m8.25 4.5 7.5 7.5-7.5 7.5" /></svg>
|
||||||
|
{% if active == "payment" %}<span class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong" aria-current="page">{{ t(key="checkout-step-payment", lang=lang) }}</span>
|
||||||
|
{% else %}<span>{{ t(key="checkout-step-payment", lang=lang) }}</span>{% endif %}
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
{%- endmacro checkout_steps %}
|
||||||
|
|
||||||
{# Title for the static info pages (controllers/pages.rs → pages/info.html),
|
{# Title for the static info pages (controllers/pages.rs → pages/info.html),
|
||||||
resolved from the `page` slug. Lives in a macro because a child template's
|
resolved from the `page` slug. Lives in a macro because a child template's
|
||||||
top-level {% set %} isn't visible inside its {% block %}s under `extends`;
|
top-level {% set %} isn't visible inside its {% block %}s under `extends`;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@
|
|||||||
<input type="hidden" name="category" value="{{ selected_category | default(value='all') }}" />
|
<input type="hidden" name="category" value="{{ selected_category | default(value='all') }}" />
|
||||||
|
|
||||||
<!-- search box -->
|
<!-- search box -->
|
||||||
<div class="flex max-w-xl gap-2">
|
<div class="hidden max-w-xl gap-2">
|
||||||
<div class="relative flex-1">
|
<div class="relative flex-1">
|
||||||
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-on-surface/50 dark:text-on-surface-dark/50">
|
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-on-surface/50 dark:text-on-surface-dark/50">
|
||||||
{{ ui::icon(name="search", size="size-5") }}
|
{{ ui::icon(name="search", size="size-5") }}
|
||||||
|
|||||||
@@ -18,11 +18,11 @@
|
|||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1">
|
||||||
{# mobile-only Home link: the navbar logo (the Home affordance) is hidden on
|
{# mobile-only Home link: the navbar logo (the Home affordance) is hidden on
|
||||||
small screens, so navigation home lives here in the drawer instead. #}
|
small screens, so navigation home lives here in the drawer instead. #}
|
||||||
<a href="/" data-nav="/" class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong lg:hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
<a href="/" data-nav="/" class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm lg:hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark">
|
||||||
{{ t(key="nav-home", lang=lang | default(value='sk')) }}
|
{{ t(key="nav-home", lang=lang | default(value='sk')) }}
|
||||||
</a>
|
</a>
|
||||||
<a href="/shop" data-nav="/shop"
|
<a href="/shop" data-nav="/shop"
|
||||||
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
class="flex items-center gap-2 rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark">
|
||||||
{{ t(key="all-products", lang=lang | default(value='sk')) }}
|
{{ t(key="all-products", lang=lang | default(value='sk')) }}
|
||||||
</a>
|
</a>
|
||||||
{% for group in category_groups %}
|
{% for group in category_groups %}
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
x-init="open = ['{{ group.slug }}'{% for child in group.children %}, '{{ child.slug }}'{% endfor %}].some(s => location.pathname === '/category/' + s)">
|
x-init="open = ['{{ group.slug }}'{% for child in group.children %}, '{{ child.slug }}'{% endfor %}].some(s => location.pathname === '/category/' + s)">
|
||||||
<div class="flex items-stretch">
|
<div class="flex items-stretch">
|
||||||
<a href="/category/{{ group.slug }}" data-nav="/category/{{ group.slug }}"
|
<a href="/category/{{ group.slug }}" data-nav="/category/{{ group.slug }}"
|
||||||
class="flex flex-1 items-center gap-2 truncate rounded-l-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
class="flex flex-1 items-center gap-2 truncate rounded-l-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark">
|
||||||
{{ group.name }}
|
{{ group.name }}
|
||||||
</a>
|
</a>
|
||||||
<button type="button" x-on:click="open = ! open" x-bind:aria-expanded="open ? 'true' : 'false'"
|
<button type="button" x-on:click="open = ! open" x-bind:aria-expanded="open ? 'true' : 'false'"
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
{% for child in group.children %}
|
{% for child in group.children %}
|
||||||
<li>
|
<li>
|
||||||
<a href="/category/{{ child.slug }}" data-nav="/category/{{ child.slug }}"
|
<a href="/category/{{ child.slug }}" data-nav="/category/{{ child.slug }}"
|
||||||
class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark">
|
||||||
{{ child.name }}
|
{{ child.name }}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="/category/{{ group.slug }}" data-nav="/category/{{ group.slug }}"
|
<a href="/category/{{ group.slug }}" data-nav="/category/{{ group.slug }}"
|
||||||
class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-primary/10 aria-[current=page]:text-on-surface-strong dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-primary-dark/10 dark:aria-[current=page]:text-on-surface-dark-strong">
|
class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark">
|
||||||
{{ group.name }}
|
{{ group.name }}
|
||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
@@ -75,8 +75,8 @@
|
|||||||
</p>
|
</p>
|
||||||
{% set L = lang | default(value='sk') %}
|
{% set L = lang | default(value='sk') %}
|
||||||
<div class="flex flex-col gap-0.5">
|
<div class="flex flex-col gap-0.5">
|
||||||
<a href="/obchodne-podmienky" class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">{{ t(key="footer-terms", lang=L) }}</a>
|
<a href="/obchodne-podmienky" data-nav="/obchodne-podmienky" class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark">{{ t(key="footer-terms", lang=L) }}</a>
|
||||||
<a href="/predajne" class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">{{ t(key="footer-stores", lang=L) }}</a>
|
<a href="/predajne" data-nav="/predajne" class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark">{{ t(key="footer-stores", lang=L) }}</a>
|
||||||
<a href="/doprava-a-platba" class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">{{ t(key="footer-shipping", lang=L) }}</a>
|
<a href="/doprava-a-platba" data-nav="/doprava-a-platba" class="flex items-center gap-2 truncate rounded-radius px-2 py-1.5 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-on-surface-strong focus:outline-hidden focus-visible:underline aria-[current=page]:bg-cta aria-[current=page]:text-on-cta aria-[current=page]:font-semibold aria-[current=page]:shadow-sm dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong dark:aria-[current=page]:bg-cta-dark dark:aria-[current=page]:text-on-cta-dark">{{ t(key="footer-shipping", lang=L) }}</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,31 +3,17 @@
|
|||||||
|
|
||||||
{% 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 breadcrumbs %}
|
||||||
{% if packeta_api_key %}<script src="https://widget.packeta.com/v6/www/js/library.js"></script>{% endif %}
|
{{ ui::checkout_steps(active="info", lang=lang | default(value='sk')) }}
|
||||||
|
{% endblock breadcrumbs %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
<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>
|
||||||
|
|
||||||
<form method="post" action="/checkout" hx-boost="false"
|
<form method="post" action="/checkout/info" hx-boost="false"
|
||||||
x-data="{
|
x-data="{
|
||||||
paymentMethod: '',
|
|
||||||
accountType: '{{ prefill_account_type | default(value='personal') }}',
|
accountType: '{{ prefill_account_type | default(value='personal') }}',
|
||||||
carrier: '',
|
deliverySame: {{ prefill_delivery_same | default(value='false') }}
|
||||||
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">
|
class="mt-6 grid gap-8 lg:grid-cols-3">
|
||||||
{{ ui::csrf_field() }}
|
{{ ui::csrf_field() }}
|
||||||
@@ -128,26 +114,26 @@
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<!-- shipping address -->
|
<!-- residence 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-residence-address", lang=lang | default(value='sk')) }}</legend>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="address" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-address", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
<label for="residence_address" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-address", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
||||||
{{ ui::input(name="address", id="address", value=prefill_address | default(value=''), required=true, autocomplete="street-address") }}
|
{{ ui::input(name="residence_address", id="residence_address", value=prefill_residence_address | default(value=''), required=true, autocomplete="billing street-address") }}
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-4 sm:grid-cols-3">
|
<div class="grid gap-4 sm:grid-cols-3">
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="city" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-city", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
<label for="residence_city" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-city", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
||||||
{{ ui::input(name="city", id="city", value=prefill_city | default(value=''), required=true, autocomplete="address-level2") }}
|
{{ ui::input(name="residence_city", id="residence_city", value=prefill_residence_city | default(value=''), required=true, autocomplete="billing address-level2") }}
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<label for="zip" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-zip", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
<label for="residence_zip" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-zip", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
||||||
{{ ui::input(name="zip", id="zip", value=prefill_zip | default(value=''), required=true, autocomplete="postal-code") }}
|
{{ ui::input(name="residence_zip", id="residence_zip", value=prefill_residence_zip | default(value=''), required=true, autocomplete="billing postal-code") }}
|
||||||
</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')) }}{{ ui::req() }}</label>
|
<label for="residence_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')) }}{{ ui::req() }}</label>
|
||||||
<div class="relative" @click.outside="countryOpen = false"
|
<div class="relative" @click.outside="countryOpen = false"
|
||||||
x-data="{ countryOpen: false, country: '{{ prefill_country | default(value=t(key='country-sk', lang=lang | default(value='sk'))) }}', opts: [
|
x-data="{ countryOpen: false, country: '{{ prefill_residence_country | default(value=t(key='country-sk', lang=lang | default(value='sk'))) }}', opts: [
|
||||||
{ v: '{{ t(key='country-sk', lang=lang | default(value='sk')) }}', l: '🇸🇰 {{ t(key='country-sk', lang=lang | default(value='sk')) }}' },
|
{ v: '{{ t(key='country-sk', lang=lang | default(value='sk')) }}', l: '🇸🇰 {{ t(key='country-sk', lang=lang | default(value='sk')) }}' },
|
||||||
{ v: '{{ t(key='country-cz', lang=lang | default(value='sk')) }}', l: '🇨🇿 {{ t(key='country-cz', lang=lang | default(value='sk')) }}' },
|
{ v: '{{ t(key='country-cz', lang=lang | default(value='sk')) }}', l: '🇨🇿 {{ t(key='country-cz', lang=lang | default(value='sk')) }}' },
|
||||||
{ v: '{{ t(key='country-at', lang=lang | default(value='sk')) }}', l: '🇦🇹 {{ t(key='country-at', lang=lang | default(value='sk')) }}' },
|
{ v: '{{ t(key='country-at', lang=lang | default(value='sk')) }}', l: '🇦🇹 {{ t(key='country-at', lang=lang | default(value='sk')) }}' },
|
||||||
@@ -155,7 +141,7 @@
|
|||||||
{ v: '{{ t(key='country-pl', lang=lang | default(value='sk')) }}', l: '🇵🇱 {{ t(key='country-pl', lang=lang | default(value='sk')) }}' },
|
{ v: '{{ t(key='country-pl', lang=lang | default(value='sk')) }}', l: '🇵🇱 {{ t(key='country-pl', lang=lang | default(value='sk')) }}' },
|
||||||
{ v: '{{ t(key='country-hu', lang=lang | default(value='sk')) }}', l: '🇭🇺 {{ t(key='country-hu', lang=lang | default(value='sk')) }}' }
|
{ v: '{{ t(key='country-hu', lang=lang | default(value='sk')) }}', l: '🇭🇺 {{ t(key='country-hu', lang=lang | default(value='sk')) }}' }
|
||||||
], get filtered() { return this.opts.filter(o => !this.country || o.v.toLowerCase().includes(this.country.toLowerCase())) } }">
|
], get filtered() { return this.opts.filter(o => !this.country || o.v.toLowerCase().includes(this.country.toLowerCase())) } }">
|
||||||
<input id="country" name="country" type="text" x-model="country" required @focus="countryOpen = true" @input="countryOpen = true"
|
<input id="residence_country" name="residence_country" type="text" x-model="country" required @focus="countryOpen = true" @input="countryOpen = true"
|
||||||
class="w-full rounded-radius border border-outline bg-surface py-2 pl-3 pr-8 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
class="w-full rounded-radius border border-outline bg-surface py-2 pl-3 pr-8 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
<button type="button" tabindex="-1" @click="countryOpen = !countryOpen"
|
<button type="button" tabindex="-1" @click="countryOpen = !countryOpen"
|
||||||
class="absolute inset-y-0 right-0 flex w-8 items-center justify-center text-on-surface/60 dark:text-on-surface-dark/60">
|
class="absolute inset-y-0 right-0 flex w-8 items-center justify-center text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
@@ -176,73 +162,55 @@
|
|||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<!-- carrier -->
|
{{ ui::checkbox(name="delivery_same_as_residence", id="delivery_same_as_residence", label=t(key="checkout-delivery-same", lang=lang | default(value='sk')), attrs='x-model="deliverySame"') }}
|
||||||
<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')) }}{{ ui::req() }}</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">
|
|
||||||
<!-- Penguin radio dot inline (the @change mixes nested single+double quotes, can't pass through a Tera macro arg) -->
|
|
||||||
<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="before:content[''] relative h-4 w-4 appearance-none rounded-full border border-outline bg-surface before:invisible before:absolute before:left-1/2 before:top-1/2 before:h-1.5 before:w-1.5 before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:bg-on-primary checked:border-primary checked:bg-primary checked:before:visible focus:outline-2 focus:outline-offset-2 focus:outline-outline-strong checked:focus:outline-primary disabled:cursor-not-allowed dark:border-outline-dark dark:bg-surface-dark dark:before:bg-on-primary-dark dark:checked:border-primary-dark dark:checked:bg-primary-dark dark:focus:outline-outline-dark-strong dark:checked:focus:outline-primary-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 }} €</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')) }}{{ ui::req() }}</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">
|
|
||||||
{{ ui::radio(name="payment_method", value="cod", attrs='required x-model="paymentMethod"') }}
|
|
||||||
<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">
|
|
||||||
{{ ui::radio(name="payment_method", value="bank_transfer", attrs='required x-model="paymentMethod"') }}
|
|
||||||
<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>
|
|
||||||
|
|
||||||
|
<!-- delivery address -->
|
||||||
|
<fieldset x-show="!deliverySame" x-cloak 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">
|
<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>
|
<label for="address" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-address", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
||||||
{{ ui::textarea(name="note", id="note", rows="3") }}
|
{{ ui::input(name="address", id="address", value=prefill_delivery_address | default(value=''), autocomplete="shipping street-address", attrs=':required="!deliverySame"') }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="grid gap-4 sm:grid-cols-3">
|
||||||
{% if logged_in_customer and not profile_filled %}
|
<div class="space-y-1.5">
|
||||||
<!-- offered only when the profile has no saved address yet; if it was filled
|
<label for="city" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-city", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
||||||
in advance we leave it untouched -->
|
{{ ui::input(name="city", id="city", value=prefill_delivery_city | default(value=''), autocomplete="shipping address-level2", attrs=':required="!deliverySame"') }}
|
||||||
{{ ui::checkbox(name="save_profile", id="save_profile", label=t(key="checkout-save-profile", lang=lang | default(value='sk'))) }}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if can_create_account %}
|
|
||||||
<!-- guests may turn this order into an account (typed by their choice above) -->
|
|
||||||
<div class="space-y-1.5 rounded-radius border border-outline bg-surface p-4 dark:border-outline-dark dark:bg-surface-dark-alt">
|
|
||||||
{{ ui::checkbox(name="create_account", id="create_account", label=t(key="checkout-create-account", lang=lang | default(value='sk'))) }}
|
|
||||||
<p class="pl-6 text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-create-account-hint", lang=lang | default(value='sk')) }}</p>
|
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
<div class="space-y-1.5">
|
||||||
|
<label for="zip" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-zip", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
|
||||||
|
{{ ui::input(name="zip", id="zip", value=prefill_delivery_zip | default(value=''), autocomplete="shipping postal-code", attrs=':required="!deliverySame"') }}
|
||||||
|
</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')) }}{{ ui::req() }}</label>
|
||||||
|
<div class="relative" @click.outside="countryOpen = false"
|
||||||
|
x-data="{ countryOpen: false, country: '{{ prefill_delivery_country | default(value=t(key='country-sk', lang=lang | default(value='sk'))) }}', opts: [
|
||||||
|
{ v: '{{ t(key='country-sk', lang=lang | default(value='sk')) }}', l: '🇸🇰 {{ t(key='country-sk', lang=lang | default(value='sk')) }}' },
|
||||||
|
{ v: '{{ t(key='country-cz', lang=lang | default(value='sk')) }}', l: '🇨🇿 {{ t(key='country-cz', lang=lang | default(value='sk')) }}' },
|
||||||
|
{ v: '{{ t(key='country-at', lang=lang | default(value='sk')) }}', l: '🇦🇹 {{ t(key='country-at', lang=lang | default(value='sk')) }}' },
|
||||||
|
{ v: '{{ t(key='country-de', lang=lang | default(value='sk')) }}', l: '🇩🇪 {{ t(key='country-de', lang=lang | default(value='sk')) }}' },
|
||||||
|
{ v: '{{ t(key='country-pl', lang=lang | default(value='sk')) }}', l: '🇵🇱 {{ t(key='country-pl', lang=lang | default(value='sk')) }}' },
|
||||||
|
{ v: '{{ t(key='country-hu', lang=lang | default(value='sk')) }}', l: '🇭🇺 {{ t(key='country-hu', lang=lang | default(value='sk')) }}' }
|
||||||
|
], get filtered() { return this.opts.filter(o => !this.country || o.v.toLowerCase().includes(this.country.toLowerCase())) } }">
|
||||||
|
<input id="country" name="country" type="text" x-model="country" :required="!deliverySame" @focus="countryOpen = true" @input="countryOpen = true"
|
||||||
|
class="w-full rounded-radius border border-outline bg-surface py-2 pl-3 pr-8 text-sm text-on-surface focus:outline-2 focus:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
|
||||||
|
<button type="button" tabindex="-1" @click="countryOpen = !countryOpen"
|
||||||
|
class="absolute inset-y-0 right-0 flex w-8 items-center justify-center text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"
|
||||||
|
class="size-4 transition-transform" :class="countryOpen && 'rotate-180'">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<ul x-show="countryOpen" x-cloak x-transition
|
||||||
|
class="absolute z-20 mt-1 max-h-56 w-full overflow-auto rounded-radius border border-outline bg-surface p-1 shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<template x-for="o in filtered" :key="o.v">
|
||||||
|
<li><button type="button" @click="country = o.v; countryOpen = false" x-text="o.l"
|
||||||
|
class="block w-full rounded-radius px-3 py-1.5 text-left text-sm text-on-surface transition hover:bg-surface-alt dark:text-on-surface-dark dark:hover:bg-surface-dark"></button></li>
|
||||||
|
</template>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- summary -->
|
<!-- summary -->
|
||||||
@@ -256,21 +224,11 @@
|
|||||||
</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 }} €</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) + ' €'"></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="checkout-subtotal", lang=lang | default(value='sk')) }}</span>
|
||||||
<span class="tabular-nums text-primary dark:text-primary-dark" x-text="fmt(subtotal + carrierPrice) + ' €'"></span>
|
<span class="tabular-nums text-primary dark:text-primary-dark">{{ subtotal }} €</span>
|
||||||
</div>
|
</div>
|
||||||
{{ ui::button(label=t(key="checkout-place-order", lang=lang | default(value='sk')), type="submit", attrs=':disabled="!canSubmit"', extra="w-full", size="px-6 py-2.5 text-sm") }}
|
{{ ui::button(label=t(key="checkout-continue-payment", lang=lang | default(value='sk')), type="submit", extra="w-full", size="px-6 py-2.5 text-sm") }}
|
||||||
</aside>
|
</aside>
|
||||||
</form>
|
</form>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
143
assets/views/shop/checkout_payment.html
Normal file
143
assets/views/shop/checkout_payment.html
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% import "macros/ui.html" as ui %}
|
||||||
|
|
||||||
|
{% block title %}{{ t(key="checkout-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||||
|
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
{{ ui::checkout_steps(active="payment", lang=lang | default(value='sk')) }}
|
||||||
|
{% endblock breadcrumbs %}
|
||||||
|
|
||||||
|
{% 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>
|
||||||
|
|
||||||
|
<form method="post" action="/checkout/payment" 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">
|
||||||
|
{{ ui::csrf_field() }}
|
||||||
|
|
||||||
|
<div class="space-y-6 lg:col-span-2">
|
||||||
|
<!-- 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')) }}{{ ui::req() }}</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">
|
||||||
|
<!-- Penguin radio dot inline (the @change mixes nested single+double quotes, can't pass through a Tera macro arg) -->
|
||||||
|
<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="before:content[''] relative h-4 w-4 appearance-none rounded-full border border-outline bg-surface before:invisible before:absolute before:left-1/2 before:top-1/2 before:h-1.5 before:w-1.5 before:-translate-x-1/2 before:-translate-y-1/2 before:rounded-full before:bg-on-primary checked:border-primary checked:bg-primary checked:before:visible focus:outline-2 focus:outline-offset-2 focus:outline-outline-strong checked:focus:outline-primary disabled:cursor-not-allowed dark:border-outline-dark dark:bg-surface-dark dark:before:bg-on-primary-dark dark:checked:border-primary-dark dark:checked:bg-primary-dark dark:focus:outline-outline-dark-strong dark:checked:focus:outline-primary-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 }} €</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')) }}{{ ui::req() }}</legend>
|
||||||
|
{% if payment_methods | length > 0 %}
|
||||||
|
{% for method in payment_methods %}
|
||||||
|
<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">
|
||||||
|
{{ ui::radio(name="payment_method", value=method.code, attrs='required x-model="paymentMethod"') }}
|
||||||
|
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key=method.label_key, lang=lang | default(value='sk')) }}</span>
|
||||||
|
</label>
|
||||||
|
{% endfor %}
|
||||||
|
{% else %}
|
||||||
|
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="payment-none", lang=lang | default(value='sk')) }}</p>
|
||||||
|
{% endif %}
|
||||||
|
</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>
|
||||||
|
{{ ui::textarea(name="note", id="note", rows="3") }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if logged_in_customer and not profile_filled %}
|
||||||
|
<!-- offered only when the profile has no saved address yet; if it was filled
|
||||||
|
in advance we leave it untouched -->
|
||||||
|
{{ ui::checkbox(name="save_profile", id="save_profile", label=t(key="checkout-save-profile", lang=lang | default(value='sk'))) }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if can_create_account %}
|
||||||
|
<!-- guests may turn this order into an account (typed by their choice in step 1) -->
|
||||||
|
<div class="space-y-1.5 rounded-radius border border-outline bg-surface p-4 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
{{ ui::checkbox(name="create_account", id="create_account", label=t(key="checkout-create-account", lang=lang | default(value='sk'))) }}
|
||||||
|
<p class="pl-6 text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="checkout-create-account-hint", lang=lang | default(value='sk')) }}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<a href="/checkout/info" class="inline-flex items-center gap-1.5 text-sm text-on-surface/70 transition hover:text-primary dark:text-on-surface-dark/70 dark:hover:text-primary-dark">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M10.5 19.5 3 12m0 0 7.5-7.5M3 12h18" /></svg>
|
||||||
|
{{ t(key="checkout-back-info", lang=lang | default(value='sk')) }}
|
||||||
|
</a>
|
||||||
|
</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">
|
||||||
|
{% for item in items %}
|
||||||
|
<li class="flex justify-between gap-2">
|
||||||
|
<span class="text-on-surface/80 dark:text-on-surface-dark/80">{{ item.name }} × {{ item.quantity }}</span>
|
||||||
|
<span class="tabular-nums">{{ item.line_total }} €</span>
|
||||||
|
</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 }} €</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) + ' €'"></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" x-text="fmt(subtotal + carrierPrice) + ' €'"></span>
|
||||||
|
</div>
|
||||||
|
{{ ui::button(label=t(key="checkout-place-order", lang=lang | default(value='sk')), type="submit", attrs=':disabled="!canSubmit"', extra="w-full", size="px-6 py-2.5 text-sm") }}
|
||||||
|
</aside>
|
||||||
|
</form>
|
||||||
|
{% endblock content %}
|
||||||
@@ -45,6 +45,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="grid gap-4 text-sm sm:grid-cols-2">
|
||||||
|
{% if order.residence_address %}
|
||||||
|
<div class="rounded-radius border border-outline bg-surface p-4 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<h2 class="mb-2 font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-residence-address", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.residence_address }}</p>
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.residence_zip }} {{ order.residence_city }}{% if order.residence_country %}, {{ order.residence_country }}{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="rounded-radius border border-outline bg-surface p-4 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||||
|
<h2 class="mb-2 font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</h2>
|
||||||
|
{% if order.address %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.address }}</p>{% endif %}
|
||||||
|
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.zip }} {{ order.city }}{% if order.country %}, {{ order.country }}{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% if order.payment_method == "bank_transfer" %}
|
{% 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">
|
<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>
|
<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>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
{% block title %}{{ product.name }}{% endblock title %}
|
{% block title %}{{ product.name }}{% endblock title %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
<div class="space-y-12">
|
||||||
<div class="grid gap-10 lg:grid-cols-2">
|
<div class="grid gap-10 lg:grid-cols-2">
|
||||||
<!-- gallery — prev/next arrows + opacity transitions adapted from
|
<!-- gallery — prev/next arrows + opacity transitions adapted from
|
||||||
penguinui/carousel/default-carousel.html; kept our product thumbnail strip
|
penguinui/carousel/default-carousel.html; kept our product thumbnail strip
|
||||||
@@ -52,12 +53,32 @@
|
|||||||
{% set fld = "w-full rounded-radius border border-outline bg-surface-alt px-3 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark" %}
|
{% set fld = "w-full rounded-radius border border-outline bg-surface-alt px-3 py-2 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark" %}
|
||||||
{% set btn = "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-radius px-5 py-2 text-sm text-center font-medium tracking-wide transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75 border border-cta bg-cta text-on-cta focus-visible:outline-cta dark:border-cta-dark dark:bg-cta-dark dark:text-on-cta-dark dark:focus-visible:outline-cta-dark" %}
|
{% set btn = "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-radius px-5 py-2 text-sm text-center font-medium tracking-wide transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 active:opacity-100 active:outline-offset-0 disabled:cursor-not-allowed disabled:opacity-75 border border-cta bg-cta text-on-cta focus-visible:outline-cta dark:border-cta-dark dark:bg-cta-dark dark:text-on-cta-dark dark:focus-visible:outline-cta-dark" %}
|
||||||
<script id="variant-data" type="application/json">{{ variants | json_encode() | safe }}</script>
|
<script id="variant-data" type="application/json">{{ variants | json_encode() | safe }}</script>
|
||||||
<div class="space-y-6" x-data="productBuy(JSON.parse(document.getElementById('variant-data').textContent))">
|
<div class="space-y-6"
|
||||||
|
x-data="{
|
||||||
|
variants: JSON.parse(document.getElementById('variant-data').textContent) || [],
|
||||||
|
sel: 0,
|
||||||
|
get current() { return this.variants[this.sel] || null },
|
||||||
|
init() {
|
||||||
|
const firstInStock = this.variants.findIndex(v => v.in_stock);
|
||||||
|
this.sel = Math.max(0, firstInStock);
|
||||||
|
},
|
||||||
|
}">
|
||||||
{% if category %}
|
{% if category %}
|
||||||
<a href="/category/{{ category.slug }}" class="text-sm font-medium text-primary dark:text-primary-dark">{{ category.name }}</a>
|
<a href="/category/{{ category.slug }}" class="text-sm font-medium text-primary dark:text-primary-dark">{{ category.name }}</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h1>
|
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h1>
|
||||||
|
|
||||||
|
{% if product.short_description %}
|
||||||
|
<div class="rich-content rich-summary text-on-surface/80 dark:text-on-surface-dark/80">
|
||||||
|
{{ product.short_description | safe }}
|
||||||
|
{% if product.description %}
|
||||||
|
<a href="#product-description" class="product-more-link inline font-medium text-primary underline underline-offset-4 hover:opacity-75 dark:text-primary-dark">{{ t(key="product-more", lang=lang | default(value='sk')) }}</a>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% elif product.description %}
|
||||||
|
<a href="#product-description" class="inline-flex text-sm font-medium text-primary underline underline-offset-4 hover:opacity-75 dark:text-primary-dark">{{ t(key="product-more", lang=lang | default(value='sk')) }}</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<template x-if="current">
|
<template x-if="current">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<!-- option picker (only when there's a real choice); first option is
|
<!-- option picker (only when there's a real choice); first option is
|
||||||
@@ -82,11 +103,6 @@
|
|||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if product.description %}
|
|
||||||
{# Authored as rich text (Quill) in the admin; render the stored HTML. #}
|
|
||||||
<div class="rich-content text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description | safe }}</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<template x-if="current.in_stock">
|
<template x-if="current.in_stock">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none" class="flex flex-wrap items-end gap-3"
|
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none" class="flex flex-wrap items-end gap-3"
|
||||||
@@ -119,16 +135,13 @@
|
|||||||
<p class="inline-flex rounded-radius bg-danger/10 px-3 py-2 text-sm font-medium text-danger">{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}</p>
|
<p class="inline-flex rounded-radius bg-danger/10 px-3 py-2 text-sm font-medium text-danger">{{ t(key="out-of-stock", lang=lang | default(value='sk')) }}</p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<script>
|
{% if product.description %}
|
||||||
function productBuy(variants) {
|
<section id="product-description" class="scroll-mt-28 border-t border-outline pt-8 dark:border-outline-dark">
|
||||||
return {
|
<h2 class="mb-4 text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="description", lang=lang | default(value='sk')) }}</h2>
|
||||||
variants: variants || [],
|
{# Authored as rich text (Quill) in the admin; render the stored HTML. #}
|
||||||
// Default to the first in-stock variant, else the first.
|
<div class="rich-content text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description | safe }}</div>
|
||||||
sel: Math.max(0, (variants || []).findIndex(v => v.in_stock)),
|
</section>
|
||||||
get current() { return this.variants[this.sel] || null; },
|
{% endif %}
|
||||||
};
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -50,6 +50,9 @@ mod m20260623_000002_strip_html_from_product_search;
|
|||||||
mod m20260623_000003_drop_currency;
|
mod m20260623_000003_drop_currency;
|
||||||
mod m20260623_000004_currencies;
|
mod m20260623_000004_currencies;
|
||||||
mod m20260625_000001_add_avatar_to_users;
|
mod m20260625_000001_add_avatar_to_users;
|
||||||
|
mod m20260627_000001_order_residence_address;
|
||||||
|
mod m20260627_000002_payment_settings;
|
||||||
|
mod m20260627_000003_account_cart_items;
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -104,6 +107,9 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260623_000003_drop_currency::Migration),
|
Box::new(m20260623_000003_drop_currency::Migration),
|
||||||
Box::new(m20260623_000004_currencies::Migration),
|
Box::new(m20260623_000004_currencies::Migration),
|
||||||
Box::new(m20260625_000001_add_avatar_to_users::Migration),
|
Box::new(m20260625_000001_add_avatar_to_users::Migration),
|
||||||
|
Box::new(m20260627_000001_order_residence_address::Migration),
|
||||||
|
Box::new(m20260627_000002_payment_settings::Migration),
|
||||||
|
Box::new(m20260627_000003_account_cart_items::Migration),
|
||||||
// inject-above (do not remove this comment)
|
// inject-above (do not remove this comment)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
22
migration/src/m20260627_000001_order_residence_address.rs
Normal file
22
migration/src/m20260627_000001_order_residence_address.rs
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
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", "residence_address", ColType::StringNull).await?;
|
||||||
|
add_column(m, "orders", "residence_city", ColType::StringNull).await?;
|
||||||
|
add_column(m, "orders", "residence_zip", ColType::StringNull).await?;
|
||||||
|
add_column(m, "orders", "residence_country", ColType::StringNull).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
remove_column(m, "orders", "residence_country").await?;
|
||||||
|
remove_column(m, "orders", "residence_zip").await?;
|
||||||
|
remove_column(m, "orders", "residence_city").await?;
|
||||||
|
remove_column(m, "orders", "residence_address").await
|
||||||
|
}
|
||||||
|
}
|
||||||
41
migration/src/m20260627_000002_payment_settings.rs
Normal file
41
migration/src/m20260627_000002_payment_settings.rs
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
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,
|
||||||
|
"payment_methods",
|
||||||
|
&[
|
||||||
|
("id", ColType::PkAuto),
|
||||||
|
("code", ColType::StringUniq),
|
||||||
|
("name", ColType::String),
|
||||||
|
("enabled", ColType::BooleanWithDefault(true)),
|
||||||
|
("position", ColType::IntegerWithDefault(0)),
|
||||||
|
],
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
create_table(
|
||||||
|
m,
|
||||||
|
"shop_settings",
|
||||||
|
&[
|
||||||
|
("id", ColType::PkAuto),
|
||||||
|
("key", ColType::StringUniq),
|
||||||
|
("value", ColType::TextNull),
|
||||||
|
],
|
||||||
|
&[],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
drop_table(m, "shop_settings").await?;
|
||||||
|
drop_table(m, "payment_methods").await
|
||||||
|
}
|
||||||
|
}
|
||||||
48
migration/src/m20260627_000003_account_cart_items.rs
Normal file
48
migration/src/m20260627_000003_account_cart_items.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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,
|
||||||
|
"account_cart_items",
|
||||||
|
&[
|
||||||
|
("id", ColType::PkAuto),
|
||||||
|
("variant_id", ColType::Integer),
|
||||||
|
("quantity", ColType::Integer),
|
||||||
|
],
|
||||||
|
&[("user", "")],
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
m.create_foreign_key(
|
||||||
|
ForeignKey::create()
|
||||||
|
.name("fk-account_cart_items-variant_id-to-product_variants")
|
||||||
|
.from(Alias::new("account_cart_items"), Alias::new("variant_id"))
|
||||||
|
.to(Alias::new("product_variants"), Alias::new("id"))
|
||||||
|
.on_delete(ForeignKeyAction::Cascade)
|
||||||
|
.on_update(ForeignKeyAction::NoAction)
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
m.create_index(
|
||||||
|
Index::create()
|
||||||
|
.name("idx_account_cart_items_user_variant_unique")
|
||||||
|
.table(Alias::new("account_cart_items"))
|
||||||
|
.col(Alias::new("user_id"))
|
||||||
|
.col(Alias::new("variant_id"))
|
||||||
|
.unique()
|
||||||
|
.to_owned(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
drop_table(m, "account_cart_items").await
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,7 +18,7 @@ use std::{path::Path, sync::Arc};
|
|||||||
use crate::{
|
use crate::{
|
||||||
controllers::{
|
controllers::{
|
||||||
account, admin_categories, admin_currencies, admin_customers, admin_dashboard,
|
account, admin_categories, admin_currencies, admin_customers, admin_dashboard,
|
||||||
admin_discount_profiles, admin_form, admin_orders, admin_products, admin_shipping,
|
admin_discount_profiles, admin_form, admin_orders, admin_payments, admin_products, admin_shipping,
|
||||||
auth, auth_pages, cart, checkout, currency, home, i18n, media, oauth2,
|
auth, auth_pages, cart, checkout, currency, home, i18n, media, oauth2,
|
||||||
pages, shop,
|
pages, shop,
|
||||||
},
|
},
|
||||||
@@ -83,6 +83,7 @@ impl Hooks for App {
|
|||||||
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),
|
Box::new(initializers::shipping_seeder::ShippingSeeder),
|
||||||
|
Box::new(initializers::payment_seeder::PaymentSeeder),
|
||||||
Box::new(initializers::currency_seeder::CurrencySeeder),
|
Box::new(initializers::currency_seeder::CurrencySeeder),
|
||||||
Box::new(initializers::oauth2::OAuth2StoreInitializer),
|
Box::new(initializers::oauth2::OAuth2StoreInitializer),
|
||||||
Box::new(initializers::oauth2_session::OAuth2SessionInitializer),
|
Box::new(initializers::oauth2_session::OAuth2SessionInitializer),
|
||||||
@@ -111,6 +112,7 @@ impl Hooks for App {
|
|||||||
.add_route(admin_discount_profiles::routes())
|
.add_route(admin_discount_profiles::routes())
|
||||||
.add_route(admin_categories::routes())
|
.add_route(admin_categories::routes())
|
||||||
.add_route(admin_orders::routes())
|
.add_route(admin_orders::routes())
|
||||||
|
.add_route(admin_payments::routes())
|
||||||
.add_route(admin_customers::routes())
|
.add_route(admin_customers::routes())
|
||||||
.add_route(admin_shipping::routes())
|
.add_route(admin_shipping::routes())
|
||||||
.add_route(admin_currencies::routes())
|
.add_route(admin_currencies::routes())
|
||||||
|
|||||||
@@ -335,6 +335,7 @@ async fn order_detail_page(
|
|||||||
.all(&ctx.db)
|
.all(&ctx.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
let (bank_iban, bank_account_name) = settings::bank_details(&ctx).await?;
|
||||||
format::view(
|
format::view(
|
||||||
&v,
|
&v,
|
||||||
"account/order_detail.html",
|
"account/order_detail.html",
|
||||||
@@ -347,8 +348,8 @@ async fn order_detail_page(
|
|||||||
"customer_avatar": user.avatar_id,
|
"customer_avatar": user.avatar_id,
|
||||||
"order": order_view::detail(
|
"order": order_view::detail(
|
||||||
&order,
|
&order,
|
||||||
settings::get(&ctx, "bank_iban").unwrap_or(""),
|
&bank_iban,
|
||||||
settings::get(&ctx, "bank_account_name").unwrap_or(""),
|
&bank_account_name,
|
||||||
),
|
),
|
||||||
"items": order_view::items(&items),
|
"items": order_view::items(&items),
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
|
|||||||
@@ -93,6 +93,7 @@ async fn render_show(
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let carrier = order_carrier(ctx, &order).await?;
|
let carrier = order_carrier(ctx, &order).await?;
|
||||||
|
let (bank_iban, bank_account_name) = settings::bank_details(ctx).await?;
|
||||||
// The order can be sent only if it maps to a real carrier and hasn't been
|
// The order can be sent only if it maps to a real carrier and hasn't been
|
||||||
// dispatched yet.
|
// dispatched yet.
|
||||||
let can_ship = carrier != "none" && order.tracking_number.is_none();
|
let can_ship = carrier != "none" && order.tracking_number.is_none();
|
||||||
@@ -103,8 +104,8 @@ async fn render_show(
|
|||||||
json!({
|
json!({
|
||||||
"order": view::detail(
|
"order": view::detail(
|
||||||
&order,
|
&order,
|
||||||
settings::get(ctx, "bank_iban").unwrap_or(""),
|
&bank_iban,
|
||||||
settings::get(ctx, "bank_account_name").unwrap_or(""),
|
&bank_account_name,
|
||||||
),
|
),
|
||||||
"items": view::items(&items),
|
"items": view::items(&items),
|
||||||
"statuses": ORDER_STATUSES,
|
"statuses": ORDER_STATUSES,
|
||||||
|
|||||||
112
src/controllers/admin_payments.rs
Normal file
112
src/controllers/admin_payments.rs
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
//! Admin management for checkout payment methods and bank-transfer details.
|
||||||
|
|
||||||
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{ActiveModelTrait, EntityTrait, QueryOrder, Set};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
controllers::i18n::current_lang,
|
||||||
|
models::{payment_methods, shop_settings},
|
||||||
|
shared::guard,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct PaymentMethodForm {
|
||||||
|
enabled: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct BankSettingsForm {
|
||||||
|
bank_account_name: String,
|
||||||
|
bank_iban: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_checked(value: &Option<String>) -> bool {
|
||||||
|
matches!(value.as_deref(), Some("on" | "true" | "1"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trimmed(value: &str) -> Option<String> {
|
||||||
|
let value = value.trim();
|
||||||
|
(!value.is_empty()).then(|| value.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn index(
|
||||||
|
auth: auth::JWT,
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let methods = payment_methods::Entity::find()
|
||||||
|
.order_by_asc(payment_methods::Column::Position)
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let rows: Vec<serde_json::Value> = methods
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
json!({
|
||||||
|
"id": m.id,
|
||||||
|
"code": m.code,
|
||||||
|
"label_key": m.label_key(),
|
||||||
|
"enabled": m.enabled,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let bank_account_name = shop_settings::Entity::get(&ctx.db, "bank_account_name")
|
||||||
|
.await?
|
||||||
|
.unwrap_or_default();
|
||||||
|
let bank_iban = shop_settings::Entity::get(&ctx.db, "bank_iban")
|
||||||
|
.await?
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"admin/payments/index.html",
|
||||||
|
json!({
|
||||||
|
"methods": rows,
|
||||||
|
"bank_account_name": bank_account_name,
|
||||||
|
"bank_iban": bank_iban,
|
||||||
|
"lang": current_lang(&jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn update_method(
|
||||||
|
auth: auth::JWT,
|
||||||
|
Path(id): Path<i32>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(form): Form<PaymentMethodForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
let method = payment_methods::Entity::find_by_id(id)
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
let mut active = method.into_active_model();
|
||||||
|
active.enabled = Set(is_checked(&form.enabled));
|
||||||
|
active.update(&ctx.db).await?;
|
||||||
|
format::redirect("/admin/payments")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn update_bank(
|
||||||
|
auth: auth::JWT,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(form): Form<BankSettingsForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
shop_settings::Entity::set(&ctx.db, "bank_account_name", trimmed(&form.bank_account_name)).await?;
|
||||||
|
shop_settings::Entity::set(&ctx.db, "bank_iban", trimmed(&form.bank_iban)).await?;
|
||||||
|
format::redirect("/admin/payments")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn routes() -> Routes {
|
||||||
|
Routes::new()
|
||||||
|
.add("/admin/payments", get(index))
|
||||||
|
.add("/admin/payments/methods/{id}", post(update_method))
|
||||||
|
.add("/admin/payments/bank", post(update_bank))
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ use crate::{
|
|||||||
shared::{
|
shared::{
|
||||||
guard,
|
guard,
|
||||||
money::{format_price, parse_price_to_cents},
|
money::{format_price, parse_price_to_cents},
|
||||||
|
shipping as shipping_rules,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -37,13 +38,17 @@ async fn index(
|
|||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
guard::current_admin(auth, &ctx).await?;
|
guard::current_admin(auth, &ctx).await?;
|
||||||
|
shipping_rules::disable_packeta_if_unconfigured(&ctx).await?;
|
||||||
let methods = shipping_methods::Entity::find()
|
let methods = shipping_methods::Entity::find()
|
||||||
.order_by_asc(shipping_methods::Column::Position)
|
.order_by_asc(shipping_methods::Column::Position)
|
||||||
.all(&ctx.db)
|
.all(&ctx.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
let packeta_ready = shipping_rules::packeta_ready(&ctx);
|
||||||
let rows: Vec<serde_json::Value> = methods
|
let rows: Vec<serde_json::Value> = methods
|
||||||
.iter()
|
.iter()
|
||||||
.map(|m| {
|
.map(|m| {
|
||||||
|
let packeta_not_ready = m.carrier == "packeta" && !packeta_ready;
|
||||||
|
let locked = packeta_not_ready && !m.enabled;
|
||||||
json!({
|
json!({
|
||||||
"id": m.id,
|
"id": m.id,
|
||||||
"code": m.code,
|
"code": m.code,
|
||||||
@@ -52,6 +57,9 @@ async fn index(
|
|||||||
"carrier": m.carrier,
|
"carrier": m.carrier,
|
||||||
"requires_pickup_point": m.requires_pickup_point,
|
"requires_pickup_point": m.requires_pickup_point,
|
||||||
"enabled": m.enabled,
|
"enabled": m.enabled,
|
||||||
|
"packeta_not_ready": packeta_not_ready,
|
||||||
|
"locked": locked,
|
||||||
|
"lock_reason": if packeta_not_ready { Some("shipping-packeta-missing-settings") } else { None::<&str> },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
@@ -74,9 +82,15 @@ async fn update(
|
|||||||
.one(&ctx.db)
|
.one(&ctx.db)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| Error::NotFound)?;
|
.ok_or_else(|| Error::NotFound)?;
|
||||||
|
let requested_enabled = is_checked(&form.enabled);
|
||||||
|
if requested_enabled && method.carrier == "packeta" && !shipping_rules::packeta_ready(&ctx) {
|
||||||
|
return Err(Error::BadRequest(
|
||||||
|
"Packeta cannot be enabled until PACKETA_API_KEY, PACKETA_API_PASSWORD and PACKETA_SENDER_LABEL are configured.".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
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(is_checked(&form.enabled));
|
active.enabled = Set(requested_enabled);
|
||||||
active.update(&ctx.db).await?;
|
active.update(&ctx.db).await?;
|
||||||
format::redirect("/admin/shipping")
|
format::redirect("/admin/shipping")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
use crate::{
|
use crate::{
|
||||||
|
controllers::cart,
|
||||||
models::users::{self, LoginParams, RegisterParams},
|
models::users::{self, LoginParams, RegisterParams},
|
||||||
views::auth::{CurrentResponse, LoginResponse},
|
views::auth::{CurrentResponse, LoginResponse},
|
||||||
mailers::auth::AuthMailer,
|
mailers::auth::AuthMailer,
|
||||||
shared::guard::is_admin,
|
shared::guard::is_admin,
|
||||||
};
|
};
|
||||||
use axum_extra::extract::cookie::{Cookie, SameSite};
|
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use regex::Regex;
|
use regex::Regex;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
@@ -88,6 +89,7 @@ pub struct ResendVerificationParams {
|
|||||||
/// welcome email to the user
|
/// welcome email to the user
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn register(
|
async fn register(
|
||||||
|
jar: CookieJar,
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
Json(params): Json<RegisterParams>,
|
Json(params): Json<RegisterParams>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
@@ -109,6 +111,7 @@ async fn register(
|
|||||||
.into_active_model()
|
.into_active_model()
|
||||||
.set_email_verification_sent(&ctx.db)
|
.set_email_verification_sent(&ctx.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
cart::claim_guest_cart(&ctx, &jar, user.id).await?;
|
||||||
|
|
||||||
AuthMailer::send_welcome(&ctx, &user).await?;
|
AuthMailer::send_welcome(&ctx, &user).await?;
|
||||||
|
|
||||||
@@ -199,8 +202,9 @@ async fn login(State(ctx): State<AppContext>, Json(params): Json<LoginParams>) -
|
|||||||
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
||||||
.or_else(|_| unauthorized("unauthorized!"))?;
|
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||||
|
|
||||||
|
let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?;
|
||||||
format::render()
|
format::render()
|
||||||
.cookies(&[auth_cookie(&token, jwt_secret.expiration)])?
|
.cookies(&[auth_cookie(&token, jwt_secret.expiration), cart_cookie])?
|
||||||
.json(LoginResponse::new(&user, &token, is_admin(&ctx, &user)))
|
.json(LoginResponse::new(&user, &token, is_admin(&ctx, &user)))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,7 +216,9 @@ async fn current(auth: auth::JWT, State(ctx): State<AppContext>) -> Result<Respo
|
|||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn logout() -> Result<Response> {
|
async fn logout() -> Result<Response> {
|
||||||
format::render().cookies(&[clear_auth_cookie()])?.json(())
|
format::render()
|
||||||
|
.cookies(&[clear_auth_cookie(), cart::cleared_cart_cookie()])?
|
||||||
|
.json(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Magic link authentication provides a secure and passwordless way to log in to the application.
|
/// Magic link authentication provides a secure and passwordless way to log in to the application.
|
||||||
@@ -274,8 +280,9 @@ async fn magic_link_verify(
|
|||||||
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
||||||
.or_else(|_| unauthorized("unauthorized!"))?;
|
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||||
|
|
||||||
|
let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?;
|
||||||
format::render()
|
format::render()
|
||||||
.cookies(&[auth_cookie(&token, jwt_secret.expiration)])?
|
.cookies(&[auth_cookie(&token, jwt_secret.expiration), cart_cookie])?
|
||||||
.json(LoginResponse::new(&user, &token, is_admin(&ctx, &user)))
|
.json(LoginResponse::new(&user, &token, is_admin(&ctx, &user)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ use serde_json::json;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
controllers::auth as auth_controller,
|
controllers::auth as auth_controller,
|
||||||
|
controllers::cart,
|
||||||
controllers::i18n::current_lang,
|
controllers::i18n::current_lang,
|
||||||
mailers::auth::AuthMailer,
|
mailers::auth::AuthMailer,
|
||||||
models::users::{self, LoginParams, RegisterParams},
|
models::users::{self, LoginParams, RegisterParams},
|
||||||
@@ -105,9 +106,13 @@ async fn login(
|
|||||||
let token = user
|
let token = user
|
||||||
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
||||||
.or_else(|_| unauthorized("unauthorized!"))?;
|
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||||
|
let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?;
|
||||||
|
|
||||||
format::render()
|
format::render()
|
||||||
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])?
|
.cookies(&[
|
||||||
|
auth_controller::auth_cookie(&token, jwt_secret.expiration),
|
||||||
|
cart_cookie,
|
||||||
|
])?
|
||||||
.redirect(home_for(&ctx, &user))
|
.redirect(home_for(&ctx, &user))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,11 +190,13 @@ async fn login_totp(
|
|||||||
let token = user
|
let token = user
|
||||||
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
.generate_jwt(&jwt_secret.secret, jwt_secret.expiration)
|
||||||
.or_else(|_| unauthorized("unauthorized!"))?;
|
.or_else(|_| unauthorized("unauthorized!"))?;
|
||||||
|
let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?;
|
||||||
|
|
||||||
format::render()
|
format::render()
|
||||||
.cookies(&[
|
.cookies(&[
|
||||||
auth_controller::auth_cookie(&token, jwt_secret.expiration),
|
auth_controller::auth_cookie(&token, jwt_secret.expiration),
|
||||||
auth_controller::clear_totp_pending_cookie(),
|
auth_controller::clear_totp_pending_cookie(),
|
||||||
|
cart_cookie,
|
||||||
])?
|
])?
|
||||||
.redirect(home_for(&ctx, &user))
|
.redirect(home_for(&ctx, &user))
|
||||||
}
|
}
|
||||||
@@ -270,6 +277,7 @@ async fn register(
|
|||||||
.into_active_model()
|
.into_active_model()
|
||||||
.set_email_verification_sent(&ctx.db)
|
.set_email_verification_sent(&ctx.db)
|
||||||
.await?;
|
.await?;
|
||||||
|
cart::claim_guest_cart(&ctx, &jar, user.id).await?;
|
||||||
|
|
||||||
// The account already exists; a failed email send shouldn't 500 the page —
|
// The account already exists; a failed email send shouldn't 500 the page —
|
||||||
// log it and let the user fall back to resend-verification.
|
// log it and let the user fall back to resend-verification.
|
||||||
@@ -304,7 +312,9 @@ async fn verify(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if user.email_verified_at.is_none() {
|
if user.email_verified_at.is_none() {
|
||||||
|
let user_id = user.id;
|
||||||
user.into_active_model().verified(&ctx.db).await?;
|
user.into_active_model().verified(&ctx.db).await?;
|
||||||
|
cart::claim_guest_cart(&ctx, &jar, user_id).await?;
|
||||||
}
|
}
|
||||||
|
|
||||||
verified_view(&v, &jar, true)
|
verified_view(&v, &jar, true)
|
||||||
@@ -446,7 +456,10 @@ async fn set_password(
|
|||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn logout() -> Result<Response> {
|
async fn logout() -> Result<Response> {
|
||||||
format::render()
|
format::render()
|
||||||
.cookies(&[auth_controller::clear_auth_cookie()])?
|
.cookies(&[
|
||||||
|
auth_controller::clear_auth_cookie(),
|
||||||
|
cart::cleared_cart_cookie(),
|
||||||
|
])?
|
||||||
.redirect("/login")
|
.redirect("/login")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
use crate::{controllers::i18n::current_lang, shared::{currency::{self, Currency}, guard, pricing}, models::{product_variants, products}};
|
use crate::{
|
||||||
|
controllers::i18n::current_lang,
|
||||||
|
models::{account_cart_items, product_variants, products, users},
|
||||||
|
shared::{
|
||||||
|
currency::{self, Currency},
|
||||||
|
guard, pricing,
|
||||||
|
},
|
||||||
|
};
|
||||||
use axum::{
|
use axum::{
|
||||||
http::{HeaderMap, StatusCode},
|
http::{HeaderMap, StatusCode},
|
||||||
response::Redirect,
|
response::Redirect,
|
||||||
@@ -64,6 +71,75 @@ fn cart_cookie(value: String) -> Cookie<'static> {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn cleared_cart_cookie() -> Cookie<'static> {
|
||||||
|
Cookie::build((CART_COOKIE, ""))
|
||||||
|
.path("/")
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
|
.max_age(TimeDuration::seconds(0))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_items(items: Vec<(i32, i32)>) -> Vec<(i32, i32)> {
|
||||||
|
let mut normalized: Vec<(i32, i32)> = Vec::new();
|
||||||
|
for (id, qty) in items.into_iter().filter(|(_, qty)| *qty > 0) {
|
||||||
|
if let Some(existing) = normalized.iter_mut().find(|(existing_id, _)| *existing_id == id) {
|
||||||
|
existing.1 += qty;
|
||||||
|
} else {
|
||||||
|
normalized.push((id, qty));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn stored_cart(
|
||||||
|
ctx: &AppContext,
|
||||||
|
user: Option<&users::Model>,
|
||||||
|
jar: &CookieJar,
|
||||||
|
) -> Result<Vec<(i32, i32)>> {
|
||||||
|
match user {
|
||||||
|
Some(user) => Ok(account_cart_items::Model::find_for_user(&ctx.db, user.id).await?),
|
||||||
|
None => Ok(normalize_items(parse_cart(jar))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn persist_cart(
|
||||||
|
ctx: &AppContext,
|
||||||
|
jar: CookieJar,
|
||||||
|
user: Option<&users::Model>,
|
||||||
|
items: &[(i32, i32)],
|
||||||
|
) -> Result<CookieJar> {
|
||||||
|
let items = normalize_items(items.to_vec());
|
||||||
|
if let Some(user) = user {
|
||||||
|
account_cart_items::Model::replace_for_user(&ctx.db, user.id, &items).await?;
|
||||||
|
}
|
||||||
|
Ok(jar.add(cart_cookie(serialize_cart(&items))))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn claim_guest_cart(
|
||||||
|
ctx: &AppContext,
|
||||||
|
jar: &CookieJar,
|
||||||
|
user_id: i32,
|
||||||
|
) -> Result<()> {
|
||||||
|
let items = normalize_items(parse_cart(jar));
|
||||||
|
if !items.is_empty() {
|
||||||
|
account_cart_items::Model::replace_for_user(&ctx.db, user_id, &items).await?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn cart_cookie_for_user(
|
||||||
|
ctx: &AppContext,
|
||||||
|
user_id: i32,
|
||||||
|
) -> Result<Cookie<'static>> {
|
||||||
|
let items = account_cart_items::Model::find_for_user(&ctx.db, user_id).await?;
|
||||||
|
Ok(cart_cookie(serialize_cart(&items)))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) async fn clear_account_cart(ctx: &AppContext, user_id: i32) -> Result<()> {
|
||||||
|
account_cart_items::Model::replace_for_user(&ctx.db, user_id, &[]).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Look up a variant whose product is published, returning the variant together
|
/// Look up a variant whose product is published, returning the variant together
|
||||||
/// with its parent product (for name/slug).
|
/// with its parent product (for name/slug).
|
||||||
async fn published_variant(
|
async fn published_variant(
|
||||||
@@ -94,7 +170,8 @@ async fn add(
|
|||||||
return Err(Error::NotFound);
|
return Err(Error::NotFound);
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut items = parse_cart(&jar);
|
let user = guard::current_user(&ctx, &jar).await;
|
||||||
|
let mut items = stored_cart(&ctx, user.as_ref(), &jar).await?;
|
||||||
let add_qty = form.quantity.unwrap_or(1).max(1);
|
let add_qty = form.quantity.unwrap_or(1).max(1);
|
||||||
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == variant.id) {
|
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == variant.id) {
|
||||||
entry.1 = variant.cap(entry.1 + add_qty);
|
entry.1 = variant.cap(entry.1 + add_qty);
|
||||||
@@ -103,7 +180,7 @@ async fn add(
|
|||||||
}
|
}
|
||||||
items.retain(|(_, qty)| *qty > 0);
|
items.retain(|(_, qty)| *qty > 0);
|
||||||
|
|
||||||
let jar = jar.add(cart_cookie(serialize_cart(&items)));
|
let jar = persist_cart(&ctx, jar, user.as_ref(), &items).await?;
|
||||||
|
|
||||||
// Adding to the cart should never navigate away: htmx requests get an empty
|
// Adding to the cart should never navigate away: htmx requests get an empty
|
||||||
// 204 (the header cart badge updates client-side), and a no-JS submit goes
|
// 204 (the header cart badge updates client-side), and a no-JS submit goes
|
||||||
@@ -135,13 +212,14 @@ async fn update(
|
|||||||
None => 0,
|
None => 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut items = parse_cart(&jar);
|
let user = guard::current_user(&ctx, &jar).await;
|
||||||
|
let mut items = stored_cart(&ctx, user.as_ref(), &jar).await?;
|
||||||
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.variant_id) {
|
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.variant_id) {
|
||||||
entry.1 = clamped;
|
entry.1 = clamped;
|
||||||
}
|
}
|
||||||
items.retain(|(_, qty)| *qty > 0);
|
items.retain(|(_, qty)| *qty > 0);
|
||||||
|
|
||||||
let jar = jar.add(cart_cookie(serialize_cart(&items)));
|
let jar = persist_cart(&ctx, jar, user.as_ref(), &items).await?;
|
||||||
cart_response(&ctx, &v, jar, &headers).await
|
cart_response(&ctx, &v, jar, &headers).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -153,10 +231,11 @@ async fn remove(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Form(form): Form<RemoveForm>,
|
Form(form): Form<RemoveForm>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let mut items = parse_cart(&jar);
|
let user = guard::current_user(&ctx, &jar).await;
|
||||||
|
let mut items = stored_cart(&ctx, user.as_ref(), &jar).await?;
|
||||||
items.retain(|(id, _)| *id != form.variant_id);
|
items.retain(|(id, _)| *id != form.variant_id);
|
||||||
|
|
||||||
let jar = jar.add(cart_cookie(serialize_cart(&items)));
|
let jar = persist_cart(&ctx, jar, user.as_ref(), &items).await?;
|
||||||
cart_response(&ctx, &v, jar, &headers).await
|
cart_response(&ctx, &v, jar, &headers).await
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -176,7 +255,8 @@ async fn cart_response(
|
|||||||
let cur = currency::resolve(ctx, &jar).await;
|
let cur = currency::resolve(ctx, &jar).await;
|
||||||
let (lines, valid, total) = resolve_cart(ctx, &jar, &cur).await?;
|
let (lines, valid, total) = resolve_cart(ctx, &jar, &cur).await?;
|
||||||
// Persist the re-validated cookie (drops now-invalid lines).
|
// Persist the re-validated cookie (drops now-invalid lines).
|
||||||
let jar = jar.add(cart_cookie(serialize_cart(&valid)));
|
let user = guard::current_user(ctx, &jar).await;
|
||||||
|
let jar = persist_cart(ctx, jar, user.as_ref(), &valid).await?;
|
||||||
let response = format::view(
|
let response = format::view(
|
||||||
v,
|
v,
|
||||||
"shop/_cart_body.html",
|
"shop/_cart_body.html",
|
||||||
@@ -190,9 +270,9 @@ async fn cart_response(
|
|||||||
Ok((jar, response).into_response())
|
Ok((jar, response).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the cart cookie into priced line items, dropping anything that is no
|
/// Resolve the active cart into priced line items, dropping anything that is no
|
||||||
/// longer purchasable and clamping quantities to current stock. Returns the
|
/// longer purchasable and clamping quantities to current stock. Guests resolve
|
||||||
/// (re-validated) lines, the rebuilt cookie value, and the total in cents.
|
/// from the cookie; authenticated users resolve from their account cart.
|
||||||
pub(crate) async fn resolve_cart(
|
pub(crate) async fn resolve_cart(
|
||||||
ctx: &AppContext,
|
ctx: &AppContext,
|
||||||
jar: &CookieJar,
|
jar: &CookieJar,
|
||||||
@@ -202,7 +282,7 @@ pub(crate) async fn resolve_cart(
|
|||||||
// for the current viewer in one batch (the price depends on who's logged in).
|
// for the current viewer in one batch (the price depends on who's logged in).
|
||||||
let user = guard::current_user(ctx, jar).await;
|
let user = guard::current_user(ctx, jar).await;
|
||||||
let mut items: Vec<(product_variants::Model, products::Model, i32)> = Vec::new();
|
let mut items: Vec<(product_variants::Model, products::Model, i32)> = Vec::new();
|
||||||
for (id, qty) in parse_cart(jar) {
|
for (id, qty) in stored_cart(ctx, user.as_ref(), jar).await? {
|
||||||
let Some((variant, product)) = published_variant(ctx, id).await? else {
|
let Some((variant, product)) = published_variant(ctx, id).await? else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
@@ -238,6 +318,10 @@ pub(crate) async fn resolve_cart(
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(user) = user.as_ref() {
|
||||||
|
account_cart_items::Model::replace_for_user(&ctx.db, user.id, &valid).await?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok((lines, valid, total))
|
Ok((lines, valid, total))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -250,8 +334,6 @@ async fn show(
|
|||||||
let cur = currency::resolve(&ctx, &jar).await;
|
let cur = currency::resolve(&ctx, &jar).await;
|
||||||
let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?;
|
let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?;
|
||||||
|
|
||||||
// Drop any now-invalid lines from the cookie so the badge stays accurate.
|
|
||||||
let rebuilt = serialize_cart(&valid);
|
|
||||||
let c = guard::chrome(&ctx, &jar).await;
|
let c = guard::chrome(&ctx, &jar).await;
|
||||||
let response = format::view(
|
let response = format::view(
|
||||||
&v,
|
&v,
|
||||||
@@ -269,7 +351,9 @@ async fn show(
|
|||||||
}),
|
}),
|
||||||
)?;
|
)?;
|
||||||
|
|
||||||
Ok((jar.add(cart_cookie(rebuilt)), response).into_response())
|
let user = guard::current_user(&ctx, &jar).await;
|
||||||
|
let jar = persist_cart(&ctx, jar, user.as_ref(), &valid).await?;
|
||||||
|
Ok((jar, response).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Mini-cart preview for the navbar hover dropdown. Lazy-loaded via htmx from
|
/// Mini-cart preview for the navbar hover dropdown. Lazy-loaded via htmx from
|
||||||
@@ -282,7 +366,6 @@ async fn preview(
|
|||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let cur = currency::resolve(&ctx, &jar).await;
|
let cur = currency::resolve(&ctx, &jar).await;
|
||||||
let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?;
|
let (lines, valid, total) = resolve_cart(&ctx, &jar, &cur).await?;
|
||||||
let rebuilt = serialize_cart(&valid);
|
|
||||||
let response = format::view(
|
let response = format::view(
|
||||||
&v,
|
&v,
|
||||||
"shop/_cart_preview.html",
|
"shop/_cart_preview.html",
|
||||||
@@ -293,7 +376,9 @@ async fn preview(
|
|||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
}),
|
}),
|
||||||
)?;
|
)?;
|
||||||
Ok((jar.add(cart_cookie(rebuilt)), response).into_response())
|
let user = guard::current_user(&ctx, &jar).await;
|
||||||
|
let jar = persist_cart(&ctx, jar, user.as_ref(), &valid).await?;
|
||||||
|
Ok((jar, response).into_response())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn routes() -> Routes {
|
pub fn routes() -> Routes {
|
||||||
|
|||||||
@@ -1,31 +1,64 @@
|
|||||||
//! Public checkout flow: the checkout form, placing an order, and the order
|
//! Public checkout flow, split across pages: the basket is `/cart`, then a
|
||||||
//! confirmation page.
|
//! two-step wizard — `/checkout/info` (contact + addresses + company details)
|
||||||
|
//! followed by `/checkout/payment` (carrier + payment, which places the order)
|
||||||
|
//! — and finally the order confirmation page.
|
||||||
|
//!
|
||||||
|
//! The info step is persisted between pages in a short-lived, base64-encoded
|
||||||
|
//! JSON cookie (`checkout_info`); the payment step reads it back, places the
|
||||||
|
//! order, and clears both it and the cart cookie.
|
||||||
|
|
||||||
use axum::extract::Query;
|
use axum::{extract::Query, response::Redirect};
|
||||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||||
|
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use time::Duration as TimeDuration;
|
use time::Duration as TimeDuration;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
controllers::cart::{resolve_cart, CART_COOKIE},
|
controllers::cart::{self, resolve_cart},
|
||||||
mailers::auth::AuthMailer,
|
mailers::auth::AuthMailer,
|
||||||
models::{
|
models::{
|
||||||
customer_profiles::{self, ProfileFields},
|
customer_profiles::{self, ProfileFields},
|
||||||
order_items, orders, shipping_methods,
|
order_items, orders, payment_methods, shipping_methods,
|
||||||
users::{self, normalize_account_type},
|
users::{self, normalize_account_type},
|
||||||
},
|
},
|
||||||
controllers::i18n::current_lang,
|
controllers::i18n::current_lang,
|
||||||
shared::{currency::Currency, guard, money::format_price, settings},
|
shared::{currency::Currency, guard, money::format_price, settings, shipping as shipping_rules},
|
||||||
views::checkout as view,
|
views::checkout as view,
|
||||||
};
|
};
|
||||||
|
|
||||||
const PAYMENT_METHODS: [&str; 2] = ["cod", "bank_transfer"];
|
const INFO_COOKIE: &str = "checkout_info";
|
||||||
|
const INFO_MAX_AGE_HOURS: i64 = 2;
|
||||||
|
|
||||||
|
/// The contact + address details captured on `/checkout/info`, carried to the
|
||||||
|
/// `/checkout/payment` step via the `checkout_info` cookie. `phone` is the local
|
||||||
|
/// number only; it is combined with `phone_prefix` when the order is placed.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
struct CheckoutInfo {
|
||||||
|
email: String,
|
||||||
|
phone_prefix: String,
|
||||||
|
phone: String,
|
||||||
|
customer_name: String,
|
||||||
|
account_type: String,
|
||||||
|
company_name: Option<String>,
|
||||||
|
company_id: Option<String>,
|
||||||
|
tax_id: Option<String>,
|
||||||
|
vat_id: Option<String>,
|
||||||
|
residence_address: String,
|
||||||
|
residence_city: String,
|
||||||
|
residence_zip: String,
|
||||||
|
residence_country: String,
|
||||||
|
delivery_same: bool,
|
||||||
|
address: String,
|
||||||
|
city: String,
|
||||||
|
zip: String,
|
||||||
|
country: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Step 1 form (`POST /checkout/info`).
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
struct CheckoutForm {
|
struct InfoForm {
|
||||||
email: String,
|
email: String,
|
||||||
phone_prefix: String,
|
phone_prefix: String,
|
||||||
phone: String,
|
phone: String,
|
||||||
@@ -35,10 +68,20 @@ struct CheckoutForm {
|
|||||||
company_id: Option<String>,
|
company_id: Option<String>,
|
||||||
tax_id: Option<String>,
|
tax_id: Option<String>,
|
||||||
vat_id: Option<String>,
|
vat_id: Option<String>,
|
||||||
address: String,
|
residence_address: String,
|
||||||
city: String,
|
residence_city: String,
|
||||||
zip: String,
|
residence_zip: String,
|
||||||
country: String,
|
residence_country: String,
|
||||||
|
delivery_same_as_residence: Option<String>,
|
||||||
|
address: Option<String>,
|
||||||
|
city: Option<String>,
|
||||||
|
zip: Option<String>,
|
||||||
|
country: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Step 2 form (`POST /checkout/payment`).
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
struct PaymentForm {
|
||||||
note: Option<String>,
|
note: Option<String>,
|
||||||
payment_method: String,
|
payment_method: String,
|
||||||
carrier_code: String,
|
carrier_code: String,
|
||||||
@@ -55,24 +98,61 @@ fn trimmed(value: &str) -> Option<String> {
|
|||||||
(!value.is_empty()).then(|| value.to_string())
|
(!value.is_empty()).then(|| value.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn cleared_cart_cookie() -> Cookie<'static> {
|
fn info_cookie(value: String) -> Cookie<'static> {
|
||||||
Cookie::build((CART_COOKIE, ""))
|
Cookie::build((INFO_COOKIE, value))
|
||||||
|
.path("/")
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
|
.http_only(true)
|
||||||
|
.max_age(TimeDuration::hours(INFO_MAX_AGE_HOURS))
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cleared_info_cookie() -> Cookie<'static> {
|
||||||
|
Cookie::build((INFO_COOKIE, ""))
|
||||||
.path("/")
|
.path("/")
|
||||||
.same_site(SameSite::Lax)
|
.same_site(SameSite::Lax)
|
||||||
.max_age(TimeDuration::seconds(0))
|
.max_age(TimeDuration::seconds(0))
|
||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn encode_info(info: &CheckoutInfo) -> String {
|
||||||
|
URL_SAFE_NO_PAD.encode(serde_json::to_vec(info).unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decode_info(jar: &CookieJar) -> Option<CheckoutInfo> {
|
||||||
|
let raw = jar.get(INFO_COOKIE)?;
|
||||||
|
let bytes = URL_SAFE_NO_PAD.decode(raw.value()).ok()?;
|
||||||
|
serde_json::from_slice(&bytes).ok()
|
||||||
|
}
|
||||||
|
|
||||||
async fn enabled_shipping_methods(ctx: &AppContext) -> Result<Vec<shipping_methods::Model>> {
|
async fn enabled_shipping_methods(ctx: &AppContext) -> Result<Vec<shipping_methods::Model>> {
|
||||||
|
shipping_rules::disable_packeta_if_unconfigured(ctx).await?;
|
||||||
|
let packeta_ready = shipping_rules::packeta_ready(ctx);
|
||||||
Ok(shipping_methods::Entity::find()
|
Ok(shipping_methods::Entity::find()
|
||||||
.filter(shipping_methods::Column::Enabled.eq(true))
|
.filter(shipping_methods::Column::Enabled.eq(true))
|
||||||
.order_by_asc(shipping_methods::Column::Position)
|
.order_by_asc(shipping_methods::Column::Position)
|
||||||
.all(&ctx.db)
|
.all(&ctx.db)
|
||||||
.await?)
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter(|method| method.carrier != "packeta" || packeta_ready)
|
||||||
|
.collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn enabled_payment_methods(ctx: &AppContext) -> Result<Vec<payment_methods::Model>> {
|
||||||
|
Ok(payment_methods::Entity::enabled(&ctx.db).await?)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `/checkout` lands on the first wizard step.
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn checkout_page(
|
async fn checkout_redirect() -> Result<Response> {
|
||||||
|
format::redirect("/checkout/info")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Step 1 page: contact, residence/delivery addresses and (for companies)
|
||||||
|
/// invoicing details. Prefilled from a returning customer's profile, or from
|
||||||
|
/// the `checkout_info` cookie when the buyer steps back from the payment page.
|
||||||
|
#[debug_handler]
|
||||||
|
async fn info_page(
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
ViewEngine(v): ViewEngine<TeraView>,
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
@@ -84,20 +164,6 @@ async fn checkout_page(
|
|||||||
return format::redirect("/cart");
|
return format::redirect("/cart");
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
|
||||||
|
|
||||||
// Prefill the form for a logged-in customer: contact name/email come from
|
// Prefill the form for a logged-in customer: contact name/email come from
|
||||||
// the user account, the address/phone from their saved profile (if any).
|
// the user account, the address/phone from their saved profile (if any).
|
||||||
let user = guard::current_user(&ctx, &jar).await;
|
let user = guard::current_user(&ctx, &jar).await;
|
||||||
@@ -110,22 +176,26 @@ async fn checkout_page(
|
|||||||
let p = |get: fn(&customer_profiles::Model) -> Option<String>| {
|
let p = |get: fn(&customer_profiles::Model) -> Option<String>| {
|
||||||
profile.as_ref().and_then(get)
|
profile.as_ref().and_then(get)
|
||||||
};
|
};
|
||||||
// Whether the customer already has a shipping address on file. When they do,
|
|
||||||
// the "save this address to my profile" opt-in is pointless (the profile was
|
// A previously entered info step (back navigation from the payment page)
|
||||||
// filled in advance), so it's hidden and the existing profile is left alone.
|
// takes precedence over the profile defaults.
|
||||||
let profile_filled = profile
|
let saved = decode_info(&jar);
|
||||||
.as_ref()
|
let s = |get: fn(&CheckoutInfo) -> String| saved.as_ref().map(get);
|
||||||
.is_some_and(|pr| pr.address.is_some() && pr.city.is_some() && pr.zip.is_some());
|
let s_opt = |get: fn(&CheckoutInfo) -> Option<String>| saved.as_ref().and_then(get);
|
||||||
|
|
||||||
|
let prefill_account_type = if is_customer {
|
||||||
|
user.as_ref().map_or("personal", |u| u.account_type.as_str()).to_string()
|
||||||
|
} else {
|
||||||
|
saved.as_ref().map_or_else(|| "personal".to_string(), |s| s.account_type.clone())
|
||||||
|
};
|
||||||
|
|
||||||
format::view(
|
format::view(
|
||||||
&v,
|
&v,
|
||||||
"shop/checkout.html",
|
"shop/checkout_info.html",
|
||||||
json!({
|
json!({
|
||||||
"items": lines,
|
"items": lines,
|
||||||
"subtotal": format_price(subtotal),
|
"subtotal": format_price(subtotal),
|
||||||
"subtotal_cents": subtotal,
|
"subtotal_cents": subtotal,
|
||||||
"shipping_methods": methods,
|
|
||||||
"packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""),
|
|
||||||
"logged_in_admin": is_admin,
|
"logged_in_admin": is_admin,
|
||||||
"logged_in_customer": is_customer,
|
"logged_in_customer": is_customer,
|
||||||
// Required by the navbar profile menu (base.html includes it whenever
|
// Required by the navbar profile menu (base.html includes it whenever
|
||||||
@@ -133,68 +203,82 @@ async fn checkout_page(
|
|||||||
"customer_name": user.as_ref().filter(|_| is_customer).map(|u| u.name.clone()),
|
"customer_name": user.as_ref().filter(|_| is_customer).map(|u| u.name.clone()),
|
||||||
"customer_account_type": user.as_ref().filter(|_| is_customer).map(|u| u.account_type.clone()),
|
"customer_account_type": user.as_ref().filter(|_| is_customer).map(|u| u.account_type.clone()),
|
||||||
"customer_avatar": user.as_ref().filter(|_| is_customer).and_then(|u| u.avatar_id.clone()),
|
"customer_avatar": user.as_ref().filter(|_| is_customer).and_then(|u| u.avatar_id.clone()),
|
||||||
"profile_filled": profile_filled,
|
// A logged-in customer's account type is fixed; only guests pick it.
|
||||||
// A logged-in customer's account type is fixed; only guests pick it
|
|
||||||
// and may opt to create an account from the order.
|
|
||||||
"account_fixed": is_customer,
|
"account_fixed": is_customer,
|
||||||
"can_create_account": user.is_none(),
|
"prefill_email": s(|x| x.email.clone()).or_else(|| user.as_ref().filter(|_| is_customer).map(|u| u.email.clone())),
|
||||||
"prefill_email": user.as_ref().filter(|_| is_customer).map(|u| u.email.clone()),
|
"prefill_name": s(|x| x.customer_name.clone()).or_else(|| user.as_ref().filter(|_| is_customer).map(|u| u.name.clone())),
|
||||||
"prefill_name": user.as_ref().filter(|_| is_customer).map(|u| u.name.clone()),
|
"prefill_account_type": prefill_account_type,
|
||||||
"prefill_account_type": user.as_ref().filter(|_| is_customer).map_or("personal", |u| u.account_type.as_str()),
|
"prefill_company_name": s_opt(|x| x.company_name.clone()).or_else(|| p(|x| x.company_name.clone())),
|
||||||
"prefill_company_name": p(|x| x.company_name.clone()),
|
"prefill_company_id": s_opt(|x| x.company_id.clone()).or_else(|| p(|x| x.company_id.clone())),
|
||||||
"prefill_company_id": p(|x| x.company_id.clone()),
|
"prefill_tax_id": s_opt(|x| x.tax_id.clone()).or_else(|| p(|x| x.tax_id.clone())),
|
||||||
"prefill_tax_id": p(|x| x.tax_id.clone()),
|
"prefill_vat_id": s_opt(|x| x.vat_id.clone()).or_else(|| p(|x| x.vat_id.clone())),
|
||||||
"prefill_vat_id": p(|x| x.vat_id.clone()),
|
"prefill_phone_prefix": s(|x| x.phone_prefix.clone()).or_else(|| p(|x| x.phone_prefix.clone())),
|
||||||
"prefill_phone_prefix": p(|x| x.phone_prefix.clone()),
|
"prefill_phone": s(|x| x.phone.clone()).or_else(|| p(|x| x.phone.clone())),
|
||||||
"prefill_phone": p(|x| x.phone.clone()),
|
"prefill_residence_address": s(|x| x.residence_address.clone()).or_else(|| p(|x| x.address.clone())),
|
||||||
"prefill_address": p(|x| x.address.clone()),
|
"prefill_residence_city": s(|x| x.residence_city.clone()).or_else(|| p(|x| x.city.clone())),
|
||||||
"prefill_city": p(|x| x.city.clone()),
|
"prefill_residence_zip": s(|x| x.residence_zip.clone()).or_else(|| p(|x| x.zip.clone())),
|
||||||
"prefill_zip": p(|x| x.zip.clone()),
|
"prefill_residence_country": s(|x| x.residence_country.clone()).or_else(|| p(|x| x.country.clone())),
|
||||||
"prefill_country": p(|x| x.country.clone()),
|
"prefill_delivery_same": saved.as_ref().is_some_and(|x| x.delivery_same),
|
||||||
|
"prefill_delivery_address": s(|x| x.address.clone()),
|
||||||
|
"prefill_delivery_city": s(|x| x.city.clone()),
|
||||||
|
"prefill_delivery_zip": s(|x| x.zip.clone()),
|
||||||
|
"prefill_delivery_country": s(|x| x.country.clone()),
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate step 1, stash it in the `checkout_info` cookie and advance to the
|
||||||
|
/// payment step.
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn place_order(
|
async fn submit_info(
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
Form(form): Form<CheckoutForm>,
|
Form(form): Form<InfoForm>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let (_lines, valid, _total) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
|
let (_lines, valid, _total) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
|
||||||
if valid.is_empty() {
|
if valid.is_empty() {
|
||||||
return format::redirect("/cart");
|
return format::redirect("/cart");
|
||||||
}
|
}
|
||||||
let email =
|
|
||||||
trimmed(&form.email).ok_or_else(|| Error::BadRequest("email is required".to_string()))?;
|
|
||||||
// Combine the dialling-code prefix with the local number into one E.164-ish
|
|
||||||
// value (e.g. "+421 900123456").
|
|
||||||
let number =
|
|
||||||
trimmed(&form.phone).ok_or_else(|| Error::BadRequest("phone is required".to_string()))?;
|
|
||||||
let phone = match trimmed(&form.phone_prefix) {
|
|
||||||
Some(prefix) => format!("{prefix} {number}"),
|
|
||||||
None => number.clone(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Contact and shipping-address fields are mandatory (also enforced in the
|
|
||||||
// browser via `required`).
|
|
||||||
let require = |value: &str, field: &str| -> Result<String> {
|
let require = |value: &str, field: &str| -> Result<String> {
|
||||||
trimmed(value).ok_or_else(|| Error::BadRequest(format!("{field} is required")))
|
trimmed(value).ok_or_else(|| Error::BadRequest(format!("{field} is required")))
|
||||||
};
|
};
|
||||||
|
let require_opt = |value: Option<&str>, field: &str| -> Result<String> {
|
||||||
|
value
|
||||||
|
.and_then(trimmed)
|
||||||
|
.ok_or_else(|| Error::BadRequest(format!("{field} is required")))
|
||||||
|
};
|
||||||
|
|
||||||
|
let email = require(&form.email, "email")?;
|
||||||
|
let number = require(&form.phone, "phone")?;
|
||||||
let customer_name = require(&form.customer_name, "name")?;
|
let customer_name = require(&form.customer_name, "name")?;
|
||||||
let address = require(&form.address, "address")?;
|
let residence_address = require(&form.residence_address, "residence address")?;
|
||||||
let city = require(&form.city, "city")?;
|
let residence_city = require(&form.residence_city, "residence city")?;
|
||||||
let zip = require(&form.zip, "zip")?;
|
let residence_zip = require(&form.residence_zip, "residence zip")?;
|
||||||
let country = require(&form.country, "country")?;
|
let residence_country = require(&form.residence_country, "residence country")?;
|
||||||
|
|
||||||
|
let delivery_same = form.delivery_same_as_residence.is_some();
|
||||||
|
let (address, city, zip, country) = if delivery_same {
|
||||||
|
(
|
||||||
|
residence_address.clone(),
|
||||||
|
residence_city.clone(),
|
||||||
|
residence_zip.clone(),
|
||||||
|
residence_country.clone(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(
|
||||||
|
require_opt(form.address.as_deref(), "delivery address")?,
|
||||||
|
require_opt(form.city.as_deref(), "delivery city")?,
|
||||||
|
require_opt(form.zip.as_deref(), "delivery zip")?,
|
||||||
|
require_opt(form.country.as_deref(), "delivery country")?,
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
// The account type is fixed for a logged-in customer (taken from their
|
// The account type is fixed for a logged-in customer (taken from their
|
||||||
// account, never the form); a guest picks it on the form. Admins are treated
|
// account, never the form); a guest picks it on the form. Admins are guests.
|
||||||
// as guests here.
|
|
||||||
let current_user = guard::current_user(&ctx, &jar).await;
|
let current_user = guard::current_user(&ctx, &jar).await;
|
||||||
let logged_in_customer = current_user
|
let logged_in_customer = current_user.as_ref().filter(|u| !guard::is_admin(&ctx, u));
|
||||||
.as_ref()
|
|
||||||
.filter(|u| !guard::is_admin(&ctx, u));
|
|
||||||
let account_type = match logged_in_customer {
|
let account_type = match logged_in_customer {
|
||||||
Some(u) => u.account_type.clone(),
|
Some(u) => u.account_type.clone(),
|
||||||
None => normalize_account_type(form.account_type.as_deref()),
|
None => normalize_account_type(form.account_type.as_deref()),
|
||||||
@@ -213,7 +297,151 @@ async fn place_order(
|
|||||||
(None, None, None, None)
|
(None, None, None, None)
|
||||||
};
|
};
|
||||||
|
|
||||||
if !PAYMENT_METHODS.contains(&form.payment_method.as_str()) {
|
let info = CheckoutInfo {
|
||||||
|
email,
|
||||||
|
phone_prefix: trimmed(&form.phone_prefix).unwrap_or_default(),
|
||||||
|
phone: number,
|
||||||
|
customer_name,
|
||||||
|
account_type,
|
||||||
|
company_name,
|
||||||
|
company_id,
|
||||||
|
tax_id,
|
||||||
|
vat_id,
|
||||||
|
residence_address,
|
||||||
|
residence_city,
|
||||||
|
residence_zip,
|
||||||
|
residence_country,
|
||||||
|
delivery_same,
|
||||||
|
address,
|
||||||
|
city,
|
||||||
|
zip,
|
||||||
|
country,
|
||||||
|
};
|
||||||
|
|
||||||
|
let jar = jar.add(info_cookie(encode_info(&info)));
|
||||||
|
Ok((jar, Redirect::to("/checkout/payment")).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Step 2 page: carrier (with optional pickup point) and payment method, plus
|
||||||
|
/// the order summary. Requires the info step to have been completed.
|
||||||
|
#[debug_handler]
|
||||||
|
async fn payment_page(
|
||||||
|
jar: CookieJar,
|
||||||
|
ViewEngine(v): ViewEngine<TeraView>,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let (lines, _valid, subtotal) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
|
||||||
|
if lines.is_empty() {
|
||||||
|
return format::redirect("/cart");
|
||||||
|
}
|
||||||
|
if decode_info(&jar).is_none() {
|
||||||
|
return format::redirect("/checkout/info");
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
let payments: Vec<serde_json::Value> = enabled_payment_methods(&ctx)
|
||||||
|
.await?
|
||||||
|
.iter()
|
||||||
|
.map(|m| {
|
||||||
|
json!({
|
||||||
|
"code": m.code,
|
||||||
|
"label_key": m.label_key(),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let user = guard::current_user(&ctx, &jar).await;
|
||||||
|
let is_admin = user.as_ref().is_some_and(|u| guard::is_admin(&ctx, u));
|
||||||
|
let is_customer = user.is_some() && !is_admin;
|
||||||
|
let profile = match (&user, is_customer) {
|
||||||
|
(Some(u), true) => customer_profiles::Model::find_for_user(&ctx.db, u.id).await?,
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
// Whether the customer already has a residence address on file. When they do,
|
||||||
|
// the "save this address to my profile" opt-in is pointless, so it's hidden.
|
||||||
|
let profile_filled = profile
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|pr| pr.address.is_some() && pr.city.is_some() && pr.zip.is_some());
|
||||||
|
|
||||||
|
format::view(
|
||||||
|
&v,
|
||||||
|
"shop/checkout_payment.html",
|
||||||
|
json!({
|
||||||
|
"items": lines,
|
||||||
|
"subtotal": format_price(subtotal),
|
||||||
|
"subtotal_cents": subtotal,
|
||||||
|
"shipping_methods": methods,
|
||||||
|
"payment_methods": payments,
|
||||||
|
"packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""),
|
||||||
|
"logged_in_admin": is_admin,
|
||||||
|
"logged_in_customer": is_customer,
|
||||||
|
"customer_name": user.as_ref().filter(|_| is_customer).map(|u| u.name.clone()),
|
||||||
|
"customer_account_type": user.as_ref().filter(|_| is_customer).map(|u| u.account_type.clone()),
|
||||||
|
"customer_avatar": user.as_ref().filter(|_| is_customer).and_then(|u| u.avatar_id.clone()),
|
||||||
|
"profile_filled": profile_filled,
|
||||||
|
"can_create_account": user.is_none(),
|
||||||
|
"lang": current_lang(&jar),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[debug_handler]
|
||||||
|
async fn place_order(
|
||||||
|
jar: CookieJar,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(form): Form<PaymentForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let (_lines, valid, _total) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
|
||||||
|
if valid.is_empty() {
|
||||||
|
return format::redirect("/cart");
|
||||||
|
}
|
||||||
|
let Some(info) = decode_info(&jar) else {
|
||||||
|
return format::redirect("/checkout/info");
|
||||||
|
};
|
||||||
|
|
||||||
|
let email = info.email.clone();
|
||||||
|
let customer_name = info.customer_name.clone();
|
||||||
|
let number = info.phone.clone();
|
||||||
|
// Combine the dialling-code prefix with the local number into one E.164-ish
|
||||||
|
// value (e.g. "+421 900123456").
|
||||||
|
let phone = if info.phone_prefix.is_empty() {
|
||||||
|
number.clone()
|
||||||
|
} else {
|
||||||
|
format!("{} {}", info.phone_prefix, number)
|
||||||
|
};
|
||||||
|
|
||||||
|
// The account type is fixed for a logged-in customer (taken from their
|
||||||
|
// account, never the cookie); a guest's choice rides in the info cookie.
|
||||||
|
let current_user = guard::current_user(&ctx, &jar).await;
|
||||||
|
let logged_in_customer = current_user.as_ref().filter(|u| !guard::is_admin(&ctx, u));
|
||||||
|
let account_type = match logged_in_customer {
|
||||||
|
Some(u) => u.account_type.clone(),
|
||||||
|
None => info.account_type.clone(),
|
||||||
|
};
|
||||||
|
let (company_name, company_id, tax_id, vat_id) = if account_type == "company" {
|
||||||
|
(
|
||||||
|
info.company_name.clone(),
|
||||||
|
info.company_id.clone(),
|
||||||
|
info.tax_id.clone(),
|
||||||
|
info.vat_id.clone(),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(None, None, None, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
if payment_methods::Entity::find_enabled(&ctx.db, &form.payment_method).await?.is_none() {
|
||||||
return Err(Error::BadRequest("invalid payment method".to_string()));
|
return Err(Error::BadRequest("invalid payment method".to_string()));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -225,6 +453,9 @@ async fn place_order(
|
|||||||
.one(&ctx.db)
|
.one(&ctx.db)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| Error::BadRequest("invalid shipping method".to_string()))?;
|
.ok_or_else(|| Error::BadRequest("invalid shipping method".to_string()))?;
|
||||||
|
if method.carrier == "packeta" && !shipping_rules::packeta_ready(&ctx) {
|
||||||
|
return Err(Error::BadRequest("invalid shipping method".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
let (pickup_point_id, pickup_point_name) = if method.requires_pickup_point {
|
let (pickup_point_id, pickup_point_name) = if method.requires_pickup_point {
|
||||||
let id = form
|
let id = form
|
||||||
@@ -237,19 +468,19 @@ async fn place_order(
|
|||||||
(None, None)
|
(None, None)
|
||||||
};
|
};
|
||||||
|
|
||||||
// The address/contact captured here, ready to seed a profile (for the
|
// The address/contact captured in the info step, ready to seed a profile (for
|
||||||
// logged-in "save my address" opt-in or a freshly created guest account).
|
// the logged-in "save my address" opt-in or a freshly created guest account).
|
||||||
let entered_profile = || ProfileFields {
|
let entered_profile = || ProfileFields {
|
||||||
company_name: company_name.clone(),
|
company_name: company_name.clone(),
|
||||||
company_id: company_id.clone(),
|
company_id: company_id.clone(),
|
||||||
tax_id: tax_id.clone(),
|
tax_id: tax_id.clone(),
|
||||||
vat_id: vat_id.clone(),
|
vat_id: vat_id.clone(),
|
||||||
phone_prefix: trimmed(&form.phone_prefix),
|
phone_prefix: trimmed(&info.phone_prefix),
|
||||||
phone: Some(number.clone()),
|
phone: Some(number.clone()),
|
||||||
address: Some(address.clone()),
|
address: Some(info.residence_address.clone()),
|
||||||
city: Some(city.clone()),
|
city: Some(info.residence_city.clone()),
|
||||||
zip: Some(zip.clone()),
|
zip: Some(info.residence_zip.clone()),
|
||||||
country: Some(country.clone()),
|
country: Some(info.residence_country.clone()),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Resolve the account that will own this order. A logged-in customer always
|
// Resolve the account that will own this order. A logged-in customer always
|
||||||
@@ -318,10 +549,14 @@ async fn place_order(
|
|||||||
company_id,
|
company_id,
|
||||||
tax_id,
|
tax_id,
|
||||||
vat_id,
|
vat_id,
|
||||||
address: Some(address),
|
residence_address: Some(info.residence_address.clone()),
|
||||||
city: Some(city),
|
residence_city: Some(info.residence_city.clone()),
|
||||||
zip: Some(zip),
|
residence_zip: Some(info.residence_zip.clone()),
|
||||||
country: Some(country),
|
residence_country: Some(info.residence_country.clone()),
|
||||||
|
address: Some(info.address.clone()),
|
||||||
|
city: Some(info.city.clone()),
|
||||||
|
zip: Some(info.zip.clone()),
|
||||||
|
country: Some(info.country.clone()),
|
||||||
note: form.note.as_deref().and_then(trimmed),
|
note: form.note.as_deref().and_then(trimmed),
|
||||||
payment_method: form.payment_method,
|
payment_method: form.payment_method,
|
||||||
method,
|
method,
|
||||||
@@ -337,8 +572,11 @@ async fn place_order(
|
|||||||
} else {
|
} else {
|
||||||
format!("/orders/{}", order.order_number)
|
format!("/orders/{}", order.order_number)
|
||||||
};
|
};
|
||||||
|
if let Some(user) = logged_in_customer {
|
||||||
|
cart::clear_account_cart(&ctx, user.id).await?;
|
||||||
|
}
|
||||||
format::render()
|
format::render()
|
||||||
.cookies(&[cleared_cart_cookie()])?
|
.cookies(&[cart::cleared_cart_cookie(), cleared_info_cookie()])?
|
||||||
.redirect(&target)
|
.redirect(&target)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,14 +600,15 @@ async fn order_confirmation(
|
|||||||
let c = guard::chrome(&ctx, &jar).await;
|
let c = guard::chrome(&ctx, &jar).await;
|
||||||
let account_created = params.contains_key("account_created");
|
let account_created = params.contains_key("account_created");
|
||||||
|
|
||||||
|
let (bank_iban, bank_account_name) = settings::bank_details(&ctx).await?;
|
||||||
format::view(
|
format::view(
|
||||||
&v,
|
&v,
|
||||||
"shop/order_confirmed.html",
|
"shop/order_confirmed.html",
|
||||||
json!({
|
json!({
|
||||||
"order": view::detail(
|
"order": view::detail(
|
||||||
&order,
|
&order,
|
||||||
settings::get(&ctx, "bank_iban").unwrap_or(""),
|
&bank_iban,
|
||||||
settings::get(&ctx, "bank_account_name").unwrap_or(""),
|
&bank_account_name,
|
||||||
),
|
),
|
||||||
"items": view::items(&items),
|
"items": view::items(&items),
|
||||||
"logged_in_admin": c.logged_in_admin,
|
"logged_in_admin": c.logged_in_admin,
|
||||||
@@ -385,7 +624,10 @@ async fn order_confirmation(
|
|||||||
|
|
||||||
pub fn routes() -> Routes {
|
pub fn routes() -> Routes {
|
||||||
Routes::new()
|
Routes::new()
|
||||||
.add("/checkout", get(checkout_page))
|
.add("/checkout", get(checkout_redirect))
|
||||||
.add("/checkout", post(place_order))
|
.add("/checkout/info", get(info_page))
|
||||||
|
.add("/checkout/info", post(submit_info))
|
||||||
|
.add("/checkout/payment", get(payment_page))
|
||||||
|
.add("/checkout/payment", post(place_order))
|
||||||
.add("/orders/{order_number}", get(order_confirmation))
|
.add("/orders/{order_number}", get(order_confirmation))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ pub mod admin_dashboard;
|
|||||||
pub mod admin_discount_profiles;
|
pub mod admin_discount_profiles;
|
||||||
pub mod admin_form;
|
pub mod admin_form;
|
||||||
pub mod admin_orders;
|
pub mod admin_orders;
|
||||||
|
pub mod admin_payments;
|
||||||
pub mod admin_products;
|
pub mod admin_products;
|
||||||
pub mod admin_shipping;
|
pub mod admin_shipping;
|
||||||
pub mod cart;
|
pub mod cart;
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ use loco_rs::prelude::*;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
controllers::auth as auth_controller,
|
controllers::auth as auth_controller,
|
||||||
|
controllers::cart,
|
||||||
models::{o_auth2_sessions, users, users::OAuth2UserProfile},
|
models::{o_auth2_sessions, users, users::OAuth2UserProfile},
|
||||||
shared::guard,
|
shared::guard,
|
||||||
};
|
};
|
||||||
@@ -36,8 +37,9 @@ async fn complete(State(ctx): State<AppContext>, user: GoogleCookieUser) -> Resu
|
|||||||
} else {
|
} else {
|
||||||
"/"
|
"/"
|
||||||
};
|
};
|
||||||
|
let cart_cookie = cart::cart_cookie_for_user(&ctx, user.id).await?;
|
||||||
format::render()
|
format::render()
|
||||||
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration)])?
|
.cookies(&[auth_controller::auth_cookie(&token, jwt_secret.expiration), cart_cookie])?
|
||||||
.redirect(dest)
|
.redirect(dest)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -473,6 +473,7 @@ async fn show(
|
|||||||
"name": product.name,
|
"name": product.name,
|
||||||
"slug": product.slug,
|
"slug": product.slug,
|
||||||
"description": product.description,
|
"description": product.description,
|
||||||
|
"short_description": product.short_description,
|
||||||
"variant_count": 0,
|
"variant_count": 0,
|
||||||
"has_options": false,
|
"has_options": false,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -2,5 +2,6 @@ pub mod admin_seeder;
|
|||||||
pub mod currency_seeder;
|
pub mod currency_seeder;
|
||||||
pub mod oauth2;
|
pub mod oauth2;
|
||||||
pub mod oauth2_session;
|
pub mod oauth2_session;
|
||||||
|
pub mod payment_seeder;
|
||||||
pub mod shipping_seeder;
|
pub mod shipping_seeder;
|
||||||
pub mod view_engine;
|
pub mod view_engine;
|
||||||
|
|||||||
73
src/initializers/payment_seeder.rs
Normal file
73
src/initializers/payment_seeder.rs
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
//! Ensures built-in payment methods and editable bank-transfer settings exist.
|
||||||
|
//!
|
||||||
|
//! Payment method enabled flags and bank account details are admin-managed in the
|
||||||
|
//! database. We seed missing rows only, so admin changes persist across restarts.
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
models::{payment_methods, shop_settings},
|
||||||
|
shared::settings,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `(code, name, enabled, position)`
|
||||||
|
const METHODS: [(&str, &str, bool, i32); 2] = [
|
||||||
|
(payment_methods::COD, "Cash on delivery", true, 0),
|
||||||
|
(payment_methods::BANK_TRANSFER, "Bank transfer", true, 1),
|
||||||
|
];
|
||||||
|
|
||||||
|
pub struct PaymentSeeder;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl Initializer for PaymentSeeder {
|
||||||
|
fn name(&self) -> String {
|
||||||
|
"payment-seeder".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn before_run(&self, ctx: &AppContext) -> Result<()> {
|
||||||
|
for (code, name, enabled, position) in METHODS {
|
||||||
|
let exists = payment_methods::Entity::find()
|
||||||
|
.filter(payment_methods::Column::Code.eq(code))
|
||||||
|
.count(&ctx.db)
|
||||||
|
.await?
|
||||||
|
> 0;
|
||||||
|
if exists {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
payment_methods::ActiveModel {
|
||||||
|
code: Set(code.to_string()),
|
||||||
|
name: Set(name.to_string()),
|
||||||
|
enabled: Set(enabled),
|
||||||
|
position: Set(position),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
tracing::info!(payment = code, "seeded built-in payment method");
|
||||||
|
}
|
||||||
|
|
||||||
|
seed_setting(ctx, "bank_iban").await?;
|
||||||
|
seed_setting(ctx, "bank_account_name").await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn seed_setting(ctx: &AppContext, key: &str) -> Result<()> {
|
||||||
|
let exists = shop_settings::Entity::find()
|
||||||
|
.filter(shop_settings::Column::Key.eq(key))
|
||||||
|
.count(&ctx.db)
|
||||||
|
.await?
|
||||||
|
> 0;
|
||||||
|
if exists {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
shop_settings::ActiveModel {
|
||||||
|
key: Set(key.to_string()),
|
||||||
|
value: Set(settings::get(ctx, key).map(str::to_string)),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ use async_trait::async_trait;
|
|||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, PaginatorTrait, QueryFilter, Set};
|
||||||
|
|
||||||
use crate::models::shipping_methods;
|
use crate::{models::shipping_methods, shared::shipping as shipping_rules};
|
||||||
|
|
||||||
/// `(code, name, carrier, requires_pickup_point, default_price_cents, position)`
|
/// `(code, name, carrier, requires_pickup_point, default_price_cents, position)`
|
||||||
const BUILTINS: [(&str, &str, &str, bool, i64, i32); 2] = [
|
const BUILTINS: [(&str, &str, &str, bool, i64, i32); 2] = [
|
||||||
@@ -49,6 +49,6 @@ impl Initializer for ShippingSeeder {
|
|||||||
.await?;
|
.await?;
|
||||||
tracing::info!(carrier = code, "seeded built-in delivery option");
|
tracing::info!(carrier = code, "seeded built-in delivery option");
|
||||||
}
|
}
|
||||||
Ok(())
|
shipping_rules::disable_packeta_if_unconfigured(ctx).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
48
src/models/_entities/account_cart_items.rs
Normal file
48
src/models/_entities/account_cart_items.rs
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "account_cart_items")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
pub variant_id: i32,
|
||||||
|
pub quantity: i32,
|
||||||
|
pub user_id: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::product_variants::Entity",
|
||||||
|
from = "Column::VariantId",
|
||||||
|
to = "super::product_variants::Column::Id",
|
||||||
|
on_update = "NoAction",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
ProductVariants,
|
||||||
|
#[sea_orm(
|
||||||
|
belongs_to = "super::users::Entity",
|
||||||
|
from = "Column::UserId",
|
||||||
|
to = "super::users::Column::Id",
|
||||||
|
on_update = "Cascade",
|
||||||
|
on_delete = "Cascade"
|
||||||
|
)]
|
||||||
|
Users,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::product_variants::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::ProductVariants.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Related<super::users::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::Users.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
pub mod prelude;
|
pub mod prelude;
|
||||||
|
|
||||||
|
pub mod account_cart_items;
|
||||||
pub mod account_discount_profiles;
|
pub mod account_discount_profiles;
|
||||||
pub mod account_product_prices;
|
pub mod account_product_prices;
|
||||||
pub mod account_product_resolutions;
|
pub mod account_product_resolutions;
|
||||||
@@ -15,10 +16,12 @@ pub mod discount_profiles;
|
|||||||
pub mod o_auth2_sessions;
|
pub mod o_auth2_sessions;
|
||||||
pub mod order_items;
|
pub mod order_items;
|
||||||
pub mod orders;
|
pub mod orders;
|
||||||
|
pub mod payment_methods;
|
||||||
pub mod product_images;
|
pub mod product_images;
|
||||||
pub mod product_product_tags;
|
pub mod product_product_tags;
|
||||||
pub mod product_tags;
|
pub mod product_tags;
|
||||||
pub mod product_variants;
|
pub mod product_variants;
|
||||||
pub mod products;
|
pub mod products;
|
||||||
pub mod shipping_methods;
|
pub mod shipping_methods;
|
||||||
|
pub mod shop_settings;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ pub struct Model {
|
|||||||
pub tax_id: Option<String>,
|
pub tax_id: Option<String>,
|
||||||
pub vat_id: Option<String>,
|
pub vat_id: Option<String>,
|
||||||
pub user_id: Option<i32>,
|
pub user_id: Option<i32>,
|
||||||
|
pub residence_address: Option<String>,
|
||||||
|
pub residence_city: Option<String>,
|
||||||
|
pub residence_zip: Option<String>,
|
||||||
|
pub residence_country: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
|||||||
21
src/models/_entities/payment_methods.rs
Normal file
21
src/models/_entities/payment_methods.rs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "payment_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 enabled: bool,
|
||||||
|
pub position: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
|
||||||
|
|
||||||
|
pub use super::account_cart_items::Entity as AccountCartItems;
|
||||||
pub use super::account_discount_profiles::Entity as AccountDiscountProfiles;
|
pub use super::account_discount_profiles::Entity as AccountDiscountProfiles;
|
||||||
pub use super::account_product_prices::Entity as AccountProductPrices;
|
pub use super::account_product_prices::Entity as AccountProductPrices;
|
||||||
pub use super::account_product_resolutions::Entity as AccountProductResolutions;
|
pub use super::account_product_resolutions::Entity as AccountProductResolutions;
|
||||||
@@ -13,10 +14,12 @@ pub use super::discount_profiles::Entity as DiscountProfiles;
|
|||||||
pub use super::o_auth2_sessions::Entity as OAuth2Sessions;
|
pub use super::o_auth2_sessions::Entity as OAuth2Sessions;
|
||||||
pub use super::order_items::Entity as OrderItems;
|
pub use super::order_items::Entity as OrderItems;
|
||||||
pub use super::orders::Entity as Orders;
|
pub use super::orders::Entity as Orders;
|
||||||
|
pub use super::payment_methods::Entity as PaymentMethods;
|
||||||
pub use super::product_images::Entity as ProductImages;
|
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::product_variants::Entity as ProductVariants;
|
pub use super::product_variants::Entity as ProductVariants;
|
||||||
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::shipping_methods::Entity as ShippingMethods;
|
||||||
|
pub use super::shop_settings::Entity as ShopSettings;
|
||||||
pub use super::users::Entity as Users;
|
pub use super::users::Entity as Users;
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ pub struct Model {
|
|||||||
|
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
pub enum Relation {
|
pub enum Relation {
|
||||||
|
#[sea_orm(has_many = "super::account_cart_items::Entity")]
|
||||||
|
AccountCartItems,
|
||||||
#[sea_orm(has_many = "super::account_product_prices::Entity")]
|
#[sea_orm(has_many = "super::account_product_prices::Entity")]
|
||||||
AccountProductPrices,
|
AccountProductPrices,
|
||||||
#[sea_orm(has_many = "super::account_product_resolutions::Entity")]
|
#[sea_orm(has_many = "super::account_product_resolutions::Entity")]
|
||||||
@@ -38,6 +40,12 @@ pub enum Relation {
|
|||||||
Products,
|
Products,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Related<super::account_cart_items::Entity> for Entity {
|
||||||
|
fn to() -> RelationDef {
|
||||||
|
Relation::AccountCartItems.def()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl Related<super::account_product_prices::Entity> for Entity {
|
impl Related<super::account_product_prices::Entity> for Entity {
|
||||||
fn to() -> RelationDef {
|
fn to() -> RelationDef {
|
||||||
Relation::AccountProductPrices.def()
|
Relation::AccountProductPrices.def()
|
||||||
|
|||||||
20
src/models/_entities/shop_settings.rs
Normal file
20
src/models/_entities/shop_settings.rs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.19
|
||||||
|
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize)]
|
||||||
|
#[sea_orm(table_name = "shop_settings")]
|
||||||
|
pub struct Model {
|
||||||
|
pub created_at: DateTimeWithTimeZone,
|
||||||
|
pub updated_at: DateTimeWithTimeZone,
|
||||||
|
#[sea_orm(primary_key)]
|
||||||
|
pub id: i32,
|
||||||
|
#[sea_orm(unique)]
|
||||||
|
pub key: String,
|
||||||
|
#[sea_orm(column_type = "Text", nullable)]
|
||||||
|
pub value: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
|
||||||
|
pub enum Relation {}
|
||||||
55
src/models/account_cart_items.rs
Normal file
55
src/models/account_cart_items.rs
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
pub use crate::models::_entities::account_cart_items::{ActiveModel, Column, Entity, Model};
|
||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use sea_orm::{ActiveValue, QueryFilter, QueryOrder};
|
||||||
|
|
||||||
|
pub type AccountCartItems = 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,
|
||||||
|
{
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Model {
|
||||||
|
pub async fn find_for_user(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
user_id: i32,
|
||||||
|
) -> Result<Vec<(i32, i32)>, DbErr> {
|
||||||
|
Ok(Entity::find()
|
||||||
|
.filter(Column::UserId.eq(user_id))
|
||||||
|
.order_by_asc(Column::Id)
|
||||||
|
.all(db)
|
||||||
|
.await?
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|item| (item.quantity > 0).then_some((item.variant_id, item.quantity)))
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn replace_for_user(
|
||||||
|
db: &DatabaseConnection,
|
||||||
|
user_id: i32,
|
||||||
|
items: &[(i32, i32)],
|
||||||
|
) -> Result<(), DbErr> {
|
||||||
|
Entity::delete_many()
|
||||||
|
.filter(Column::UserId.eq(user_id))
|
||||||
|
.exec(db)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
for (variant_id, quantity) in items.iter().copied().filter(|(_, qty)| *qty > 0) {
|
||||||
|
ActiveModel {
|
||||||
|
user_id: ActiveValue::set(user_id),
|
||||||
|
variant_id: ActiveValue::set(variant_id),
|
||||||
|
quantity: ActiveValue::set(quantity),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(db)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
pub mod _entities;
|
pub mod _entities;
|
||||||
|
|
||||||
|
pub mod account_cart_items;
|
||||||
pub mod account_discount_profiles;
|
pub mod account_discount_profiles;
|
||||||
pub mod account_product_prices;
|
pub mod account_product_prices;
|
||||||
pub mod account_product_resolutions;
|
pub mod account_product_resolutions;
|
||||||
@@ -19,10 +20,12 @@ pub mod customer_profiles;
|
|||||||
pub mod o_auth2_sessions;
|
pub mod o_auth2_sessions;
|
||||||
pub mod order_items;
|
pub mod order_items;
|
||||||
pub mod orders;
|
pub mod orders;
|
||||||
|
pub mod payment_methods;
|
||||||
pub mod product_images;
|
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 shipping_methods;
|
||||||
|
pub mod shop_settings;
|
||||||
pub mod users;
|
pub mod users;
|
||||||
pub mod product_variants;
|
pub mod product_variants;
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ pub struct Checkout {
|
|||||||
pub company_id: Option<String>,
|
pub company_id: Option<String>,
|
||||||
pub tax_id: Option<String>,
|
pub tax_id: Option<String>,
|
||||||
pub vat_id: Option<String>,
|
pub vat_id: Option<String>,
|
||||||
|
pub residence_address: Option<String>,
|
||||||
|
pub residence_city: Option<String>,
|
||||||
|
pub residence_zip: Option<String>,
|
||||||
|
pub residence_country: Option<String>,
|
||||||
pub address: Option<String>,
|
pub address: Option<String>,
|
||||||
pub city: Option<String>,
|
pub city: Option<String>,
|
||||||
pub zip: Option<String>,
|
pub zip: Option<String>,
|
||||||
@@ -50,6 +54,23 @@ pub async fn place(
|
|||||||
details: Checkout,
|
details: Checkout,
|
||||||
user: Option<&users::Model>,
|
user: Option<&users::Model>,
|
||||||
) -> Result<Model> {
|
) -> Result<Model> {
|
||||||
|
// Resolve the price of every line *before* opening the transaction. Pricing
|
||||||
|
// loads its context from the connection pool; doing it while the order
|
||||||
|
// transaction holds a connection would acquire a second one and can exhaust
|
||||||
|
// the pool (it times out under contention or a small pool). Prices are stable
|
||||||
|
// within a request, so this snapshot is what we charge; stock is still
|
||||||
|
// re-validated against the transaction below.
|
||||||
|
let line_variants = product_variants::Entity::find()
|
||||||
|
.filter(product_variants::Column::Id.is_in(items.iter().map(|(id, _)| *id)))
|
||||||
|
.all(&ctx.db)
|
||||||
|
.await?;
|
||||||
|
let priced = pricing::price_variants(ctx, &line_variants, user).await?;
|
||||||
|
let price_by_variant: std::collections::HashMap<i32, i64> = line_variants
|
||||||
|
.iter()
|
||||||
|
.zip(priced)
|
||||||
|
.map(|(v, p)| (v.id, p.price_cents))
|
||||||
|
.collect();
|
||||||
|
|
||||||
let txn = ctx.db.begin().await?;
|
let txn = ctx.db.begin().await?;
|
||||||
|
|
||||||
let mut subtotal: i64 = 0;
|
let mut subtotal: i64 = 0;
|
||||||
@@ -74,10 +95,12 @@ pub async fn place(
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Snapshot the price the buyer actually pays — public sale or, for a
|
// The price the buyer actually pays — public sale or, for a business
|
||||||
// business account, their negotiated/lowest price (same resolver the
|
// account, their negotiated/lowest price (resolved above, outside the
|
||||||
// cart and storefront use).
|
// transaction, with the same resolver the cart and storefront use).
|
||||||
let unit_price_cents = pricing::price_variant(ctx, &variant, user).await?.price_cents;
|
let unit_price_cents = *price_by_variant
|
||||||
|
.get(&variant.id)
|
||||||
|
.ok_or_else(|| Error::BadRequest("an item is no longer available".to_string()))?;
|
||||||
subtotal += unit_price_cents * i64::from(*qty);
|
subtotal += unit_price_cents * i64::from(*qty);
|
||||||
|
|
||||||
if let Some(on_hand) = variant.stock {
|
if let Some(on_hand) = variant.stock {
|
||||||
@@ -102,6 +125,10 @@ pub async fn place(
|
|||||||
company_id: Set(details.company_id),
|
company_id: Set(details.company_id),
|
||||||
tax_id: Set(details.tax_id),
|
tax_id: Set(details.tax_id),
|
||||||
vat_id: Set(details.vat_id),
|
vat_id: Set(details.vat_id),
|
||||||
|
residence_address: Set(details.residence_address),
|
||||||
|
residence_city: Set(details.residence_city),
|
||||||
|
residence_zip: Set(details.residence_zip),
|
||||||
|
residence_country: Set(details.residence_country),
|
||||||
address: Set(details.address),
|
address: Set(details.address),
|
||||||
city: Set(details.city),
|
city: Set(details.city),
|
||||||
zip: Set(details.zip),
|
zip: Set(details.zip),
|
||||||
|
|||||||
54
src/models/payment_methods.rs
Normal file
54
src/models/payment_methods.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use sea_orm::{ActiveValue, ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
|
||||||
|
|
||||||
|
pub use crate::models::_entities::payment_methods::{ActiveModel, Column, Entity, Model};
|
||||||
|
pub type PaymentMethods = Entity;
|
||||||
|
|
||||||
|
pub const COD: &str = "cod";
|
||||||
|
pub const BANK_TRANSFER: &str = "bank_transfer";
|
||||||
|
|
||||||
|
#[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 = ActiveValue::set(chrono::Utc::now().into());
|
||||||
|
Ok(this)
|
||||||
|
} else {
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Entity {
|
||||||
|
pub async fn enabled<C: ConnectionTrait>(db: &C) -> Result<Vec<Model>, DbErr> {
|
||||||
|
Entity::find()
|
||||||
|
.filter(Column::Enabled.eq(true))
|
||||||
|
.order_by_asc(Column::Position)
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn find_enabled<C: ConnectionTrait>(db: &C, code: &str) -> Result<Option<Model>, DbErr> {
|
||||||
|
Entity::find()
|
||||||
|
.filter(Column::Code.eq(code))
|
||||||
|
.filter(Column::Enabled.eq(true))
|
||||||
|
.one(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Model {
|
||||||
|
pub fn label_key(&self) -> &'static str {
|
||||||
|
match self.code.as_str() {
|
||||||
|
COD => "payment-cod",
|
||||||
|
BANK_TRANSFER => "payment-bank",
|
||||||
|
_ => "payment-custom",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ActiveModel {}
|
||||||
47
src/models/shop_settings.rs
Normal file
47
src/models/shop_settings.rs
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
use sea_orm::entity::prelude::*;
|
||||||
|
use sea_orm::{ActiveValue, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, TryIntoModel};
|
||||||
|
|
||||||
|
pub use crate::models::_entities::shop_settings::{ActiveModel, Column, Entity, Model};
|
||||||
|
pub type ShopSettings = 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 = ActiveValue::set(chrono::Utc::now().into());
|
||||||
|
Ok(this)
|
||||||
|
} else {
|
||||||
|
Ok(self)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Entity {
|
||||||
|
pub async fn get<C: ConnectionTrait>(db: &C, key: &str) -> Result<Option<String>, DbErr> {
|
||||||
|
Ok(Entity::find()
|
||||||
|
.filter(Column::Key.eq(key))
|
||||||
|
.one(db)
|
||||||
|
.await?
|
||||||
|
.and_then(|setting| setting.value))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set<C: ConnectionTrait>(db: &C, key: &str, value: Option<String>) -> Result<Model, DbErr> {
|
||||||
|
let mut active = match Entity::find()
|
||||||
|
.filter(Column::Key.eq(key))
|
||||||
|
.one(db)
|
||||||
|
.await?
|
||||||
|
{
|
||||||
|
Some(existing) => existing.into_active_model(),
|
||||||
|
None => ActiveModel {
|
||||||
|
key: ActiveValue::set(key.to_string()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
};
|
||||||
|
active.value = ActiveValue::set(value);
|
||||||
|
active.save(db).await?.try_into_model()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,4 +7,5 @@ pub mod money;
|
|||||||
pub mod pricing;
|
pub mod pricing;
|
||||||
pub mod rbac;
|
pub mod rbac;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
|
pub mod shipping;
|
||||||
pub mod slug;
|
pub mod slug;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
|
|
||||||
|
use crate::models::shop_settings;
|
||||||
|
|
||||||
/// Look up a string-valued `settings.<key>` entry, returning `None` if config
|
/// Look up a string-valued `settings.<key>` entry, returning `None` if config
|
||||||
/// has no settings map, the key is missing, or the value is not a string.
|
/// has no settings map, the key is missing, or the value is not a string.
|
||||||
pub fn get<'a>(ctx: &'a AppContext, key: &str) -> Option<&'a str> {
|
pub fn get<'a>(ctx: &'a AppContext, key: &str) -> Option<&'a str> {
|
||||||
@@ -11,3 +13,20 @@ pub fn get<'a>(ctx: &'a AppContext, key: &str) -> Option<&'a str> {
|
|||||||
.and_then(|settings| settings.get(key))
|
.and_then(|settings| settings.get(key))
|
||||||
.and_then(|value| value.as_str())
|
.and_then(|value| value.as_str())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Look up an admin-editable setting in the database, falling back to config when
|
||||||
|
/// the row is missing. Empty DB values are returned as-is so admins can clear a
|
||||||
|
/// setting deliberately.
|
||||||
|
pub async fn get_editable(ctx: &AppContext, key: &str) -> Result<String> {
|
||||||
|
Ok(match shop_settings::Entity::get(&ctx.db, key).await? {
|
||||||
|
Some(value) => value,
|
||||||
|
None => get(ctx, key).unwrap_or("").to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn bank_details(ctx: &AppContext) -> Result<(String, String)> {
|
||||||
|
Ok((
|
||||||
|
get_editable(ctx, "bank_iban").await?,
|
||||||
|
get_editable(ctx, "bank_account_name").await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|||||||
31
src/shared/shipping.rs
Normal file
31
src/shared/shipping.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use loco_rs::prelude::*;
|
||||||
|
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, IntoActiveModel, QueryFilter, Set};
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
models::shipping_methods,
|
||||||
|
shared::settings,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn packeta_ready(ctx: &AppContext) -> bool {
|
||||||
|
["packeta_api_key", "packeta_api_password", "packeta_sender_label"]
|
||||||
|
.iter()
|
||||||
|
.all(|key| settings::get(ctx, key).is_some_and(|value| !value.trim().is_empty()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn disable_packeta_if_unconfigured(ctx: &AppContext) -> Result<()> {
|
||||||
|
if packeta_ready(ctx) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let Some(method) = shipping_methods::Entity::find()
|
||||||
|
.filter(shipping_methods::Column::Carrier.eq("packeta"))
|
||||||
|
.filter(shipping_methods::Column::Enabled.eq(true))
|
||||||
|
.one(&ctx.db)
|
||||||
|
.await?
|
||||||
|
else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
let mut active = method.into_active_model();
|
||||||
|
active.enabled = Set(false);
|
||||||
|
active.update(&ctx.db).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -40,6 +40,10 @@ pub fn detail(order: &orders::Model, bank_iban: &str, bank_account_name: &str) -
|
|||||||
"subtotal": format_price(order.total_cents - order.shipping_cents),
|
"subtotal": format_price(order.total_cents - order.shipping_cents),
|
||||||
"shipping": format_price(order.shipping_cents),
|
"shipping": format_price(order.shipping_cents),
|
||||||
"total": format_price(order.total_cents),
|
"total": format_price(order.total_cents),
|
||||||
|
"residence_address": order.residence_address,
|
||||||
|
"residence_city": order.residence_city,
|
||||||
|
"residence_zip": order.residence_zip,
|
||||||
|
"residence_country": order.residence_country,
|
||||||
"address": order.address,
|
"address": order.address,
|
||||||
"city": order.city,
|
"city": order.city,
|
||||||
"zip": order.zip,
|
"zip": order.zip,
|
||||||
|
|||||||
Reference in New Issue
Block a user