required
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled

This commit is contained in:
Priec
2026-06-18 21:38:32 +02:00
parent 996358be87
commit 46cc2459bd
7 changed files with 84 additions and 34 deletions

View File

@@ -273,6 +273,7 @@ profile-title = My profile
profile-intro = We'll use these details to prefill checkout. profile-intro = We'll use these details to prefill checkout.
profile-saved = Profile saved. profile-saved = Profile saved.
profile-save = Save profile profile-save = Save profile
profile-company-required = For a company account, please fill in company name, IČO and DIČ.
order-confirmed-title = Thank you for your order! order-confirmed-title = Thank you for your order!
order-confirmed-sub = We have received your order. order-confirmed-sub = We have received your order.
order-number = Order number order-number = Order number

View File

@@ -273,6 +273,7 @@ profile-title = Môj profil
profile-intro = Tieto údaje použijeme na predvyplnenie pokladne. profile-intro = Tieto údaje použijeme na predvyplnenie pokladne.
profile-saved = Profil bol uložený. profile-saved = Profil bol uložený.
profile-save = Uložiť profil profile-save = Uložiť profil
profile-company-required = Pri firemnom účte vyplňte názov firmy, IČO a DIČ.
order-confirmed-title = Ďakujeme za objednávku! order-confirmed-title = Ďakujeme za objednávku!
order-confirmed-sub = Vašu objednávku sme prijali. order-confirmed-sub = Vašu objednávku sme prijali.
order-number = Číslo objednávky order-number = Číslo objednávky

File diff suppressed because one or more lines are too long

View File

@@ -13,6 +13,9 @@
{{ t(key="profile-saved", lang=lang | default(value='sk')) }} {{ t(key="profile-saved", lang=lang | default(value='sk')) }}
</div> </div>
{% endif %} {% endif %}
{% if error %}
{{ ui::alert_danger(message=t(key="profile-company-required", lang=lang | default(value='sk')), extra="mt-4") }}
{% endif %}
<form method="post" action="/account/profile" hx-boost="false" class="mt-6 space-y-6" <form method="post" action="/account/profile" hx-boost="false" class="mt-6 space-y-6"
x-data="{ accountType: '{{ account_type | default(value='personal') }}' }"> x-data="{ accountType: '{{ account_type | default(value='personal') }}' }">
@@ -35,16 +38,16 @@
<fieldset x-show="accountType === 'company'" x-cloak class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt"> <fieldset x-show="accountType === 'company'" x-cloak class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="account-company-details", lang=lang | default(value='sk')) }}</legend> <legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="account-company-details", lang=lang | default(value='sk')) }}</legend>
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="company_name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-name", lang=lang | default(value='sk')) }}</label> <label for="company_name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-name", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
{{ ui::input(name="company_name", id="company_name", value=company_name | default(value=''), autocomplete="organization") }} {{ ui::input(name="company_name", id="company_name", value=company_name | default(value=''), autocomplete="organization") }}
</div> </div>
<div class="grid gap-4 sm:grid-cols-3"> <div class="grid gap-4 sm:grid-cols-3">
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="company_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-ico", lang=lang | default(value='sk')) }}</label> <label for="company_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-ico", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
{{ ui::input(name="company_id", id="company_id", value=company_id | default(value='')) }} {{ ui::input(name="company_id", id="company_id", value=company_id | default(value='')) }}
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="tax_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-dic", lang=lang | default(value='sk')) }}</label> <label for="tax_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-dic", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
{{ ui::input(name="tax_id", id="tax_id", value=tax_id | default(value='')) }} {{ ui::input(name="tax_id", id="tax_id", value=tax_id | default(value='')) }}
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">

View File

