7 Commits

Author SHA1 Message Date
Priec
96c428eadd discounts now work well
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-22 23:12:26 +02:00
Priec
5e6263e853 orders search query also working now
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-22 21:52:22 +02:00
Priec
5a474f3474 search in admin also 2026-06-22 21:38:03 +02:00
Priec
1e66bfd657 defaults for the search implemented 2026-06-22 21:18:13 +02:00
Priec
f512fbbb94 saerch query in the shop now works well 2026-06-22 21:12:47 +02:00
Priec
1ecfac2ad6 search with parameters 2026-06-22 21:01:02 +02:00
Priec
3b9c2f7d64 search implement
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled
2026-06-22 20:37:06 +02:00
24 changed files with 1460 additions and 195 deletions

View File

@@ -307,6 +307,30 @@ confirm-delete = Delete this for good?
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: AZ
sort-name_desc = Name: ZA
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

View File

@@ -307,6 +307,30 @@ confirm-delete = Naozaj zmazať?
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: AZ
sort-name_desc = Názov: ZA
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

View File

@@ -15,82 +15,80 @@
{{ ui::button(variant="outline-secondary", label=t(key="cancel", lang=lang | default(value='sk')), href="/admin/catalog/products?audience=" ~ audience, size="px-3 py-2 text-sm") }}
</div>
{# One discount row per option (variant). Each row picks a fixed sale price or a #}
{# percentage off its own regular price; a blank input clears that option's #}
{# discount. Both the fixed and percent inputs always submit (the server reads the #}
{# active mode); rows are pre-filled from `rows` (DB values, or submitted values #}
{# when repainting after a validation error) and indexed as v[<variant id>][...]. #}
<script id="discount-data" type="application/json">{{ rows | json_encode() | safe }}</script>
<form method="post" action="/admin/catalog/products/{{ product.id }}/discount?audience={{ audience }}"
x-data="{
mode: '{{ mode }}',
fixed: '{{ fixed }}',
percent: '{{ percent }}',
regular: {{ product.regular_cents }},
num(v) { let n = parseFloat(String(v).replace(',', '.')); return isFinite(n) ? n : null; },
get afterCents() {
if (this.mode === 'percent') {
let p = this.num(this.percent); if (p === null) return null;
return this.regular - Math.round(this.regular * p / 100);
}
let f = this.num(this.fixed); if (f === null) return null;
return Math.round(f * 100);
},
money(c) { return (c / 100).toFixed(2); },
get valid() { let a = this.afterCents; return a !== null && a > 0 && a < this.regular; },
get percentOff() { let a = this.afterCents; return (a === null || this.regular <= 0) ? null : Math.round((this.regular - a) / this.regular * 100); }
}"
class="mt-6 max-w-md space-y-5 rounded-radius border border-outline bg-surface p-6 dark:border-outline-dark dark:bg-surface-dark-alt">
x-data="discountEditor(JSON.parse(document.getElementById('discount-data').textContent))"
class="mt-6 max-w-2xl space-y-5">
{{ ui::csrf_field() }}
{% if error %}
{{ ui::alert_danger(message=t(key=error, lang=lang | default(value='sk'))) }}
{% endif %}
<div class="flex items-center justify-between gap-3 rounded-radius bg-surface-alt px-4 py-3 dark:bg-surface-dark/40">
<span class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="price", lang=lang | default(value='sk')) }}</span>
<span class="font-semibold tabular-nums text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.regular_price }} {{ product.currency }}</span>
</div>
<template x-for="row in rows" :key="row.id">
<div class="space-y-4 rounded-radius border border-outline bg-surface p-5 dark:border-outline-dark dark:bg-surface-dark-alt">
<div class="flex items-center justify-between gap-3">
<span class="font-semibold text-on-surface-strong dark:text-on-surface-dark-strong"
x-text="row.label || ('#' + row.id)"></span>
<span class="text-sm tabular-nums text-on-surface/70 dark:text-on-surface-dark/70">
{{ t(key="price", lang=lang | default(value='sk')) }}:
<span x-text="row.regular_price"></span> <span x-text="row.currency"></span>
</span>
</div>
<!-- mode toggle -->
<div class="grid grid-cols-2 gap-2">
<label class="flex cursor-pointer items-center justify-center gap-2 rounded-radius border px-3 py-2 text-sm transition"
:class="mode === 'fixed' ? 'border-primary bg-primary/10 text-on-surface-strong dark:border-primary-dark dark:bg-primary-dark/10 dark:text-on-surface-dark-strong' : 'border-outline text-on-surface dark:border-outline-dark dark:text-on-surface-dark'">
<input type="radio" name="mode" value="fixed" x-model="mode" class="sr-only">
{{ t(key="discount-mode-fixed", lang=lang | default(value='sk')) }}
</label>
<label class="flex cursor-pointer items-center justify-center gap-2 rounded-radius border px-3 py-2 text-sm transition"
:class="mode === 'percent' ? 'border-primary bg-primary/10 text-on-surface-strong dark:border-primary-dark dark:bg-primary-dark/10 dark:text-on-surface-dark-strong' : 'border-outline text-on-surface dark:border-outline-dark dark:text-on-surface-dark'">
<input type="radio" name="mode" value="percent" x-model="mode" class="sr-only">
{{ t(key="discount-mode-percent", lang=lang | default(value='sk')) }}
</label>
</div>
<input type="hidden" :name="`v[${row.id}][mode]`" :value="row.mode">
<!-- fixed price input -->
<div class="space-y-1.5" x-show="mode === 'fixed'">
<label for="sale_price" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="sale-price", lang=lang | default(value='sk')) }}</label>
{{ ui::input(name="sale_price", id="sale_price", value=fixed, placeholder="0.00", attrs='inputmode="decimal" x-model="fixed"') }}
</div>
<div class="grid gap-4 sm:grid-cols-2">
<!-- mode toggle -->
<div class="grid grid-cols-2 gap-2">
<label class="flex cursor-pointer items-center justify-center gap-2 rounded-radius border px-3 py-2 text-sm transition"
:class="row.mode === 'fixed' ? 'border-primary bg-primary/10 text-on-surface-strong dark:border-primary-dark dark:bg-primary-dark/10 dark:text-on-surface-dark-strong' : 'border-outline text-on-surface dark:border-outline-dark dark:text-on-surface-dark'">
<input type="radio" :name="`mode-ui-${row.id}`" value="fixed" x-model="row.mode" class="sr-only">
{{ t(key="discount-mode-fixed", lang=lang | default(value='sk')) }}
</label>
<label class="flex cursor-pointer items-center justify-center gap-2 rounded-radius border px-3 py-2 text-sm transition"
:class="row.mode === 'percent' ? 'border-primary bg-primary/10 text-on-surface-strong dark:border-primary-dark dark:bg-primary-dark/10 dark:text-on-surface-dark-strong' : 'border-outline text-on-surface dark:border-outline-dark dark:text-on-surface-dark'">
<input type="radio" :name="`mode-ui-${row.id}`" value="percent" x-model="row.mode" class="sr-only">
{{ t(key="discount-mode-percent", lang=lang | default(value='sk')) }}
</label>
</div>
<!-- percentage input -->
<div class="space-y-1.5" x-show="mode === 'percent'">
<label for="percent" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="discount-percent", lang=lang | default(value='sk')) }}</label>
{{ ui::input(name="percent", id="percent", value=percent, placeholder="0", attrs='inputmode="decimal" min="0" max="100" x-model="percent"') }}
</div>
<!-- value input: both fields stay in the DOM and submit; the server reads
whichever matches the row's mode -->
<div class="space-y-1.5">
<div x-show="row.mode === 'fixed'">
<label class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="sale-price", lang=lang | default(value='sk')) }}</label>
<input :name="`v[${row.id}][fixed]`" x-model="row.fixed" inputmode="decimal" placeholder="0.00"
class="w-full rounded-radius border border-outline bg-surface-alt px-4 py-2 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-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark">
</div>
<div x-show="row.mode === 'percent'">
<label class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="discount-percent", lang=lang | default(value='sk')) }}</label>
<input :name="`v[${row.id}][percent]`" x-model="row.percent" inputmode="decimal" min="0" max="100" placeholder="0"
class="w-full rounded-radius border border-outline bg-surface-alt px-4 py-2 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-alt/50 dark:text-on-surface-dark dark:focus-visible:outline-primary-dark">
</div>
</div>
</div>
<!-- live preview -->
<div x-show="afterCents !== null" x-cloak
class="space-y-2 rounded-radius border border-outline bg-surface-alt px-4 py-3 dark:border-outline-dark dark:bg-surface-dark/40">
<div class="flex items-center justify-between gap-3 text-sm">
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="discount-preview-before", lang=lang | default(value='sk')) }}</span>
<span class="tabular-nums text-on-surface/60 line-through dark:text-on-surface-dark/60"><span x-text="money(regular)"></span> {{ product.currency }}</span>
<!-- live preview -->
<div x-show="afterCents(row) !== null" x-cloak
class="flex flex-wrap items-center justify-between gap-3 rounded-radius border border-outline bg-surface-alt px-4 py-2.5 text-sm dark:border-outline-dark dark:bg-surface-dark/40">
<span class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="discount-preview-after", lang=lang | default(value='sk')) }}</span>
<span class="flex items-center gap-2">
<span class="tabular-nums text-on-surface/50 line-through dark:text-on-surface-dark/50" x-text="money(row.regular_cents) + ' ' + row.currency"></span>
<span class="text-base font-semibold tabular-nums" :class="valid(row) ? 'text-danger' : 'text-on-surface/40 dark:text-on-surface-dark/40'"
x-text="money(afterCents(row)) + ' ' + row.currency"></span>
<span x-show="valid(row)" class="text-xs text-on-surface/60 dark:text-on-surface-dark/60" x-text="'(' + percentOff(row) + '%)'"></span>
</span>
</div>
<p x-show="afterCents(row) !== null && !valid(row)" class="text-xs text-danger">{{ t(key="discount-below-regular", lang=lang | default(value='sk')) }}</p>
</div>
<div class="flex items-center justify-between gap-3">
<span class="text-sm text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="discount-preview-after", lang=lang | default(value='sk')) }}</span>
<span class="text-lg font-semibold tabular-nums" :class="valid ? 'text-danger' : 'text-on-surface/40 dark:text-on-surface-dark/40'">
<span x-text="money(afterCents)"></span> {{ product.currency }}
</span>
</div>
<div x-show="valid" class="flex items-center justify-between gap-3 text-xs text-on-surface/60 dark:text-on-surface-dark/60">
<span>{{ t(key="discount-preview-save", lang=lang | default(value='sk')) }}</span>
<span class="tabular-nums"><span x-text="money(regular - afterCents)"></span> {{ product.currency }} (<span x-text="percentOff"></span>%)</span>
</div>
<p x-show="!valid" class="text-xs text-danger">{{ t(key="discount-below-regular", lang=lang | default(value='sk')) }}</p>
</div>
</template>
<div class="flex flex-wrap gap-3 pt-2">
{{ ui::button(label=t(key="save", lang=lang | default(value='sk')), type="submit", attrs=`onclick="return confirm('` ~ t(key="discount-apply-confirm", lang=lang | default(value='sk')) ~ `')"`) }}
@@ -99,4 +97,36 @@
{% endif %}
</div>
</form>
<script>
function discountEditor(initial) {
return {
rows: (initial || []).map(r => ({
id: r.id,
label: r.label || '',
regular_cents: r.regular_cents,
regular_price: r.regular_price,
currency: r.currency,
mode: r.mode || 'fixed',
fixed: r.fixed || '',
percent: r.percent || '',
})),
num(v) { let n = parseFloat(String(v).replace(',', '.')); return isFinite(n) ? n : null; },
money(c) { return (c / 100).toFixed(2); },
afterCents(row) {
if (row.mode === 'percent') {
let p = this.num(row.percent); if (p === null) return null;
return row.regular_cents - Math.round(row.regular_cents * p / 100);
}
let f = this.num(row.fixed); if (f === null) return null;
return Math.round(f * 100);
},
valid(row) { let a = this.afterCents(row); return a !== null && a > 0 && a < row.regular_cents; },
percentOff(row) {
let a = this.afterCents(row);
return (a === null || row.regular_cents <= 0) ? null : Math.round((row.regular_cents - a) / row.regular_cents * 100);
},
};
}
</script>
{% endblock content %}

