6 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
Priec
14ae859152 status in the admin page order
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 12:22:17 +02:00
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
19 changed files with 573 additions and 56 deletions

View File

@@ -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

View File

@@ -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

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

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

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

View File

@@ -6,7 +6,15 @@
{% block content %}
<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>
<div class="flex flex-wrap items-center gap-3">
<h1 class="font-mono text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ order.order_number }}</h1>
{% if order.status == "delivered" %}{{ ui::badge(label=t(key="order-status-" ~ order.status, lang=lang | default(value='sk')), variant="success") }}
{% elif order.status == "shipped" %}{{ ui::badge(label=t(key="order-status-" ~ order.status, lang=lang | default(value='sk')), variant="primary") }}
{% elif order.status == "paid" %}{{ ui::badge(label=t(key="order-status-" ~ order.status, lang=lang | default(value='sk')), variant="info") }}
{% elif order.status == "cancelled" %}{{ ui::badge(label=t(key="order-status-" ~ order.status, lang=lang | default(value='sk')), variant="danger") }}
{% else %}{{ ui::badge(label=t(key="order-status-" ~ order.status, lang=lang | default(value='sk')), variant="warning") }}
{% endif %}
</div>
{{ ui::button(variant="outline-secondary", label=t(key="admin-orders", lang=lang | default(value='sk')), href="/admin/orders", size="px-3 py-2 text-sm") }}
</div>

View File