@@ -82,6 +82,11 @@
{# Compact danger alert (form/inline errors). Adapted from {# Compact danger alert (form/inline errors). Adapted from
penguinui/alert/default-alert.html (danger variant), trimmed to a single line penguinui/alert/default-alert.html (danger variant), trimmed to a single line
with the danger icon. #} with the danger icon. #}
{# Required-field marker: a red asterisk appended to a field label. #}
{% macro req() -%}
<span class="ml-0.5 text-danger" aria-hidden="true">*</span>
{%- endmacro req %}
{% macro alert_danger(message, extra="") -%} {% macro alert_danger(message, extra="") -%}
<div class="flex w-full items-center gap-2 overflow-hidden rounded-radius border border-danger bg-danger/10 px-3 py-2 text-sm text-danger {{ extra }}" role="alert"> <div class="flex w-full items-center gap-2 overflow-hidden rounded-radius border border-danger bg-danger/10 px-3 py-2 text-sm text-danger {{ extra }}" role="alert">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5 shrink-0" aria-hidden="true"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="size-5 shrink-0" aria-hidden="true">

View File

@@ -51,16 +51,16 @@
<fieldset x-show="accountType === 'company'" x-cloak class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt"> <fieldset x-show="accountType === 'company'" x-cloak class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="account-company-details", lang=lang | default(value='sk')) }}</legend> <legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="account-company-details", lang=lang | default(value='sk')) }}</legend>
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="company_name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-name", lang=lang | default(value='sk')) }}</label> <label for="company_name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-name", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
{{ ui::input(name="company_name", id="company_name", value=prefill_company_name | default(value=''), autocomplete="organization") }} {{ ui::input(name="company_name", id="company_name", value=prefill_company_name | default(value=''), autocomplete="organization") }}
</div> </div>
<div class="grid gap-4 sm:grid-cols-3"> <div class="grid gap-4 sm:grid-cols-3">
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="company_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-ico", lang=lang | default(value='sk')) }}</label> <label for="company_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-ico", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
{{ ui::input(name="company_id", id="company_id", value=prefill_company_id | default(value='')) }} {{ ui::input(name="company_id", id="company_id", value=prefill_company_id | default(value='')) }}
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="tax_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-dic", lang=lang | default(value='sk')) }}</label> <label for="tax_id" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="company-dic", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
{{ ui::input(name="tax_id", id="tax_id", value=prefill_tax_id | default(value='')) }} {{ ui::input(name="tax_id", id="tax_id", value=prefill_tax_id | default(value='')) }}
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
@@ -74,15 +74,15 @@
<fieldset class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt"> <fieldset class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-contact", lang=lang | default(value='sk')) }}</legend> <legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-contact", lang=lang | default(value='sk')) }}</legend>
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="email" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-email", lang=lang | default(value='sk')) }}</label> <label for="email" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-email", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
{{ ui::input(name="email", id="email", type="email", value=prefill_email | default(value=''), required=true, autocomplete="email") }} {{ ui::input(name="email", id="email", type="email", value=prefill_email | default(value=''), required=true, autocomplete="email") }}
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="customer_name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-name", lang=lang | default(value='sk')) }}</label> <label for="customer_name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-name", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
{{ ui::input(name="customer_name", id="customer_name", value=prefill_name | default(value=''), required=true, autocomplete="name") }} {{ ui::input(name="customer_name", id="customer_name", value=prefill_name | default(value=''), required=true, autocomplete="name") }}
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="phone" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-phone", lang=lang | default(value='sk')) }}</label> <label for="phone" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-phone", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
<div class="flex gap-2"> <div class="flex gap-2">
<!-- editable combobox: type freely or pick from the dropdown --> <!-- editable combobox: type freely or pick from the dropdown -->
<div class="relative w-28 shrink-0" @click.outside="prefixOpen = false" <div class="relative w-28 shrink-0" @click.outside="prefixOpen = false"
@@ -119,20 +119,20 @@
<fieldset class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt"> <fieldset class="space-y-4 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</legend> <legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</legend>
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="address" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-address", lang=lang | default(value='sk')) }}</label> <label for="address" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-address", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
{{ ui::input(name="address", id="address", value=prefill_address | default(value=''), required=true, autocomplete="street-address") }} {{ ui::input(name="address", id="address", value=prefill_address | default(value=''), required=true, autocomplete="street-address") }}
</div> </div>
<div class="grid gap-4 sm:grid-cols-3"> <div class="grid gap-4 sm:grid-cols-3">
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="city" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-city", lang=lang | default(value='sk')) }}</label> <label for="city" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-city", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
{{ ui::input(name="city", id="city", value=prefill_city | default(value=''), required=true, autocomplete="address-level2") }} {{ ui::input(name="city", id="city", value=prefill_city | default(value=''), required=true, autocomplete="address-level2") }}
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="zip" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-zip", lang=lang | default(value='sk')) }}</label> <label for="zip" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-zip", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
{{ ui::input(name="zip", id="zip", value=prefill_zip | default(value=''), required=true, autocomplete="postal-code") }} {{ ui::input(name="zip", id="zip", value=prefill_zip | default(value=''), required=true, autocomplete="postal-code") }}
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<label for="country" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-country", lang=lang | default(value='sk')) }}</label> <label for="country" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-country", lang=lang | default(value='sk')) }}{{ ui::req() }}</label>
<div class="relative" @click.outside="countryOpen = false" <div class="relative" @click.outside="countryOpen = false"
x-data="{ countryOpen: false, country: '{{ prefill_country | default(value=t(key='country-sk', lang=lang | default(value='sk'))) }}', opts: [ x-data="{ countryOpen: false, country: '{{ prefill_country | default(value=t(key='country-sk', lang=lang | default(value='sk'))) }}', opts: [
{ v: '{{ t(key='country-sk', lang=lang | default(value='sk')) }}', l: '🇸🇰 {{ t(key='country-sk', lang=lang | default(value='sk')) }}' }, { v: '{{ t(key='country-sk', lang=lang | default(value='sk')) }}', l: '🇸🇰 {{ t(key='country-sk', lang=lang | default(value='sk')) }}' },
@@ -165,7 +165,7 @@
<!-- carrier --> <!-- carrier -->
<fieldset class="space-y-3 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt"> <fieldset class="space-y-3 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-carrier", lang=lang | default(value='sk')) }}</legend> <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 %} {% 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"> <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"> <span class="flex items-center gap-3">
@@ -201,7 +201,7 @@
<!-- payment --> <!-- payment -->
<fieldset class="space-y-3 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt"> <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')) }}</legend> <legend class="px-1 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-payment", lang=lang | default(value='sk')) }}{{ ui::req() }}</legend>
<label class="flex cursor-pointer items-center gap-3 rounded-radius border border-outline px-4 py-3 transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark"> <label class="flex cursor-pointer items-center gap-3 rounded-radius border border-outline px-4 py-3 transition has-[:checked]:border-primary dark:border-outline-dark dark:has-[:checked]:border-primary-dark">
{{ ui::radio(name="payment_method", value="cod", attrs='required x-model="paymentMethod"') }} {{ ui::radio(name="payment_method", value="cod", attrs='required x-model="paymentMethod"') }}
<span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-cod", lang=lang | default(value='sk')) }}</span> <span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-cod", lang=lang | default(value='sk')) }}</span>

