4 Commits

Author SHA1 Message Date
Priec
e51eda9a8c default unchecked
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-19 11:39:40 +02:00
Priec
12e00a782d profile name surname and save profile data 2026-06-19 11:37:51 +02:00
Priec
5278988842 registration password match 2026-06-19 11:19:30 +02:00
Priec
e70743996b register form fields 2026-06-19 11:14:47 +02:00
8 changed files with 195 additions and 22 deletions

View File

@@ -274,6 +274,11 @@ 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Č. profile-company-required = For a company account, please fill in company name, IČO and DIČ.
profile-first-name = First name
profile-last-name = Surname
profile-edit = Edit profile
profile-cancel = Cancel
profile-not-set = Not set
account-type-locked = Account type can't be changed after registration. account-type-locked = Account type can't be changed after registration.
checkout-create-account = Create an account from this order checkout-create-account = Create an account from this order
checkout-create-account-hint = We'll email you a link to set your password. This order will be linked to your account. checkout-create-account-hint = We'll email you a link to set your password. This order will be linked to your account.

View File

@@ -274,6 +274,11 @@ 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Č. profile-company-required = Pri firemnom účte vyplňte názov firmy, IČO a DIČ.
profile-first-name = Meno
profile-last-name = Priezvisko
profile-edit = Upraviť profil
profile-cancel = Zrušiť
profile-not-set = Neuvedené
account-type-locked = Typ účtu sa po registrácii nedá zmeniť. account-type-locked = Typ účtu sa po registrácii nedá zmeniť.
checkout-create-account = Vytvoriť účet z tejto objednávky checkout-create-account = Vytvoriť účet z tejto objednávky
checkout-create-account-hint = Pošleme vám e-mail na nastavenie hesla. Objednávka sa priradí k vášmu účtu. checkout-create-account-hint = Pošleme vám e-mail na nastavenie hesla. Objednávka sa priradí k vášmu účtu.

View File

