5 Commits

Author SHA1 Message Date
Priec
43c6c04dcf my profile orders and sidebar
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:59:56 +02:00
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
13 changed files with 573 additions and 25 deletions

View File

@@ -274,6 +274,21 @@ profile-intro = We'll use these details to prefill checkout.
profile-saved = Profile saved.
profile-save = Save profile
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
nav-account = My account
account-orders = My orders
account-change-password = Change password
orders-active = Active orders
orders-past = Past orders
orders-empty = You don't have any orders yet.
password-change-title = Change password
password-current = Current password
password-current-wrong = Your current password is incorrect.
password-changed = Your password has been changed.
account-type-locked = Account type can't be changed after registration.
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.
@@ -303,6 +318,7 @@ admin-no-orders = No orders yet.
order-status-pending = Pending
order-status-paid = Paid
order-status-shipped = Shipped
order-status-delivered = Delivered
order-status-cancelled = Cancelled
order-update-status = Update status

View File

@@ -274,6 +274,21 @@ profile-intro = Tieto údaje použijeme na predvyplnenie pokladne.
profile-saved = Profil bol uložený.
profile-save = Uložiť profil
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é
nav-account = Môj účet
account-orders = Moje objednávky
account-change-password = Zmeniť heslo
orders-active = Aktívne objednávky
orders-past = Staršie objednávky
orders-empty = Zatiaľ nemáte žiadne objednávky.
password-change-title = Zmeniť heslo
password-current = Súčasné heslo
password-current-wrong = Vaše súčasné heslo je nesprávne.
password-changed = Vaše heslo bolo zmenené.
account-type-locked = Typ účtu sa po registrácii nedá zmeniť.
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.
@@ -303,6 +318,7 @@ admin-no-orders = Zatiaľ žiadne objednávky.
order-status-pending = Čaká na spracovanie
order-status-paid = Zaplatené
order-status-shipped = Odoslané
order-status-delivered = Doručené
order-status-cancelled = Zrušené
order-update-status = Zmeniť stav

View File

@@ -0,0 +1,76 @@
{% extends "base.html" %}
{% import "macros/ui.html" as ui %}
{% block title %}{{ order.order_number }}{% endblock title %}
{% macro status_badge(status) %}
{% if status == "delivered" %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="success") }}
{% elif status == "shipped" %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="primary") }}
{% elif status == "paid" %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="info") }}
{% elif status == "cancelled" %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="danger") }}
{% else %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="warning") }}
{% endif %}
{% endmacro status_badge %}
{% block content %}
<div class="mx-auto max-w-2xl space-y-6">
<a href="/account/orders" class="inline-flex items-center gap-1 text-sm text-primary underline-offset-2 hover:underline dark: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="M15.75 19.5 8.25 12l7.5-7.5" /></svg>
{{ t(key="account-orders", lang=lang | default(value='sk')) }}
</a>
<div class="flex flex-wrap items-center justify-between gap-3">
<h1 class="font-mono text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.order_number }}</h1>
{{ self::status_badge(status=order.status) }}
</div>
<p class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ order.created_at | truncate(length=10, end="") }}</p>
<div class="rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
<ul class="space-y-2 pb-3 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.product_name }} × {{ item.quantity }}</span>
<span class="tabular-nums">{{ item.line_total }} {{ order.currency }}</span>
</li>
{% endfor %}
</ul>
<div class="space-y-1 border-t border-outline py-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">{{ order.subtotal }} {{ order.currency }}</span></div>
<div class="flex justify-between"><span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ order.carrier_name }}</span><span class="tabular-nums">{{ order.shipping }} {{ order.currency }}</span></div>
{% if order.pickup_point_name %}<div class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ order.pickup_point_name }}</div>{% endif %}
</div>
<div class="flex justify-between border-t border-outline pt-3 font-bold dark:border-outline-dark">
<span>{{ t(key="order-total", lang=lang | default(value='sk')) }}</span>
<span class="tabular-nums text-primary dark:text-primary-dark">{{ order.total }} {{ order.currency }}</span>
</div>
</div>
{% if order.tracking_number %}
<div class="rounded-radius border border-outline bg-surface p-4 text-sm dark:border-outline-dark dark:bg-surface-dark-alt">
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="order-tracking", lang=lang | default(value='sk')) }}</span>
<span class="font-mono font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.tracking_number }}</span>
</div>
</div>
{% endif %}
<div class="rounded-radius border border-outline bg-surface p-6 text-sm dark:border-outline-dark dark:bg-surface-dark-alt">
<h2 class="mb-2 font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}</h2>
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.customer_name }}</p>
{% if order.address %}<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.address }}</p>{% endif %}
<p class="text-on-surface/80 dark:text-on-surface-dark/80">{{ order.zip }} {{ order.city }}{% if order.country %}, {{ order.country }}{% endif %}</p>
</div>
{% if order.payment_method == "bank_transfer" and order.status == "pending" %}
<div class="space-y-2 rounded-radius border border-primary/40 bg-primary/5 p-6 text-sm dark:border-primary-dark/40">
<p class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="payment-bank-instructions", lang=lang | default(value='sk')) }}</p>
<div class="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1">
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-account-name", lang=lang | default(value='sk')) }}</span><span class="font-medium">{{ order.bank_account_name }}</span>
<span class="text-on-surface/70 dark:text-on-surface-dark/70">IBAN</span><span class="font-mono font-medium">{{ order.bank_iban }}</span>
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-variable-symbol", lang=lang | default(value='sk')) }}</span><span class="font-mono font-medium">{{ order.variable_symbol }}</span>
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="bank-amount", lang=lang | default(value='sk')) }}</span><span class="font-medium tabular-nums">{{ order.total }} {{ order.currency }}</span>
</div>
</div>
{% endif %}
</div>
{% endblock content %}

