4 Commits

Author SHA1 Message Date
Priec
42f30261d0 proper spacing and bascket icon
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 23:07:39 +02:00
Priec
ffda718a46 fixed menus now 2026-06-19 22:54:15 +02:00
Priec
673b28c361 working profile pic, but its trash, redoing the navbar icons now 2026-06-19 22:34:11 +02:00
Priec
454d5cb349 navbar profile 2026-06-19 13:59:31 +02:00
12 changed files with 192 additions and 58 deletions

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -117,8 +117,8 @@
{% block crumb %}{{ t(key="admin-title", lang=lang | default(value='sk')) }}{% endblock crumb %} {% block crumb %}{{ t(key="admin-title", lang=lang | default(value='sk')) }}{% endblock crumb %}
</span> </span>
<!-- settings (language + theme) dropdown --> <!-- settings (language + theme) dropdown (self-contained Alpine state) -->
<div x-data="{ open: false }" @keydown.escape="open = false" class="relative ml-auto"> <div class="ml-auto">
{% include "partials/settings_dropdown.html" %} {% include "partials/settings_dropdown.html" %}
</div> </div>
</header> </header>

View File

@@ -57,6 +57,9 @@
</script> </script>
<link href="/static/css/app.css?v=2026-06-16" rel="stylesheet" type="text/css"> <link href="/static/css/app.css?v=2026-06-16" rel="stylesheet" type="text/css">
<script src="/static/vendor/htmx/htmx-1.9.12.min.js"></script> <script src="/static/vendor/htmx/htmx-1.9.12.min.js"></script>
<!-- Alpine Focus plugin (x-trap / $focus) — must load before Alpine core;
required by the Penguin UI keyboard-accessible dropdowns. -->
<script defer src="/static/vendor/alpine/alpine-focus-3.14.9.min.js"></script>
<script defer src="/static/vendor/alpine/alpinejs-3.14.9.min.js"></script> <script defer src="/static/vendor/alpine/alpinejs-3.14.9.min.js"></script>
</head> </head>
<body hx-boost="true" <body hx-boost="true"
@@ -86,12 +89,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>
@@ -99,9 +97,14 @@
</ul> </ul>
<!-- right side: cart + settings + mobile toggle --> <!-- right side: cart + settings + mobile toggle -->
<div class="ml-auto flex items-center gap-1"> <div class="ml-auto flex items-center gap-2">
<!-- cart with live item-count badge read from the `cart` cookie --> <!-- customer profile dropdown (avatar + name + account type) -->
<a href="/cart" data-nav="/cart" {% if logged_in_customer %}
{% include "partials/profile_menu.html" %}
{% endif %}
<!-- cart with live item-count badge read from the `cart` cookie.
hx-boost=false: a plain full-page navigation to /cart, no SPA swap. -->
<a href="/cart" data-nav="/cart" hx-boost="false"
x-data="{ count: 0 }" x-data="{ count: 0 }"
x-init="count = cartCount(); ['htmx:afterSwap', 'htmx:afterRequest'].forEach(function (e) { window.addEventListener(e, function () { count = cartCount() }) })" x-init="count = cartCount(); ['htmx:afterSwap', 'htmx:afterRequest'].forEach(function (e) { window.addEventListener(e, function () { count = cartCount() }) })"
aria-label="{{ t(key='cart-title', lang=lang | default(value='sk')) }}" aria-label="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
@@ -111,10 +114,9 @@
<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>
<!-- settings (language + theme) dropdown -->
<div x-data="{ open: false }" @keydown.escape="open = false" class="relative"> <!-- settings (language + theme) dropdown (self-contained Alpine state) -->
{% include "partials/settings_dropdown.html" %} {% include "partials/settings_dropdown.html" %}
</div>
<!-- mobile hamburger — Penguin animated icon swap (bars ↔ X), kept in <!-- mobile hamburger — Penguin animated icon swap (bars ↔ X), kept in
our ghost-square icon-button shell for consistency with cart/gear --> our ghost-square icon-button shell for consistency with cart/gear -->

View File

