navbar profile

This commit is contained in:
Priec
2026-06-19 13:59:31 +02:00
parent 14ae859152
commit 454d5cb349
8 changed files with 124 additions and 37 deletions

View File

@@ -86,12 +86,7 @@
</form> </form>
</li> </li>
{% elif logged_in_customer %} {% elif logged_in_customer %}
<li>{{ ui::nav_link(label=t(key="nav-profile", lang=lang | default(value='sk')), href="/account/profile", data_nav="/account") }}</li> {# customer account links live in the profile dropdown next to the cart #}
<li>
<form method="post" action="/logout" hx-boost="false">
<button type="submit" class="text-sm font-medium text-danger underline-offset-2 transition hover:opacity-75 focus:outline-hidden focus-visible:underline">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
</form>
</li>
{% else %} {% else %}
<li>{{ ui::nav_link(label=t(key="nav-login", lang=lang | default(value='sk')), href="/login", data_nav="/login") }}</li> <li>{{ ui::nav_link(label=t(key="nav-login", lang=lang | default(value='sk')), href="/login", data_nav="/login") }}</li>
<li>{{ ui::nav_link(label=t(key="nav-register", lang=lang | default(value='sk')), href="/register", data_nav="/register") }}</li> <li>{{ ui::nav_link(label=t(key="nav-register", lang=lang | default(value='sk')), href="/register", data_nav="/register") }}</li>
@@ -111,6 +106,13 @@
<span x-show="count > 0" x-cloak x-text="count" <span x-show="count > 0" x-cloak x-text="count"
class="absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-4 text-on-primary dark:bg-primary-dark dark:text-on-primary-dark"></span> class="absolute -right-1 -top-1 inline-flex min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] font-semibold leading-4 text-on-primary dark:bg-primary-dark dark:text-on-primary-dark"></span>
</a> </a>
<!-- customer profile dropdown (avatar + name + account type) -->
{% if logged_in_customer %}
<div x-data="{ open: false }" @keydown.escape="open = false" class="relative">
{% include "partials/profile_menu.html" %}
</div>
{% endif %}
<!-- settings (language + theme) dropdown --> <!-- settings (language + theme) dropdown -->
<div x-data="{ open: false }" @keydown.escape="open = false" class="relative"> <div x-data="{ open: false }" @keydown.escape="open = false" class="relative">
{% include "partials/settings_dropdown.html" %} {% include "partials/settings_dropdown.html" %}

View File

@@ -0,0 +1,46 @@
{# Customer profile dropdown shown in the storefront navbar next to the cart.
Trigger shows an initials avatar, the customer's name and their account type
(personal / company); clicking opens a quick-navigation menu.
The host template provides the wrapper
<div x-data="{ open: false }" @keydown.escape="open = false" class="relative">. #}
{# initials from the name, e.g. "Filip Priec" -> "FP" #}
{% set _name = customer_name | default(value='') | trim %}
{% set _parts = _name | split(pat=' ') %}
{% set _initials = _parts.0 | truncate(length=1, end='') | upper %}
{% if _parts | length > 1 %}{% set _second = _parts | last | truncate(length=1, end='') | upper %}{% set _initials = _initials ~ _second %}{% endif %}
{% if customer_account_type == "company" %}{% set _type_label = t(key="account-company", lang=lang | default(value='sk')) %}{% else %}{% set _type_label = t(key="account-personal", lang=lang | default(value='sk')) %}{% endif %}
<button type="button" @click="open = !open" :aria-expanded="open"
aria-label="{{ t(key='nav-account', lang=lang | default(value='sk')) }}"
class="flex items-center gap-2 rounded-radius border border-outline bg-surface-alt py-1 pl-1 pr-2 text-left transition hover:border-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:hover:border-primary-dark">
<span class="inline-flex size-7 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-semibold text-on-primary dark:bg-primary-dark dark:text-on-primary-dark">{{ _initials }}</span>
<span class="hidden min-w-0 flex-col leading-tight sm:flex">
<span class="truncate text-xs font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ _name }}</span>
<span class="truncate text-[10px] text-on-surface/60 dark:text-on-surface-dark/60">{{ _type_label }}</span>
</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="hidden size-4 text-on-surface/60 sm:block dark:text-on-surface-dark/60" :class="open && 'rotate-180'"><path fill-rule="evenodd" d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z" clip-rule="evenodd" /></svg>
</button>
<div x-show="open" x-cloak @click.outside="open = false" x-transition.origin.top.right
class="absolute right-0 mt-2 flex w-56 flex-col overflow-hidden rounded-radius border border-outline bg-surface-alt py-1 shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt"
role="menu">
<div class="flex items-center gap-3 border-b border-outline px-4 py-3 dark:border-outline-dark">
<span class="inline-flex size-9 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-semibold text-on-primary dark:bg-primary-dark dark:text-on-primary-dark">{{ _initials }}</span>
<span class="flex min-w-0 flex-col leading-tight">
<span class="truncate text-sm font-semibold text-on-surface-strong dark:text-on-surface-dark-strong">{{ _name }}</span>
<span class="truncate text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ _type_label }}</span>
</span>
</div>
<a href="/account/orders" data-nav="/account/orders" role="menuitem"
class="px-4 py-2 text-sm text-on-surface transition hover:bg-primary/5 hover:text-on-surface-strong focus-visible:bg-primary/10 focus-visible:outline-hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">{{ t(key="account-orders", lang=lang | default(value='sk')) }}</a>
<a href="/account/profile" data-nav="/account/profile" role="menuitem"
class="px-4 py-2 text-sm text-on-surface transition hover:bg-primary/5 hover:text-on-surface-strong focus-visible:bg-primary/10 focus-visible:outline-hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">{{ t(key="profile-title", lang=lang | default(value='sk')) }}</a>
<a href="/account/password" data-nav="/account/password" role="menuitem"
class="px-4 py-2 text-sm text-on-surface transition hover:bg-primary/5 hover:text-on-surface-strong focus-visible:bg-primary/10 focus-visible:outline-hidden dark:text-on-surface-dark dark:hover:bg-primary-dark/5 dark:hover:text-on-surface-dark-strong">{{ t(key="account-change-password", lang=lang | default(value='sk')) }}</a>
<form method="post" action="/logout" hx-boost="false" class="border-t border-outline dark:border-outline-dark">
<button type="submit" role="menuitem"
class="block w-full px-4 py-2 text-left text-sm font-medium text-danger transition hover:bg-primary/5 focus-visible:bg-primary/10 focus-visible:outline-hidden">{{ t(key="logout", lang=lang | default(value='sk')) }}</button>
</form>
</div>

View File

@@ -126,6 +126,8 @@ fn profile_view(
"logged_in_admin": false, "logged_in_admin": false,
"logged_in_customer": true, "logged_in_customer": true,
"account_nav": true, "account_nav": true,
"customer_name": user.name,
"customer_account_type": user.account_type,
"saved": saved, "saved": saved,
"error": error, "error": error,
"name": user.name, "name": user.name,
@@ -232,6 +234,8 @@ async fn orders_page(
"logged_in_admin": false, "logged_in_admin": false,
"logged_in_customer": true, "logged_in_customer": true,
"account_nav": true, "account_nav": true,
"customer_name": user.name,
"customer_account_type": user.account_type,
"active_orders": shape(active), "active_orders": shape(active),
"past_orders": shape(past), "past_orders": shape(past),
"lang": current_lang(&jar), "lang": current_lang(&jar),
@@ -272,6 +276,8 @@ async fn order_detail_page(
"logged_in_admin": false, "logged_in_admin": false,
"logged_in_customer": true, "logged_in_customer": true,
"account_nav": true, "account_nav": true,
"customer_name": user.name,
"customer_account_type": user.account_type,
"order": order_view::detail( "order": order_view::detail(
&order, &order,
settings::get(&ctx, "bank_iban").unwrap_or(""), settings::get(&ctx, "bank_iban").unwrap_or(""),
@@ -293,6 +299,7 @@ struct ChangePasswordForm {
fn password_view( fn password_view(
v: &TeraView, v: &TeraView,
jar: &CookieJar, jar: &CookieJar,
user: &users::Model,
changed: bool, changed: bool,
error: Option<&str>, error: Option<&str>,
) -> Result<Response> { ) -> Result<Response> {
@@ -303,6 +310,8 @@ fn password_view(
"logged_in_admin": false, "logged_in_admin": false,
"logged_in_customer": true, "logged_in_customer": true,
"account_nav": true, "account_nav": true,
"customer_name": user.name,
"customer_account_type": user.account_type,
"changed": changed, "changed": changed,
"error": error, "error": error,
"lang": current_lang(jar), "lang": current_lang(jar),
@@ -322,7 +331,7 @@ async fn change_password_page(
if guard::is_admin(&ctx, &user) { if guard::is_admin(&ctx, &user) {
return format::redirect("/admin/dashboard"); return format::redirect("/admin/dashboard");
} }
password_view(&v, &jar, false, None) password_view(&v, &jar, &user, false, None)
} }
#[debug_handler] #[debug_handler]
@@ -339,18 +348,19 @@ async fn change_password(
return format::redirect("/admin/dashboard"); return format::redirect("/admin/dashboard");
} }
if !user.verify_password(&form.current_password) { if !user.verify_password(&form.current_password) {
return password_view(&v, &jar, false, Some("current")); return password_view(&v, &jar, &user, false, Some("current"));
} }
if form.password != form.password_confirm { if form.password != form.password_confirm {
return password_view(&v, &jar, false, Some("mismatch")); return password_view(&v, &jar, &user, false, Some("mismatch"));
} }
if form.password.len() < 8 { if form.password.len() < 8 {
return password_view(&v, &jar, false, Some("weak")); return password_view(&v, &jar, &user, false, Some("weak"));
} }
user.into_active_model() let user = user
.into_active_model()
.reset_password(&ctx.db, &form.password) .reset_password(&ctx.db, &form.password)
.await?; .await?;
password_view(&v, &jar, true, None) password_view(&v, &jar, &user, true, None)
} }
pub fn routes() -> Routes { pub fn routes() -> Routes {

View File

@@ -234,7 +234,7 @@ async fn show(
// Drop any now-invalid lines from the cookie so the badge stays accurate. // Drop any now-invalid lines from the cookie so the badge stays accurate.
let rebuilt = serialize_cart(&valid); let rebuilt = serialize_cart(&valid);
let (logged_in_admin, logged_in_customer) = guard::chrome(&ctx, &jar).await; let c = guard::chrome(&ctx, &jar).await;
let response = format::view( let response = format::view(
&v, &v,
"shop/cart.html", "shop/cart.html",
@@ -242,8 +242,10 @@ async fn show(
"items": lines, "items": lines,
"total": format_price(total), "total": format_price(total),
"currency": currency, "currency": currency,
"logged_in_admin": logged_in_admin, "logged_in_admin": c.logged_in_admin,
"logged_in_customer": logged_in_customer, "logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
"customer_account_type": c.customer_account_type,
"lang": current_lang(&jar), "lang": current_lang(&jar),
}), }),
)?; )?;

View File

@@ -357,7 +357,7 @@ async fn order_confirmation(
.filter(order_items::Column::OrderId.eq(order.id)) .filter(order_items::Column::OrderId.eq(order.id))
.all(&ctx.db) .all(&ctx.db)
.await?; .await?;
let (logged_in_admin, logged_in_customer) = guard::chrome(&ctx, &jar).await; let c = guard::chrome(&ctx, &jar).await;
let account_created = params.contains_key("account_created"); let account_created = params.contains_key("account_created");
format::view( format::view(
@@ -370,8 +370,10 @@ async fn order_confirmation(
settings::get(&ctx, "bank_account_name").unwrap_or(""), settings::get(&ctx, "bank_account_name").unwrap_or(""),
), ),
"items": view::items(&items), "items": view::items(&items),
"logged_in_admin": logged_in_admin, "logged_in_admin": c.logged_in_admin,
"logged_in_customer": logged_in_customer, "logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
"customer_account_type": c.customer_account_type,
"account_created": account_created, "account_created": account_created,
"lang": current_lang(&jar), "lang": current_lang(&jar),
}), }),

View File

@@ -13,15 +13,17 @@ async fn index(
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
let products = shop::featured_products(&ctx, 8).await?; let products = shop::featured_products(&ctx, 8).await?;
let (logged_in_admin, logged_in_customer) = guard::chrome(&ctx, &jar).await; let c = guard::chrome(&ctx, &jar).await;
format::view( format::view(
&v, &v,
"home/index.html", "home/index.html",
json!({ json!({
"products": products, "products": products,
"logged_in_admin": logged_in_admin, "logged_in_admin": c.logged_in_admin,
"logged_in_customer": logged_in_customer, "logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
"customer_account_type": c.customer_account_type,
"lang": current_lang(&jar), "lang": current_lang(&jar),
}), }),
) )

View File

@@ -69,14 +69,16 @@ async fn index(
.all(&ctx.db) .all(&ctx.db)
.await?; .await?;
let (logged_in_admin, logged_in_customer) = guard::chrome(&ctx, &jar).await; let c = guard::chrome(&ctx, &jar).await;
format::view( format::view(
&v, &v,
"shop/index.html", "shop/index.html",
json!({ json!({
"products": product_rows(&ctx, list).await?, "products": product_rows(&ctx, list).await?,
"logged_in_admin": logged_in_admin, "logged_in_admin": c.logged_in_admin,
"logged_in_customer": logged_in_customer, "logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
"customer_account_type": c.customer_account_type,
"lang": current_lang(&jar), "lang": current_lang(&jar),
}), }),
) )
@@ -110,7 +112,7 @@ async fn show(
None => None, None => None,
}; };
let (logged_in_admin, logged_in_customer) = guard::chrome(&ctx, &jar).await; let c = guard::chrome(&ctx, &jar).await;
format::view( format::view(
&v, &v,
"shop/show.html", "shop/show.html",
@@ -118,8 +120,10 @@ async fn show(
"product": view::product_card(&product, None, category.as_ref().map(|c| c.name.clone())), "product": view::product_card(&product, None, category.as_ref().map(|c| c.name.clone())),
"images": images.iter().map(|i| i.image_id.clone()).collect::<Vec<_>>(), "images": images.iter().map(|i| i.image_id.clone()).collect::<Vec<_>>(),
"category": category, "category": category,
"logged_in_admin": logged_in_admin, "logged_in_admin": c.logged_in_admin,
"logged_in_customer": logged_in_customer, "logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
"customer_account_type": c.customer_account_type,
"lang": current_lang(&jar), "lang": current_lang(&jar),
}), }),
) )
@@ -155,7 +159,7 @@ async fn category(
.all(&ctx.db) .all(&ctx.db)
.await?; .await?;
let (logged_in_admin, logged_in_customer) = guard::chrome(&ctx, &jar).await; let c = guard::chrome(&ctx, &jar).await;
format::view( format::view(
&v, &v,
"shop/category.html", "shop/category.html",
@@ -164,8 +168,10 @@ async fn category(
"breadcrumbs": breadcrumbs, "breadcrumbs": breadcrumbs,
"children": children, "children": children,
"products": product_rows(&ctx, list).await?, "products": product_rows(&ctx, list).await?,
"logged_in_admin": logged_in_admin, "logged_in_admin": c.logged_in_admin,
"logged_in_customer": logged_in_customer, "logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
"customer_account_type": c.customer_account_type,
"lang": current_lang(&jar), "lang": current_lang(&jar),
}), }),
) )

View File

@@ -46,13 +46,30 @@ pub async fn logged_in(ctx: &AppContext, jar: &CookieJar) -> bool {
} }
} }
/// Nav chrome flags for storefront pages, in one DB lookup: returns /// Nav chrome for storefront pages, resolved in one DB lookup. A customer is any
/// `(logged_in_admin, logged_in_customer)`. A customer is any authenticated /// authenticated non-admin user; `customer_name`/`customer_account_type` are set
/// non-admin user. Both are `false` for anonymous visitors. /// only for such a customer (used by the navbar profile menu). Everything is the
pub async fn chrome(ctx: &AppContext, jar: &CookieJar) -> (bool, bool) { /// zero value for anonymous visitors and the name/type stay `None` for admins.
#[derive(Debug, Default)]
pub struct Chrome {
pub logged_in_admin: bool,
pub logged_in_customer: bool,
pub customer_name: Option<String>,
pub customer_account_type: Option<String>,
}
pub async fn chrome(ctx: &AppContext, jar: &CookieJar) -> Chrome {
match current_user(ctx, jar).await { match current_user(ctx, jar).await {
Some(user) if is_admin(ctx, &user) => (true, false), Some(user) if is_admin(ctx, &user) => Chrome {
Some(_) => (false, true), logged_in_admin: true,
None => (false, false), ..Default::default()
},
Some(user) => Chrome {
logged_in_customer: true,
customer_name: Some(user.name),
customer_account_type: Some(user.account_type),
..Default::default()
},
None => Chrome::default(),
} }
} }