View File

@@ -0,0 +1,52 @@
{% extends "base.html" %}
{% import "macros/ui.html" as ui %}
{% block title %}{{ t(key="account-orders", lang=lang | default(value='sk')) }}{% endblock title %}
{# status → badge variant #}
{% macro status_badge(status) %}
{% if status == "delivered" %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="success") }}
{% elif status == "shipped" %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="primary") }}
{% elif status == "paid" %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="info") }}
{% elif status == "cancelled" %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="danger") }}
{% else %}{{ ui::badge(label=t(key="order-status-" ~ status, lang=lang | default(value='sk')), variant="warning") }}
{% endif %}
{% endmacro status_badge %}
{% macro order_row(order) %}
<a href="/account/orders/{{ order.order_number }}"
class="flex flex-wrap items-center justify-between gap-3 rounded-radius border border-outline bg-surface p-4 transition hover:border-primary hover:bg-primary/5 dark:border-outline-dark dark:bg-surface-dark-alt dark:hover:border-primary-dark">
<div class="min-w-0">
<p class="font-mono text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.order_number }}</p>
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ order.created_at | truncate(length=10, end="") }}</p>
</div>
<div class="flex items-center gap-4">
{{ self::status_badge(status=order.status) }}
<span class="tabular-nums text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.total }} {{ order.currency }}</span>
</div>
</a>
{% endmacro order_row %}
{% block content %}
<div class="mx-auto max-w-3xl space-y-8">
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="account-orders", lang=lang | default(value='sk')) }}</h1>
{% if active_orders | length == 0 and past_orders | length == 0 %}
<p class="rounded-radius border border-outline bg-surface p-6 text-sm text-on-surface/70 dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark/70">{{ t(key="orders-empty", lang=lang | default(value='sk')) }}</p>
{% endif %}
{% if active_orders | length > 0 %}
<section class="space-y-3">
<h2 class="text-lg font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="orders-active", lang=lang | default(value='sk')) }}</h2>
{% for order in active_orders %}{{ self::order_row(order=order) }}{% endfor %}
</section>
{% endif %}
{% if past_orders | length > 0 %}
<section class="space-y-3">
<h2 class="text-lg font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="orders-past", lang=lang | default(value='sk')) }}</h2>
{% for order in past_orders %}{{ self::order_row(order=order) }}{% endfor %}
</section>
{% endif %}
</div>
{% endblock content %}

View File

