diff --git a/assets/i18n/en/main.ftl b/assets/i18n/en/main.ftl
index 28a4f61..c166590 100644
--- a/assets/i18n/en/main.ftl
+++ b/assets/i18n/en/main.ftl
@@ -279,6 +279,16 @@ 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.
@@ -308,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
diff --git a/assets/i18n/sk/main.ftl b/assets/i18n/sk/main.ftl
index d6ab07d..bba36c6 100644
--- a/assets/i18n/sk/main.ftl
+++ b/assets/i18n/sk/main.ftl
@@ -279,6 +279,16 @@ 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.
@@ -308,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
diff --git a/assets/views/account/order_detail.html b/assets/views/account/order_detail.html
new file mode 100644
index 0000000..f9ad41d
--- /dev/null
+++ b/assets/views/account/order_detail.html
@@ -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 %}
+
+
+
+ {{ t(key="account-orders", lang=lang | default(value='sk')) }}
+
+
+
+
{{ order.order_number }}
+ {{ self::status_badge(status=order.status) }}
+
+
{{ order.created_at | truncate(length=10, end="") }}
+
+
+
+ {% for item in items %}
+ -
+ {{ item.product_name }} × {{ item.quantity }}
+ {{ item.line_total }} {{ order.currency }}
+
+ {% endfor %}
+
+
+
{{ t(key="checkout-subtotal", lang=lang | default(value='sk')) }}{{ order.subtotal }} {{ order.currency }}
+
{{ order.carrier_name }}{{ order.shipping }} {{ order.currency }}
+ {% if order.pickup_point_name %}
{{ order.pickup_point_name }}
{% endif %}
+
+
+ {{ t(key="order-total", lang=lang | default(value='sk')) }}
+ {{ order.total }} {{ order.currency }}
+
+
+
+ {% if order.tracking_number %}
+
+
+ {{ t(key="order-tracking", lang=lang | default(value='sk')) }}
+ {{ order.tracking_number }}
+
+
+ {% endif %}
+
+
+
{{ t(key="checkout-shipping", lang=lang | default(value='sk')) }}
+
{{ order.customer_name }}
+ {% if order.address %}
{{ order.address }}
{% endif %}
+
{{ order.zip }} {{ order.city }}{% if order.country %}, {{ order.country }}{% endif %}
+
+
+ {% if order.payment_method == "bank_transfer" and order.status == "pending" %}
+
+
{{ t(key="payment-bank-instructions", lang=lang | default(value='sk')) }}
+
+ {{ t(key="bank-account-name", lang=lang | default(value='sk')) }}{{ order.bank_account_name }}
+ IBAN{{ order.bank_iban }}
+ {{ t(key="bank-variable-symbol", lang=lang | default(value='sk')) }}{{ order.variable_symbol }}
+ {{ t(key="bank-amount", lang=lang | default(value='sk')) }}{{ order.total }} {{ order.currency }}
+
+
+ {% endif %}
+
+{% endblock content %}
diff --git a/assets/views/account/orders.html b/assets/views/account/orders.html
new file mode 100644
index 0000000..5f9c437
--- /dev/null
+++ b/assets/views/account/orders.html
@@ -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) %}
+
+
+
{{ order.order_number }}
+
{{ order.created_at | truncate(length=10, end="") }}
+
+
+ {{ self::status_badge(status=order.status) }}
+ {{ order.total }} {{ order.currency }}
+
+
+{% endmacro order_row %}
+
+{% block content %}
+
+
{{ t(key="account-orders", lang=lang | default(value='sk')) }}
+
+ {% if active_orders | length == 0 and past_orders | length == 0 %}
+
{{ t(key="orders-empty", lang=lang | default(value='sk')) }}
+ {% endif %}
+
+ {% if active_orders | length > 0 %}
+
+ {{ t(key="orders-active", lang=lang | default(value='sk')) }}
+ {% for order in active_orders %}{{ self::order_row(order=order) }}{% endfor %}
+
+ {% endif %}
+
+ {% if past_orders | length > 0 %}
+
+ {{ t(key="orders-past", lang=lang | default(value='sk')) }}
+ {% for order in past_orders %}{{ self::order_row(order=order) }}{% endfor %}
+
+ {% endif %}
+
+{% endblock content %}
diff --git a/assets/views/account/password.html b/assets/views/account/password.html
new file mode 100644
index 0000000..784df64
--- /dev/null
+++ b/assets/views/account/password.html
@@ -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 %}
+
+
{{ t(key="password-change-title", lang=lang | default(value='sk')) }}
+
+ {% if changed %}
+
+ {{ t(key="password-changed", lang=lang | default(value='sk')) }}
+
+ {% 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 %}
+
+
+
+{% endblock content %}
diff --git a/assets/views/base.html b/assets/views/base.html
index 43baef3..6a3245a 100644
--- a/assets/views/base.html
+++ b/assets/views/base.html
@@ -158,6 +158,22 @@
class="fixed inset-0 z-30 bg-black/50 lg:hidden">
+ {% if account_nav %}
+
+
+ {% else %}
@@ -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">
+ {% endif %}
{% block content %}{% endblock content %}
diff --git a/src/controllers/account.rs b/src/controllers/account.rs
index 73181c8..3b59e3b 100644
--- a/src/controllers/account.rs
+++ b/src/controllers/account.rs
@@ -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,11 +17,16 @@ 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,
@@ -119,6 +125,7 @@ fn profile_view(
json!({
"logged_in_admin": false,
"logged_in_customer": true,
+ "account_nav": true,
"saved": saved,
"error": error,
"name": user.name,
@@ -193,8 +200,165 @@ async fn save_profile(
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,
+ State(ctx): State,
+) -> Result {
+ 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,
+ State(ctx): State,
+ Path(order_number): Path,
+) -> Result {
+ 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 {
+ 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,
+ State(ctx): State,
+) -> Result {
+ 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,
+ State(ctx): State,
+ Form(form): Form,
+) -> Result {
+ 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))
}
diff --git a/src/controllers/admin_orders.rs b/src/controllers/admin_orders.rs
index d60390f..59728de 100644
--- a/src/controllers/admin_orders.rs
+++ b/src/controllers/admin_orders.rs
@@ -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;