From 43c6c04dcff462f198f2259fc935e792259f6ba0 Mon Sep 17 00:00:00 2001 From: Priec Date: Fri, 19 Jun 2026 11:59:56 +0200 Subject: [PATCH] my profile orders and sidebar --- assets/i18n/en/main.ftl | 11 ++ assets/i18n/sk/main.ftl | 11 ++ assets/views/account/order_detail.html | 76 +++++++++++ assets/views/account/orders.html | 52 ++++++++ assets/views/account/password.html | 43 +++++++ assets/views/base.html | 17 +++ src/controllers/account.rs | 168 ++++++++++++++++++++++++- src/controllers/admin_orders.rs | 3 +- 8 files changed, 378 insertions(+), 3 deletions(-) create mode 100644 assets/views/account/order_detail.html create mode 100644 assets/views/account/orders.html create mode 100644 assets/views/account/password.html 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 %} + +
+
+ + {{ ui::input(name="current_password", id="current_password", type="password", required=true, autocomplete="current-password") }} +
+
+ + {{ ui::input(name="password", id="password", type="password", required=true, autocomplete="new-password", attrs='x-model="password"') }} +
+
+ + {{ ui::input(name="password_confirm", id="password_confirm", type="password", required=true, autocomplete="new-password", attrs='x-model="confirm"') }} + + {{ t(key="set-password-mismatch", lang=lang | default(value='sk')) }} + +
+ {{ ui::button(label=t(key="password-change-title", lang=lang | default(value='sk')), type="submit", extra="mt-1 w-full", attrs=':disabled="password !== confirm"') }} +
+
+{% 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;