@@ -0,0 +1,73 @@
{# Customer profile dropdown in the storefront navbar.
Proper Penguin UI dropdown: behaviour is the vendored
dropdowns/dropdown-with-icons.html verbatim (isOpen / openedWithKeyboard,
x-trap + $focus keyboard nav, x-cloak x-show, @click.outside). Trigger is the
round initials avatar (avatar-with-initials.html, primary variant). Menu items
are our account links.
Needs the Alpine Focus plugin (loaded before Alpine core in base.html) for
x-trap / $focus. Self-contained Alpine state; the host only needs to place it
in the navbar flex row. The panel has NO id on purpose — an id would make htmx
hx-boost "settle" it across boosted navigations and reappear; id-less Penguin
dropdowns are unaffected. #}
{# initials from the full 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 %}
{% set _person_icon = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" class="size-5"><path fill-rule="evenodd" d="M7.5 6a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM3.751 20.105a8.25 8.25 0 0116.498 0 .75.75 0 01-.437.695A18.683 18.683 0 0112 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 01-.437-.695z" clip-rule="evenodd"/></svg>' %}
<div x-data="{ isOpen: false, openedWithKeyboard: false }"
x-on:keydown.esc.window="isOpen = false, openedWithKeyboard = false"
class="relative">
<!-- Toggle Button: round initials avatar -->
<button type="button" x-on:click="isOpen = ! isOpen"
x-on:keydown.space.prevent="openedWithKeyboard = true" x-on:keydown.enter.prevent="openedWithKeyboard = true" x-on:keydown.down.prevent="openedWithKeyboard = true"
x-bind:aria-expanded="isOpen || openedWithKeyboard" aria-haspopup="true"
aria-label="{{ t(key='nav-account', lang=lang | default(value='sk')) }}"
class="flex size-9 shrink-0 items-center justify-center overflow-hidden rounded-full border border-primary bg-primary text-sm font-bold tracking-wider text-on-primary/90 transition hover:opacity-90 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark/90 dark:focus-visible:outline-primary-dark">
{%- if _initials %}{{ _initials }}{% else %}{{ _person_icon | safe }}{% endif -%}
</button>
<!-- Dropdown Menu (positioned like the settings cog: right-0 mt-2) -->
<div x-cloak x-show="isOpen || openedWithKeyboard" x-transition x-trap="openedWithKeyboard"
x-on:click.outside="isOpen = false, openedWithKeyboard = false"
x-on:keydown.down.prevent="$focus.wrap().next()" x-on:keydown.up.prevent="$focus.wrap().previous()"
class="absolute right-0 mt-2 flex w-60 min-w-48 flex-col divide-y divide-outline overflow-hidden rounded-radius border border-outline bg-surface-alt shadow-lg dark:divide-outline-dark dark:border-outline-dark dark:bg-surface-dark-alt" role="menu">
<!-- header: avatar + name + account type -->
<div class="flex items-center gap-3 px-4 py-2.5">
<span class="flex size-11 shrink-0 items-center justify-center overflow-hidden rounded-full border border-primary bg-primary text-base font-bold tracking-wider text-on-primary/90 dark:border-primary-dark dark:bg-primary-dark dark:text-on-primary-dark/90">
{%- if _initials %}{{ _initials }}{% else %}{{ _person_icon | safe }}{% endif -%}
</span>
<div class="flex min-w-0 flex-col">
<span class="truncate text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ _name }}</span>
<p class="truncate text-xs text-on-surface dark:text-on-surface-dark">{{ _type_label }}</p>
</div>
</div>
<!-- account links (with icons) -->
<div class="flex flex-col py-1.5">
<a href="/account/orders" data-nav="/account/orders" role="menuitem" class="flex items-center gap-2 bg-surface-alt px-4 py-2 text-sm text-on-surface hover:bg-surface-dark-alt/5 hover:text-on-surface-strong focus-visible:bg-surface-dark-alt/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:bg-surface-alt/5 dark:hover:text-on-surface-dark-strong dark:focus-visible:bg-surface-alt/10 dark:focus-visible:text-on-surface-dark-strong">
{{ ui::icon(name="cart", size="size-4", extra="shrink-0") }}
{{ t(key="account-orders", lang=lang | default(value='sk')) }}
</a>
<a href="/account/profile" data-nav="/account/profile" role="menuitem" class="flex items-center gap-2 bg-surface-alt px-4 py-2 text-sm text-on-surface hover:bg-surface-dark-alt/5 hover:text-on-surface-strong focus-visible:bg-surface-dark-alt/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:bg-surface-alt/5 dark:hover:text-on-surface-dark-strong dark:focus-visible:bg-surface-alt/10 dark:focus-visible:text-on-surface-dark-strong">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" class="size-4 shrink-0"><path fill-rule="evenodd" d="M7.5 6a4.5 4.5 0 119 0 4.5 4.5 0 01-9 0zM3.751 20.105a8.25 8.25 0 0116.498 0 .75.75 0 01-.437.695A18.683 18.683 0 0112 22.5c-2.786 0-5.433-.608-7.812-1.7a.75.75 0 01-.437-.695z" clip-rule="evenodd"/></svg>
{{ t(key="profile-title", lang=lang | default(value='sk')) }}
</a>
<a href="/account/password" data-nav="/account/password" role="menuitem" class="flex items-center gap-2 bg-surface-alt px-4 py-2 text-sm text-on-surface hover:bg-surface-dark-alt/5 hover:text-on-surface-strong focus-visible:bg-surface-dark-alt/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:bg-surface-alt/5 dark:hover:text-on-surface-dark-strong dark:focus-visible:bg-surface-alt/10 dark:focus-visible:text-on-surface-dark-strong">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" class="size-4 shrink-0"><path fill-rule="evenodd" d="M15.75 1.5a6.75 6.75 0 00-6.651 7.906c.067.39-.032.717-.221.906l-6.5 6.499a3 3 0 00-.878 2.121v2.818c0 .414.336.75.75.75H6a.75.75 0 00.75-.75v-1.5h1.5A.75.75 0 009 21v-1.5h1.5a.75.75 0 00.53-.22l2.658-2.658c.19-.189.517-.288.906-.22A6.75 6.75 0 1015.75 1.5zm0 3a.75.75 0 000 1.5A2.25 2.25 0 0118 8.25a.75.75 0 001.5 0 3.75 3.75 0 00-3.75-3.75z" clip-rule="evenodd"/></svg>
{{ t(key="account-change-password", lang=lang | default(value='sk')) }}
</a>
</div>
<!-- logout -->
<div class="flex flex-col py-1.5">
<form method="post" action="/logout" hx-boost="false"><button type="submit" role="menuitem" class="flex w-full items-center gap-2 bg-surface-alt px-4 py-2 text-left text-sm text-on-surface hover:bg-surface-dark-alt/5 hover:text-on-surface-strong focus-visible:bg-surface-dark-alt/10 focus-visible:text-on-surface-strong focus-visible:outline-hidden dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:bg-surface-alt/5 dark:hover:text-on-surface-dark-strong dark:focus-visible:bg-surface-alt/10 dark:focus-visible:text-on-surface-dark-strong">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true" fill="currentColor" class="size-4 shrink-0"><path fill-rule="evenodd" d="M7.5 3.75A1.5 1.5 0 006 5.25v13.5a1.5 1.5 0 001.5 1.5h6a1.5 1.5 0 001.5-1.5V15a.75.75 0 011.5 0v3.75a3 3 0 01-3 3h-6a3 3 0 01-3-3V5.25a3 3 0 013-3h6a3 3 0 013 3V9A.75.75 0 0115 9V5.25a1.5 1.5 0 00-1.5-1.5h-6zm10.72 4.72a.75.75 0 011.06 0l3 3a.75.75 0 010 1.06l-3 3a.75.75 0 11-1.06-1.06l1.72-1.72H9a.75.75 0 010-1.5h10.94l-1.72-1.72a.75.75 0 010-1.06z" clip-rule="evenodd"/></svg>
{{ t(key="logout", lang=lang | default(value='sk')) }}
</button></form>
</div>
</div>
</div>

View File

@@ -1,18 +1,22 @@
{# Settings dropdown (language + theme). Shared by base.html and admin/base.html {# Settings dropdown (language + theme). Shared by base.html and admin/base.html
to kill the former ~100-line copy-paste duplication. to kill the former ~100-line copy-paste duplication.
Adapted from the vendored Penguin UI component Proper Penguin UI dropdown: behaviour is the vendored
penguinui-components/dropdowns/dropdown-with-click.html: Penguin's dropdown dropdowns/dropdown-with-icons.html verbatim (isOpen / openedWithKeyboard,
menu container + item treatment. Deviations: kept our gear icon-only trigger x-trap + $focus keyboard nav, x-cloak x-show, @click.outside). Trigger is our
and our core-Alpine open / @click.outside toggle (upstream's x-trap / $focus gear icon-only button; content is the language form + theme toggle. Needs the
need the Alpine Focus plugin, which we don't bundle); item hover uses Alpine Focus plugin (loaded in base.html) for x-trap / $focus.
bg-primary/5 to stay consistent with the rest of our Penguin-ified UI.
The host template provides the wrapper Self-contained Alpine state + relative positioning; the host only places it
<div x-data="{ open: false }" @keydown.escape="open = false" class="relative ..."> (e.g. ml-auto in admin). The panel has NO id on purpose (see profile_menu.html
so it controls its own positioning (e.g. ml-auto in admin). #} for why — htmx hx-boost settles by id). #}
{{ ui::icon_button(aria_label=t(key='settings', lang=lang | default(value='sk')), attrs='@click="open = !open" :aria-expanded="open"', icon='<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>') }} <div x-data="{ isOpen: false, openedWithKeyboard: false }"
<div x-show="open" x-cloak @click.outside="open = false" x-transition.origin.top.right x-on:keydown.esc.window="isOpen = false, openedWithKeyboard = false"
class="relative">
{{ ui::icon_button(aria_label=t(key='settings', lang=lang | default(value='sk')), attrs='x-on:click="isOpen = ! isOpen" x-on:keydown.space.prevent="openedWithKeyboard = true" x-on:keydown.enter.prevent="openedWithKeyboard = true" x-on:keydown.down.prevent="openedWithKeyboard = true" x-bind:aria-expanded="isOpen || openedWithKeyboard" aria-haspopup="true"', icon='<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="size-5"><path stroke-linecap="round" stroke-linejoin="round" d="M9.594 3.94c.09-.542.56-.94 1.11-.94h2.593c.55 0 1.02.398 1.11.94l.213 1.281c.063.374.313.686.645.87.074.04.147.083.22.127.324.196.72.257 1.075.124l1.217-.456a1.125 1.125 0 0 1 1.37.49l1.296 2.247a1.125 1.125 0 0 1-.26 1.431l-1.003.827c-.293.241-.438.613-.43.992a7.723 7.723 0 0 1 0 .255c-.008.378.137.75.43.991l1.004.827c.424.35.534.955.26 1.43l-1.298 2.247a1.125 1.125 0 0 1-1.369.491l-1.217-.456c-.355-.133-.75-.072-1.076.124a6.47 6.47 0 0 1-.22.128c-.331.183-.581.495-.644.869l-.213 1.281c-.09.543-.56.94-1.11.94h-2.594c-.55 0-1.019-.398-1.11-.94l-.213-1.281c-.062-.374-.312-.686-.644-.87a6.52 6.52 0 0 1-.22-.127c-.325-.196-.72-.257-1.076-.124l-1.217.456a1.125 1.125 0 0 1-1.369-.49l-1.297-2.247a1.125 1.125 0 0 1 .26-1.431l1.004-.827c.292-.24.437-.613.43-.991a6.932 6.932 0 0 1 0-.255c.007-.38-.138-.751-.43-.992l-1.004-.827a1.125 1.125 0 0 1-.26-1.43l1.297-2.247a1.125 1.125 0 0 1 1.37-.491l1.216.456c.356.133.751.072 1.076-.124.072-.044.146-.086.22-.128.332-.183.582-.495.644-.869l.214-1.28Z" /><path stroke-linecap="round" stroke-linejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" /></svg>') }}
<div x-cloak x-show="isOpen || openedWithKeyboard" x-transition x-trap="openedWithKeyboard"
x-on:click.outside="isOpen = false, openedWithKeyboard = false"
x-on:keydown.down.prevent="$focus.wrap().next()" x-on:keydown.up.prevent="$focus.wrap().previous()"
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" 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"> role="menu">
<form method="post" action="/lang" hx-boost="false"> <form method="post" action="/lang" hx-boost="false">
@@ -54,3 +58,4 @@
</label> </label>
</div> </div>
</div> </div>
</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(),
} }
} }