From 1bde553f7920f5cf38e658faa1827091f08f7b93 Mon Sep 17 00:00:00 2001 From: Priec Date: Sun, 28 Jun 2026 21:06:10 +0200 Subject: [PATCH] checkout --- Cargo.lock | 1 + Cargo.toml | 2 + assets/i18n/en/main.ftl | 6 + assets/i18n/sk/main.ftl | 6 + assets/views/macros/ui.html | 19 + .../{checkout.html => checkout_info.html} | 121 +----- assets/views/shop/checkout_payment.html | 143 +++++++ src/controllers/checkout.rs | 403 +++++++++++++----- 8 files changed, 490 insertions(+), 211 deletions(-) rename assets/views/shop/{checkout.html => checkout_info.html} (70%) create mode 100644 assets/views/shop/checkout_payment.html diff --git a/Cargo.lock b/Cargo.lock index 09f9267..283cba7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2646,6 +2646,7 @@ dependencies = [ "axum", "axum-casbin", "axum-extra 0.10.3", + "base64", "bytes", "chrono", "dotenvy", diff --git a/Cargo.toml b/Cargo.toml index e242543..98249a8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/assets/i18n/en/main.ftl b/assets/i18n/en/main.ftl index 603c253..120e5cf 100644 --- a/assets/i18n/en/main.ftl +++ b/assets/i18n/en/main.ftl @@ -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. diff --git a/assets/i18n/sk/main.ftl b/assets/i18n/sk/main.ftl index 641b010..b172884 100644 --- a/assets/i18n/sk/main.ftl +++ b/assets/i18n/sk/main.ftl @@ -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. diff --git a/assets/views/macros/ui.html b/assets/views/macros/ui.html index 33fbd04..d888e8b 100644 --- a/assets/views/macros/ui.html +++ b/assets/views/macros/ui.html @@ -292,6 +292,25 @@ border-t border-outline dark:border-outline-dark
  • {{ label }}
  • {%- 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) -%} + +{%- 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`; diff --git a/assets/views/shop/checkout.html b/assets/views/shop/checkout_info.html similarity index 70% rename from assets/views/shop/checkout.html rename to assets/views/shop/checkout_info.html index 1ab3e1b..5022996 100644 --- a/assets/views/shop/checkout.html +++ b/assets/views/shop/checkout_info.html @@ -3,32 +3,17 @@ {% block title %}{{ t(key="checkout-title", lang=lang | default(value='sk')) }}{% endblock title %} -{% block content %} -{% if packeta_api_key %}{% endif %} +{% block breadcrumbs %} +{{ ui::checkout_steps(active="info", lang=lang | default(value='sk')) }} +{% endblock breadcrumbs %} +{% block content %}

    {{ t(key="checkout-title", lang=lang | default(value='sk')) }}

    -
    {{ ui::csrf_field() }} @@ -184,21 +169,21 @@ {{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}
    - {{ 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"') }}
    - {{ 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"') }}
    - {{ 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"') }}
    - - -
    - {{ t(key="checkout-carrier", lang=lang | default(value='sk')) }}{{ ui::req() }} - {% for m in shipping_methods %} - - {% endfor %} - - -
    - - - {% if packeta_api_key %} - -

    - {{ t(key="checkout-chosen-point", lang=lang | default(value='sk')) }}: -

    - {% else %} - - - {% endif %} -
    -
    - - -
    - {{ t(key="checkout-payment", lang=lang | default(value='sk')) }}{{ ui::req() }} - {% if payment_methods | length > 0 %} - {% for method in payment_methods %} - - {% endfor %} - {% else %} -

    {{ t(key="payment-none", lang=lang | default(value='sk')) }}

    - {% endif %} -
    - -
    - - {{ ui::textarea(name="note", id="note", rows="3") }} -
    - - {% if logged_in_customer and not profile_filled %} - - {{ ui::checkbox(name="save_profile", id="save_profile", label=t(key="checkout-save-profile", lang=lang | default(value='sk'))) }} - {% endif %} - - {% if can_create_account %} - -
    - {{ ui::checkbox(name="create_account", id="create_account", label=t(key="checkout-create-account", lang=lang | default(value='sk'))) }} -

    {{ t(key="checkout-create-account-hint", lang=lang | default(value='sk')) }}

    -
    - {% endif %}
    @@ -309,21 +224,11 @@ {% endfor %} -
    -
    - {{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }} - {{ subtotal }} € -
    -
    - {{ t(key="checkout-shipping-cost", lang=lang | default(value='sk')) }} - -
    -
    - {{ t(key="cart-total", lang=lang | default(value='sk')) }} - + {{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }} + {{ subtotal }} €
    - {{ 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") }} {% endblock content %} diff --git a/assets/views/shop/checkout_payment.html b/assets/views/shop/checkout_payment.html new file mode 100644 index 0000000..2d30e78 --- /dev/null +++ b/assets/views/shop/checkout_payment.html @@ -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 %}{% endif %} + +

    {{ t(key="checkout-title", lang=lang | default(value='sk')) }}

    + +
    + {{ ui::csrf_field() }} + +
    + +
    + {{ t(key="checkout-carrier", lang=lang | default(value='sk')) }}{{ ui::req() }} + {% for m in shipping_methods %} + + {% endfor %} + + +
    + + + {% if packeta_api_key %} + +

    + {{ t(key="checkout-chosen-point", lang=lang | default(value='sk')) }}: +

    + {% else %} + + + {% endif %} +
    +
    + + +
    + {{ t(key="checkout-payment", lang=lang | default(value='sk')) }}{{ ui::req() }} + {% if payment_methods | length > 0 %} + {% for method in payment_methods %} + + {% endfor %} + {% else %} +

    {{ t(key="payment-none", lang=lang | default(value='sk')) }}

    + {% endif %} +
    + +
    + + {{ ui::textarea(name="note", id="note", rows="3") }} +
    + + {% if logged_in_customer and not profile_filled %} + + {{ ui::checkbox(name="save_profile", id="save_profile", label=t(key="checkout-save-profile", lang=lang | default(value='sk'))) }} + {% endif %} + + {% if can_create_account %} + +
    + {{ ui::checkbox(name="create_account", id="create_account", label=t(key="checkout-create-account", lang=lang | default(value='sk'))) }} +

    {{ t(key="checkout-create-account-hint", lang=lang | default(value='sk')) }}

    +
    + {% endif %} + + + + {{ t(key="checkout-back-info", lang=lang | default(value='sk')) }} + +
    + + + +
    +{% endblock content %} diff --git a/src/controllers/checkout.rs b/src/controllers/checkout.rs index b0d34b9..cc419e7 100644 --- a/src/controllers/checkout.rs +++ b/src/controllers/checkout.rs @@ -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, + company_id: Option, + tax_id: Option, + vat_id: Option, + 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, zip: Option, country: Option, +} + +/// Step 2 form (`POST /checkout/payment`). +#[derive(Debug, Deserialize)] +struct PaymentForm { note: Option, payment_method: String, carrier_code: String, @@ -56,6 +98,33 @@ fn trimmed(value: &str) -> Option { (!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 { + 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> { 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 Result { + 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, State(ctx): State, @@ -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| { + 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| 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, + Form(form): Form, +) -> Result { + 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 { + trimmed(value).ok_or_else(|| Error::BadRequest(format!("{field} is required"))) + }; + let require_opt = |value: Option<&str>, field: &str| -> Result { + 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, + State(ctx): State, +) -> Result { + 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 = 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| { - 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, - Form(form): Form, + Form(form): Form, ) -> Result { 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 { - trimmed(value).ok_or_else(|| Error::BadRequest(format!("{field} is required"))) - }; - let require_opt = |value: Option<&str>, field: &str| -> Result { - 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)) }