Compare commits
7 Commits
14ae859152
...
v0.1.12
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e138fb6579 | ||
|
|
3da840c0c9 | ||
|
|
0310f2d2f4 | ||
|
|
42f30261d0 | ||
|
|
ffda718a46 | ||
|
|
673b28c361 | ||
|
|
454d5cb349 |
File diff suppressed because one or more lines are too long
15
assets/static/vendor/alpine/alpine-focus-3.14.9.min.js
vendored
Normal file
15
assets/static/vendor/alpine/alpine-focus-3.14.9.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
@@ -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>
|
||||
|
||||
@@ -48,6 +48,12 @@
|
||||
if (!v) return 0;
|
||||
return v.split(',').reduce(function (s, e) { return s + (parseInt(e.split(':')[1]) || 0) }, 0);
|
||||
}
|
||||
// True while any other navbar menu (profile / settings / mobile / category
|
||||
// toggle) is open — those triggers expose aria-expanded="true". Used to
|
||||
// suppress the cart hover preview so menus don't stack/overlap.
|
||||
function anyMenuOpen() {
|
||||
return !!document.querySelector('header [aria-expanded="true"]');
|
||||
}
|
||||
// Show a floating toast notification. Usage: toast('Saved').
|
||||
// Bridges to the vendored Penguin UI toast component, which listens for a
|
||||
// `notify` event with { variant, title, message }.
|
||||
@@ -57,6 +63,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 +95,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,23 +103,47 @@
|
||||
</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"
|
||||
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')) }}"
|
||||
title="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
|
||||
class="relative inline-flex size-9 shrink-0 items-center justify-center rounded-radius bg-transparent text-secondary transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-secondary active:opacity-100 active:outline-offset-0 dark:text-secondary-dark dark:focus-visible:outline-secondary-dark">
|
||||
{{ ui::icon(name="cart") }}
|
||||
<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 class="ml-auto flex items-center gap-3">
|
||||
<!-- customer profile dropdown (avatar + name + account type) -->
|
||||
{% if logged_in_customer %}
|
||||
{% include "partials/profile_menu.html" %}
|
||||
{% endif %}
|
||||
<!-- cart: hover opens an Alza-style mini-cart preview (Penguin
|
||||
dropdown-with-hover), lazy-loaded from /partials/cart on each hover
|
||||
so it's always fresh. Click still does a full navigation to /cart
|
||||
(hx-boost=false; the explicit hx-trigger is mouseenter, so click is
|
||||
not an htmx trigger). The badge reads the `cart` cookie client-side. -->
|
||||
<div x-data="{ isOpen: false, leaveTimeout: null }"
|
||||
x-on:mouseleave="leaveTimeout = setTimeout(() => isOpen = false, 250)"
|
||||
x-on:mouseenter="leaveTimeout && clearTimeout(leaveTimeout)"
|
||||
x-on:keydown.esc.window="isOpen = false"
|
||||
class="relative">
|
||||
<a href="/cart" data-nav="/cart" hx-boost="false"
|
||||
x-on:mouseenter="if (!anyMenuOpen()) isOpen = true"
|
||||
x-data="{ count: 0 }"
|
||||
x-init="count = cartCount(); ['htmx:afterSwap', 'htmx:afterRequest'].forEach(function (e) { window.addEventListener(e, function () { count = cartCount() }) })"
|
||||
hx-get="/partials/cart" hx-trigger="mouseenter delay:150ms" hx-target="#cart-preview-body" hx-swap="innerHTML"
|
||||
aria-label="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
|
||||
title="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
|
||||
class="relative inline-flex size-9 shrink-0 items-center justify-center rounded-radius bg-transparent text-secondary transition hover:opacity-75 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-secondary active:opacity-100 active:outline-offset-0 dark:text-secondary-dark dark:focus-visible:outline-secondary-dark">
|
||||
{{ ui::icon(name="cart") }}
|
||||
<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>
|
||||
<!-- hover preview panel (no id on the panel → not htmx-settled on boosted nav) -->
|
||||
<div x-cloak x-show="isOpen" x-transition
|
||||
x-on:mouseenter="isOpen = true"
|
||||
class="absolute right-0 mt-2 w-80 overflow-hidden rounded-radius border border-outline bg-surface-alt shadow-lg dark:border-outline-dark dark:bg-surface-dark-alt"
|
||||
role="dialog" aria-label="{{ t(key='cart-title', lang=lang | default(value='sk')) }}">
|
||||
<div id="cart-preview-body">
|
||||
<div class="px-4 py-10 text-center text-sm text-on-surface dark:text-on-surface-dark">…</div>
|
||||
</div>
|
||||
</div>
|
||||
</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 -->
|
||||
<button type="button" @click="mobile = !mobile" :aria-expanded="mobile" aria-label="{{ t(key='menu', lang=lang | default(value='sk')) }}"
|
||||
|
||||
73
assets/views/partials/profile_menu.html
Normal file
73
assets/views/partials/profile_menu.html
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
31
assets/views/shop/_cart_preview.html
Normal file
31
assets/views/shop/_cart_preview.html
Normal file
@@ -0,0 +1,31 @@
|
||||
{# Mini-cart preview shown on hover over the navbar cart (Alza-style).
|
||||
Lazy-loaded via htmx from /partials/cart into the hover dropdown panel in
|
||||
base.html. Receives: items[], total, currency, lang. #}
|
||||
{% import "macros/ui.html" as ui %}
|
||||
{% if items | length > 0 %}
|
||||
<div class="max-h-80 divide-y divide-outline overflow-y-auto dark:divide-outline-dark">
|
||||
{% for item in items %}
|
||||
<div class="flex items-start gap-3 px-4 py-3">
|
||||
<div class="min-w-0 flex-1">
|
||||
<a href="/shop/{{ item.slug }}" class="block truncate text-sm font-medium text-on-surface-strong hover:text-primary dark:text-on-surface-dark-strong dark:hover:text-primary-dark">{{ item.name }}</a>
|
||||
<p class="mt-0.5 text-xs tabular-nums text-on-surface dark:text-on-surface-dark">{{ item.quantity }} × {{ item.price }} {{ item.currency }}</p>
|
||||
</div>
|
||||
<span class="shrink-0 text-sm font-semibold tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{{ item.line_total }} {{ item.currency }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="border-t border-outline px-4 py-3 dark:border-outline-dark">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<span class="text-sm text-on-surface dark:text-on-surface-dark">{{ t(key="cart-total", lang=lang | default(value='sk')) }}</span>
|
||||
<span class="text-base font-bold tabular-nums text-primary dark:text-primary-dark">{{ total }} {{ currency }}</span>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
{{ ui::button(href="/cart", variant="outline-primary", label=t(key="cart-title", lang=lang | default(value='sk')), extra="flex-1", attrs='hx-boost="false"') }}
|
||||
{{ ui::button(href="/checkout", variant="primary", label=t(key="cart-checkout", lang=lang | default(value='sk')), extra="flex-1", attrs='hx-boost="false"') }}
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="px-4 py-10 text-center text-sm text-on-surface dark:text-on-surface-dark">
|
||||
{{ t(key="cart-empty", lang=lang | default(value='sk')) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
@@ -126,6 +126,8 @@ fn profile_view(
|
||||
"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,
|
||||
@@ -232,6 +234,8 @@ async fn orders_page(
|
||||
"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),
|
||||
@@ -272,6 +276,8 @@ async fn order_detail_page(
|
||||
"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(""),
|
||||
@@ -293,6 +299,7 @@ struct ChangePasswordForm {
|
||||
fn password_view(
|
||||
v: &TeraView,
|
||||
jar: &CookieJar,
|
||||
user: &users::Model,
|
||||
changed: bool,
|
||||
error: Option<&str>,
|
||||
) -> Result<Response> {
|
||||
@@ -303,6 +310,8 @@ fn password_view(
|
||||
"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),
|
||||
@@ -322,7 +331,7 @@ async fn change_password_page(
|
||||
if guard::is_admin(&ctx, &user) {
|
||||
return format::redirect("/admin/dashboard");
|
||||
}
|
||||
password_view(&v, &jar, false, None)
|
||||
password_view(&v, &jar, &user, false, None)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
@@ -339,18 +348,19 @@ async fn change_password(
|
||||
return format::redirect("/admin/dashboard");
|
||||
}
|
||||
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 {
|
||||
return password_view(&v, &jar, false, Some("mismatch"));
|
||||
return password_view(&v, &jar, &user, false, Some("mismatch"));
|
||||
}
|
||||
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)
|
||||
.await?;
|
||||
password_view(&v, &jar, true, None)
|
||||
password_view(&v, &jar, &user, true, None)
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
)?;
|
||||
@@ -251,10 +253,39 @@ async fn show(
|
||||
Ok((jar.add(cart_cookie(rebuilt)), response).into_response())
|
||||
}
|
||||
|
||||
/// Mini-cart preview for the navbar hover dropdown. Lazy-loaded via htmx from
|
||||
/// the header; returns just the `shop/_cart_preview.html` fragment.
|
||||
#[debug_handler]
|
||||
async fn preview(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let (lines, valid, total) = resolve_cart(&ctx, &jar).await?;
|
||||
let currency = lines
|
||||
.first()
|
||||
.and_then(|line| line["currency"].as_str())
|
||||
.unwrap_or("EUR")
|
||||
.to_string();
|
||||
let rebuilt = serialize_cart(&valid);
|
||||
let response = format::view(
|
||||
&v,
|
||||
"shop/_cart_preview.html",
|
||||
json!({
|
||||
"items": lines,
|
||||
"total": format_price(total),
|
||||
"currency": currency,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)?;
|
||||
Ok((jar.add(cart_cookie(rebuilt)), response).into_response())
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.add("/cart", get(show))
|
||||
.add("/cart/add", post(add))
|
||||
.add("/cart/update", post(update))
|
||||
.add("/cart/remove", post(remove))
|
||||
.add("/partials/cart", get(preview))
|
||||
}
|
||||
|
||||
@@ -132,6 +132,10 @@ async fn checkout_page(
|
||||
"packeta_api_key": settings::get(&ctx, "packeta_api_key").unwrap_or(""),
|
||||
"logged_in_admin": is_admin,
|
||||
"logged_in_customer": is_customer,
|
||||
// Required by the navbar profile menu (base.html includes it whenever
|
||||
// logged_in_customer is true); None for admins/guests.
|
||||
"customer_name": user.as_ref().filter(|_| is_customer).map(|u| u.name.clone()),
|
||||
"customer_account_type": user.as_ref().filter(|_| is_customer).map(|u| u.account_type.clone()),
|
||||
"profile_filled": profile_filled,
|
||||
// A logged-in customer's account type is fixed; only guests pick it
|
||||
// and may opt to create an account from the order.
|
||||
@@ -357,7 +361,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 +374,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),
|
||||
}),
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
api_key: lo-95ec80d7-cb60-4b70-9b4b-9ef74cb88758
|
||||
name: user1
|
||||
theme: light
|
||||
account_type: personal
|
||||
created_at: "2023-11-12T12:34:56.789Z"
|
||||
updated_at: "2023-11-12T12:34:56.789Z"
|
||||
- id: 3
|
||||
@@ -15,5 +16,6 @@
|
||||
api_key: lo-153561ca-fa84-4e1b-813a-c62526d0a77e
|
||||
name: user2
|
||||
theme: light
|
||||
account_type: personal
|
||||
created_at: "2023-11-12T12:34:56.789Z"
|
||||
updated_at: "2023-11-12T12:34:56.789Z"
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user