@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% import "macros/ui.html" as ui %}
{% block title %}{{ t(key="password-change-title", lang=lang | default(value='sk')) }}{% endblock title %}
{% block content %}
<div class="mx-auto max-w-md">
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="password-change-title", lang=lang | default(value='sk')) }}</h1>
{% if changed %}
<div class="mt-4 rounded-radius border border-success bg-success/10 px-4 py-3 text-sm text-success" role="status">
{{ t(key="password-changed", lang=lang | default(value='sk')) }}
</div>
{% endif %}
{% if error == "current" %}
{{ ui::alert_danger(message=t(key="password-current-wrong", lang=lang | default(value='sk')), extra="mt-4") }}
{% elif error == "mismatch" %}
{{ ui::alert_danger(message=t(key="set-password-mismatch", lang=lang | default(value='sk')), extra="mt-4") }}
{% elif error == "weak" %}
{{ ui::alert_danger(message=t(key="set-password-weak", lang=lang | default(value='sk')), extra="mt-4") }}
{% endif %}
<form method="post" action="/account/password" hx-boost="false" class="mt-6 flex flex-col gap-4"
x-data="{ password: '', confirm: '' }">
<div class="flex flex-col gap-1">
<label for="current_password" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="password-current", lang=lang | default(value='sk')) }}</label>
{{ ui::input(name="current_password", id="current_password", type="password", required=true, autocomplete="current-password") }}
</div>
<div class="flex flex-col gap-1">
<label for="password" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="set-password-new", lang=lang | default(value='sk')) }}</label>
{{ ui::input(name="password", id="password", type="password", required=true, autocomplete="new-password", attrs='x-model="password"') }}
</div>
<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="password-change-title", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full", attrs=':disabled="password !== confirm"') }}
</form>
</div>
{% endblock content %}

View File

