Compare commits
2 Commits
v0.1.11
...
3da840c0c9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3da840c0c9 | ||
|
|
0310f2d2f4 |
File diff suppressed because one or more lines are too long
@@ -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 }.
|
||||
@@ -97,23 +103,43 @@
|
||||
</ul>
|
||||
|
||||
<!-- 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) -->
|
||||
{% 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')) }}"
|
||||
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>
|
||||
<!-- 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" %}
|
||||
|
||||
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())
|
||||
}
|
||||
|
||||
/// 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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user