saerch query in the shop now works well

This commit is contained in:
Priec
2026-06-22 21:12:47 +02:00
parent 1ecfac2ad6
commit f512fbbb94
4 changed files with 130 additions and 133 deletions

View File

@@ -0,0 +1,102 @@
{# Shared storefront search + filter toolbar and results region, used by the shop
index and every category page. One form drives the whole listing: htmx re-runs
/search and swaps only #shop-results; the toolbar keeps its own DOM state.
Triggers: live (debounced) typing in the search box, immediate on any
select/checkbox change, and submit (Enter / Apply) for the price band. Degrades
to a plain GET form without JS.
Expects: query, category_groups, selected_category, selected_category_id,
uncategorized_count, sort, min_price, max_price, price_floor, price_ceil,
in_stock, plus the result vars consumed by _results.html. #}
{% set L = lang | default(value='sk') %}
<div class="space-y-6">
<form action="/search" method="get" role="search"
hx-get="/search" hx-target="#shop-results" hx-swap="innerHTML"
hx-push-url="true" hx-indicator="#search-spinner"
hx-trigger="submit, change, keyup changed delay:350ms from:input[name='q']"
class="space-y-3">
<!-- search box -->
<div class="relative max-w-xl">
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-on-surface/50 dark:text-on-surface-dark/50">
{{ ui::icon(name="search", size="size-5") }}
</span>
<input type="search" name="q" value="{{ query | default(value='') }}" autocomplete="off"
placeholder="{{ t(key='search-placeholder', lang=L) }}"
aria-label="{{ t(key='search-placeholder', lang=L) }}"
class="w-full rounded-radius border border-outline bg-surface py-2 pl-10 pr-10 text-sm text-on-surface placeholder:text-on-surface/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50 dark:focus-visible:outline-primary-dark" />
<span id="search-spinner" class="htmx-indicator pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-on-surface/50 dark:text-on-surface-dark/50">
<svg class="size-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.4 0 0 5.4 0 12h4Z"></path>
</svg>
</span>
</div>
<!-- filter toolbar -->
<div class="flex flex-wrap items-end gap-3 rounded-radius border border-outline bg-surface-alt p-3 dark:border-outline-dark dark:bg-surface-dark-alt">
<!-- category -->
<label class="flex flex-col gap-1 text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70">
{{ t(key="filter-category", lang=L) }}
<select name="category"
class="rounded-radius border border-outline bg-surface px-2 py-1.5 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
<option value="all"{% if selected_category == "all" %} selected{% endif %}>{{ t(key="filter-all-categories", lang=L) }}</option>
{% for g in category_groups %}
<option value="{{ g.id }}"{% if selected_category_id == g.id %} selected{% endif %}>{{ g.name }} ({{ g.count }})</option>
{% for ch in g.children %}
<option value="{{ ch.id }}"{% if selected_category_id == ch.id %} selected{% endif %}>&nbsp;&nbsp;— {{ ch.name }} ({{ ch.count }})</option>
{% endfor %}
{% endfor %}
{% if uncategorized_count > 0 %}
<option value="none"{% if selected_category == "none" %} selected{% endif %}>{{ t(key="filter-uncategorized", lang=L) }} ({{ uncategorized_count }})</option>
{% endif %}
</select>
</label>
<!-- sort -->
<label class="flex flex-col gap-1 text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70">
{{ t(key="sort-label", lang=L) }}
<select name="sort"
class="rounded-radius border border-outline bg-surface px-2 py-1.5 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
{% for opt in ["relevance", "newest", "price_asc", "price_desc", "name_asc", "name_desc"] %}
<option value="{{ opt }}"{% if sort == opt %} selected{% endif %}>{{ t(key="sort-" ~ opt, lang=L) }}</option>
{% endfor %}
</select>
</label>
<!-- price band -->
<label class="flex flex-col gap-1 text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70">
{{ t(key="filter-price", lang=L) }}
<span class="flex items-center gap-1">
<input type="number" name="min_price" min="0" step="0.01" inputmode="decimal"
value="{{ min_price | default(value='') }}" placeholder="{{ price_floor }}"
aria-label="{{ t(key='filter-price-from', lang=L) }}"
class="w-24 rounded-radius border border-outline bg-surface px-2 py-1.5 text-sm text-on-surface dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark" />
<span class="text-on-surface/50 dark:text-on-surface-dark/50"></span>
<input type="number" name="max_price" min="0" step="0.01" inputmode="decimal"
value="{{ max_price | default(value='') }}" placeholder="{{ price_ceil }}"
aria-label="{{ t(key='filter-price-to', lang=L) }}"
class="w-24 rounded-radius border border-outline bg-surface px-2 py-1.5 text-sm text-on-surface dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark" />
</span>
</label>
<!-- in stock -->
<label class="flex items-center gap-2 pb-1.5 text-sm text-on-surface dark:text-on-surface-dark">
<input type="checkbox" name="in_stock" value="1"{% if in_stock %} checked{% endif %}
class="size-4 rounded border-outline text-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:text-primary-dark" />
{{ t(key="filter-in-stock", lang=L) }}
</label>
<div class="ml-auto flex items-end gap-2">
{{ ui::button(label=t(key="filter-apply", lang=L), type="submit", variant="secondary") }}
<a href="/shop" hx-get="/search" hx-target="#shop-results" hx-push-url="true"
class="self-end pb-1.5 text-sm font-medium text-on-surface/70 underline-offset-2 transition hover:text-primary hover:underline dark:text-on-surface-dark/70 dark:hover:text-primary-dark">
{{ t(key="filter-clear", lang=L) }}
</a>
</div>
</div>
</form>
<div id="shop-results">
{% include "shop/_results.html" %}
</div>
</div>

View File

@@ -4,10 +4,11 @@
{% block title %}{{ category.name }}{% endblock title %} {% block title %}{{ category.name }}{% endblock title %}
{% block content %} {% block content %}
<div class="space-y-8"> {% set L = lang | default(value='sk') %}
<div class="space-y-6">
<header class="space-y-2"> <header class="space-y-2">
<nav class="text-sm text-on-surface/60 dark:text-on-surface-dark/60"> <nav class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">
<a href="/shop" class="hover:text-primary dark:hover:text-primary-dark">{{ t(key="nav-shop", lang=lang | default(value='sk')) }}</a> <a href="/shop" class="hover:text-primary dark:hover:text-primary-dark">{{ t(key="nav-shop", lang=L) }}</a>
{% for crumb in breadcrumbs %} {% for crumb in breadcrumbs %}
<span class="px-1">/</span> <span class="px-1">/</span>
<a href="/category/{{ crumb.slug }}" class="hover:text-primary dark:hover:text-primary-dark">{{ crumb.name }}</a> <a href="/category/{{ crumb.slug }}" class="hover:text-primary dark:hover:text-primary-dark">{{ crumb.name }}</a>
@@ -28,12 +29,7 @@
{% endif %} {% endif %}
</header> </header>
{% if products | length > 0 %} {# Same search + filters as the shop, with this category preselected. #}
{% include "shop/_product_grid.html" %} {% include "shop/_search.html" %}
{% else %}
<div class="rounded-radius border border-outline px-6 py-16 text-center text-on-surface/70 dark:border-outline-dark dark:text-on-surface-dark/70">
{{ t(key="shop-empty", lang=lang | default(value='sk')) }}
</div>
{% endif %}
</div> </div>
{% endblock content %} {% endblock content %}

View File

@@ -11,99 +11,6 @@
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-subtitle", lang=L) }}</p> <p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-subtitle", lang=L) }}</p>
</header> </header>
{# One form drives the whole listing. htmx re-runs /search and swaps only the {% include "shop/_search.html" %}
results region; the toolbar keeps its own DOM state. Triggers: live (debounced)
typing in the search box, immediate on any select/checkbox change, and submit
(Enter / Apply) for the price band. Degrades to a plain GET form without JS. #}
<form action="/search" method="get" role="search"
hx-get="/search" hx-target="#shop-results" hx-swap="innerHTML"
hx-push-url="true" hx-indicator="#search-spinner"
hx-trigger="submit, change, keyup changed delay:350ms from:input[name='q']"
class="space-y-3">
<!-- search box -->
<div class="relative max-w-xl">
<span class="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-on-surface/50 dark:text-on-surface-dark/50">
{{ ui::icon(name="search", size="size-5") }}
</span>
<input type="search" name="q" value="{{ query | default(value='') }}" autocomplete="off"
placeholder="{{ t(key='search-placeholder', lang=L) }}"
aria-label="{{ t(key='search-placeholder', lang=L) }}"
class="w-full rounded-radius border border-outline bg-surface py-2 pl-10 pr-10 text-sm text-on-surface placeholder:text-on-surface/50 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:placeholder:text-on-surface-dark/50 dark:focus-visible:outline-primary-dark" />
<span id="search-spinner" class="htmx-indicator pointer-events-none absolute inset-y-0 right-0 flex items-center pr-3 text-on-surface/50 dark:text-on-surface-dark/50">
<svg class="size-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 0 1 8-8V0C5.4 0 0 5.4 0 12h4Z"></path>
</svg>
</span>
</div>
<!-- filter toolbar -->
<div class="flex flex-wrap items-end gap-3 rounded-radius border border-outline bg-surface-alt p-3 dark:border-outline-dark dark:bg-surface-dark-alt">
<!-- category -->
<label class="flex flex-col gap-1 text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70">
{{ t(key="filter-category", lang=L) }}
<select name="category"
class="rounded-radius border border-outline bg-surface px-2 py-1.5 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
<option value="all"{% if selected_category == "all" %} selected{% endif %}>{{ t(key="filter-all-categories", lang=L) }}</option>
{% for g in category_groups %}
<option value="{{ g.id }}"{% if selected_category_id == g.id %} selected{% endif %}>{{ g.name }} ({{ g.count }})</option>
{% for ch in g.children %}
<option value="{{ ch.id }}"{% if selected_category_id == ch.id %} selected{% endif %}>&nbsp;&nbsp;— {{ ch.name }} ({{ ch.count }})</option>
{% endfor %}
{% endfor %}
{% if uncategorized_count > 0 %}
<option value="none"{% if selected_category == "none" %} selected{% endif %}>{{ t(key="filter-uncategorized", lang=L) }} ({{ uncategorized_count }})</option>
{% endif %}
</select>
</label>
<!-- sort -->
<label class="flex flex-col gap-1 text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70">
{{ t(key="sort-label", lang=L) }}
<select name="sort"
class="rounded-radius border border-outline bg-surface px-2 py-1.5 text-sm text-on-surface focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark">
{% for opt in ["relevance", "newest", "price_asc", "price_desc", "name_asc", "name_desc"] %}
<option value="{{ opt }}"{% if sort == opt %} selected{% endif %}>{{ t(key="sort-" ~ opt, lang=L) }}</option>
{% endfor %}
</select>
</label>
<!-- price band -->
<label class="flex flex-col gap-1 text-xs font-medium text-on-surface/70 dark:text-on-surface-dark/70">
{{ t(key="filter-price", lang=L) }}
<span class="flex items-center gap-1">
<input type="number" name="min_price" min="0" step="0.01" inputmode="decimal"
value="{{ min_price | default(value='') }}" placeholder="{{ price_floor }}"
aria-label="{{ t(key='filter-price-from', lang=L) }}"
class="w-24 rounded-radius border border-outline bg-surface px-2 py-1.5 text-sm text-on-surface dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark" />
<span class="text-on-surface/50 dark:text-on-surface-dark/50"></span>
<input type="number" name="max_price" min="0" step="0.01" inputmode="decimal"
value="{{ max_price | default(value='') }}" placeholder="{{ price_ceil }}"
aria-label="{{ t(key='filter-price-to', lang=L) }}"
class="w-24 rounded-radius border border-outline bg-surface px-2 py-1.5 text-sm text-on-surface dark:border-outline-dark dark:bg-surface-dark dark:text-on-surface-dark" />
</span>
</label>
<!-- in stock -->
<label class="flex items-center gap-2 pb-1.5 text-sm text-on-surface dark:text-on-surface-dark">
<input type="checkbox" name="in_stock" value="1"{% if in_stock %} checked{% endif %}
class="size-4 rounded border-outline text-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary dark:border-outline-dark dark:text-primary-dark" />
{{ t(key="filter-in-stock", lang=L) }}
</label>
<div class="ml-auto flex items-end gap-2">
{{ ui::button(label=t(key="filter-apply", lang=L), type="submit", variant="secondary") }}
<a href="/shop" hx-get="/search" hx-target="#shop-results" hx-push-url="true"
class="self-end pb-1.5 text-sm font-medium text-on-surface/70 underline-offset-2 transition hover:text-primary hover:underline dark:text-on-surface-dark/70 dark:hover:text-primary-dark">
{{ t(key="filter-clear", lang=L) }}
</a>
</div>
</div>
</form>
<div id="shop-results">
{% include "shop/_results.html" %}
</div>
</div> </div>
{% endblock content %} {% endblock content %}

View File

@@ -164,13 +164,13 @@ async fn run_search(
let filter = view::category_filter_ids(&all_categories, &selected_category); let filter = view::category_filter_ids(&all_categories, &selected_category);
items.retain(|i| view::category_filter_keep(&filter, i.product.category_id)); items.retain(|i| view::category_filter_keep(&filter, i.product.category_id));
// 6. Sort. Relevance keeps the search rank (newest when there is no query). // 6. Sort. Relevance is the default; with no text query it keeps the base
let default_sort = if q_trim.is_empty() { "newest" } else { "relevance" }; // order (newest-first), and with a query it keeps the ranked search order.
let sort = params let sort = params
.sort .sort
.clone() .clone()
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.unwrap_or_else(|| default_sort.to_string()); .unwrap_or_else(|| "relevance".to_string());
match sort.as_str() { match sort.as_str() {
"price_asc" => items.sort_by(|a, b| a.priced.price_cents.cmp(&b.priced.price_cents)), "price_asc" => items.sort_by(|a, b| a.priced.price_cents.cmp(&b.priced.price_cents)),
"price_desc" => items.sort_by(|a, b| b.priced.price_cents.cmp(&a.priced.price_cents)), "price_desc" => items.sort_by(|a, b| b.priced.price_cents.cmp(&a.priced.price_cents)),
@@ -434,11 +434,17 @@ async fn show(
) )
} }
/// Category page: the same faceted search as the shop, but with this category
/// preselected as the default filter (plus breadcrumbs and subcategory chips).
/// Any other filters/sort/query on the URL are honoured; the category itself is
/// always forced to this page's category. Interacting with the toolbar navigates
/// to `/search` (the category stays selected there too).
#[debug_handler] #[debug_handler]
async fn category( async fn category(
jar: CookieJar, jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>, ViewEngine(v): ViewEngine<TeraView>,
Path(slug): Path<String>, Path(slug): Path<String>,
Query(params): Query<SearchParams>,
State(ctx): State<AppContext>, State(ctx): State<AppContext>,
) -> Result<Response> { ) -> Result<Response> {
let published = categories::published(&ctx).await?; let published = categories::published(&ctx).await?;
@@ -451,36 +457,22 @@ async fn category(
let breadcrumbs = categories::ancestors(&published, category.parent_id); let breadcrumbs = categories::ancestors(&published, category.parent_id);
let children = categories::children_of(&published, category.id); let children = categories::children_of(&published, category.id);
// Products listed here span this category and all of its descendants, so a // Force the category filter to this page's category, keeping any other params.
// parent category is never empty just because its products live in leaves. let params = SearchParams {
let mut category_ids: Vec<i32> = categories::descendant_ids(&published, category.id) category: Some(category.id.to_string()),
.into_iter() ..params
.collect(); };
category_ids.push(category.id);
let list = products::Entity::find()
.filter(products::Column::CategoryId.is_in(category_ids))
.filter(products::Column::Published.eq(true))
.order_by_desc(products::Column::PublishedAt)
.all(&ctx.db)
.await?;
let user = guard::current_user(&ctx, &jar).await; let user = guard::current_user(&ctx, &jar).await;
let mut context = run_search(&ctx, user.as_ref(), &params).await?;
if let Some(map) = context.as_object_mut() {
map.insert("category".into(), serde_json::to_value(&category)?);
map.insert("breadcrumbs".into(), serde_json::to_value(&breadcrumbs)?);
map.insert("children".into(), serde_json::to_value(&children)?);
}
let c = guard::chrome_from(&ctx, user.as_ref()); let c = guard::chrome_from(&ctx, user.as_ref());
format::view( add_chrome(&mut context, &c, &current_lang(&jar));
&v, format::view(&v, "shop/category.html", context)
"shop/category.html",
json!({
"category": category,
"breadcrumbs": breadcrumbs,
"children": children,
"products": product_rows(&ctx, user.as_ref(), list).await?,
"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),
}),
)
} }
pub fn routes() -> Routes { pub fn routes() -> Routes {