View File

@@ -64,16 +64,49 @@ impl From<ProfileForm> for ProfileFields {
} }
} }
/// Render the profile form for `profile` (which may be `None` for a customer /// The profile fields held by a saved profile, for re-prefilling the form.
/// who hasn't saved anything yet). `saved` shows the success banner after a fn fields_of(profile: Option<&customer_profiles::Model>) -> ProfileFields {
/// POST. match profile {
Some(p) => ProfileFields {
account_type: p.account_type.clone(),
company_name: p.company_name.clone(),
company_id: p.company_id.clone(),
tax_id: p.tax_id.clone(),
vat_id: p.vat_id.clone(),
phone_prefix: p.phone_prefix.clone(),
phone: p.phone.clone(),
address: p.address.clone(),
city: p.city.clone(),
zip: p.zip.clone(),
country: p.country.clone(),
},
None => ProfileFields {
account_type: "personal".to_string(),
..Default::default()
},
}
}
/// A company profile must carry its invoicing identity (company name + IČO +
/// DIČ; IČ DPH stays optional). Personal profiles have no such requirement.
fn company_fields_missing(fields: &ProfileFields) -> bool {
fields.account_type == "company"
&& (fields.company_name.is_none()
|| fields.company_id.is_none()
|| fields.tax_id.is_none())
}
/// Render the profile form prefilled from `fields`. `saved` shows the success
/// banner; `error` shows a validation message and is set when a company profile
/// is missing required identifiers.
fn profile_view( fn profile_view(
v: &TeraView, v: &TeraView,
jar: &CookieJar, jar: &CookieJar,
name: &str, name: &str,
email: &str, email: &str,
profile: Option<&customer_profiles::Model>, fields: &ProfileFields,
saved: bool, saved: bool,
error: bool,
) -> Result<Response> { ) -> Result<Response> {
format::view( format::view(
v, v,
@@ -82,19 +115,20 @@ fn profile_view(
"logged_in_admin": false, "logged_in_admin": false,
"logged_in_customer": true, "logged_in_customer": true,
"saved": saved, "saved": saved,
"error": error,
"name": name, "name": name,
"email": email, "email": email,
"account_type": profile.map_or("personal", |p| p.account_type.as_str()), "account_type": fields.account_type,
"company_name": profile.and_then(|p| p.company_name.clone()), "company_name": fields.company_name,
"company_id": profile.and_then(|p| p.company_id.clone()), "company_id": fields.company_id,
"tax_id": profile.and_then(|p| p.tax_id.clone()), "tax_id": fields.tax_id,
"vat_id": profile.and_then(|p| p.vat_id.clone()), "vat_id": fields.vat_id,
"phone_prefix": profile.and_then(|p| p.phone_prefix.clone()), "phone_prefix": fields.phone_prefix,
"phone": profile.and_then(|p| p.phone.clone()), "phone": fields.phone,
"address": profile.and_then(|p| p.address.clone()), "address": fields.address,
"city": profile.and_then(|p| p.city.clone()), "city": fields.city,
"zip": profile.and_then(|p| p.zip.clone()), "zip": fields.zip,
"country": profile.and_then(|p| p.country.clone()), "country": fields.country,
"lang": current_lang(jar), "lang": current_lang(jar),
}), }),
) )
@@ -113,7 +147,7 @@ async fn profile_page(
return format::redirect("/admin/dashboard"); return format::redirect("/admin/dashboard");
} }
let profile = customer_profiles::Model::find_for_user(&ctx.db, user.id).await?; let profile = customer_profiles::Model::find_for_user(&ctx.db, user.id).await?;
profile_view(&v, &jar, &user.name, &user.email, profile.as_ref(), false) profile_view(&v, &jar, &user.name, &user.email, &fields_of(profile.as_ref()), false, false)
} }
#[debug_handler] #[debug_handler]
@@ -129,8 +163,14 @@ async fn save_profile(
if guard::is_admin(&ctx, &user) { if guard::is_admin(&ctx, &user) {
return format::redirect("/admin/dashboard"); return format::redirect("/admin/dashboard");
} }
let profile = customer_profiles::Model::upsert(&ctx.db, user.id, form.into()).await?; let fields: ProfileFields = form.into();
profile_view(&v, &jar, &user.name, &user.email, Some(&profile), true) // A company profile is rejected (and the form re-shown with the entered
// values) until it carries its required identifiers.
if company_fields_missing(&fields) {
return profile_view(&v, &jar, &user.name, &user.email, &fields, false, true);
}
customer_profiles::Model::upsert(&ctx.db, user.id, fields.clone()).await?;
profile_view(&v, &jar, &user.name, &user.email, &fields, true, false)
} }
pub fn routes() -> Routes { pub fn routes() -> Routes {