View File

@@ -60,7 +60,7 @@
{# items-end bottom-aligns every input regardless of how many lines each
label takes, so the row stays aligned even with the "(optional)" notes. #}
<div class="grid flex-1 grid-cols-2 gap-3 sm:grid-cols-12 sm:items-end">
<div class="space-y-1 col-span-2 sm:col-span-4">
<div class="space-y-1 col-span-2 sm:col-span-6">
<label class="{{ sublabel }} block truncate">{{ t(key="option-label", lang=lang | default(value='sk')) }}{{ opt }}</label>
<input :name="`variants[${i}][label]`" x-model="row.label" class="{{ inp }}" placeholder="napr. 10cm x 13cm">
</div>
@@ -76,10 +76,6 @@
<label class="{{ sublabel }} block truncate">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
<input :name="`variants[${i}][price]`" x-model="row.price" inputmode="decimal" required class="{{ inp }}" placeholder="0.00">
</div>
<div class="space-y-1 sm:col-span-2">
<label class="{{ sublabel }} block truncate">{{ t(key="business-price", lang=lang | default(value='sk')) }}{{ opt }}</label>
<input :name="`variants[${i}][business_sale]`" x-model="row.business_sale" inputmode="decimal" class="{{ inp }}" placeholder="—">
</div>
</div>
<button type="button" @click="remove(i)"
@@ -90,7 +86,7 @@
<script>
function variantEditor(initial) {
const blank = () => ({ id: '', label: '', sku: '', stock: '', price: '', business_sale: '' });
const blank = () => ({ id: '', label: '', sku: '', stock: '', price: '' });
return {
rows: (initial || []).map(r => ({
id: r.id || '',
@@ -98,7 +94,6 @@
sku: r.sku || '',
stock: (r.stock === null || r.stock === undefined) ? '' : r.stock,
price: r.price || '',
business_sale: r.business_sale || '',
})),
init() { if (this.rows.length === 0) this.add(); },
add() { this.rows.push(blank()); },

View File

@@ -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" %}
@@ -122,6 +138,14 @@
<td class="px-4 py-3">
<div class="flex flex-wrap justify-end gap-2">
{{ ui::button(variant="outline-secondary", label=t(key="edit", lang=lang | default(value='sk')), href="/admin/catalog/products/" ~ product.id ~ "/edit", size="px-3 py-1.5 text-xs") }}
{{ ui::button(variant="outline-secondary", label=t(key="set-discount", lang=lang | default(value='sk')), href="/admin/catalog/products/" ~ product.id ~ "/discount/edit?audience=" ~ audience, size="px-3 py-1.5 text-xs") }}
{% if product.on_sale %}
<form method="post" action="/admin/catalog/products/{{ product.id }}/discount/remove?audience={{ audience }}"
onsubmit="return confirm('{{ t(key="discount-remove-confirm", lang=lang | default(value='sk')) }}')">
{{ ui::csrf_field() }}
{{ ui::button(variant="outline-danger", label=t(key="remove-discount", lang=lang | default(value='sk')), type="submit", size="px-3 py-1.5 text-xs") }}
</form>
{% endif %}
{{ ui::button(variant="outline-secondary", label=t(key="view", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, size="px-3 py-1.5 text-xs") }}
<form method="post" action="/admin/catalog/products/{{ product.id }}/delete"
onsubmit="return confirm('{{ t(key="confirm-delete", lang=lang | default(value='sk')) }}')">

View File

@@ -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() }}">

View File

@@ -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 %}

View File

@@ -83,6 +83,8 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12" /></svg>
{%- elif name == "chevron-double-left" -%}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><path stroke-linecap="round" stroke-linejoin="round" d="m18.75 4.5-7.5 7.5 7.5 7.5m-6-15L5.25 12l7.5 7.5" /></svg>
{%- elif name == "search" -%}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><path stroke-linecap="round" stroke-linejoin="round" d="m21 21-5.197-5.197m0 0A7.5 7.5 0 1 0 5.196 5.196a7.5 7.5 0 0 0 10.607 10.607Z" /></svg>
{%- else -%}
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="{{ size }}{% if extra %} {{ extra }}{% endif %}" aria-hidden="true" {{ attrs | safe }}><path stroke-linecap="round" stroke-linejoin="round" d="M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5" /></svg>
{%- endif -%}

View File

@@ -1,3 +1,7 @@
{# Imported locally (not just inherited from base.html) so the card also renders
inside standalone htmx fragments like shop/_results.html, where Tera's import
chain from the layout isn't present. #}
{% import "macros/ui.html" as ui %}
{# Adapted from the vendored Penguin UI component
(penguinui-components/card/ecommerce-product-card.html):
wired to our product data + i18n + htmx add-to-cart + toast. The demo rating

View File

@@ -0,0 +1,46 @@
{# 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>

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 ["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>

View File

@@ -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 %}

View File

@@ -4,18 +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-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>
{% 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>
{% 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 %}
{% include "shop/_search.html" %}
</div>
{% endblock content %}

View File

@@ -42,6 +42,9 @@ mod m20260621_000004_add_business_sale_price_to_products;
mod m20260622_000001_audience_discount_profiles;
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]
@@ -88,6 +91,9 @@ impl MigratorTrait for Migrator {
Box::new(m20260622_000001_audience_discount_profiles::Migration),
Box::new(m20260622_000002_product_variants::Migration),
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)
]
}

View File

@@ -0,0 +1,92 @@
//! Full-text + fuzzy search over the product catalog.
//!
//! Storefront search has to cope with Slovak text (diacritics, ad-hoc spelling)
//! and customer typos, while staying entirely inside Postgres — the catalog is
//! small (hundreds of products), so a separate search engine would be pure
//! operational overhead. This migration sets up:
//!
//! 1. `unaccent` + `pg_trgm` extensions, and an IMMUTABLE `f_unaccent` wrapper
//! (the stock `unaccent` is only STABLE, so it can't be used in an index
//! expression without wrapping it).
//! 2. a `sk_unaccent` text-search configuration: the `simple` dictionary
//! (no English stemming, which would mangle Slovak) folded through
//! `unaccent` so "kompresor" and "kompresór" tokenize identically.
//! 3. a STORED generated `products.search_vector`, weighting the name above
//! the description, with a GIN index for `@@` matching.
//! 4. a trigram GIN index on the (unaccented) name for fuzzy matching.
//!
//! The matching query itself lives in `products::Entity::search`.
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> {
let db = m.get_connection();
db.execute_unprepared(
r#"
CREATE EXTENSION IF NOT EXISTS unaccent;
CREATE EXTENSION IF NOT EXISTS pg_trgm;
-- IMMUTABLE wrapper so unaccent() can be used in generated columns
-- and index expressions (the extension's own unaccent() is STABLE).
CREATE OR REPLACE FUNCTION f_unaccent(text)
RETURNS text
LANGUAGE sql IMMUTABLE PARALLEL SAFE STRICT AS
$func$ SELECT public.unaccent('public.unaccent', $1) $func$;
-- 'simple' (no stemming) + unaccent: a good fit for Slovak, where
-- English stemming is wrong and accents are typed inconsistently.
DROP TEXT SEARCH CONFIGURATION IF EXISTS sk_unaccent;
CREATE TEXT SEARCH CONFIGURATION sk_unaccent ( COPY = simple );
ALTER TEXT SEARCH CONFIGURATION sk_unaccent
ALTER MAPPING FOR hword, hword_part, word
WITH unaccent, simple;
"#,
)
.await?;
db.execute_unprepared(
r#"
ALTER TABLE products
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('sk_unaccent', COALESCE(name, '')), 'A') ||
setweight(to_tsvector('sk_unaccent', COALESCE(description, '')), 'B')
) STORED;
CREATE INDEX idx_products_search_vector
ON products USING GIN (search_vector);
CREATE INDEX idx_products_name_trgm
ON products USING GIN (f_unaccent(name) gin_trgm_ops);
"#,
)
.await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
let db = m.get_connection();
// Drop the trigram index (it depends on f_unaccent) before the function;
// dropping the column takes its own GIN index with it.
db.execute_unprepared(
r#"
DROP INDEX IF EXISTS idx_products_name_trgm;
ALTER TABLE products DROP COLUMN IF EXISTS search_vector;
DROP TEXT SEARCH CONFIGURATION IF EXISTS sk_unaccent;
DROP FUNCTION IF EXISTS f_unaccent(text);
"#,
)
.await?;
// The unaccent / pg_trgm extensions are left installed: other objects may
// rely on them and they are harmless on their own.
Ok(())
}
}