@@ -57,6 +57,9 @@
</script>
<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>
<!-- 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>
</head>
<body hx-boost="true"
@@ -86,12 +89,7 @@
</form>
</li>
{% 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>
<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>
{# customer account links live in the profile dropdown next to the cart #}
{% 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-register", lang=lang | default(value='sk')), href="/register", data_nav="/register") }}</li>
@@ -99,9 +97,14 @@
</ul>
<!-- right side: cart + settings + mobile toggle -->
<div class="ml-auto flex items-center gap-1">
<!-- cart with live item-count badge read from the `cart` cookie -->
<a href="/cart" data-nav="/cart"
<div class="ml-auto flex items-center gap-2">
<!-- customer profile dropdown (avatar + name + account type) -->
{% 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-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')) }}"
@@ -111,10 +114,9 @@
<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>
</a>
<!-- settings (language + theme) dropdown -->
<div x-data="{ open: false }" @keydown.escape="open = false" class="relative">
{% include "partials/settings_dropdown.html" %}
</div>
<!-- settings (language + theme) dropdown (self-contained Alpine state) -->
{% include "partials/settings_dropdown.html" %}
<!-- mobile hamburger — Penguin animated icon swap (bars ↔ X), kept in
our ghost-square icon-button shell for consistency with cart/gear -->
@@ -158,6 +160,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 +184,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

@@ -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
to kill the former ~100-line copy-paste duplication.
Adapted from the vendored Penguin UI component
penguinui-components/dropdowns/dropdown-with-click.html: Penguin's dropdown
menu container + item treatment. Deviations: kept our gear icon-only trigger
and our core-Alpine open / @click.outside toggle (upstream's x-trap / $focus
need the Alpine Focus plugin, which we don't bundle); item hover uses
bg-primary/5 to stay consistent with the rest of our Penguin-ified UI.
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 our
gear icon-only button; content is the language form + theme toggle. Needs the
Alpine Focus plugin (loaded in base.html) for x-trap / $focus.
The host template provides the wrapper
<div x-data="{ open: false }" @keydown.escape="open = false" class="relative ...">
so it controls its own positioning (e.g. ml-auto in admin). #}
{{ 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-show="open" x-cloak @click.outside="open = false" x-transition.origin.top.right
Self-contained Alpine state + relative positioning; the host only places it
(e.g. ml-auto in admin). The panel has NO id on purpose (see profile_menu.html
for why — htmx hx-boost settles by id). #}
<div x-data="{ isOpen: false, openedWithKeyboard: false }"
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"
role="menu">
<form method="post" action="/lang" hx-boost="false">
@@ -54,3 +58,4 @@
</label>
</div>
</div>
</div>

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,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<String>,
@@ -119,6 +125,9 @@ fn profile_view(
json!({
"logged_in_admin": false,
"logged_in_customer": true,
"account_nav": true,
"customer_name": user.name,
"customer_account_type": user.account_type,
"saved": saved,
"error": error,
"name": user.name,
@@ -193,8 +202,173 @@ 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<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,
"customer_name": user.name,
"customer_account_type": user.account_type,
"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,
"customer_name": user.name,
"customer_account_type": user.account_type,
"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,
user: &users::Model,
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,
"customer_name": user.name,
"customer_account_type": user.account_type,
"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, &user, 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, &user, false, Some("current"));
}
if form.password != form.password_confirm {
return password_view(&v, &jar, &user, false, Some("mismatch"));
}
if form.password.len() < 8 {
return password_view(&v, &jar, &user, false, Some("weak"));
}
let user = user
.into_active_model()
.reset_password(&ctx.db, &form.password)
.await?;
password_view(&v, &jar, &user, 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

@@ -234,7 +234,7 @@ async fn show(
// Drop any now-invalid lines from the cookie so the badge stays accurate.
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(
&v,
"shop/cart.html",
@@ -242,8 +242,10 @@ async fn show(
"items": lines,
"total": format_price(total),
"currency": currency,
"logged_in_admin": logged_in_admin,
"logged_in_customer": logged_in_customer,
"logged_in_admin": c.logged_in_admin,
"logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
"customer_account_type": c.customer_account_type,
"lang": current_lang(&jar),
}),
)?;

View File

@@ -357,7 +357,7 @@ async fn order_confirmation(
.filter(order_items::Column::OrderId.eq(order.id))
.all(&ctx.db)
.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");
format::view(
@@ -370,8 +370,10 @@ async fn order_confirmation(
settings::get(&ctx, "bank_account_name").unwrap_or(""),
),
"items": view::items(&items),
"logged_in_admin": logged_in_admin,
"logged_in_customer": logged_in_customer,
"logged_in_admin": c.logged_in_admin,
"logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
"customer_account_type": c.customer_account_type,
"account_created": account_created,
"lang": current_lang(&jar),
}),

View File

@@ -13,15 +13,17 @@ async fn index(
State(ctx): State<AppContext>,
) -> Result<Response> {
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(
&v,
"home/index.html",
json!({
"products": products,
"logged_in_admin": logged_in_admin,
"logged_in_customer": logged_in_customer,
"logged_in_admin": c.logged_in_admin,
"logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
"customer_account_type": c.customer_account_type,
"lang": current_lang(&jar),
}),
)

View File

@@ -69,14 +69,16 @@ async fn index(
.all(&ctx.db)
.await?;
let (logged_in_admin, logged_in_customer) = guard::chrome(&ctx, &jar).await;
let c = guard::chrome(&ctx, &jar).await;
format::view(
&v,
"shop/index.html",
json!({
"products": product_rows(&ctx, list).await?,
"logged_in_admin": logged_in_admin,
"logged_in_customer": logged_in_customer,
"logged_in_admin": c.logged_in_admin,
"logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
"customer_account_type": c.customer_account_type,
"lang": current_lang(&jar),
}),
)
@@ -110,7 +112,7 @@ async fn show(
None => None,
};
let (logged_in_admin, logged_in_customer) = guard::chrome(&ctx, &jar).await;
let c = guard::chrome(&ctx, &jar).await;
format::view(
&v,
"shop/show.html",
@@ -118,8 +120,10 @@ async fn show(
"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<_>>(),
"category": category,
"logged_in_admin": logged_in_admin,
"logged_in_customer": logged_in_customer,
"logged_in_admin": c.logged_in_admin,
"logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
"customer_account_type": c.customer_account_type,
"lang": current_lang(&jar),
}),
)
@@ -155,7 +159,7 @@ async fn category(
.all(&ctx.db)
.await?;
let (logged_in_admin, logged_in_customer) = guard::chrome(&ctx, &jar).await;
let c = guard::chrome(&ctx, &jar).await;
format::view(
&v,
"shop/category.html",
@@ -164,8 +168,10 @@ async fn category(
"breadcrumbs": breadcrumbs,
"children": children,
"products": product_rows(&ctx, list).await?,
"logged_in_admin": logged_in_admin,
"logged_in_customer": logged_in_customer,
"logged_in_admin": c.logged_in_admin,
"logged_in_customer": c.logged_in_customer,
"customer_name": c.customer_name,
"customer_account_type": c.customer_account_type,
"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
/// `(logged_in_admin, logged_in_customer)`. A customer is any authenticated
/// non-admin user. Both are `false` for anonymous visitors.
pub async fn chrome(ctx: &AppContext, jar: &CookieJar) -> (bool, bool) {
/// Nav chrome for storefront pages, resolved in one DB lookup. A customer is any
/// authenticated non-admin user; `customer_name`/`customer_account_type` are set
/// only for such a customer (used by the navbar profile menu). Everything is the
/// 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 {
Some(user) if is_admin(ctx, &user) => (true, false),
Some(_) => (false, true),
None => (false, false),
Some(user) if is_admin(ctx, &user) => Chrome {
logged_in_admin: true,
..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(),
}
}