Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5e6263e853 | ||
|
|
5a474f3474 | ||
|
|
1e66bfd657 | ||
|
|
f512fbbb94 | ||
|
|
1ecfac2ad6 |
@@ -308,7 +308,29 @@ shop-title = Shop
|
||||
shop-subtitle = browse our products.
|
||||
shop-empty = There are no products here yet.
|
||||
search-placeholder = Search products…
|
||||
order-search-placeholder = Search orders…
|
||||
search-empty = Nothing matched your search:
|
||||
results-count = { $count } products
|
||||
sort-label = Sort
|
||||
sort-relevance = Relevance
|
||||
sort-newest = Newest
|
||||
sort-price_asc = Price: low to high
|
||||
sort-price_desc = Price: high to low
|
||||
sort-name_asc = Name: A–Z
|
||||
sort-name_desc = Name: Z–A
|
||||
filter-category = Category
|
||||
filter-all-categories = All categories
|
||||
filter-uncategorized = Uncategorized
|
||||
filter-price = Price
|
||||
filter-price-from = Price from
|
||||
filter-price-to = Price to
|
||||
filter-in-stock = In stock only
|
||||
filter-apply = Apply
|
||||
filter-clear = Clear
|
||||
pagination = Pagination
|
||||
page-of = Page { $page } of { $pages }
|
||||
prev = Previous
|
||||
next = Next
|
||||
view-grid = Grid view
|
||||
view-list = List view
|
||||
categories = Categories
|
||||
|
||||
@@ -308,7 +308,29 @@ shop-title = Obchod
|
||||
shop-subtitle = prezrite si našu ponuku produktov.
|
||||
shop-empty = Zatiaľ tu nie sú žiadne produkty.
|
||||
search-placeholder = Hľadať produkty…
|
||||
order-search-placeholder = Hľadať objednávky…
|
||||
search-empty = Pre váš výraz sme nič nenašli:
|
||||
results-count = { $count } produktov
|
||||
sort-label = Zoradiť
|
||||
sort-relevance = Relevancia
|
||||
sort-newest = Najnovšie
|
||||
sort-price_asc = Cena: od najnižšej
|
||||
sort-price_desc = Cena: od najvyššej
|
||||
sort-name_asc = Názov: A–Z
|
||||
sort-name_desc = Názov: Z–A
|
||||
filter-category = Kategória
|
||||
filter-all-categories = Všetky kategórie
|
||||
filter-uncategorized = Bez kategórie
|
||||
filter-price = Cena
|
||||
filter-price-from = Cena od
|
||||
filter-price-to = Cena do
|
||||
filter-in-stock = Len skladom
|
||||
filter-apply = Použiť
|
||||
filter-clear = Zrušiť
|
||||
pagination = Stránkovanie
|
||||
page-of = Strana { $page } z { $pages }
|
||||
prev = Predchádzajúce
|
||||
next = Ďalšie
|
||||
view-grid = Zobrazenie v mriežke
|
||||
view-list = Zobrazenie v zozname
|
||||
categories = Kategórie
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -7,6 +7,8 @@
|
||||
|
||||
{% block content %}
|
||||
{% set business = audience == "business" %}
|
||||
{% set L = lang | default(value='sk') %}
|
||||
{% set q_enc = query | default(value='') | urlencode %}
|
||||
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-products", lang=lang | default(value='sk')) }}</h1>
|
||||
@@ -15,20 +17,34 @@
|
||||
{{ ui::button(label=t(key="new-product", lang=lang | default(value='sk')), href="/admin/catalog/products/new") }}
|
||||
</div>
|
||||
|
||||
<!-- audience tabs -->
|
||||
<div class="mt-4 inline-flex rounded-radius border border-outline p-1 dark:border-outline-dark">
|
||||
<a href="/admin/catalog/products?audience=personal"
|
||||
class="rounded-radius px-4 py-1.5 text-sm font-medium {% if not business %}bg-primary/10 text-on-surface-strong dark:bg-primary-dark/10 dark:text-on-surface-dark-strong{% else %}text-on-surface/70 dark:text-on-surface-dark/70{% endif %}">
|
||||
{{ t(key="audience-personal", lang=lang | default(value='sk')) }}
|
||||
</a>
|
||||
<a href="/admin/catalog/products?audience=business"
|
||||
class="rounded-radius px-4 py-1.5 text-sm font-medium {% if business %}bg-primary/10 text-on-surface-strong dark:bg-primary-dark/10 dark:text-on-surface-dark-strong{% else %}text-on-surface/70 dark:text-on-surface-dark/70{% endif %}">
|
||||
{{ t(key="audience-business", lang=lang | default(value='sk')) }}
|
||||
</a>
|
||||
<div class="mt-4 flex flex-wrap items-center justify-between gap-3">
|
||||
<!-- audience tabs -->
|
||||
<div class="inline-flex rounded-radius border border-outline p-1 dark:border-outline-dark">
|
||||
<a href="/admin/catalog/products?audience=personal&q={{ q_enc }}"
|
||||
class="rounded-radius px-4 py-1.5 text-sm font-medium {% if not business %}bg-primary/10 text-on-surface-strong dark:bg-primary-dark/10 dark:text-on-surface-dark-strong{% else %}text-on-surface/70 dark:text-on-surface-dark/70{% endif %}">
|
||||
{{ t(key="audience-personal", lang=L) }}
|
||||
</a>
|
||||
<a href="/admin/catalog/products?audience=business&q={{ q_enc }}"
|
||||
class="rounded-radius px-4 py-1.5 text-sm font-medium {% if business %}bg-primary/10 text-on-surface-strong dark:bg-primary-dark/10 dark:text-on-surface-dark-strong{% else %}text-on-surface/70 dark:text-on-surface-dark/70{% endif %}">
|
||||
{{ t(key="audience-business", lang=L) }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- product search (drafts included); keeps the active audience + category -->
|
||||
<form method="get" action="/admin/catalog/products" role="search" class="relative w-full max-w-xs">
|
||||
<input type="hidden" name="audience" value="{{ audience }}">
|
||||
<input type="hidden" name="category" value="{{ selected_category }}">
|
||||
<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-3 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">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% set category_base = "/admin/catalog/products" %}
|
||||
{% set category_suffix = "&audience=" ~ audience %}
|
||||
{% set category_suffix = "&audience=" ~ audience ~ "&q=" ~ q_enc %}
|
||||
<div class="mt-4 flex flex-col gap-6 md:flex-row md:items-start">
|
||||
{% include "admin/partials/category_filter.html" %}
|
||||
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
{% block crumb %}{{ t(key="admin-customers", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||
|
||||
{% block content %}
|
||||
{% set L = lang | default(value='sk') %}
|
||||
{% set q_enc = query | default(value='') | urlencode %}
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ customer.name }}</h1>
|
||||
@@ -41,10 +43,23 @@
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<p class="mt-6 text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="negotiated-prices-hint", lang=lang | default(value='sk')) }}</p>
|
||||
<div class="mt-6 flex flex-wrap items-center justify-between gap-3">
|
||||
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="negotiated-prices-hint", lang=L) }}</p>
|
||||
|
||||
<!-- product search (drafts included); keeps the active category -->
|
||||
<form method="get" action="/admin/customers/{{ customer.id }}" role="search" class="relative w-full max-w-xs">
|
||||
<input type="hidden" name="category" value="{{ selected_category }}">
|
||||
<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-3 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">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% set category_base = "/admin/customers/" ~ customer.id %}
|
||||
{% set category_suffix = "" %}
|
||||
{% set category_suffix = "&q=" ~ q_enc %}
|
||||
<div class="mt-3 flex flex-col gap-6 md:flex-row md:items-start">
|
||||
{% include "admin/partials/category_filter.html" %}
|
||||
<div class="min-w-0 flex-1 {{ ui::table_wrap_cls() }}">
|
||||
|
||||
@@ -5,7 +5,24 @@
|
||||
{% block crumb %}{{ t(key="admin-orders", lang=lang | default(value='sk')) }}{% endblock crumb %}
|
||||
|
||||
{% block content %}
|
||||
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-orders", lang=lang | default(value='sk')) }}</h1>
|
||||
{% set L = lang | default(value='sk') %}
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<h1 class="text-2xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="admin-orders", lang=L) }}</h1>
|
||||
|
||||
<!-- order search: order number, customer, email, company, phone, tracking -->
|
||||
<form method="get" action="/admin/orders" role="search" class="relative w-full max-w-xs">
|
||||
<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='order-search-placeholder', lang=L) }}" aria-label="{{ t(key='order-search-placeholder', lang=L) }}"
|
||||
class="w-full rounded-radius border border-outline bg-surface py-2 pl-10 pr-3 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">
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if query and query != "" %}
|
||||
<p class="mt-2 text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="results-count", lang=L, count=total) }} · “{{ query }}”</p>
|
||||
{% endif %}
|
||||
|
||||
<div class="mt-6 {{ ui::table_wrap_cls() }}">
|
||||
{% if orders | length > 0 %}
|
||||
|
||||
@@ -1,13 +1,46 @@
|
||||
{# Search / listing results, swapped in by htmx on each query and rendered
|
||||
server-side on first load. Mirrors the empty-state handling of index.html. #}
|
||||
{% if products | length > 0 %}
|
||||
{% include "shop/_product_grid.html" %}
|
||||
{% elif query and query != "" %}
|
||||
<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="search-empty", lang=lang | default(value='sk')) }} <span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ query }}</span>
|
||||
{# Results region: swapped in by htmx on each query/filter change and rendered
|
||||
server-side on first load. Holds the result summary, the product grid and
|
||||
pagination. #}
|
||||
{% set L = lang | default(value='sk') %}
|
||||
<div class="space-y-4">
|
||||
<p class="text-sm text-on-surface/70 dark:text-on-surface-dark/70" aria-live="polite">
|
||||
{{ t(key="results-count", lang=L, count=total) }}{% if query and query != "" %} · “{{ query }}”{% endif %}
|
||||
</p>
|
||||
|
||||
{% if products | length > 0 %}
|
||||
{% include "shop/_product_grid.html" %}
|
||||
|
||||
{% if pages > 1 %}
|
||||
<nav class="flex items-center justify-center gap-2 pt-2" aria-label="{{ t(key='pagination', lang=L) }}">
|
||||
{% if has_prev %}
|
||||
<button type="button"
|
||||
hx-get="/search?{% if query_base %}{{ query_base }}&{% endif %}page={{ prev_page }}"
|
||||
hx-target="#shop-results" hx-swap="innerHTML" hx-push-url="true"
|
||||
class="rounded-radius border border-outline px-3 py-1.5 text-sm font-medium text-on-surface transition hover:bg-primary/5 hover:text-primary dark:border-outline-dark dark:text-on-surface-dark dark:hover:text-primary-dark">
|
||||
{{ t(key="prev", lang=L) }}
|
||||
</button>
|
||||
{% endif %}
|
||||
<span class="px-2 text-sm text-on-surface/70 dark:text-on-surface-dark/70">
|
||||
{{ t(key="page-of", lang=L, page=page, pages=pages) }}
|
||||
</span>
|
||||
{% if has_next %}
|
||||
<button type="button"
|
||||
hx-get="/search?{% if query_base %}{{ query_base }}&{% endif %}page={{ next_page }}"
|
||||
hx-target="#shop-results" hx-swap="innerHTML" hx-push-url="true"
|
||||
class="rounded-radius border border-outline px-3 py-1.5 text-sm font-medium text-on-surface transition hover:bg-primary/5 hover:text-primary dark:border-outline-dark dark:text-on-surface-dark dark:hover:text-primary-dark">
|
||||
{{ t(key="next", lang=L) }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% elif query and query != "" %}
|
||||
<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="search-empty", lang=L) }} <span class="font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ query }}</span>
|
||||
</div>
|
||||
{% 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=L) }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% 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 %}
|
||||
|
||||
102
assets/views/shop/_search.html
Normal file
102
assets/views/shop/_search.html
Normal 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 %}> — {{ 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 ["newest", "relevance", "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>
|
||||
@@ -4,10 +4,11 @@
|
||||
{% block title %}{{ category.name }}{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-8">
|
||||
{% set L = lang | default(value='sk') %}
|
||||
<div class="space-y-6">
|
||||
<header class="space-y-2">
|
||||
<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 %}
|
||||
<span class="px-1">/</span>
|
||||
<a href="/category/{{ crumb.slug }}" class="hover:text-primary dark:hover:text-primary-dark">{{ crumb.name }}</a>
|
||||
@@ -28,12 +29,7 @@
|
||||
{% endif %}
|
||||
</header>
|
||||
|
||||
{% if products | length > 0 %}
|
||||
{% include "shop/_product_grid.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 %}
|
||||
{# Same search + filters as the shop, with this category preselected. #}
|
||||
{% include "shop/_search.html" %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
@@ -4,42 +4,13 @@
|
||||
{% block title %}{{ t(key="nav-shop", lang=lang | default(value='sk')) }}{% endblock title %}
|
||||
|
||||
{% block content %}
|
||||
<div class="space-y-8">
|
||||
<header class="space-y-4">
|
||||
<div class="space-y-2">
|
||||
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shop-title", lang=lang | default(value='sk')) }}</h1>
|
||||
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-subtitle", lang=lang | default(value='sk')) }}</p>
|
||||
</div>
|
||||
|
||||
{# Live search: htmx GETs /search as the customer types (debounced) and
|
||||
swaps only the results below. hx-push-url keeps the URL shareable; the
|
||||
spinner shows while a request is in flight. Degrades to a normal GET form
|
||||
submit when JS/htmx is unavailable. #}
|
||||
<form action="/search" method="get" role="search"
|
||||
class="relative max-w-md"
|
||||
hx-get="/search" hx-target="#shop-results" hx-swap="innerHTML"
|
||||
hx-trigger="input changed delay:300ms from:input[name='q'], submit"
|
||||
hx-push-url="true" hx-indicator="#search-spinner">
|
||||
<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=lang | default(value='sk')) }}"
|
||||
aria-label="{{ t(key='search-placeholder', lang=lang | default(value='sk')) }}"
|
||||
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>
|
||||
</form>
|
||||
{% set L = lang | default(value='sk') %}
|
||||
<div class="space-y-6">
|
||||
<header class="space-y-1">
|
||||
<h1 class="text-3xl font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="shop-title", lang=L) }}</h1>
|
||||
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-subtitle", lang=L) }}</p>
|
||||
</header>
|
||||
|
||||
<div id="shop-results">
|
||||
{% include "shop/_results.html" %}
|
||||
</div>
|
||||
{% include "shop/_search.html" %}
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
@@ -44,6 +44,7 @@ mod m20260622_000002_product_variants;
|
||||
mod m20260622_000003_variant_stock_nullable;
|
||||
mod m20260622_000004_product_search;
|
||||
mod m20260622_000005_product_search_aggregate;
|
||||
mod m20260622_000006_order_search_indexes;
|
||||
pub struct Migrator;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
@@ -92,6 +93,7 @@ impl MigratorTrait for Migrator {
|
||||
Box::new(m20260622_000003_variant_stock_nullable::Migration),
|
||||
Box::new(m20260622_000004_product_search::Migration),
|
||||
Box::new(m20260622_000005_product_search_aggregate::Migration),
|
||||
Box::new(m20260622_000006_order_search_indexes::Migration),
|
||||
// inject-above (do not remove this comment)
|
||||
]
|
||||
}
|
||||
|
||||
48
migration/src/m20260622_000006_order_search_indexes.rs
Normal file
48
migration/src/m20260622_000006_order_search_indexes.rs
Normal file
@@ -0,0 +1,48 @@
|
||||
//! Trigram indexes so the admin order search stays fast as orders pile up.
|
||||
//!
|
||||
//! Order search is a plain substring (`ILIKE`) match over the high-signal,
|
||||
//! free-text order fields — order number, email, customer/company name — run
|
||||
//! through `f_unaccent` so diacritics and case never matter (see
|
||||
//! `orders::Entity::search`). These `pg_trgm` GIN indexes let those `ILIKE`
|
||||
//! lookups use an index instead of scanning every row. `pg_trgm` + `f_unaccent`
|
||||
//! already exist from the product-search migration.
|
||||
|
||||
use sea_orm_migration::prelude::*;
|
||||
|
||||
#[derive(DeriveMigrationName)]
|
||||
pub struct Migration;
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl MigrationTrait for Migration {
|
||||
async fn up(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
m.get_connection()
|
||||
.execute_unprepared(
|
||||
r#"
|
||||
CREATE INDEX idx_orders_number_trgm
|
||||
ON orders USING GIN (f_unaccent(order_number) gin_trgm_ops);
|
||||
CREATE INDEX idx_orders_email_trgm
|
||||
ON orders USING GIN (f_unaccent(email) gin_trgm_ops);
|
||||
CREATE INDEX idx_orders_customer_name_trgm
|
||||
ON orders USING GIN (f_unaccent(COALESCE(customer_name, '')) gin_trgm_ops);
|
||||
CREATE INDEX idx_orders_company_name_trgm
|
||||
ON orders USING GIN (f_unaccent(COALESCE(company_name, '')) gin_trgm_ops);
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||
m.get_connection()
|
||||
.execute_unprepared(
|
||||
r#"
|
||||
DROP INDEX IF EXISTS idx_orders_company_name_trgm;
|
||||
DROP INDEX IF EXISTS idx_orders_customer_name_trgm;
|
||||
DROP INDEX IF EXISTS idx_orders_email_trgm;
|
||||
DROP INDEX IF EXISTS idx_orders_number_trgm;
|
||||
"#,
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -138,10 +138,17 @@ async fn show(
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
|
||||
let list = products::Entity::find()
|
||||
.order_by_asc(products::Column::Name)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
// Optional text search (drafts included), otherwise the whole catalog by
|
||||
// name. Reuses the storefront's hybrid full-text + fuzzy product search.
|
||||
let query = params.get("q").map(String::as_str).unwrap_or("").trim().to_string();
|
||||
let list = if query.is_empty() {
|
||||
products::Entity::find()
|
||||
.order_by_asc(products::Column::Name)
|
||||
.all(&ctx.db)
|
||||
.await?
|
||||
} else {
|
||||
products::Entity::search(&ctx.db, &query, 1000, false).await?
|
||||
};
|
||||
|
||||
// Category sidebar tree (counts over the full, unfiltered product list) plus
|
||||
// the active `?category=` filter applied to the rows.
|
||||
@@ -212,6 +219,7 @@ async fn show(
|
||||
"products": rows,
|
||||
"category_groups": category_groups,
|
||||
"selected_category": selected_category,
|
||||
"query": query,
|
||||
"total_count": list.len(),
|
||||
"uncategorized_count": category_ids.iter().filter(|c| c.is_none()).count(),
|
||||
"error": params.get("error"),
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
//! Admin order list, detail, status updates, and manual carrier dispatch.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::extract::Query;
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter, QueryOrder, Set};
|
||||
@@ -30,18 +33,31 @@ async fn index(
|
||||
auth: auth::JWT,
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
guard::current_admin(auth, &ctx).await?;
|
||||
let list = orders::Entity::find()
|
||||
.order_by_desc(orders::Column::CreatedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
// Optional search over order number / customer / email / etc., otherwise the
|
||||
// full list newest first.
|
||||
let query = params.get("q").map(String::as_str).unwrap_or("").trim().to_string();
|
||||
let list = if query.is_empty() {
|
||||
orders::Entity::find()
|
||||
.order_by_desc(orders::Column::CreatedAt)
|
||||
.all(&ctx.db)
|
||||
.await?
|
||||
} else {
|
||||
orders::Entity::search(&ctx.db, &query, 500).await?
|
||||
};
|
||||
let rows: Vec<serde_json::Value> = list.iter().map(view::summary).collect();
|
||||
format::view(
|
||||
&v,
|
||||
"admin/orders/index.html",
|
||||
json!({ "orders": rows, "lang": current_lang(&jar) }),
|
||||
json!({
|
||||
"orders": rows,
|
||||
"query": query,
|
||||
"total": list.len(),
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -281,10 +281,17 @@ async fn index(
|
||||
.map(|c| (c.id, c.name.clone()))
|
||||
.collect();
|
||||
|
||||
let list = products::Entity::find()
|
||||
.order_by_desc(products::Column::CreatedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
// Optional text search (drafts included), otherwise the full catalog newest
|
||||
// first. Reuses the storefront's hybrid full-text + fuzzy product search.
|
||||
let query = params.get("q").map(String::as_str).unwrap_or("").trim().to_string();
|
||||
let list = if query.is_empty() {
|
||||
products::Entity::find()
|
||||
.order_by_desc(products::Column::CreatedAt)
|
||||
.all(&ctx.db)
|
||||
.await?
|
||||
} else {
|
||||
products::Entity::search(&ctx.db, &query, 1000, false).await?
|
||||
};
|
||||
let ids: Vec<i32> = list.iter().map(|p| p.id).collect();
|
||||
let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &ids).await?;
|
||||
|
||||
@@ -352,6 +359,7 @@ async fn index(
|
||||
"audience": audience,
|
||||
"category_groups": category_groups,
|
||||
"selected_category": selected_category,
|
||||
"query": query,
|
||||
"total_count": list.len(),
|
||||
"uncategorized_count": category_ids.iter().filter(|c| c.is_none()).count(),
|
||||
"lang": current_lang(&jar),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
//! Public storefront: product listings, product detail, category pages and the
|
||||
//! lazily-loaded category sidebar.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::extract::Query;
|
||||
use axum::http::HeaderMap;
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
@@ -10,15 +12,224 @@ use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
controllers::i18n::current_lang,
|
||||
shared::{guard, pricing},
|
||||
shared::{
|
||||
guard,
|
||||
money::{format_price, parse_price_to_cents},
|
||||
pricing,
|
||||
},
|
||||
models::{categories, product_images, product_variants, products, users},
|
||||
views::shop as view,
|
||||
};
|
||||
|
||||
/// Query string for the storefront search box.
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
/// Results per page in the storefront listing/search.
|
||||
const PER_PAGE: usize = 24;
|
||||
/// Hard cap on candidates a single text search considers before faceting; well
|
||||
/// above any realistic page of results for this catalog.
|
||||
const SEARCH_CAP: u64 = 1000;
|
||||
|
||||
/// All storefront listing controls: free-text query, category, price band,
|
||||
/// stock, sort and page. Everything is optional so `/shop` and `/search` share
|
||||
/// one shape.
|
||||
#[derive(Debug, Default, serde::Deserialize)]
|
||||
struct SearchParams {
|
||||
q: Option<String>,
|
||||
category: Option<String>,
|
||||
min_price: Option<String>,
|
||||
max_price: Option<String>,
|
||||
in_stock: Option<String>,
|
||||
sort: Option<String>,
|
||||
page: Option<u32>,
|
||||
}
|
||||
|
||||
/// A candidate product with everything the listing needs to filter, sort and
|
||||
/// render it: its representative (first) variant, the resolved price for the
|
||||
/// viewer, stock, variant count and original search rank (for relevance order).
|
||||
struct Candidate {
|
||||
product: products::Model,
|
||||
rep: product_variants::Model,
|
||||
priced: pricing::PricedProduct,
|
||||
in_stock: bool,
|
||||
count: usize,
|
||||
rank: usize,
|
||||
}
|
||||
|
||||
/// Whether a checkbox-style param is on (present and not an explicit "off"/"0").
|
||||
fn is_on(v: &Option<String>) -> bool {
|
||||
matches!(v.as_deref(), Some(s) if !s.is_empty() && s != "0" && s != "false" && s != "off")
|
||||
}
|
||||
|
||||
/// Rebuild the query string from `params` minus `page`, so pagination links can
|
||||
/// preserve the active query + filters + sort.
|
||||
fn query_base(params: &SearchParams) -> String {
|
||||
let mut ser = form_urlencoded::Serializer::new(String::new());
|
||||
if let Some(q) = params.q.as_deref().filter(|s| !s.is_empty()) {
|
||||
ser.append_pair("q", q);
|
||||
}
|
||||
if let Some(c) = params.category.as_deref().filter(|s| !s.is_empty() && *s != "all") {
|
||||
ser.append_pair("category", c);
|
||||
}
|
||||
if let Some(p) = params.min_price.as_deref().filter(|s| !s.is_empty()) {
|
||||
ser.append_pair("min_price", p);
|
||||
}
|
||||
if let Some(p) = params.max_price.as_deref().filter(|s| !s.is_empty()) {
|
||||
ser.append_pair("max_price", p);
|
||||
}
|
||||
if is_on(¶ms.in_stock) {
|
||||
ser.append_pair("in_stock", "1");
|
||||
}
|
||||
if let Some(s) = params.sort.as_deref().filter(|s| !s.is_empty()) {
|
||||
ser.append_pair("sort", s);
|
||||
}
|
||||
ser.finish()
|
||||
}
|
||||
|
||||
/// Run the full faceted listing pipeline for `params` and shape the template
|
||||
/// context (results page + facet data + pagination). Reused by `/shop` and
|
||||
/// `/search`; the caller adds chrome and picks the template.
|
||||
async fn run_search(
|
||||
ctx: &AppContext,
|
||||
user: Option<&users::Model>,
|
||||
params: &SearchParams,
|
||||
) -> Result<serde_json::Value> {
|
||||
let q = params.q.clone().unwrap_or_default();
|
||||
let q_trim = q.trim().to_string();
|
||||
|
||||
// 1. Base candidates: ranked search hits, or the full published listing.
|
||||
let base: Vec<products::Model> = if q_trim.is_empty() {
|
||||
products::Entity::find()
|
||||
.filter(products::Column::Published.eq(true))
|
||||
.order_by_desc(products::Column::PublishedAt)
|
||||
.all(&ctx.db)
|
||||
.await?
|
||||
} else {
|
||||
products::Entity::search(&ctx.db, &q_trim, SEARCH_CAP, true).await?
|
||||
};
|
||||
|
||||
// 2. Attach representative variant + resolved price to each (drop products
|
||||
// with no purchasable variant).
|
||||
let ids: Vec<i32> = base.iter().map(|p| p.id).collect();
|
||||
let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &ids).await?;
|
||||
let mut staged: Vec<(products::Model, product_variants::Model, usize, usize)> = Vec::new();
|
||||
for (rank, product) in base.into_iter().enumerate() {
|
||||
if let Some(vs) = grouped.get(&product.id) {
|
||||
if let Some(rep) = vs.first() {
|
||||
staged.push((product, rep.clone(), vs.len(), rank));
|
||||
}
|
||||
}
|
||||
}
|
||||
let reps: Vec<product_variants::Model> = staged.iter().map(|(_, r, _, _)| r.clone()).collect();
|
||||
let priced = pricing::price_variants(ctx, &reps, user).await?;
|
||||
let mut items: Vec<Candidate> = staged
|
||||
.into_iter()
|
||||
.zip(priced.iter())
|
||||
.map(|((product, rep, count, rank), p)| Candidate {
|
||||
in_stock: rep.in_stock(),
|
||||
product,
|
||||
rep,
|
||||
priced: *p,
|
||||
count,
|
||||
rank,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Price band bounds across all matches, to hint the filter UI.
|
||||
let price_floor = items.iter().map(|i| i.priced.price_cents).min().unwrap_or(0);
|
||||
let price_ceil = items.iter().map(|i| i.priced.price_cents).max().unwrap_or(0);
|
||||
|
||||
// 3. Non-category filters: price band + in-stock.
|
||||
let min_c = params.min_price.as_deref().and_then(|s| parse_price_to_cents(s).ok());
|
||||
let max_c = params.max_price.as_deref().and_then(|s| parse_price_to_cents(s).ok());
|
||||
let in_stock_only = is_on(¶ms.in_stock);
|
||||
items.retain(|i| {
|
||||
min_c.is_none_or(|m| i.priced.price_cents >= m)
|
||||
&& max_c.is_none_or(|m| i.priced.price_cents <= m)
|
||||
&& (!in_stock_only || i.in_stock)
|
||||
});
|
||||
|
||||
// 4. Category facets: counts computed over the price/stock-filtered set
|
||||
// (i.e. before applying the category choice itself).
|
||||
let all_categories = categories::published(ctx).await?;
|
||||
let cat_ids: Vec<Option<i32>> = items.iter().map(|i| i.product.category_id).collect();
|
||||
let category_groups = view::admin_category_groups(&all_categories, &cat_ids);
|
||||
let uncategorized_count = cat_ids.iter().filter(|c| c.is_none()).count();
|
||||
let category_name: HashMap<i32, String> =
|
||||
all_categories.iter().map(|c| (c.id, c.name.clone())).collect();
|
||||
|
||||
// 5. Apply the category filter.
|
||||
let selected_category = params
|
||||
.category
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "all".to_string());
|
||||
let filter = view::category_filter_ids(&all_categories, &selected_category);
|
||||
items.retain(|i| view::category_filter_keep(&filter, i.product.category_id));
|
||||
|
||||
// 6. Sort. Newest-first is the default; relevance (the ranked search order)
|
||||
// is available explicitly via the sort control.
|
||||
let sort = params
|
||||
.sort
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "newest".to_string());
|
||||
match sort.as_str() {
|
||||
"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)),
|
||||
"name_asc" => items.sort_by(|a, b| {
|
||||
a.product.name.to_lowercase().cmp(&b.product.name.to_lowercase())
|
||||
}),
|
||||
"name_desc" => items.sort_by(|a, b| {
|
||||
b.product.name.to_lowercase().cmp(&a.product.name.to_lowercase())
|
||||
}),
|
||||
"newest" => items.sort_by(|a, b| b.product.published_at.cmp(&a.product.published_at)),
|
||||
// "relevance" and anything unknown: original search rank.
|
||||
_ => items.sort_by_key(|i| i.rank),
|
||||
}
|
||||
|
||||
// 7. Paginate.
|
||||
let total = items.len();
|
||||
let pages = total.div_ceil(PER_PAGE).max(1);
|
||||
let page = params.page.unwrap_or(1).clamp(1, pages as u32);
|
||||
let start = (page as usize - 1) * PER_PAGE;
|
||||
|
||||
// 8. Render only the current page's cards (images fetched per row).
|
||||
let mut rows = Vec::new();
|
||||
for item in items.iter().skip(start).take(PER_PAGE) {
|
||||
let image = product_images::first_for(ctx, item.product.id).await?;
|
||||
let cat_name = item.product.category_id.and_then(|id| category_name.get(&id).cloned());
|
||||
rows.push(view::product_card(
|
||||
&item.product,
|
||||
&item.rep,
|
||||
&item.priced,
|
||||
item.count,
|
||||
image,
|
||||
cat_name,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"products": rows,
|
||||
"query": q,
|
||||
"category_groups": category_groups,
|
||||
"selected_category": selected_category,
|
||||
// Numeric form so the <select> can mark the active option (Tera can't
|
||||
// compare a string param against a numeric category id).
|
||||
"selected_category_id": selected_category.parse::<i32>().unwrap_or(-1),
|
||||
"uncategorized_count": uncategorized_count,
|
||||
"sort": sort,
|
||||
"in_stock": in_stock_only,
|
||||
"min_price": params.min_price.clone().unwrap_or_default(),
|
||||
"max_price": params.max_price.clone().unwrap_or_default(),
|
||||
"price_floor": format_price(price_floor),
|
||||
"price_ceil": format_price(price_ceil),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pages": pages,
|
||||
"has_prev": page > 1,
|
||||
"has_next": (page as usize) < pages,
|
||||
"prev_page": page.saturating_sub(1).max(1),
|
||||
"next_page": page + 1,
|
||||
"query_base": query_base(params),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Shape a list of products into card rows for `user` (None = public). Each card
|
||||
@@ -90,39 +301,36 @@ async fn category_sidebar(
|
||||
)
|
||||
}
|
||||
|
||||
/// Fold the page chrome (login state, names) and language into a `run_search`
|
||||
/// context so the full page can render the layout.
|
||||
fn add_chrome(ctx_value: &mut serde_json::Value, c: &guard::Chrome, lang: &str) {
|
||||
if let Some(map) = ctx_value.as_object_mut() {
|
||||
map.insert("logged_in_admin".into(), json!(c.logged_in_admin));
|
||||
map.insert("logged_in_customer".into(), json!(c.logged_in_customer));
|
||||
map.insert("customer_name".into(), json!(c.customer_name));
|
||||
map.insert("customer_account_type".into(), json!(c.customer_account_type));
|
||||
map.insert("lang".into(), json!(lang));
|
||||
}
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn index(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let list = products::Entity::find()
|
||||
.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 mut context = run_search(&ctx, user.as_ref(), &SearchParams::default()).await?;
|
||||
let c = guard::chrome_from(&ctx, user.as_ref());
|
||||
format::view(
|
||||
&v,
|
||||
"shop/index.html",
|
||||
json!({
|
||||
"products": product_rows(&ctx, user.as_ref(), list).await?,
|
||||
"query": "",
|
||||
"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),
|
||||
}),
|
||||
)
|
||||
add_chrome(&mut context, &c, ¤t_lang(&jar));
|
||||
format::view(&v, "shop/index.html", context)
|
||||
}
|
||||
|
||||
/// Storefront search. Reuses the shop listing's card shaping, ranking results by
|
||||
/// the hybrid full-text + fuzzy query in [`products::Entity::search`]. A blank
|
||||
/// query falls back to the full published listing (so clearing the box restores
|
||||
/// the shop). htmx requests get just the results fragment for live updates;
|
||||
/// Storefront search + faceted browse. Combines the hybrid full-text/fuzzy query
|
||||
/// ([`products::Entity::search`]) with category, price-band, in-stock and sort
|
||||
/// filters, ranked and paginated by [`run_search`]. A blank query falls back to
|
||||
/// the full published listing, so the same endpoint powers both "browse" and
|
||||
/// "search". htmx requests get just the results fragment (for live updates);
|
||||
/// direct navigation (or no-JS) renders the whole page.
|
||||
#[debug_handler]
|
||||
async fn search(
|
||||
@@ -132,44 +340,20 @@ async fn search(
|
||||
Query(params): Query<SearchParams>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let q = params.q.unwrap_or_default();
|
||||
let trimmed = q.trim();
|
||||
let list = if trimmed.is_empty() {
|
||||
products::Entity::find()
|
||||
.filter(products::Column::Published.eq(true))
|
||||
.order_by_desc(products::Column::PublishedAt)
|
||||
.all(&ctx.db)
|
||||
.await?
|
||||
} else {
|
||||
products::Entity::search(&ctx.db, trimmed).await?
|
||||
};
|
||||
|
||||
let user = guard::current_user(&ctx, &jar).await;
|
||||
let rows = product_rows(&ctx, user.as_ref(), list).await?;
|
||||
let mut context = run_search(&ctx, user.as_ref(), ¶ms).await?;
|
||||
let lang = current_lang(&jar);
|
||||
|
||||
if headers.contains_key("HX-Request") {
|
||||
return format::view(
|
||||
&v,
|
||||
"shop/_results.html",
|
||||
json!({ "products": rows, "query": q, "lang": lang }),
|
||||
);
|
||||
if let Some(map) = context.as_object_mut() {
|
||||
map.insert("lang".into(), json!(lang));
|
||||
}
|
||||
return format::view(&v, "shop/_results.html", context);
|
||||
}
|
||||
|
||||
let c = guard::chrome_from(&ctx, user.as_ref());
|
||||
format::view(
|
||||
&v,
|
||||
"shop/index.html",
|
||||
json!({
|
||||
"products": rows,
|
||||
"query": q,
|
||||
"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": lang,
|
||||
}),
|
||||
)
|
||||
add_chrome(&mut context, &c, &lang);
|
||||
format::view(&v, "shop/index.html", context)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
@@ -250,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]
|
||||
async fn category(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
Path(slug): Path<String>,
|
||||
Query(params): Query<SearchParams>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let published = categories::published(&ctx).await?;
|
||||
@@ -267,36 +457,22 @@ async fn category(
|
||||
let breadcrumbs = categories::ancestors(&published, category.parent_id);
|
||||
let children = categories::children_of(&published, category.id);
|
||||
|
||||
// Products listed here span this category and all of its descendants, so a
|
||||
// parent category is never empty just because its products live in leaves.
|
||||
let mut category_ids: Vec<i32> = categories::descendant_ids(&published, category.id)
|
||||
.into_iter()
|
||||
.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?;
|
||||
// Force the category filter to this page's category, keeping any other params.
|
||||
let params = SearchParams {
|
||||
category: Some(category.id.to_string()),
|
||||
..params
|
||||
};
|
||||
|
||||
let user = guard::current_user(&ctx, &jar).await;
|
||||
let mut context = run_search(&ctx, user.as_ref(), ¶ms).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());
|
||||
format::view(
|
||||
&v,
|
||||
"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),
|
||||
}),
|
||||
)
|
||||
add_chrome(&mut context, &c, ¤t_lang(&jar));
|
||||
format::view(&v, "shop/category.html", context)
|
||||
}
|
||||
|
||||
pub fn routes() -> Routes {
|
||||
|
||||
@@ -163,4 +163,45 @@ impl Model {}
|
||||
impl ActiveModel {}
|
||||
|
||||
// implement your custom finders, selectors oriented logic here
|
||||
impl Entity {}
|
||||
impl Entity {
|
||||
/// Admin order search: a diacritic- and case-insensitive substring match over
|
||||
/// the free-text order fields an admin would actually type — order number,
|
||||
/// email, customer name, company name, phone and tracking number. Backed by
|
||||
/// the trigram indexes from the `order_search_indexes` migration. Newest
|
||||
/// first, capped at `limit`. A blank query returns nothing (callers fall back
|
||||
/// to the full list).
|
||||
pub async fn search<C: sea_orm::ConnectionTrait>(
|
||||
db: &C,
|
||||
query: &str,
|
||||
limit: u64,
|
||||
) -> Result<Vec<Model>, DbErr> {
|
||||
let q = query.trim();
|
||||
if q.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
// Treat the query literally: escape LIKE wildcards, then wrap in %…%.
|
||||
let escaped = q.replace('\\', "\\\\").replace('%', "\\%").replace('_', "\\_");
|
||||
let pattern = format!("%{escaped}%");
|
||||
|
||||
let sql = r#"
|
||||
SELECT * FROM orders o
|
||||
WHERE f_unaccent(o.order_number) ILIKE f_unaccent($1)
|
||||
OR f_unaccent(o.email) ILIKE f_unaccent($1)
|
||||
OR f_unaccent(COALESCE(o.customer_name,'')) ILIKE f_unaccent($1)
|
||||
OR f_unaccent(COALESCE(o.company_name,'')) ILIKE f_unaccent($1)
|
||||
OR f_unaccent(COALESCE(o.phone,'')) ILIKE f_unaccent($1)
|
||||
OR f_unaccent(COALESCE(o.tracking_number,'')) ILIKE f_unaccent($1)
|
||||
ORDER BY o.created_at DESC
|
||||
LIMIT $2
|
||||
"#;
|
||||
|
||||
Entity::find()
|
||||
.from_raw_sql(sea_orm::Statement::from_sql_and_values(
|
||||
db.get_database_backend(),
|
||||
sql,
|
||||
[pattern.into(), (limit as i64).into()],
|
||||
))
|
||||
.all(db)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,7 +37,14 @@ impl Entity {
|
||||
/// `f_unaccent`, so diacritics never matter. Results are ranked by full-text
|
||||
/// rank, then trigram closeness of the name, then recency. An empty/blank
|
||||
/// query returns nothing — callers fall back to the plain listing.
|
||||
pub async fn search<C: ConnectionTrait>(db: &C, query: &str) -> Result<Vec<Model>, DbErr> {
|
||||
/// `published_only` filters to the storefront-visible set; pass `false` for
|
||||
/// admin tools that also need to find drafts.
|
||||
pub async fn search<C: ConnectionTrait>(
|
||||
db: &C,
|
||||
query: &str,
|
||||
limit: u64,
|
||||
published_only: bool,
|
||||
) -> Result<Vec<Model>, DbErr> {
|
||||
let q = query.trim();
|
||||
if q.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
@@ -45,13 +52,14 @@ impl Entity {
|
||||
|
||||
// Only the model's own columns are selected; the generated `search_vector`
|
||||
// is left out so the row maps cleanly back onto `Model`. `$1` is reused
|
||||
// for every occurrence of the query term.
|
||||
let sql = r#"
|
||||
// for every occurrence of the query term; `$2` caps the result set.
|
||||
let published_clause = if published_only { "p.published = TRUE AND" } else { "" };
|
||||
let sql = format!(
|
||||
r#"
|
||||
SELECT p.created_at, p.updated_at, p.id, p.name, p.slug, p.description,
|
||||
p.currency, p.view_count, p.published, p.published_at, p.category_id
|
||||
FROM products p
|
||||
WHERE p.published = TRUE
|
||||
AND (
|
||||
WHERE {published_clause} (
|
||||
p.search_vector @@ websearch_to_tsquery('sk_unaccent', $1)
|
||||
OR word_similarity(f_unaccent($1), f_unaccent(p.name)) > 0.3
|
||||
OR word_similarity(f_unaccent($1), f_unaccent(COALESCE(p.description, ''))) > 0.3
|
||||
@@ -60,14 +68,15 @@ impl Entity {
|
||||
ts_rank(p.search_vector, websearch_to_tsquery('sk_unaccent', $1)) DESC,
|
||||
word_similarity(f_unaccent($1), f_unaccent(p.name)) DESC,
|
||||
p.published_at DESC NULLS LAST
|
||||
LIMIT 60
|
||||
"#;
|
||||
LIMIT $2
|
||||
"#
|
||||
);
|
||||
|
||||
Entity::find()
|
||||
.from_raw_sql(Statement::from_sql_and_values(
|
||||
db.get_database_backend(),
|
||||
sql,
|
||||
[q.into()],
|
||||
&sql,
|
||||
[q.into(), (limit as i64).into()],
|
||||
))
|
||||
.all(db)
|
||||
.await
|
||||
|
||||
Reference in New Issue
Block a user