View File

@@ -0,0 +1,232 @@
//! Broaden product search to the whole purchasable surface.
//!
//! The `product_search` migration could only index columns living on `products`
//! itself (name, description), because a STORED generated column may not read
//! other tables. To also match by tag, variant label and SKU, `search_vector`
//! becomes a plain column maintained by triggers:
//!
//! * `kompress_build_product_search(name, description, id)` builds the weighted
//! vector for one product, pulling tags + variant labels + SKUs by id
//! (name = A, tags + labels = B, description + SKU = C).
//! * a BEFORE trigger on `products` keeps a product's own row in sync, and
//! * AFTER triggers on `product_variants`, `product_product_tags` and tag
//! renames refresh the affected product(s).
//!
//! The result is one `products.search_vector` that every search query can reuse,
//! always consistent with the catalog.
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> {
let db = m.get_connection();
// Swap the generated column (name + description only) for a plain column
// the triggers can own. Dropping it takes its GIN index with it.
db.execute_unprepared(
r#"
ALTER TABLE products DROP COLUMN IF EXISTS search_vector;
ALTER TABLE products ADD COLUMN search_vector tsvector;
"#,
)
.await?;
// Single source of truth for a product's search document.
db.execute_unprepared(
r#"
CREATE OR REPLACE FUNCTION kompress_build_product_search(
p_name text, p_description text, p_id integer
) RETURNS tsvector
LANGUAGE sql STABLE AS $func$
SELECT
setweight(to_tsvector('sk_unaccent', COALESCE(p_name, '')), 'A')
|| setweight(to_tsvector('sk_unaccent', COALESCE((
SELECT string_agg(t.name, ' ')
FROM product_product_tags ppt
JOIN product_tags t ON t.id = ppt.product_tag_id
WHERE ppt.product_id = p_id
), '')), 'B')
|| setweight(to_tsvector('sk_unaccent', COALESCE((
SELECT string_agg(v.label, ' ')
FROM product_variants v
WHERE v.product_id = p_id
), '')), 'B')
|| setweight(to_tsvector('sk_unaccent', COALESCE(p_description, '')), 'C')
|| setweight(to_tsvector('sk_unaccent', COALESCE((
SELECT string_agg(v.sku, ' ')
FROM product_variants v
WHERE v.product_id = p_id AND v.sku IS NOT NULL
), '')), 'C');
$func$;
-- Refresh one product's stored vector (used by the satellite triggers).
CREATE OR REPLACE FUNCTION kompress_refresh_product_search(p_id integer)
RETURNS void LANGUAGE sql AS $func$
UPDATE products
SET search_vector = kompress_build_product_search(name, description, id)
WHERE id = p_id;
$func$;
"#,
)
.await?;
// BEFORE trigger on products: recompute on its own writes. When a refresh
// only touches search_vector (name + description unchanged) it skips the
// recompute and keeps the supplied value — which also breaks recursion.
db.execute_unprepared(
r#"
CREATE OR REPLACE FUNCTION kompress_products_search_tg() RETURNS trigger
LANGUAGE plpgsql AS $func$
BEGIN
IF TG_OP = 'UPDATE'
AND NEW.name IS NOT DISTINCT FROM OLD.name
AND NEW.description IS NOT DISTINCT FROM OLD.description
AND NEW.search_vector IS DISTINCT FROM OLD.search_vector THEN
RETURN NEW;
END IF;
NEW.search_vector :=
kompress_build_product_search(NEW.name, NEW.description, NEW.id);
RETURN NEW;
END;
$func$;
DROP TRIGGER IF EXISTS products_search_tg ON products;
CREATE TRIGGER products_search_tg
BEFORE INSERT OR UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION kompress_products_search_tg();
"#,
)
.await?;
// Variants: any change refreshes the owning product (both, on reparent).
db.execute_unprepared(
r#"
CREATE OR REPLACE FUNCTION kompress_variants_search_tg() RETURNS trigger
LANGUAGE plpgsql AS $func$
BEGIN
IF TG_OP = 'DELETE' THEN
PERFORM kompress_refresh_product_search(OLD.product_id);
RETURN OLD;
END IF;
PERFORM kompress_refresh_product_search(NEW.product_id);
IF TG_OP = 'UPDATE' AND NEW.product_id IS DISTINCT FROM OLD.product_id THEN
PERFORM kompress_refresh_product_search(OLD.product_id);
END IF;
RETURN NEW;
END;
$func$;
DROP TRIGGER IF EXISTS product_variants_search_tg ON product_variants;
CREATE TRIGGER product_variants_search_tg
AFTER INSERT OR UPDATE OR DELETE ON product_variants
FOR EACH ROW EXECUTE FUNCTION kompress_variants_search_tg();
"#,
)
.await?;
// Tag links: attaching/detaching a tag refreshes the product.
db.execute_unprepared(
r#"
CREATE OR REPLACE FUNCTION kompress_product_tags_link_search_tg() RETURNS trigger
LANGUAGE plpgsql AS $func$
BEGIN
IF TG_OP = 'DELETE' THEN
PERFORM kompress_refresh_product_search(OLD.product_id);
RETURN OLD;
END IF;
PERFORM kompress_refresh_product_search(NEW.product_id);
RETURN NEW;
END;
$func$;
DROP TRIGGER IF EXISTS product_product_tags_search_tg ON product_product_tags;
CREATE TRIGGER product_product_tags_search_tg
AFTER INSERT OR UPDATE OR DELETE ON product_product_tags
FOR EACH ROW EXECUTE FUNCTION kompress_product_tags_link_search_tg();
"#,
)
.await?;
// Renaming a tag refreshes every product carrying it.
db.execute_unprepared(
r#"
CREATE OR REPLACE FUNCTION kompress_tag_rename_search_tg() RETURNS trigger
LANGUAGE plpgsql AS $func$
BEGIN
UPDATE products p
SET search_vector =
kompress_build_product_search(p.name, p.description, p.id)
WHERE p.id IN (
SELECT ppt.product_id FROM product_product_tags ppt
WHERE ppt.product_tag_id = NEW.id
);
RETURN NEW;
END;
$func$;
DROP TRIGGER IF EXISTS product_tags_rename_search_tg ON product_tags;
CREATE TRIGGER product_tags_rename_search_tg
AFTER UPDATE OF name ON product_tags
FOR EACH ROW EXECUTE FUNCTION kompress_tag_rename_search_tg();
"#,
)
.await?;
// Backfill existing rows, then (re)create the GIN index for `@@`.
db.execute_unprepared(
r#"
UPDATE products
SET search_vector = kompress_build_product_search(name, description, id);
CREATE INDEX idx_products_search_vector
ON products USING GIN (search_vector);
"#,
)
.await?;
Ok(())
}
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
let db = m.get_connection();
db.execute_unprepared(
r#"
DROP TRIGGER IF EXISTS product_tags_rename_search_tg ON product_tags;
DROP TRIGGER IF EXISTS product_product_tags_search_tg ON product_product_tags;
DROP TRIGGER IF EXISTS product_variants_search_tg ON product_variants;
DROP TRIGGER IF EXISTS products_search_tg ON products;
DROP FUNCTION IF EXISTS kompress_tag_rename_search_tg();
DROP FUNCTION IF EXISTS kompress_product_tags_link_search_tg();
DROP FUNCTION IF EXISTS kompress_variants_search_tg();
DROP FUNCTION IF EXISTS kompress_products_search_tg();
DROP FUNCTION IF EXISTS kompress_refresh_product_search(integer);
DROP FUNCTION IF EXISTS kompress_build_product_search(text, text, integer);
ALTER TABLE products DROP COLUMN IF EXISTS search_vector;
"#,
)
.await?;
// Restore the name + description generated column from the prior migration.
db.execute_unprepared(
r#"
ALTER TABLE products
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('sk_unaccent', COALESCE(name, '')), 'A') ||
setweight(to_tsvector('sk_unaccent', COALESCE(description, '')), 'B')
) STORED;
CREATE INDEX idx_products_search_vector
ON products USING GIN (search_vector);
"#,
)
.await?;
Ok(())
}
}