@@ -3,8 +3,19 @@
{% block title %}{{ t(key="profile-title", lang=lang | default(value='sk')) }}{% endblock title %} {% block title %}{{ t(key="profile-title", lang=lang | default(value='sk')) }}{% endblock title %}
{% macro field(label, value) %}
<div class="space-y-1.5">
<label class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ label }}</label>
{% if value %}
<p class="text-sm text-on-surface/80 dark:text-on-surface-dark/80">{{ value }}</p>
{% else %}
<p class="text-sm italic text-on-surface/50 dark:text-on-surface-dark/50">{{ t(key="profile-not-set", lang=lang | default(value='sk')) }}</p>
{% endif %}
</div>
{% endmacro field %}
{% block content %} {% block content %}
<div class="mx-auto max-w-2xl"> <div class="mx-auto max-w-2xl" x-data="{ editing: {% if error %}true{% else %}false{% endif %} }">
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="profile-title", lang=lang | default(value='sk')) }}</h1> <h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="profile-title", lang=lang | default(value='sk')) }}</h1>
<p class="mt-2 text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="profile-intro", lang=lang | default(value='sk')) }}</p> <p class="mt-2 text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="profile-intro", lang=lang | default(value='sk')) }}</p>
@@ -17,7 +28,60 @@
{{ ui::alert_danger(message=t(key="profile-company-required", lang=lang | default(value='sk')), extra="mt-4") }} {{ ui::alert_danger(message=t(key="profile-company-required", lang=lang | default(value='sk')), extra="mt-4") }}
{% endif %} {% endif %}
<form method="post" action="/account/profile" hx-boost="false" class="mt-6 space-y-6"> <!-- read-only view (default) -->
<div x-show="!editing" class="mt-6 space-y-6">
<fieldset class="space-y-2 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-type", lang=lang | default(value='sk')) }}</legend>
<div class="flex items-center gap-2">
{% if account_type == "company" %}
{{ ui::badge(label=t(key="account-company", lang=lang | default(value='sk')), variant="primary") }}
{% else %}
{{ ui::badge(label=t(key="account-personal", lang=lang | default(value='sk')), variant="neutral") }}
{% endif %}
<span class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="account-type-locked", lang=lang | default(value='sk')) }}</span>
</div>
</fieldset>
{% if account_type == "company" %}
<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="account-company-details", lang=lang | default(value='sk')) }}</legend>
{{ self::field(label=t(key="company-name", lang=lang | default(value='sk')), value=company_name) }}
<div class="grid gap-4 sm:grid-cols-3">
{{ self::field(label=t(key="company-ico", lang=lang | default(value='sk')), value=company_id) }}
{{ self::field(label=t(key="company-dic", lang=lang | default(value='sk')), value=tax_id) }}
{{ self::field(label=t(key="company-icdph", lang=lang | default(value='sk')), value=vat_id) }}
</div>
</fieldset>
{% endif %}
<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>
{{ self::field(label=t(key="checkout-name", lang=lang | default(value='sk')), value=name) }}
{{ self::field(label=t(key="checkout-email", lang=lang | default(value='sk')), value=email) }}
{% if phone %}
{% set phone_full = phone_prefix | default(value='') %}
{% set phone_full = phone_full ~ ' ' ~ phone %}
{{ self::field(label=t(key="checkout-phone", lang=lang | default(value='sk')), value=phone_full) }}
{% else %}
{{ self::field(label=t(key="checkout-phone", lang=lang | default(value='sk')), value='') }}
{% endif %}
</fieldset>
<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>
{{ self::field(label=t(key="checkout-address", lang=lang | default(value='sk')), value=address) }}
<div class="grid gap-4 sm:grid-cols-3">
{{ self::field(label=t(key="checkout-city", lang=lang | default(value='sk')), value=city) }}
{{ self::field(label=t(key="checkout-zip", lang=lang | default(value='sk')), value=zip) }}
{{ self::field(label=t(key="checkout-country", lang=lang | default(value='sk')), value=country) }}
</div>
</fieldset>
{{ ui::button(label=t(key="profile-edit", lang=lang | default(value='sk')), type="button", size="px-6 py-2.5 text-sm", attrs='@click="editing = true"') }}
</div>
<!-- edit form -->
<form x-show="editing" x-cloak method="post" action="/account/profile" hx-boost="false" class="mt-6 space-y-6">
<!-- account type is fixed at registration and shown read-only --> <!-- account type is fixed at registration and shown read-only -->
<fieldset class="space-y-2 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt"> <fieldset class="space-y-2 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-type", 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-type", lang=lang | default(value='sk')) }}</legend>
@@ -59,9 +123,15 @@
<!-- contact (name/email are managed by the login) --> <!-- contact (name/email are managed by the login) -->
<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="grid gap-4 sm:grid-cols-2">
<div class="space-y-1.5"> <div class="space-y-1.5">
<label 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="first_name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="profile-first-name", lang=lang | default(value='sk')) }}</label>
<p class="text-sm text-on-surface/80 dark:text-on-surface-dark/80">{{ name }}</p> {{ ui::input(name="first_name", id="first_name", value=first_name | default(value=''), autocomplete="given-name") }}
</div>
<div class="space-y-1.5">
<label for="last_name" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="profile-last-name", lang=lang | default(value='sk')) }}</label>
{{ ui::input(name="last_name", id="last_name", value=last_name | default(value=''), autocomplete="family-name") }}
</div>
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
<label 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 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>
@@ -149,7 +219,10 @@
</div> </div>
</fieldset> </fieldset>
<div class="flex items-center gap-3">
{{ ui::button(label=t(key="profile-save", lang=lang | default(value='sk')), type="submit", size="px-6 py-2.5 text-sm") }} {{ ui::button(label=t(key="profile-save", lang=lang | default(value='sk')), type="submit", size="px-6 py-2.5 text-sm") }}
{{ ui::button(label=t(key="profile-cancel", lang=lang | default(value='sk')), type="button", variant="outline-secondary", size="px-6 py-2.5 text-sm", attrs='@click="editing = false"') }}
</div>
</form> </form>
</div> </div>
{% endblock content %} {% endblock content %}

View File

