hover menu
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -97,23 +97,43 @@
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<!-- right side: cart + settings + mobile toggle -->
|
<!-- right side: cart + settings + mobile toggle -->
|
||||||
<div class="ml-auto flex items-center gap-2">
|
<div class="ml-auto flex items-center gap-3">
|
||||||
<!-- customer profile dropdown (avatar + name + account type) -->
|
<!-- customer profile dropdown (avatar + name + account type) -->
|
||||||
{% if logged_in_customer %}
|
{% if logged_in_customer %}
|
||||||
{% include "partials/profile_menu.html" %}
|
{% include "partials/profile_menu.html" %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<!-- cart with live item-count badge read from the `cart` cookie.
|
<!-- cart: hover opens an Alza-style mini-cart preview (Penguin
|
||||||
hx-boost=false: a plain full-page navigation to /cart, no SPA swap. -->
|
dropdown-with-hover), lazy-loaded from /partials/cart on each hover
|
||||||
<a href="/cart" data-nav="/cart" hx-boost="false"
|
so it's always fresh. Click still does a full navigation to /cart
|
||||||
x-data="{ count: 0 }"
|
(hx-boost=false; the explicit hx-trigger is mouseenter, so click is
|
||||||
x-init="count = cartCount(); ['htmx:afterSwap', 'htmx:afterRequest'].forEach(function (e) { window.addEventListener(e, function () { count = cartCount() }) })"
|
not an htmx trigger). The badge reads the `cart` cookie client-side. -->
|
||||||
aria-label="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
|
<div x-data="{ isOpen: false, leaveTimeout: null }"
|
||||||
title="{{ t(key='cart-title', lang=lang | default(value='sk')) }}"
|
x-on:mouseleave="leaveTimeout = setTimeout(() => isOpen = false, 250)"
|
||||||
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">
|
x-on:mouseenter="leaveTimeout && clearTimeout(leaveTimeout)"
|
||||||
{{ ui::icon(name="cart") }}
|
x-on:keydown.esc.window="isOpen = false"
|
||||||
<span x-show="count > 0" x-cloak x-text="count"
|
class="relative">
|
||||||
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 href="/cart" data-nav="/cart" hx-boost="false"
|
||||||
</a>
|
x-on:mouseenter="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) -->
|
<!-- settings (language + theme) dropdown (self-contained Alpine state) -->
|
||||||
{% include "partials/settings_dropdown.html" %}
|
{% include "partials/settings_dropdown.html" %}
|
||||||
|
|||||||
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 %}
|
||||||
@@ -253,10 +253,39 @@ async fn show(
|
|||||||
Ok((jar.add(cart_cookie(rebuilt)), response).into_response())
|
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 {
|
pub fn routes() -> Routes {
|
||||||
Routes::new()
|
Routes::new()
|
||||||
.add("/cart", get(show))
|
.add("/cart", get(show))
|
||||||
.add("/cart/add", post(add))
|
.add("/cart/add", post(add))
|
||||||
.add("/cart/update", post(update))
|
.add("/cart/update", post(update))
|
||||||
.add("/cart/remove", post(remove))
|
.add("/cart/remove", post(remove))
|
||||||
|
.add("/partials/cart", get(preview))
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user