Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1168da8f11 | ||
|
|
1bde553f79 |
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" }
|
||||||
|
|
||||||
|
|||||||
@@ -350,6 +350,10 @@ 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 = Delivery address
|
checkout-shipping = Delivery address
|
||||||
checkout-residence-address = Residence address
|
checkout-residence-address = Residence address
|
||||||
@@ -380,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.
|
||||||
|
|||||||
@@ -350,6 +350,10 @@ 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-residence-address = Adresa bydliska
|
||||||
@@ -380,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.
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
|||||||
@@ -3,32 +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') }}',
|
||||||
deliverySame: false,
|
deliverySame: {{ prefill_delivery_same | default(value='false') }}
|
||||||
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">
|
class="mt-6 grid gap-8 lg:grid-cols-3">
|
||||||
{{ ui::csrf_field() }}
|
{{ ui::csrf_field() }}
|
||||||
@@ -184,21 +169,21 @@
|
|||||||
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</legend>
|
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</legend>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
<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="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", autocomplete="shipping street-address", attrs=':required="!deliverySame"') }}
|
{{ 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">
|
<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="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", autocomplete="shipping address-level2", attrs=':required="!deliverySame"') }}
|
{{ ui::input(name="city", id="city", value=prefill_delivery_city | default(value=''), autocomplete="shipping address-level2", attrs=':required="!deliverySame"') }}
|
||||||
</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="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", autocomplete="shipping postal-code", attrs=':required="!deliverySame"') }}
|
{{ ui::input(name="zip", id="zip", value=prefill_delivery_zip | default(value=''), autocomplete="shipping postal-code", attrs=':required="!deliverySame"') }}
|
||||||
</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="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: '{{ t(key='country-sk', lang=lang | default(value='sk')) }}', opts: [
|
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-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')) }}' },
|
||||||
@@ -226,76 +211,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<!-- carrier -->
|
|
||||||
<fieldset class="space-y-3 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
|
||||||
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-carrier", lang=lang | default(value='sk')) }}{{ 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 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>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- summary -->
|
<!-- summary -->
|
||||||
@@ -309,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 %}
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
//! 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::CookieJar;
|
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 crate::{
|
use crate::{
|
||||||
controllers::cart::{self, resolve_cart},
|
controllers::cart::{self, resolve_cart},
|
||||||
mailers::auth::AuthMailer,
|
mailers::auth::AuthMailer,
|
||||||
@@ -20,8 +28,37 @@ use crate::{
|
|||||||
views::checkout as view,
|
views::checkout as view,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
@@ -40,6 +77,11 @@ struct CheckoutForm {
|
|||||||
city: Option<String>,
|
city: Option<String>,
|
||||||
zip: Option<String>,
|
zip: Option<String>,
|
||||||
country: 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,
|
||||||
@@ -56,6 +98,33 @@ fn trimmed(value: &str) -> Option<String> {
|
|||||||
(!value.is_empty()).then(|| value.to_string())
|
(!value.is_empty()).then(|| value.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn info_cookie(value: String) -> Cookie<'static> {
|
||||||
|
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("/")
|
||||||
|
.same_site(SameSite::Lax)
|
||||||
|
.max_age(TimeDuration::seconds(0))
|
||||||
|
.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?;
|
shipping_rules::disable_packeta_if_unconfigured(ctx).await?;
|
||||||
let packeta_ready = shipping_rules::packeta_ready(ctx);
|
let packeta_ready = shipping_rules::packeta_ready(ctx);
|
||||||
@@ -73,8 +142,17 @@ async fn enabled_payment_methods(ctx: &AppContext) -> Result<Vec<payment_methods
|
|||||||
Ok(payment_methods::Entity::enabled(&ctx.db).await?)
|
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>,
|
||||||
@@ -86,6 +164,180 @@ async fn checkout_page(
|
|||||||
return format::redirect("/cart");
|
return format::redirect("/cart");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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).
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
let p = |get: fn(&customer_profiles::Model) -> Option<String>| {
|
||||||
|
profile.as_ref().and_then(get)
|
||||||
|
};
|
||||||
|
|
||||||
|
// A previously entered info step (back navigation from the payment page)
|
||||||
|
// takes precedence over the profile defaults.
|
||||||
|
let saved = decode_info(&jar);
|
||||||
|
let s = |get: fn(&CheckoutInfo) -> String| saved.as_ref().map(get);
|
||||||
|
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(
|
||||||
|
&v,
|
||||||
|
"shop/checkout_info.html",
|
||||||
|
json!({
|
||||||
|
"items": lines,
|
||||||
|
"subtotal": format_price(subtotal),
|
||||||
|
"subtotal_cents": subtotal,
|
||||||
|
"logged_in_admin": is_admin,
|
||||||
|
"logged_in_customer": is_customer,
|
||||||
|
// Required by the navbar profile menu (base.html includes it whenever
|
||||||
|
// logged_in_customer is true); None for admins/guests.
|
||||||
|
"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()),
|
||||||
|
// A logged-in customer's account type is fixed; only guests pick it.
|
||||||
|
"account_fixed": is_customer,
|
||||||
|
"prefill_email": s(|x| x.email.clone()).or_else(|| 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_account_type": prefill_account_type,
|
||||||
|
"prefill_company_name": s_opt(|x| x.company_name.clone()).or_else(|| 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_tax_id": s_opt(|x| x.tax_id.clone()).or_else(|| 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_phone_prefix": s(|x| x.phone_prefix.clone()).or_else(|| p(|x| x.phone_prefix.clone())),
|
||||||
|
"prefill_phone": s(|x| x.phone.clone()).or_else(|| p(|x| x.phone.clone())),
|
||||||
|
"prefill_residence_address": s(|x| x.residence_address.clone()).or_else(|| p(|x| x.address.clone())),
|
||||||
|
"prefill_residence_city": s(|x| x.residence_city.clone()).or_else(|| p(|x| x.city.clone())),
|
||||||
|
"prefill_residence_zip": s(|x| x.residence_zip.clone()).or_else(|| p(|x| x.zip.clone())),
|
||||||
|
"prefill_residence_country": s(|x| x.residence_country.clone()).or_else(|| 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),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate step 1, stash it in the `checkout_info` cookie and advance to the
|
||||||
|
/// payment step.
|
||||||
|
#[debug_handler]
|
||||||
|
async fn submit_info(
|
||||||
|
jar: CookieJar,
|
||||||
|
State(ctx): State<AppContext>,
|
||||||
|
Form(form): Form<InfoForm>,
|
||||||
|
) -> Result<Response> {
|
||||||
|
let (_lines, valid, _total) = resolve_cart(&ctx, &jar, &Currency::eur()).await?;
|
||||||
|
if valid.is_empty() {
|
||||||
|
return format::redirect("/cart");
|
||||||
|
}
|
||||||
|
|
||||||
|
let require = |value: &str, field: &str| -> Result<String> {
|
||||||
|
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 residence_address = require(&form.residence_address, "residence address")?;
|
||||||
|
let residence_city = require(&form.residence_city, "residence city")?;
|
||||||
|
let residence_zip = require(&form.residence_zip, "residence zip")?;
|
||||||
|
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
|
||||||
|
// account, never the form); a guest picks it on the form. Admins are guests.
|
||||||
|
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 => normalize_account_type(form.account_type.as_deref()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Company purchases must carry the invoicing identifiers (IČO + DIČ
|
||||||
|
// required, IČ DPH optional). Personal orders carry none.
|
||||||
|
let (company_name, company_id, tax_id, vat_id) = if account_type == "company" {
|
||||||
|
(
|
||||||
|
Some(require(form.company_name.as_deref().unwrap_or(""), "company name")?),
|
||||||
|
Some(require(form.company_id.as_deref().unwrap_or(""), "IČO")?),
|
||||||
|
Some(require(form.tax_id.as_deref().unwrap_or(""), "DIČ")?),
|
||||||
|
form.vat_id.as_deref().and_then(trimmed),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
(None, None, None, None)
|
||||||
|
};
|
||||||
|
|
||||||
|
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)
|
let methods: Vec<serde_json::Value> = enabled_shipping_methods(&ctx)
|
||||||
.await?
|
.await?
|
||||||
.iter()
|
.iter()
|
||||||
@@ -110,8 +362,6 @@ async fn checkout_page(
|
|||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
// 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).
|
|
||||||
let user = guard::current_user(&ctx, &jar).await;
|
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_admin = user.as_ref().is_some_and(|u| guard::is_admin(&ctx, u));
|
||||||
let is_customer = user.is_some() && !is_admin;
|
let is_customer = user.is_some() && !is_admin;
|
||||||
@@ -119,19 +369,15 @@ async fn checkout_page(
|
|||||||
(Some(u), true) => customer_profiles::Model::find_for_user(&ctx.db, u.id).await?,
|
(Some(u), true) => customer_profiles::Model::find_for_user(&ctx.db, u.id).await?,
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
let p = |get: fn(&customer_profiles::Model) -> Option<String>| {
|
|
||||||
profile.as_ref().and_then(get)
|
|
||||||
};
|
|
||||||
// Whether the customer already has a residence address on file. When they do,
|
// Whether the customer already has a residence address on file. When they do,
|
||||||
// the "save this address to my profile" opt-in is pointless (the profile was
|
// the "save this address to my profile" opt-in is pointless, so it's hidden.
|
||||||
// filled in advance), so it's hidden and the existing profile is left alone.
|
|
||||||
let profile_filled = profile
|
let profile_filled = profile
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|pr| pr.address.is_some() && pr.city.is_some() && pr.zip.is_some());
|
.is_some_and(|pr| pr.address.is_some() && pr.city.is_some() && pr.zip.is_some());
|
||||||
|
|
||||||
format::view(
|
format::view(
|
||||||
&v,
|
&v,
|
||||||
"shop/checkout.html",
|
"shop/checkout_payment.html",
|
||||||
json!({
|
json!({
|
||||||
"items": lines,
|
"items": lines,
|
||||||
"subtotal": format_price(subtotal),
|
"subtotal": format_price(subtotal),
|
||||||
@@ -141,29 +387,11 @@ async fn checkout_page(
|
|||||||
"packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""),
|
"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
|
|
||||||
// logged_in_customer is true); None for admins/guests.
|
|
||||||
"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,
|
"profile_filled": profile_filled,
|
||||||
// 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,
|
|
||||||
"can_create_account": user.is_none(),
|
"can_create_account": user.is_none(),
|
||||||
"prefill_email": user.as_ref().filter(|_| is_customer).map(|u| u.email.clone()),
|
|
||||||
"prefill_name": user.as_ref().filter(|_| is_customer).map(|u| u.name.clone()),
|
|
||||||
"prefill_account_type": user.as_ref().filter(|_| is_customer).map_or("personal", |u| u.account_type.as_str()),
|
|
||||||
"prefill_company_name": p(|x| x.company_name.clone()),
|
|
||||||
"prefill_company_id": p(|x| x.company_id.clone()),
|
|
||||||
"prefill_tax_id": p(|x| x.tax_id.clone()),
|
|
||||||
"prefill_vat_id": p(|x| x.vat_id.clone()),
|
|
||||||
"prefill_phone_prefix": p(|x| x.phone_prefix.clone()),
|
|
||||||
"prefill_phone": p(|x| x.phone.clone()),
|
|
||||||
"prefill_residence_address": p(|x| x.address.clone()),
|
|
||||||
"prefill_residence_city": p(|x| x.city.clone()),
|
|
||||||
"prefill_residence_zip": p(|x| x.zip.clone()),
|
|
||||||
"prefill_residence_country": p(|x| x.country.clone()),
|
|
||||||
"lang": current_lang(&jar),
|
"lang": current_lang(&jar),
|
||||||
}),
|
}),
|
||||||
)
|
)
|
||||||
@@ -173,75 +401,41 @@ async fn checkout_page(
|
|||||||
async fn place_order(
|
async fn place_order(
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
State(ctx): State<AppContext>,
|
State(ctx): State<AppContext>,
|
||||||
Form(form): Form<CheckoutForm>,
|
Form(form): Form<PaymentForm>,
|
||||||
) -> 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 =
|
let Some(info) = decode_info(&jar) else {
|
||||||
trimmed(&form.email).ok_or_else(|| Error::BadRequest("email is required".to_string()))?;
|
return format::redirect("/checkout/info");
|
||||||
// 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 residence-address fields are mandatory (also enforced in the
|
let email = info.email.clone();
|
||||||
// browser via `required`).
|
let customer_name = info.customer_name.clone();
|
||||||
let require = |value: &str, field: &str| -> Result<String> {
|
let number = info.phone.clone();
|
||||||
trimmed(value).ok_or_else(|| Error::BadRequest(format!("{field} is required")))
|
// Combine the dialling-code prefix with the local number into one E.164-ish
|
||||||
};
|
// value (e.g. "+421 900123456").
|
||||||
let require_opt = |value: Option<&str>, field: &str| -> Result<String> {
|
let phone = if info.phone_prefix.is_empty() {
|
||||||
value
|
number.clone()
|
||||||
.and_then(trimmed)
|
|
||||||
.ok_or_else(|| Error::BadRequest(format!("{field} is required")))
|
|
||||||
};
|
|
||||||
let customer_name = require(&form.customer_name, "name")?;
|
|
||||||
let residence_address = require(&form.residence_address, "residence address")?;
|
|
||||||
let residence_city = require(&form.residence_city, "residence city")?;
|
|
||||||
let residence_zip = require(&form.residence_zip, "residence zip")?;
|
|
||||||
let residence_country = require(&form.residence_country, "residence country")?;
|
|
||||||
let same_address = form.delivery_same_as_residence.is_some();
|
|
||||||
let (address, city, zip, country) = if same_address {
|
|
||||||
(
|
|
||||||
residence_address.clone(),
|
|
||||||
residence_city.clone(),
|
|
||||||
residence_zip.clone(),
|
|
||||||
residence_country.clone(),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
(
|
format!("{} {}", info.phone_prefix, number)
|
||||||
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 cookie); a guest's choice rides in the info cookie.
|
||||||
// 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 => info.account_type.clone(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Company purchases must carry the invoicing identifiers (IČO + DIČ
|
|
||||||
// required, IČ DPH optional). Personal orders carry none.
|
|
||||||
let (company_name, company_id, tax_id, vat_id) = if account_type == "company" {
|
let (company_name, company_id, tax_id, vat_id) = if account_type == "company" {
|
||||||
(
|
(
|
||||||
Some(require(form.company_name.as_deref().unwrap_or(""), "company name")?),
|
info.company_name.clone(),
|
||||||
Some(require(form.company_id.as_deref().unwrap_or(""), "IČO")?),
|
info.company_id.clone(),
|
||||||
Some(require(form.tax_id.as_deref().unwrap_or(""), "DIČ")?),
|
info.tax_id.clone(),
|
||||||
form.vat_id.as_deref().and_then(trimmed),
|
info.vat_id.clone(),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
(None, None, None, None)
|
(None, None, None, None)
|
||||||
@@ -274,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(residence_address.clone()),
|
address: Some(info.residence_address.clone()),
|
||||||
city: Some(residence_city.clone()),
|
city: Some(info.residence_city.clone()),
|
||||||
zip: Some(residence_zip.clone()),
|
zip: Some(info.residence_zip.clone()),
|
||||||
country: Some(residence_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
|
||||||
@@ -355,14 +549,14 @@ async fn place_order(
|
|||||||
company_id,
|
company_id,
|
||||||
tax_id,
|
tax_id,
|
||||||
vat_id,
|
vat_id,
|
||||||
residence_address: Some(residence_address),
|
residence_address: Some(info.residence_address.clone()),
|
||||||
residence_city: Some(residence_city),
|
residence_city: Some(info.residence_city.clone()),
|
||||||
residence_zip: Some(residence_zip),
|
residence_zip: Some(info.residence_zip.clone()),
|
||||||
residence_country: Some(residence_country),
|
residence_country: Some(info.residence_country.clone()),
|
||||||
address: Some(address),
|
address: Some(info.address.clone()),
|
||||||
city: Some(city),
|
city: Some(info.city.clone()),
|
||||||
zip: Some(zip),
|
zip: Some(info.zip.clone()),
|
||||||
country: Some(country),
|
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,
|
||||||
@@ -382,7 +576,7 @@ async fn place_order(
|
|||||||
cart::clear_account_cart(&ctx, user.id).await?;
|
cart::clear_account_cart(&ctx, user.id).await?;
|
||||||
}
|
}
|
||||||
format::render()
|
format::render()
|
||||||
.cookies(&[cart::cleared_cart_cookie()])?
|
.cookies(&[cart::cleared_cart_cookie(), cleared_info_cookie()])?
|
||||||
.redirect(&target)
|
.redirect(&target)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -430,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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -54,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;
|
||||||
@@ -78,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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user