@@ -22,11 +22,16 @@
{% if error == "exists" %} {% if error == "exists" %}
{{ ui::alert_danger(message=t(key="register-error-exists", lang=lang | default(value='sk')), extra="mt-3") }} {{ ui::alert_danger(message=t(key="register-error-exists", lang=lang | default(value='sk')), extra="mt-3") }}
{% elif error == "mismatch" %}
{{ ui::alert_danger(message=t(key="set-password-mismatch", lang=lang | default(value='sk')), extra="mt-3") }}
{% elif error == "weak" %}
{{ ui::alert_danger(message=t(key="set-password-weak", lang=lang | default(value='sk')), extra="mt-3") }}
{% elif error %} {% elif error %}
{{ ui::alert_danger(message=t(key="register-error-invalid", lang=lang | default(value='sk')), extra="mt-3") }} {{ ui::alert_danger(message=t(key="register-error-invalid", lang=lang | default(value='sk')), extra="mt-3") }}
{% endif %} {% endif %}
<form method="post" action="/register" hx-boost="false" class="mt-4 flex flex-col gap-4"> <form method="post" action="/register" hx-boost="false" class="mt-4 flex flex-col gap-4"
x-data="{ password: '', confirm: '' }">
<div class="flex flex-col gap-1.5"> <div class="flex flex-col gap-1.5">
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="account-type", lang=lang | default(value='sk')) }}</span> <span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="account-type", lang=lang | default(value='sk')) }}</span>
<div class="grid grid-cols-2 gap-2"> <div class="grid grid-cols-2 gap-2">
@@ -42,20 +47,12 @@
<span class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="account-type-locked", lang=lang | default(value='sk')) }}</span> <span class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="account-type-locked", lang=lang | default(value='sk')) }}</span>
</div> </div>
<div class="flex flex-col gap-1">
<label for="name"
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="register-name", lang=lang | default(value='sk')) }}
</label>
{{ ui::input(name="name", id="name", required=true, autocomplete="name", attrs="autofocus") }}
</div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<label for="email" <label for="email"
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong"> class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="login-email", lang=lang | default(value='sk')) }} {{ t(key="login-email", lang=lang | default(value='sk')) }}
</label> </label>
{{ ui::input(name="email", id="email", type="email", required=true, autocomplete="email") }} {{ ui::input(name="email", id="email", type="email", required=true, autocomplete="email", attrs="autofocus") }}
</div> </div>
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
@@ -63,10 +60,22 @@
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong"> class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="login-password", lang=lang | default(value='sk')) }} {{ t(key="login-password", lang=lang | default(value='sk')) }}
</label> </label>
{{ ui::input(name="password", id="password", type="password", required=true, autocomplete="new-password") }} {{ ui::input(name="password", id="password", type="password", required=true, autocomplete="new-password", attrs='x-model="password"') }}
</div> </div>
{{ ui::button(label=t(key="register-submit", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full") }} <div class="flex flex-col gap-1">
<label for="password_confirm"
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="set-password-confirm", lang=lang | default(value='sk')) }}
</label>
{{ ui::input(name="password_confirm", id="password_confirm", type="password", required=true, autocomplete="new-password", attrs='x-model="confirm"') }}
<span x-cloak x-show="confirm.length > 0 && password !== confirm"
class="text-xs text-danger dark:text-danger">
{{ t(key="set-password-mismatch", lang=lang | default(value='sk')) }}
</span>
</div>
{{ ui::button(label=t(key="register-submit", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full", attrs=':disabled="password !== confirm"') }}
</form> </form>
<div class="mt-5 flex items-center gap-3 text-xs text-on-surface/50 dark:text-on-surface-dark/50"> <div class="mt-5 flex items-center gap-3 text-xs text-on-surface/50 dark:text-on-surface-dark/50">

View File

@@ -229,9 +229,10 @@
{{ ui::textarea(name="note", id="note", rows="3") }} {{ ui::textarea(name="note", id="note", rows="3") }}
</div> </div>
{% if logged_in_customer %} {% if logged_in_customer and not profile_filled %}
<!-- logged-in customers can persist this address to their profile for next time --> <!-- offered only when the profile has no saved address yet; if it was filled
{{ ui::checkbox(name="save_profile", id="save_profile", label=t(key="checkout-save-profile", lang=lang | default(value='sk')), checked=true) }} 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 %} {% endif %}
{% if can_create_account %} {% if can_create_account %}

View File

@@ -23,6 +23,8 @@ use crate::{
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
struct ProfileForm { struct ProfileForm {
first_name: Option<String>,
last_name: Option<String>,
company_name: Option<String>, company_name: Option<String>,
company_id: Option<String>, company_id: Option<String>,
tax_id: Option<String>, tax_id: Option<String>,
@@ -39,6 +41,24 @@ fn trimmed(value: Option<&str>) -> Option<String> {
value.map(str::trim).filter(|v| !v.is_empty()).map(String::from) value.map(str::trim).filter(|v| !v.is_empty()).map(String::from)
} }
/// Split a stored full name into (first name, surname). The surname is
/// everything after the first whitespace, so multi-word surnames round-trip.
fn split_name(name: &str) -> (String, String) {
match name.trim().split_once(char::is_whitespace) {
Some((first, rest)) => (first.to_string(), rest.trim().to_string()),
None => (name.trim().to_string(), String::new()),
}
}
/// Recombine the two name fields into the single stored `name`. Returns `None`
/// when the result is too short to be a valid name (the user can't blank it out).
fn full_name_from_form(form: &ProfileForm) -> Option<String> {
let first = form.first_name.as_deref().unwrap_or("").trim();
let last = form.last_name.as_deref().unwrap_or("").trim();
let full = format!("{first} {last}").trim().to_string();
(full.chars().count() >= 2).then_some(full)
}
/// Build the persisted fields from the submitted form. Company identifiers are /// Build the persisted fields from the submitted form. Company identifiers are
/// only kept for company accounts (a personal account can never carry them). /// only kept for company accounts (a personal account can never carry them).
fn fields_from_form(form: &ProfileForm, is_company: bool) -> ProfileFields { fn fields_from_form(form: &ProfileForm, is_company: bool) -> ProfileFields {
@@ -92,6 +112,7 @@ fn profile_view(
saved: bool, saved: bool,
error: bool, error: bool,
) -> Result<Response> { ) -> Result<Response> {
let (first_name, last_name) = split_name(&user.name);
format::view( format::view(
v, v,
"account/profile.html", "account/profile.html",
@@ -101,6 +122,8 @@ fn profile_view(
"saved": saved, "saved": saved,
"error": error, "error": error,
"name": user.name, "name": user.name,
"first_name": first_name,
"last_name": last_name,
"email": user.email, "email": user.email,
"account_type": user.account_type, "account_type": user.account_type,
"company_name": fields.company_name, "company_name": fields.company_name,
@@ -147,12 +170,25 @@ 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");
} }
// Apply the edited name to a working copy so it's reflected on both the
// success and re-rendered-error views. A blank/too-short name is ignored —
// the field can't be cleared.
let mut user = user;
let new_name = full_name_from_form(&form).filter(|n| *n != user.name);
if let Some(name) = new_name.clone() {
user.name = name;
}
let fields = fields_from_form(&form, user.is_company()); let fields = fields_from_form(&form, user.is_company());
// A company account's profile is rejected (and re-shown with the entered // A company account's profile is rejected (and re-shown with the entered
// values) until it carries its required identifiers. // values) until it carries its required identifiers.
if user.is_company() && company_fields_missing(&fields) { if user.is_company() && company_fields_missing(&fields) {
return profile_view(&v, &jar, &user, &fields, false, true); return profile_view(&v, &jar, &user, &fields, false, true);
} }
if let Some(name) = new_name {
let mut active = user.clone().into_active_model();
active.name = ActiveValue::set(name);
active.update(&ctx.db).await?;
}
customer_profiles::Model::upsert(&ctx.db, user.id, fields.clone()).await?; customer_profiles::Model::upsert(&ctx.db, user.id, fields.clone()).await?;
profile_view(&v, &jar, &user, &fields, true, false) profile_view(&v, &jar, &user, &fields, true, false)
} }

View File

@@ -106,13 +106,50 @@ async fn register_page(
register_view(&v, &jar, None) register_view(&v, &jar, None)
} }
/// Registration form. The name is no longer collected from the user — it is
/// derived from the email — and the password is entered twice to guard against
/// typos.
#[derive(Debug, serde::Deserialize)]
struct RegisterForm {
email: String,
password: String,
password_confirm: String,
#[serde(default)]
account_type: Option<String>,
}
/// Derive a display name from an email address (its local part), falling back to
/// the full address when the local part is too short for the name validator.
fn name_from_email(email: &str) -> String {
let local = email.split('@').next().unwrap_or("").trim();
if local.chars().count() >= 2 {
local.to_string()
} else {
email.trim().to_string()
}
}
#[debug_handler] #[debug_handler]
async fn register( async fn register(
jar: CookieJar, jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>, ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
Form(params): Form<RegisterParams>, Form(form): Form<RegisterForm>,
) -> Result<Response> { ) -> Result<Response> {
if form.password != form.password_confirm {
return register_view(&v, &jar, Some("mismatch"));
}
if form.password.len() < 8 {
return register_view(&v, &jar, Some("weak"));
}
let params = RegisterParams {
name: name_from_email(&form.email),
email: form.email,
password: form.password,
account_type: form.account_type,
};
let user = match users::Model::create_with_password(&ctx.db, &params).await { let user = match users::Model::create_with_password(&ctx.db, &params).await {
Ok(user) => user, Ok(user) => user,
Err(ModelError::EntityAlreadyExists {}) => { Err(ModelError::EntityAlreadyExists {}) => {

View File

@@ -113,6 +113,12 @@ async fn checkout_page(
let p = |get: fn(&customer_profiles::Model) -> Option<String>| { let p = |get: fn(&customer_profiles::Model) -> Option<String>| {
profile.as_ref().and_then(get) profile.as_ref().and_then(get)
}; };
// Whether the customer already has a shipping address on file. When they do,
// the "save this address to my profile" opt-in is pointless (the profile was
// filled in advance), so it's hidden and the existing profile is left alone.
let profile_filled = profile
.as_ref()
.is_some_and(|pr| pr.address.is_some() && pr.city.is_some() && pr.zip.is_some());
format::view( format::view(
&v, &v,
@@ -126,6 +132,7 @@ async fn checkout_page(
"packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""), "packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""),
"logged_in_admin": is_admin, "logged_in_admin": is_admin,
"logged_in_customer": is_customer, "logged_in_customer": is_customer,
"profile_filled": profile_filled,
// A logged-in customer's account type is fixed; only guests pick it // A logged-in customer's account type is fixed; only guests pick it
// and may opt to create an account from the order. // and may opt to create an account from the order.
"account_fixed": is_customer, "account_fixed": is_customer,