Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1168da8f11 | ||
|
|
1bde553f79 | ||
|
|
e5c84e631f | ||
|
|
0f3189ca26 | ||
|
|
f4c66936c0 | ||
|
|
4a5e0404c7 | ||
|
|
80f3e7d48e | ||
|
|
97c4c23af1 |
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -2646,6 +2646,7 @@ dependencies = [
|
||||
"axum",
|
||||
"axum-casbin",
|
||||
"axum-extra 0.10.3",
|
||||
"base64",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dotenvy",
|
||||
|
||||
@@ -56,6 +56,8 @@ hmac = { version = "0.12" }
|
||||
sha2 = { version = "0.10" }
|
||||
subtle = { version = "2.6" }
|
||||
form_urlencoded = { version = "1" }
|
||||
# base64: cookie-safe encoding of the multi-step checkout info JSON
|
||||
base64 = { version = "0.22" }
|
||||
multer = { version = "3" }
|
||||
futures-util = { version = "0.3" }
|
||||
|
||||
|
||||
@@ -350,6 +350,10 @@ cart-remove-confirm = Remove this item from the cart?
|
||||
cart-update = Update
|
||||
cart-continue = Continue shopping
|
||||
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-shipping = Delivery address
|
||||
checkout-residence-address = Residence address
|
||||
@@ -380,6 +384,8 @@ company-dic = Tax ID (DIČ)
|
||||
company-icdph = VAT ID (IČ DPH)
|
||||
field-optional = optional
|
||||
checkout-place-order = Place order
|
||||
checkout-continue-payment = Continue
|
||||
checkout-back-info = Back to details
|
||||
checkout-summary = Order summary
|
||||
profile-title = My profile
|
||||
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-continue = Pokračovať v nákupe
|
||||
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-shipping = Dodacia adresa
|
||||
checkout-residence-address = Adresa bydliska
|
||||
@@ -380,6 +384,8 @@ company-dic = DIČ
|
||||
company-icdph = IČ DPH
|
||||
field-optional = nepovinné
|
||||
checkout-place-order = Odoslať objednávku
|
||||
checkout-continue-payment = Pokračovať
|
||||
checkout-back-info = Späť na údaje
|
||||
checkout-summary = Súhrn objednávky
|
||||
profile-title = Môj profil
|
||||
profile-intro = Tieto údaje použijeme na predvyplnenie pokladne.
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -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" />
|
||||
</a>
|
||||
|
||||
<!-- in-header search → existing GET /search (q param). Only on the home
|
||||
page; elsewhere the shop's own toolbar carries the search box. Hidden
|
||||
on small screens (a compact copy lives in the mobile menu below). -->
|
||||
{% if on_home | default(value=false) %}
|
||||
<form action="/search" method="get" role="search" class="hidden min-w-0 flex-1 md:flex md:max-w-xl">
|
||||
<!-- in-header search → existing GET /search (q param). Hidden on small
|
||||
screens; the shop page keeps its compact mobile search row there. -->
|
||||
<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 selected_category and selected_category != "all" %}
|
||||
<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">
|
||||
<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')) }}"
|
||||
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-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>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<!-- right side: kurz + account + cart + settings + mobile toggle -->
|
||||
<div class="ml-auto flex items-center gap-2 sm:gap-3">
|
||||
@@ -196,6 +196,22 @@
|
||||
{% include "partials/settings_dropdown.html" %}
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- dark overlay behind the category drawer on small screens -->
|
||||
@@ -240,19 +256,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- site footer (Kompress design): brand blurb + Informácie / Účet / Kontakt
|
||||
link columns + copyright bar. Static links; reuses the nav i18n keys. -->
|
||||
<!-- site footer (Kompress design): Informácie / Účet / Kontakt link columns
|
||||
+ 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">
|
||||
<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="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="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="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>
|
||||
<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 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="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>
|
||||
</a>
|
||||
</li>
|
||||
@@ -96,19 +96,6 @@
|
||||
</div>
|
||||
</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>
|
||||
</div>
|
||||
{% 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>
|
||||
{%- 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),
|
||||
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`;
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
<input type="hidden" name="category" value="{{ selected_category | default(value='all') }}" />
|
||||
|
||||
<!-- search box -->
|
||||
<div class="flex max-w-xl gap-2">
|
||||
<div class="hidden max-w-xl gap-2">
|
||||
<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">
|
||||
{{ ui::icon(name="search", size="size-5") }}
|
||||
|
||||
@@ -18,11 +18,11 @@
|
||||
<div class="flex flex-col gap-1">
|
||||
{# mobile-only Home link: the navbar logo (the Home affordance) is hidden on
|
||||
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')) }}
|
||||
</a>
|
||||
<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')) }}
|
||||
</a>
|
||||
{% 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)">
|
||||
<div class="flex items-stretch">
|
||||
<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 }}
|
||||
</a>
|
||||
<button type="button" x-on:click="open = ! open" x-bind:aria-expanded="open ? 'true' : 'false'"
|
||||
@@ -47,7 +47,7 @@
|
||||
{% for child in group.children %}
|
||||
<li>
|
||||
<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 }}
|
||||
</a>
|
||||
</li>
|
||||
@@ -56,7 +56,7 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<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 }}
|
||||
</a>
|
||||
{% endif %}
|
||||
@@ -75,8 +75,8 @@
|
||||
</p>
|
||||
{% set L = lang | default(value='sk') %}
|
||||
<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="/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="/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="/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" 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" 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>
|
||||
|
||||
@@ -3,32 +3,17 @@
|
||||
|
||||
{% block title %}{{ t(key="checkout-title", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
{% if packeta_api_key %}<script src="https://widget.packeta.com/v6/www/js/library.js"></script>{% endif %}
|
||||
{% block breadcrumbs %}
|
||||
{{ 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>
|
||||
|
||||
<form method="post" action="/checkout" hx-boost="false"
|
||||
<form method="post" action="/checkout/info" hx-boost="false"
|
||||
x-data="{
|
||||
paymentMethod: '',
|
||||
accountType: '{{ prefill_account_type | default(value='personal') }}',
|
||||
deliverySame: 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)
|
||||
}
|
||||
deliverySame: {{ prefill_delivery_same | default(value='false') }}
|
||||
}"
|
||||
class="mt-6 grid gap-8 lg:grid-cols-3">
|
||||
{{ 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>
|
||||
<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>
|
||||
{{ 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 class="grid gap-4 sm:grid-cols-3">
|
||||
<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>
|
||||
{{ 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 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", 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 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: '{{ 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-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')) }}' },
|
||||
@@ -226,76 +211,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- carrier -->
|
||||
<fieldset class="space-y-3 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
|
||||
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-carrier", lang=lang | default(value='sk')) }}{{ 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>
|
||||
|
||||
<!-- summary -->
|
||||
@@ -309,21 +224,11 @@
|
||||
</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>
|
||||
<span>{{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }}</span>
|
||||
<span class="tabular-nums text-primary dark:text-primary-dark">{{ subtotal }} €</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") }}
|
||||
{{ 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>
|
||||
</form>
|
||||
{% 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
|
||||
//! confirmation page.
|
||||
//! Public checkout flow, split across pages: the basket is `/cart`, then a
|
||||
//! 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_extra::extract::cookie::CookieJar;
|
||||
use axum::{extract::Query, response::Redirect};
|
||||
use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder};
|
||||
use serde::Deserialize;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
use time::Duration as TimeDuration;
|
||||
use crate::{
|
||||
controllers::cart::{self, resolve_cart},
|
||||
mailers::auth::AuthMailer,
|
||||
@@ -20,8 +28,37 @@ use crate::{
|
||||
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)]
|
||||
struct CheckoutForm {
|
||||
struct InfoForm {
|
||||
email: String,
|
||||
phone_prefix: String,
|
||||
phone: String,
|
||||
@@ -40,6 +77,11 @@ struct CheckoutForm {
|
||||
city: Option<String>,
|
||||
zip: Option<String>,
|
||||
country: Option<String>,
|
||||
}
|
||||
|
||||
/// Step 2 form (`POST /checkout/payment`).
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct PaymentForm {
|
||||
note: Option<String>,
|
||||
payment_method: String,
|
||||
carrier_code: String,
|
||||
@@ -56,6 +98,33 @@ fn trimmed(value: &str) -> Option<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>> {
|
||||
shipping_rules::disable_packeta_if_unconfigured(ctx).await?;
|
||||
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?)
|
||||
}
|
||||
|
||||
/// `/checkout` lands on the first wizard step.
|
||||
#[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,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
@@ -86,6 +164,180 @@ async fn checkout_page(
|
||||
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)
|
||||
.await?
|
||||
.iter()
|
||||
@@ -110,8 +362,6 @@ async fn checkout_page(
|
||||
})
|
||||
.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 is_admin = user.as_ref().is_some_and(|u| guard::is_admin(&ctx, u));
|
||||
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?,
|
||||
_ => 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,
|
||||
// the "save this address to my profile" opt-in is pointless (the profile was
|
||||
// filled in advance), so it's hidden and the existing profile is left alone.
|
||||
// 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.html",
|
||||
"shop/checkout_payment.html",
|
||||
json!({
|
||||
"items": lines,
|
||||
"subtotal": format_price(subtotal),
|
||||
@@ -141,29 +387,11 @@ async fn checkout_page(
|
||||
"packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""),
|
||||
"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()),
|
||||
"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(),
|
||||
"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),
|
||||
}),
|
||||
)
|
||||
@@ -173,75 +401,41 @@ async fn checkout_page(
|
||||
async fn place_order(
|
||||
jar: CookieJar,
|
||||
State(ctx): State<AppContext>,
|
||||
Form(form): Form<CheckoutForm>,
|
||||
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 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(),
|
||||
let Some(info) = decode_info(&jar) else {
|
||||
return format::redirect("/checkout/info");
|
||||
};
|
||||
|
||||
// Contact and residence-address fields are mandatory (also enforced in the
|
||||
// browser via `required`).
|
||||
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 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(),
|
||||
)
|
||||
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 {
|
||||
(
|
||||
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")?,
|
||||
)
|
||||
format!("{} {}", info.phone_prefix, number)
|
||||
};
|
||||
|
||||
// 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
|
||||
// as guests here.
|
||||
// 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 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()),
|
||||
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" {
|
||||
(
|
||||
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),
|
||||
info.company_name.clone(),
|
||||
info.company_id.clone(),
|
||||
info.tax_id.clone(),
|
||||
info.vat_id.clone(),
|
||||
)
|
||||
} else {
|
||||
(None, None, None, None)
|
||||
@@ -274,19 +468,19 @@ async fn place_order(
|
||||
(None, None)
|
||||
};
|
||||
|
||||
// The address/contact captured here, ready to seed a profile (for the
|
||||
// logged-in "save my address" opt-in or a freshly created guest account).
|
||||
// The address/contact captured in the info step, ready to seed a profile (for
|
||||
// the logged-in "save my address" opt-in or a freshly created guest account).
|
||||
let entered_profile = || ProfileFields {
|
||||
company_name: company_name.clone(),
|
||||
company_id: company_id.clone(),
|
||||
tax_id: tax_id.clone(),
|
||||
vat_id: vat_id.clone(),
|
||||
phone_prefix: trimmed(&form.phone_prefix),
|
||||
phone_prefix: trimmed(&info.phone_prefix),
|
||||
phone: Some(number.clone()),
|
||||
address: Some(residence_address.clone()),
|
||||
city: Some(residence_city.clone()),
|
||||
zip: Some(residence_zip.clone()),
|
||||
country: Some(residence_country.clone()),
|
||||
address: Some(info.residence_address.clone()),
|
||||
city: Some(info.residence_city.clone()),
|
||||
zip: Some(info.residence_zip.clone()),
|
||||
country: Some(info.residence_country.clone()),
|
||||
};
|
||||
|
||||
// Resolve the account that will own this order. A logged-in customer always
|
||||
@@ -355,14 +549,14 @@ async fn place_order(
|
||||
company_id,
|
||||
tax_id,
|
||||
vat_id,
|
||||
residence_address: Some(residence_address),
|
||||
residence_city: Some(residence_city),
|
||||
residence_zip: Some(residence_zip),
|
||||
residence_country: Some(residence_country),
|
||||
address: Some(address),
|
||||
city: Some(city),
|
||||
zip: Some(zip),
|
||||
country: Some(country),
|
||||
residence_address: Some(info.residence_address.clone()),
|
||||
residence_city: Some(info.residence_city.clone()),
|
||||
residence_zip: Some(info.residence_zip.clone()),
|
||||
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),
|
||||
payment_method: form.payment_method,
|
||||
method,
|
||||
@@ -382,7 +576,7 @@ async fn place_order(
|
||||
cart::clear_account_cart(&ctx, user.id).await?;
|
||||
}
|
||||
format::render()
|
||||
.cookies(&[cart::cleared_cart_cookie()])?
|
||||
.cookies(&[cart::cleared_cart_cookie(), cleared_info_cookie()])?
|
||||
.redirect(&target)
|
||||
}
|
||||
|
||||
@@ -430,7 +624,10 @@ async fn order_confirmation(
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.add("/checkout", get(checkout_page))
|
||||
.add("/checkout", post(place_order))
|
||||
.add("/checkout", get(checkout_redirect))
|
||||
.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))
|
||||
}
|
||||
|
||||
@@ -54,6 +54,23 @@ pub async fn place(
|
||||
details: Checkout,
|
||||
user: Option<&users::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 mut subtotal: i64 = 0;
|
||||
@@ -78,10 +95,12 @@ pub async fn place(
|
||||
)));
|
||||
}
|
||||
}
|
||||
// Snapshot the price the buyer actually pays — public sale or, for a
|
||||
// business account, their negotiated/lowest price (same resolver the
|
||||
// cart and storefront use).
|
||||
let unit_price_cents = pricing::price_variant(ctx, &variant, user).await?.price_cents;
|
||||
// The price the buyer actually pays — public sale or, for a business
|
||||
// account, their negotiated/lowest price (resolved above, outside the
|
||||
// transaction, with the same resolver the cart and storefront use).
|
||||
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);
|
||||
|
||||
if let Some(on_hand) = variant.stock {
|
||||
|
||||
Reference in New Issue
Block a user