View 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(())
}
}

View File

@@ -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"),

View File

@@ -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),
}),
)
}

View File

@@ -26,7 +26,7 @@ use crate::{
},
shared::{
guard,
money::{format_bp, format_price, parse_price_to_cents},
money::{format_bp, format_price, parse_percent, parse_price_to_cents},
pricing,
slug::{slugify, unique_slug},
},
@@ -105,30 +105,9 @@ struct VariantInput {
/// `None` = available but not inventory-tracked.
stock: Option<i32>,
price_cents: i64,
business_sale_cents: Option<i64>,
position: i32,
}
/// The optional business-sale price field on a variant row: blank means "no
/// business quick-sale", a value must parse and be below the regular price.
fn parse_optional_sale(
form: &MultipartForm,
i: usize,
key: &str,
price_cents: i64,
) -> Result<Option<i64>> {
let Some(raw) = form.text(&format!("variants[{i}][{key}]")) else {
return Ok(None);
};
let cents = parse_price_to_cents(&raw)?;
if cents <= 0 || cents >= price_cents {
return Err(Error::BadRequest(
"a sale price must be positive and below the regular price".to_string(),
));
}
Ok(Some(cents))
}
/// Parse the repeated variant rows from the form, in submission order. Blank
/// rows (no price and no label) are skipped; at least one valid row is required.
fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
@@ -168,7 +147,6 @@ fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
.ok_or_else(|| Error::BadRequest("stock must be 0 or more".to_string()))?,
),
};
let business_sale_cents = parse_optional_sale(form, i, "business_sale", price_cents)?;
let id = form
.text(&format!("variants[{i}][id]"))
.and_then(|s| s.parse::<i32>().ok());
@@ -179,7 +157,6 @@ fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
sku,
stock,
price_cents,
business_sale_cents,
position: out.len() as i32,
});
}
@@ -197,9 +174,9 @@ fn apply_variant(active: &mut product_variants::ActiveModel, input: &VariantInpu
active.sku = Set(input.sku.clone());
active.stock = Set(input.stock);
active.price_cents = Set(input.price_cents);
// The per-variant public sale price was removed from the UI; keep it cleared.
active.sale_price_cents = Set(None);
active.business_sale_price_cents = Set(input.business_sale_cents);
// Discounts (public + business sale) are owned by the discount page and keyed
// per option/audience; the product form must leave those columns untouched so
// it never clobbers a discount. New variants default them to NULL.
active.position = Set(input.position);
}
@@ -257,7 +234,6 @@ fn variant_form_json(variant: &product_variants::Model) -> serde_json::Value {
"sku": variant.sku,
"stock": variant.stock,
"price": format_price(variant.price_cents),
"business_sale": variant.business_sale_price_cents.map(format_price),
})
}
@@ -281,10 +257,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?;
@@ -333,9 +316,13 @@ async fn index(
.sum::<i32>()
.to_string()
};
// The product is "on sale" for this audience if any option carries a
// discount; the per-option amounts live on the discount page.
let on_sale = variants.iter().any(|v| current_value(v, audience).is_some());
rows.push(product_row(
product,
priced,
on_sale,
variants.len(),
stock_display,
image,
@@ -352,6 +339,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),
@@ -365,6 +353,7 @@ async fn index(
fn product_row(
product: &products::Model,
effective: &pricing::PricedProduct,
on_sale: bool,
variant_count: usize,
stock_display: String,
image: Option<String>,
@@ -382,6 +371,7 @@ fn product_row(
"image": image,
"category_name": category_name,
"regular_price": format_price(effective.regular_cents),
"on_sale": on_sale,
"effective_price": format_price(effective.price_cents),
"effective_reduced": effective.is_reduced(),
"effective_percent_off": percent_off(effective.regular_cents, effective.price_cents),
@@ -611,6 +601,30 @@ fn list_redirect(audience: &str) -> Result<Response> {
format::redirect(&format!("/admin/catalog/products?audience={audience}"))
}
/// Resolve a percentage off the regular price into a fixed sale price in cents.
fn percent_to_sale_cents(regular_cents: i64, percent: f64) -> i64 {
let off = (regular_cents as f64 * percent / 100.0).round() as i64;
regular_cents - off
}
/// Which discount value an audience tab sees on a variant.
fn current_value(variant: &product_variants::Model, audience: &str) -> Option<i64> {
if audience == BUSINESS {
variant.business_sale_price_cents
} else {
variant.sale_price_cents
}
}
/// Set the discount column on a variant for a given audience.
fn set_value(active: &mut product_variants::ActiveModel, audience: &str, value: Option<i64>) {
if audience == BUSINESS {
active.business_sale_price_cents = Set(value);
} else {
active.sale_price_cents = Set(value);
}
}
/// Percent off the regular price, rounded to a whole number.
fn percent_off(regular_cents: i64, sale_cents: i64) -> i64 {
if regular_cents <= 0 {
@@ -734,6 +748,231 @@ async fn sync_profiles(
list_redirect(audience)
}
// --- Per-variant discounts ---------------------------------------------------
//
// Each product is sold as one or more options (variants). A discount can be set
// on every option individually, for the active audience: personal writes the
// public `sale_price_cents`, business writes `business_sale_price_cents`. Per
// option the admin picks a fixed sale price or a percentage off the regular
// price; an empty value clears that option's discount.
/// One option row in the discount form. Carries enough to pre-fill the editor
/// and to survive a validation-error round-trip.
struct DiscountRow {
id: i32,
label: String,
regular_cents: i64,
mode: String,
fixed: String,
percent: String,
has_discount: bool,
}
impl DiscountRow {
/// Pre-fill from the discount stored for this audience.
fn from_db(v: &product_variants::Model, audience: &str) -> Self {
let sale = current_value(v, audience);
DiscountRow {
id: v.id,
label: v.label.clone(),
regular_cents: v.price_cents,
mode: "fixed".to_string(),
fixed: sale.map(format_price).unwrap_or_default(),
percent: String::new(),
has_discount: sale.is_some(),
}
}
/// Pre-fill from the submitted values, to repaint the form after an error.
fn from_submitted(
v: &product_variants::Model,
audience: &str,
pairs: &HashMap<String, String>,
) -> Self {
let get = |key: &str| {
pairs
.get(&format!("v[{}][{key}]", v.id))
.map(|s| s.trim().to_string())
.unwrap_or_default()
};
let mode = get("mode");
DiscountRow {
id: v.id,
label: v.label.clone(),
regular_cents: v.price_cents,
mode: if mode == "percent" {
mode
} else {
"fixed".to_string()
},
fixed: get("fixed"),
percent: get("percent"),
has_discount: current_value(v, audience).is_some(),
}
}
fn to_json(&self, currency: &str) -> serde_json::Value {
json!({
"id": self.id,
"label": self.label,
"regular_cents": self.regular_cents,
"regular_price": format_price(self.regular_cents),
"currency": currency,
"mode": self.mode,
"fixed": self.fixed,
"percent": self.percent,
"has_discount": self.has_discount,
})
}
}
/// Resolve one submitted option into the sale price to store. `Ok(None)` clears
/// the discount; `Err` is an i18n key for the validation message.
fn resolve_row(
regular_cents: i64,
mode: &str,
fixed: &str,
percent: &str,
) -> std::result::Result<Option<i64>, &'static str> {
let sale_cents = if mode == "percent" {
if percent.is_empty() {
return Ok(None);
}
let pct = parse_percent(percent).ok_or("discount-invalid")?;
if pct <= 0.0 || pct >= 100.0 {
return Err("discount-percent-range");
}
percent_to_sale_cents(regular_cents, pct)
} else {
if fixed.is_empty() {
return Ok(None);
}
parse_price_to_cents(fixed).map_err(|_| "discount-invalid")?
};
if sale_cents <= 0 {
return Err("discount-must-be-positive");
}
if sale_cents >= regular_cents {
return Err("discount-below-regular");
}
Ok(Some(sale_cents))
}
async fn discount_view(
v: &TeraView,
jar: &CookieJar,
product: &products::Model,
rows: &[DiscountRow],
audience: &str,
error: Option<&str>,
) -> Result<Response> {
let rows_json: Vec<_> = rows.iter().map(|r| r.to_json(&product.currency)).collect();
let has_discount = rows.iter().any(|r| r.has_discount);
format::view(
v,
"admin/catalog/discount_form.html",
json!({
"product": {
"id": product.id,
"name": product.name,
"currency": product.currency,
},
"rows": rows_json,
"audience": audience,
"has_discount": has_discount,
"error": error.map(|e| e.to_string()),
"lang": current_lang(jar),
}),
)
}
#[debug_handler]
async fn discount_show(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<i32>,
Query(params): Query<HashMap<String, String>>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let audience = read_audience(&params);
let product = product_by_id(&ctx, id).await?;
let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
let rows: Vec<DiscountRow> = variants
.iter()
.map(|variant| DiscountRow::from_db(variant, audience))
.collect();
discount_view(&v, &jar, &product, &rows, audience, None).await
}
#[debug_handler]
async fn discount_update(
auth: auth::JWT,
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
Path(id): Path<i32>,
Query(params): Query<HashMap<String, String>>,
State(ctx): State<AppContext>,
body: String,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let audience = read_audience(&params);
let product = product_by_id(&ctx, id).await?;
let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
let pairs: HashMap<String, String> = form_urlencoded::parse(body.as_bytes())
.into_owned()
.collect();
// Resolve every option before persisting anything, so one bad row can't leave
// the product half-discounted. On the first error, repaint with the inputs.
let mut resolved: Vec<(product_variants::Model, Option<i64>)> = Vec::new();
for variant in &variants {
let row = DiscountRow::from_submitted(variant, audience, &pairs);
match resolve_row(variant.price_cents, &row.mode, &row.fixed, &row.percent) {
Ok(value) => resolved.push((variant.clone(), value)),
Err(key) => {
let rows: Vec<DiscountRow> = variants
.iter()
.map(|v| DiscountRow::from_submitted(v, audience, &pairs))
.collect();
return discount_view(&v, &jar, &product, &rows, audience, Some(key)).await;
}
}
}
let txn = ctx.db.begin().await?;
for (variant, value) in &resolved {
let mut active = variant.clone().into_active_model();
set_value(&mut active, audience, *value);
active.update(&txn).await?;
}
txn.commit().await?;
list_redirect(audience)
}
#[debug_handler]
async fn discount_remove(
auth: auth::JWT,
Path(id): Path<i32>,
Query(params): Query<HashMap<String, String>>,
State(ctx): State<AppContext>,
) -> Result<Response> {
guard::current_admin(auth, &ctx).await?;
let audience = read_audience(&params);
let _product = product_by_id(&ctx, id).await?;
let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
let txn = ctx.db.begin().await?;
for variant in &variants {
let mut active = variant.clone().into_active_model();
set_value(&mut active, audience, None);
active.update(&txn).await?;
}
txn.commit().await?;
list_redirect(audience)
}
pub fn routes() -> Routes {
// Several images may be uploaded in one submission; allow a generous total
// (per-file size is still capped at IMAGE_MAX_BYTES while reading).
@@ -756,4 +995,16 @@ pub fn routes() -> Routes {
post(update).layer(image_limit),
)
.add("/admin/catalog/products/{id}/delete", post(delete))
.add(
"/admin/catalog/products/{id}/discount/edit",
get(discount_show),
)
.add(
"/admin/catalog/products/{id}/discount",
post(discount_update),
)
.add(
"/admin/catalog/products/{id}/discount/remove",
post(discount_remove),
)
}

View File

@@ -1,6 +1,10 @@
//! 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;
use loco_rs::prelude::*;
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set};
@@ -8,11 +12,226 @@ 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,
};
/// 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(&params.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(&params.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
/// shows the resolved price of the product's representative (first) variant; the
/// `variant_count` lets the template render "from {price}" for multi-variant
@@ -82,32 +301,59 @@ 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?,
"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, &current_lang(&jar));
format::view(&v, "shop/index.html", context)
}
/// 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(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
headers: HeaderMap,
Query(params): Query<SearchParams>,
State(ctx): State<AppContext>,
) -> Result<Response> {
let user = guard::current_user(&ctx, &jar).await;
let mut context = run_search(&ctx, user.as_ref(), &params).await?;
let lang = current_lang(&jar);
if headers.contains_key("HX-Request") {
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());
add_chrome(&mut context, &c, &lang);
format::view(&v, "shop/index.html", context)
}
#[debug_handler]
@@ -188,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?;
@@ -205,41 +457,30 @@ 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(), &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());
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, &current_lang(&jar));
format::view(&v, "shop/category.html", context)
}
pub fn routes() -> Routes {
Routes::new()
.add("/shop", get(index))
// Top-level path (not /shop/search) so it never collides with the
// /shop/{slug} product route.
.add("/search", get(search))
.add("/shop/{slug}", get(show))
.add("/category/{slug}", get(category))
.add("/partials/categories", get(category_sidebar))

View File

@@ -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
}
}

View File

@@ -1,4 +1,5 @@
use sea_orm::entity::prelude::*;
use sea_orm::{ConnectionTrait, Statement};
pub use crate::models::_entities::products::{ActiveModel, Column, Entity, Model};
pub type Products = Entity;
@@ -25,4 +26,59 @@ impl Model {}
impl ActiveModel {}
// implement your custom finders, selectors oriented logic here
impl Entity {}
impl Entity {
/// Published products matching a free-text query, best matches first.
///
/// Hybrid Postgres search. The `tsvector` full-text match covers the whole
/// purchasable surface — name, description, tags, variant labels and SKUs
/// (kept in sync by triggers; see the `product_search_aggregate` migration) —
/// OR a `pg_trgm` `word_similarity` match on name/description for typo
/// tolerance ("komprsor" still finds "kompresor"). Both sides run through
/// `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.
/// `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());
}
// 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; `$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 {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
)
ORDER BY
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 $2
"#
);
Entity::find()
.from_raw_sql(Statement::from_sql_and_values(
db.get_database_backend(),
&sql,
[q.into(), (limit as i64).into()],
))
.all(db)
.await
}
}