@@ -3,8 +3,19 @@
{% 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 %}
<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>
<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") }}
{% 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 -->
<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>
@@ -59,9 +123,15 @@
<!-- 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">
<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">
<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>
<p class="text-sm text-on-surface/80 dark:text-on-surface-dark/80">{{ name }}</p>
<div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-1.5">
<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>
{{ 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 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>
@@ -149,7 +219,10 @@
</div>
</fieldset>
{{ ui::button(label=t(key="profile-save", lang=lang | default(value='sk')), type="submit", size="px-6 py-2.5 text-sm") }}
<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-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>
</div>
{% endblock content %}

View File

@@ -22,11 +22,16 @@
{% if error == "exists" %}
{{ 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 %}
{{ ui::alert_danger(message=t(key="register-error-invalid", lang=lang | default(value='sk')), extra="mt-3") }}
{% 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">
<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">
@@ -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>
</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">
<label for="email"
class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">
{{ t(key="login-email", lang=lang | default(value='sk')) }}
</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 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">
{{ t(key="login-password", lang=lang | default(value='sk')) }}
</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>
{{ 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>
<div class="mt-5 flex items-center gap-3 text-xs text-on-surface/50 dark:text-on-surface-dark/50">

View File

@@ -158,6 +158,22 @@
class="fixed inset-0 z-30 bg-black/50 lg:hidden"></div>
<div class="mx-auto flex w-full max-w-7xl gap-8 px-4 py-8">
{% if account_nav %}
<!-- account-area sidebar: replaces the storefront categories while the
customer is inside /account/*. -->
<aside x-cloak x-show="cats || lg" aria-label="{{ t(key='nav-account', lang=lang | default(value='sk')) }}"
class="fixed inset-y-0 left-0 z-40 w-64 overflow-y-auto border-r border-outline bg-surface-alt p-4 lg:static lg:z-auto lg:w-64 lg:shrink-0 lg:self-start lg:overflow-visible lg:rounded-radius lg:border lg:p-3 dark:border-outline-dark dark:bg-surface-dark-alt">
<h2 class="px-3 pb-2 text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="nav-account", lang=lang | default(value='sk')) }}</h2>
<ul class="space-y-1">
<li><a href="/account/orders" data-nav="/account/orders" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="account-orders", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/account/profile" data-nav="/account/profile" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="profile-title", lang=lang | default(value='sk')) }}</a></li>
<li><a href="/account/password" data-nav="/account/password" class="block rounded-radius px-3 py-2 text-sm font-medium text-on-surface underline-offset-2 transition hover:bg-primary/5 hover:text-primary focus:outline-hidden focus-visible:underline aria-[current=page]:font-semibold aria-[current=page]:bg-primary/10 aria-[current=page]:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark dark:aria-[current=page]:text-primary-dark">{{ t(key="account-change-password", lang=lang | default(value='sk')) }}</a></li>
</ul>
<form method="post" action="/logout" hx-boost="false" class="mt-4 border-t border-outline pt-3 dark:border-outline-dark">
<button type="submit" class="block w-full rounded-radius px-3 py-2 text-left text-sm font-medium text-danger underline-offset-2 transition hover:bg-primary/5 focus:outline-hidden focus-visible:underline">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
</form>
</aside>
{% else %}
<!-- persistent category sidebar (off-canvas drawer on mobile).
hx-preserve keeps this node across boosted page swaps, so it is
fetched once (hx-trigger=load) and never reloaded on navigation. -->
@@ -166,6 +182,7 @@
hx-get="/partials/categories" hx-trigger="load"
class="fixed inset-y-0 left-0 z-40 w-64 overflow-y-auto border-r border-outline bg-surface-alt p-4 lg:static lg:z-auto lg:w-64 lg:shrink-0 lg:self-start lg:overflow-visible lg:rounded-radius lg:border lg:p-3 dark:border-outline-dark dark:bg-surface-dark-alt">
</aside>
{% endif %}
<main class="min-w-0 flex-1">
{% block content %}{% endblock content %}

View File

@@ -229,9 +229,10 @@
{{ ui::textarea(name="note", id="note", rows="3") }}
</div>
{% if logged_in_customer %}
<!-- logged-in customers can persist this address to their profile for next time -->
{{ ui::checkbox(name="save_profile", id="save_profile", label=t(key="checkout-save-profile", lang=lang | default(value='sk')), checked=true) }}
{% 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 %}

View File

@@ -9,6 +9,7 @@
use axum_extra::extract::cookie::CookieJar;
use loco_rs::prelude::*;
use sea_orm::QueryOrder;
use serde::Deserialize;
use serde_json::json;
@@ -16,13 +17,20 @@ use crate::{
controllers::i18n::current_lang,
models::{
customer_profiles::{self, ProfileFields},
users,
order_items, orders, users,
},
shared::guard,
shared::{guard, settings},
views::checkout as order_view,
};
/// Active (still-being-fulfilled) order statuses. Anything else
/// (`delivered`, `cancelled`) is considered closed/past.
const ACTIVE_STATUSES: [&str; 3] = ["pending", "paid", "shipped"];
#[derive(Debug, Deserialize)]
struct ProfileForm {
first_name: Option<String>,
last_name: Option<String>,
company_name: Option<String>,
company_id: Option<String>,
tax_id: Option<String>,
@@ -39,6 +47,24 @@ fn trimmed(value: Option<&str>) -> Option<String> {
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
/// only kept for company accounts (a personal account can never carry them).
fn fields_from_form(form: &ProfileForm, is_company: bool) -> ProfileFields {
@@ -92,15 +118,19 @@ fn profile_view(
saved: bool,
error: bool,
) -> Result<Response> {
let (first_name, last_name) = split_name(&user.name);
format::view(
v,
"account/profile.html",
json!({
"logged_in_admin": false,
"logged_in_customer": true,
"account_nav": true,
"saved": saved,
"error": error,
"name": user.name,
"first_name": first_name,
"last_name": last_name,
"email": user.email,
"account_type": user.account_type,
"company_name": fields.company_name,
@@ -147,18 +177,188 @@ async fn save_profile(
if guard::is_admin(&ctx, &user) {
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());
// A company account's profile is rejected (and re-shown with the entered
// values) until it carries its required identifiers.
if user.is_company() && company_fields_missing(&fields) {
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?;
profile_view(&v, &jar, &user, &fields, true, false)
}
/// Lists the signed-in customer's orders, split into still-active and past.
#[debug_handler]
async fn orders_page(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let Some(user) = guard::current_user(&ctx, &jar).await else {
return format::redirect("/login");
};
if guard::is_admin(&ctx, &user) {
return format::redirect("/admin/dashboard");
}
let rows = orders::Entity::find()
.filter(orders::Column::UserId.eq(user.id))
.order_by_desc(orders::Column::CreatedAt)
.all(&ctx.db)
.await?;
let (active, past): (Vec<_>, Vec<_>) = rows
.iter()
.partition(|o| ACTIVE_STATUSES.contains(&o.status.as_str()));
let shape = |list: Vec<&orders::Model>| -> Vec<_> {
list.into_iter().map(order_view::summary).collect()
};
format::view(
&v,
"account/orders.html",
json!({
"logged_in_admin": false,
"logged_in_customer": true,
"account_nav": true,
"active_orders": shape(active),
"past_orders": shape(past),
"lang": current_lang(&jar),
}),
)
}
/// Shows a single order belonging to the signed-in customer. Orders owned by
/// someone else (or guest orders) are not found here.
#[debug_handler]
async fn order_detail_page(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
Path(order_number): Path<String>,
) -> Result<Response> {
let Some(user) = guard::current_user(&ctx, &jar).await else {
return format::redirect("/login");
};
if guard::is_admin(&ctx, &user) {
return format::redirect("/admin/dashboard");
}
let order = orders::Entity::find()
.filter(orders::Column::OrderNumber.eq(order_number))
.one(&ctx.db)
.await?
.filter(|o| o.user_id == Some(user.id))
.ok_or_else(|| Error::NotFound)?;
let items = order_items::Entity::find()
.filter(order_items::Column::OrderId.eq(order.id))
.all(&ctx.db)
.await?;
format::view(
&v,
"account/order_detail.html",
json!({
"logged_in_admin": false,
"logged_in_customer": true,
"account_nav": true,
"order": order_view::detail(
&order,
settings::get(&ctx, "bank_iban").unwrap_or(""),
settings::get(&ctx, "bank_account_name").unwrap_or(""),
),
"items": order_view::items(&items),
"lang": current_lang(&jar),
}),
)
}
#[derive(Debug, Deserialize)]
struct ChangePasswordForm {
current_password: String,
password: String,
password_confirm: String,
}
fn password_view(
v: &TeraView,
jar: &CookieJar,
changed: bool,
error: Option<&str>,
) -> Result<Response> {
format::view(
v,
"account/password.html",
json!({
"logged_in_admin": false,
"logged_in_customer": true,
"account_nav": true,
"changed": changed,
"error": error,
"lang": current_lang(jar),
}),
)
}
#[debug_handler]
async fn change_password_page(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let Some(user) = guard::current_user(&ctx, &jar).await else {
return format::redirect("/login");
};
if guard::is_admin(&ctx, &user) {
return format::redirect("/admin/dashboard");
}
password_view(&v, &jar, false, None)
}
#[debug_handler]
async fn change_password(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
Form(form): Form<ChangePasswordForm>,
) -> Result<Response> {
let Some(user) = guard::current_user(&ctx, &jar).await else {
return format::redirect("/login");
};
if guard::is_admin(&ctx, &user) {
return format::redirect("/admin/dashboard");
}
if !user.verify_password(&form.current_password) {
return password_view(&v, &jar, false, Some("current"));
}
if form.password != form.password_confirm {
return password_view(&v, &jar, false, Some("mismatch"));
}
if form.password.len() < 8 {
return password_view(&v, &jar, false, Some("weak"));
}
user.into_active_model()
.reset_password(&ctx.db, &form.password)
.await?;
password_view(&v, &jar, true, None)
}
pub fn routes() -> Routes {
Routes::new()
.add("/account/profile", get(profile_page))
.add("/account/profile", post(save_profile))
.add("/account/orders", get(orders_page))
.add("/account/orders/{order_number}", get(order_detail_page))
.add("/account/password", get(change_password_page))
.add("/account/password", post(change_password))
}

View File

@@ -14,7 +14,8 @@ use crate::{
shared::{guard, settings},
};
pub(crate) const ORDER_STATUSES: [&str; 4] = ["pending", "paid", "shipped", "cancelled"];
pub(crate) const ORDER_STATUSES: [&str; 5] =
["pending", "paid", "shipped", "delivered", "cancelled"];
/// Fallback parcel weight when products carry no weight of their own.
const DEFAULT_PARCEL_WEIGHT_GRAMS: i32 = 1000;

View File

@@ -106,13 +106,50 @@ async fn register_page(
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]
async fn register(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
Form(params): Form<RegisterParams>,
Form(form): Form<RegisterForm>,
) -> 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 {
Ok(user) => user,
Err(ModelError::EntityAlreadyExists {}) => {

View File

@@ -113,6 +113,12 @@ async fn checkout_page(
let p = |get: fn(&customer_profiles::Model) -> Option<String>| {
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(
&v,
@@ -126,6 +132,7 @@ 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,
"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,