Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b9c2f7d64 | ||
|
|
e5cac27010 | ||
|
|
a45f9ef030 | ||
|
|
51155f2fd2 | ||
|
|
2d2aa012ec | ||
|
|
125be1798e | ||
|
|
f724e9763f | ||
|
|
681c88f85d | ||
|
|
6828854f24 | ||
|
|
3a1ea7cdb4 |
@@ -212,6 +212,9 @@ sale-price = Sale price
|
|||||||
variants-options = Variants / options
|
variants-options = Variants / options
|
||||||
add-option = Add option
|
add-option = Add option
|
||||||
option-label = Option label
|
option-label = Option label
|
||||||
|
optional = optional
|
||||||
|
stock-untracked-hint = Leave blank = available without stock tracking
|
||||||
|
available = Available
|
||||||
choose-option = Choose an option
|
choose-option = Choose an option
|
||||||
from-price = from { $price }
|
from-price = from { $price }
|
||||||
admin-discounts = Discounts
|
admin-discounts = Discounts
|
||||||
@@ -282,6 +285,10 @@ currency = Currency
|
|||||||
category = Category
|
category = Category
|
||||||
no-category = No category
|
no-category = No category
|
||||||
image = Image
|
image = Image
|
||||||
|
images = Images
|
||||||
|
main-image = Main
|
||||||
|
gallery-hint = The first image is the main one. Drag to reorder, click ✕ to remove.
|
||||||
|
add-images = Add images
|
||||||
slug = URL slug
|
slug = URL slug
|
||||||
slug-auto = generated automatically
|
slug-auto = generated automatically
|
||||||
position = Position
|
position = Position
|
||||||
@@ -300,6 +307,10 @@ confirm-delete = Delete this for good?
|
|||||||
shop-title = Shop
|
shop-title = Shop
|
||||||
shop-subtitle = browse our products.
|
shop-subtitle = browse our products.
|
||||||
shop-empty = There are no products here yet.
|
shop-empty = There are no products here yet.
|
||||||
|
search-placeholder = Search products…
|
||||||
|
search-empty = Nothing matched your search:
|
||||||
|
view-grid = Grid view
|
||||||
|
view-list = List view
|
||||||
categories = Categories
|
categories = Categories
|
||||||
all-products = All products
|
all-products = All products
|
||||||
uncategorized = Uncategorized
|
uncategorized = Uncategorized
|
||||||
|
|||||||
@@ -212,6 +212,9 @@ sale-price = Zľavnená cena
|
|||||||
variants-options = Varianty / možnosti
|
variants-options = Varianty / možnosti
|
||||||
add-option = Pridať možnosť
|
add-option = Pridať možnosť
|
||||||
option-label = Označenie možnosti
|
option-label = Označenie možnosti
|
||||||
|
optional = voliteľné
|
||||||
|
stock-untracked-hint = Nechajte prázdne = dostupné bez sledovania zásob
|
||||||
|
available = Dostupné
|
||||||
choose-option = Vyberte možnosť
|
choose-option = Vyberte možnosť
|
||||||
from-price = od { $price }
|
from-price = od { $price }
|
||||||
admin-discounts = Zľavy
|
admin-discounts = Zľavy
|
||||||
@@ -282,6 +285,10 @@ currency = Mena
|
|||||||
category = Kategória
|
category = Kategória
|
||||||
no-category = Bez kategórie
|
no-category = Bez kategórie
|
||||||
image = Obrázok
|
image = Obrázok
|
||||||
|
images = Obrázky
|
||||||
|
main-image = Hlavný
|
||||||
|
gallery-hint = Prvý obrázok je hlavný. Potiahnutím zmeníte poradie, krížikom obrázok odstránite.
|
||||||
|
add-images = Pridať obrázky
|
||||||
slug = URL adresa
|
slug = URL adresa
|
||||||
slug-auto = vygeneruje sa automaticky
|
slug-auto = vygeneruje sa automaticky
|
||||||
position = Poradie
|
position = Poradie
|
||||||
@@ -300,6 +307,10 @@ confirm-delete = Naozaj zmazať?
|
|||||||
shop-title = Obchod
|
shop-title = Obchod
|
||||||
shop-subtitle = prezrite si našu ponuku produktov.
|
shop-subtitle = prezrite si našu ponuku produktov.
|
||||||
shop-empty = Zatiaľ tu nie sú žiadne produkty.
|
shop-empty = Zatiaľ tu nie sú žiadne produkty.
|
||||||
|
search-placeholder = Hľadať produkty…
|
||||||
|
search-empty = Pre váš výraz sme nič nenašli:
|
||||||
|
view-grid = Zobrazenie v mriežke
|
||||||
|
view-list = Zobrazenie v zozname
|
||||||
categories = Kategórie
|
categories = Kategórie
|
||||||
all-products = Všetky produkty
|
all-products = Všetky produkty
|
||||||
uncategorized = Bez kategórie
|
uncategorized = Bez kategórie
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -37,9 +37,12 @@
|
|||||||
|
|
||||||
{# --- Variants / options editor ------------------------------------------- #}
|
{# --- Variants / options editor ------------------------------------------- #}
|
||||||
{# Each product is sold as one or more variants (a free-text label such as #}
|
{# Each product is sold as one or more variants (a free-text label such as #}
|
||||||
{# "10cm x 13cm" or "5ml" plus its own price/stock/sku, and optional public & #}
|
{# "10cm x 13cm" or "5ml" plus its own price). Price is required. Stock is #}
|
||||||
{# business sale prices). Rows are managed client-side; names are indexed #}
|
{# optional — leave it blank ("∞") to mark the option simply available (not #}
|
||||||
{# (variants[i][...]) and read back by the controller. #}
|
{# inventory-tracked). SKU and business price are optional too. Rows are #}
|
||||||
|
{# managed client-side; names are indexed (variants[i][…]) and read back by #}
|
||||||
|
{# the controller. #}
|
||||||
|
{% set opt = " (" ~ t(key="optional", lang=lang | default(value='sk')) ~ ")" %}
|
||||||
<script id="variants-data" type="application/json">{{ variants | json_encode() | safe }}</script>
|
<script id="variants-data" type="application/json">{{ variants | json_encode() | safe }}</script>
|
||||||
<div class="space-y-3" x-data="variantEditor(JSON.parse(document.getElementById('variants-data').textContent))">
|
<div class="space-y-3" x-data="variantEditor(JSON.parse(document.getElementById('variants-data').textContent))">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
@@ -51,53 +54,50 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<template x-for="(row, i) in rows" :key="i">
|
<template x-for="(row, i) in rows" :key="i">
|
||||||
<div class="grid gap-3 rounded-radius border border-outline bg-surface-alt/40 p-3 sm:grid-cols-12 dark:border-outline-dark dark:bg-surface-dark-alt/30">
|
<div class="flex items-end gap-3 rounded-radius border border-outline bg-surface-alt/40 p-3 dark:border-outline-dark dark:bg-surface-dark-alt/30">
|
||||||
<input type="hidden" :name="`variants[${i}][id]`" :value="row.id">
|
<input type="hidden" :name="`variants[${i}][id]`" :value="row.id">
|
||||||
|
|
||||||
<div class="space-y-1 sm:col-span-5">
|
{# items-end bottom-aligns every input regardless of how many lines each
|
||||||
<label class="{{ sublabel }}">{{ t(key="option-label", lang=lang | default(value='sk')) }}</label>
|
label takes, so the row stays aligned even with the "(optional)" notes. #}
|
||||||
<input :name="`variants[${i}][label]`" x-model="row.label" class="{{ inp }}" placeholder="napr. 10cm x 13cm">
|
<div class="grid flex-1 grid-cols-2 gap-3 sm:grid-cols-12 sm:items-end">
|
||||||
</div>
|
<div class="space-y-1 col-span-2 sm:col-span-4">
|
||||||
<div class="space-y-1 sm:col-span-3">
|
<label class="{{ sublabel }} block truncate">{{ t(key="option-label", lang=lang | default(value='sk')) }}{{ opt }}</label>
|
||||||
<label class="{{ sublabel }}">{{ t(key="sku", lang=lang | default(value='sk')) }}</label>
|
<input :name="`variants[${i}][label]`" x-model="row.label" class="{{ inp }}" placeholder="napr. 10cm x 13cm">
|
||||||
<input :name="`variants[${i}][sku]`" x-model="row.sku" class="{{ inp }}">
|
</div>
|
||||||
</div>
|
<div class="space-y-1 sm:col-span-2">
|
||||||
<div class="space-y-1 sm:col-span-2">
|
<label class="{{ sublabel }} block truncate">{{ t(key="sku", lang=lang | default(value='sk')) }}{{ opt }}</label>
|
||||||
<label class="{{ sublabel }}">{{ t(key="stock", lang=lang | default(value='sk')) }}</label>
|
<input :name="`variants[${i}][sku]`" x-model="row.sku" class="{{ inp }}">
|
||||||
<input type="number" min="0" :name="`variants[${i}][stock]`" x-model="row.stock" class="{{ inp }}">
|
</div>
|
||||||
</div>
|
<div class="space-y-1 sm:col-span-2">
|
||||||
<div class="flex items-end sm:col-span-2">
|
<label class="{{ sublabel }} block truncate">{{ t(key="stock", lang=lang | default(value='sk')) }}{{ opt }}</label>
|
||||||
<button type="button" @click="remove(i)"
|
<input type="number" min="0" :name="`variants[${i}][stock]`" x-model="row.stock" class="{{ inp }}" placeholder="∞" title="{{ t(key='stock-untracked-hint', lang=lang | default(value='sk')) }}">
|
||||||
class="ml-auto rounded-radius px-2 py-2 text-sm text-danger hover:bg-danger/10" title="{{ t(key='delete', lang=lang | default(value='sk')) }}">✕</button>
|
</div>
|
||||||
|
<div class="space-y-1 sm:col-span-2">
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1 sm:col-span-4">
|
<button type="button" @click="remove(i)"
|
||||||
<label class="{{ sublabel }}">{{ t(key="price", lang=lang | default(value='sk')) }}</label>
|
class="mb-1 shrink-0 rounded-radius px-2 py-2 text-sm text-danger hover:bg-danger/10" title="{{ t(key='delete', lang=lang | default(value='sk')) }}">✕</button>
|
||||||
<input :name="`variants[${i}][price]`" x-model="row.price" inputmode="decimal" class="{{ inp }}" placeholder="0.00">
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1 sm:col-span-4">
|
|
||||||
<label class="{{ sublabel }}">{{ t(key="sale-price", lang=lang | default(value='sk')) }}</label>
|
|
||||||
<input :name="`variants[${i}][sale]`" x-model="row.sale" inputmode="decimal" class="{{ inp }}" placeholder="—">
|
|
||||||
</div>
|
|
||||||
<div class="space-y-1 sm:col-span-4">
|
|
||||||
<label class="{{ sublabel }}">{{ t(key="business-price", lang=lang | default(value='sk')) }}</label>
|
|
||||||
<input :name="`variants[${i}][business_sale]`" x-model="row.business_sale" inputmode="decimal" class="{{ inp }}" placeholder="—">
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
function variantEditor(initial) {
|
function variantEditor(initial) {
|
||||||
const blank = () => ({ id: '', label: '', sku: '', stock: 0, price: '', sale: '', business_sale: '' });
|
const blank = () => ({ id: '', label: '', sku: '', stock: '', price: '', business_sale: '' });
|
||||||
return {
|
return {
|
||||||
rows: (initial || []).map(r => ({
|
rows: (initial || []).map(r => ({
|
||||||
id: r.id || '',
|
id: r.id || '',
|
||||||
label: r.label || '',
|
label: r.label || '',
|
||||||
sku: r.sku || '',
|
sku: r.sku || '',
|
||||||
stock: (r.stock === null || r.stock === undefined) ? 0 : r.stock,
|
stock: (r.stock === null || r.stock === undefined) ? '' : r.stock,
|
||||||
price: r.price || '',
|
price: r.price || '',
|
||||||
sale: r.sale || '',
|
|
||||||
business_sale: r.business_sale || '',
|
business_sale: r.business_sale || '',
|
||||||
})),
|
})),
|
||||||
init() { if (this.rows.length === 0) this.add(); },
|
init() { if (this.rows.length === 0) this.add(); },
|
||||||
@@ -126,12 +126,86 @@
|
|||||||
{{ ui::textarea(name="description", id="description", rows="5", value=v_desc) }}
|
{{ ui::textarea(name="description", id="description", rows="5", value=v_desc) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-1.5">
|
{# --- Images gallery ------------------------------------------------------- #}
|
||||||
<label for="image" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="image", lang=lang | default(value='sk')) }}</label>
|
{# Unified drag-orderable gallery: existing images (with id) and new uploads #}
|
||||||
{% if product and product.image %}
|
{# (placeholder blobs) live in a single list. The full order is submitted as #}
|
||||||
<img src="/images/{{ product.image }}" alt="" class="size-24 rounded-radius object-cover">
|
{# repeated `image_order` fields — an integer id for kept images or `new` for #}
|
||||||
{% endif %}
|
{# each uploaded file. The DataTransfer backing the hidden `image` file input #}
|
||||||
{{ ui::file_input(name="image", id="image", accept="image/*") }}
|
{# is rebuilt after every reorder / add / remove so the file-part order matches #}
|
||||||
|
{# the relative order of `new` slots in `image_order`. #}
|
||||||
|
<script id="images-data" type="application/json">{% if product %}{{ product.images | json_encode() | safe }}{% else %}[]{% endif %}</script>
|
||||||
|
<div class="space-y-2" x-data="{
|
||||||
|
init() {
|
||||||
|
const existing = JSON.parse(document.getElementById('images-data').textContent);
|
||||||
|
this.items = existing.map(im => ({ type: 'existing', id: im.id, image_id: im.image_id }));
|
||||||
|
},
|
||||||
|
items: [],
|
||||||
|
dt: new DataTransfer(),
|
||||||
|
dragIndex: null,
|
||||||
|
|
||||||
|
rebuildDt() {
|
||||||
|
this.dt = new DataTransfer();
|
||||||
|
for (const it of this.items) {
|
||||||
|
if (it.type === 'new') this.dt.items.add(it.file);
|
||||||
|
}
|
||||||
|
this.$refs.holder.files = this.dt.files;
|
||||||
|
},
|
||||||
|
|
||||||
|
onDrop(i) {
|
||||||
|
if (this.dragIndex === null || this.dragIndex === i) { this.dragIndex = null; return; }
|
||||||
|
this.items.splice(i, 0, this.items.splice(this.dragIndex, 1)[0]);
|
||||||
|
this.dragIndex = null;
|
||||||
|
this.rebuildDt();
|
||||||
|
},
|
||||||
|
|
||||||
|
addFiles(e) {
|
||||||
|
for (const f of e.target.files) {
|
||||||
|
this.items.push({ type: 'new', file: f, url: URL.createObjectURL(f) });
|
||||||
|
}
|
||||||
|
this.rebuildDt();
|
||||||
|
e.target.value = '';
|
||||||
|
},
|
||||||
|
|
||||||
|
remove(i) {
|
||||||
|
const it = this.items[i];
|
||||||
|
if (it.type === 'new') URL.revokeObjectURL(it.url);
|
||||||
|
this.items.splice(i, 1);
|
||||||
|
this.rebuildDt();
|
||||||
|
},
|
||||||
|
}">
|
||||||
|
<span class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="images", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<p class="{{ sublabel }}">{{ t(key="gallery-hint", lang=lang | default(value='sk')) }}</p>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-3" x-show="items.length">
|
||||||
|
<template x-for="(it, i) in items" :key="it.type === 'existing' ? it.id : it.url">
|
||||||
|
<div draggable="true"
|
||||||
|
@dragstart="dragIndex = i"
|
||||||
|
@dragover.prevent
|
||||||
|
@drop.prevent="onDrop(i)"
|
||||||
|
:class="dragIndex === i ? 'opacity-50' : ''"
|
||||||
|
class="group relative size-24 cursor-move overflow-hidden rounded-radius border border-outline dark:border-outline-dark">
|
||||||
|
|
||||||
|
<input type="hidden" name="image_order" :value="it.type === 'existing' ? it.id : 'new'">
|
||||||
|
|
||||||
|
<img :src="it.type === 'existing' ? `/images/${it.image_id}` : it.url" alt="" class="size-full object-cover">
|
||||||
|
|
||||||
|
<span x-show="i === 0"
|
||||||
|
class="absolute left-1 top-1 rounded-radius bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-on-primary dark:bg-primary-dark dark:text-on-primary-dark">{{ t(key="main-image", lang=lang | default(value='sk')) }}</span>
|
||||||
|
<button type="button" @click="remove(i)"
|
||||||
|
class="absolute right-1 top-1 flex size-5 items-center justify-center rounded-full bg-surface/70 text-xs text-danger opacity-0 transition group-hover:opacity-100 dark:bg-surface-dark/70"
|
||||||
|
title="{{ t(key='delete', lang=lang | default(value='sk')) }}">✕</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Hidden input carries the accumulated files on submit; the visible picker #}
|
||||||
|
{# only feeds addFiles() and is reset after each pick so selections stack. #}
|
||||||
|
<input type="file" name="image" multiple class="hidden" x-ref="holder">
|
||||||
|
<input type="file" accept="image/*" multiple class="hidden" x-ref="picker" @change="addFiles($event)">
|
||||||
|
<button type="button" @click="$refs.picker.click()"
|
||||||
|
class="rounded-radius border border-outline px-3 py-1.5 text-sm font-medium text-on-surface hover:bg-surface-alt dark:border-outline-dark dark:text-on-surface-dark dark:hover:bg-surface-dark-alt/50">
|
||||||
|
+ {{ t(key="add-images", lang=lang | default(value='sk')) }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ ui::checkbox(name="published", id="published", label=t(key="published", lang=lang | default(value='sk')), checked=v_pub) }}
|
{{ ui::checkbox(name="published", id="published", label=t(key="published", lang=lang | default(value='sk')), checked=v_pub) }}
|
||||||
|
|||||||
@@ -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>
|
<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" -%}
|
{%- 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>
|
<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 -%}
|
{%- 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>
|
<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 -%}
|
{%- endif -%}
|
||||||
|
|||||||
@@ -1,39 +1,49 @@
|
|||||||
|
{# 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
|
{# Adapted from the vendored Penguin UI component
|
||||||
(penguinui-components/card/ecommerce-product-card.html):
|
(penguinui-components/card/ecommerce-product-card.html):
|
||||||
wired to our product data + i18n + htmx add-to-cart + toast. The demo rating
|
wired to our product data + i18n + htmx add-to-cart + toast. The demo rating
|
||||||
stars, hardcoded title/price/description/image and the `max-w-sm` (which fights
|
stars, hardcoded title/price/description/image and the `max-w-sm` (which fights
|
||||||
the shop grid) are dropped; the whole card links to the product page. #}
|
the shop grid) are dropped; the whole card links to the product page. #}
|
||||||
|
{# Layout adapts to the `view` Alpine state set by _product_grid.html:
|
||||||
|
'grid' (default) → vertical card; 'list' → horizontal row. On pages without
|
||||||
|
that state (e.g. home) `view` is undefined, so the grid layout applies. #}
|
||||||
<article
|
<article
|
||||||
class="group flex flex-col overflow-hidden rounded-radius border border-outline bg-surface-alt text-on-surface transition hover:border-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:border-primary-dark">
|
class="group flex overflow-hidden rounded-radius border border-outline bg-surface-alt text-on-surface transition hover:border-primary dark:border-outline-dark dark:bg-surface-dark-alt dark:text-on-surface-dark dark:hover:border-primary-dark"
|
||||||
<a href="/shop/{{ product.slug }}" class="flex flex-1 flex-col">
|
:class="view === 'list' ? 'flex-row flex-wrap' : 'flex-col'">
|
||||||
|
<a href="/shop/{{ product.slug }}" class="flex min-w-0 flex-1"
|
||||||
|
:class="view === 'list' ? 'flex-row' : 'flex-col'">
|
||||||
<!-- Image -->
|
<!-- Image -->
|
||||||
<div class="h-44 overflow-hidden bg-surface-alt md:h-64 dark:bg-surface-dark">
|
<div class="overflow-hidden bg-surface-alt dark:bg-surface-dark"
|
||||||
|
:class="view === 'list' ? 'size-28 shrink-0 sm:size-40' : 'h-44 md:h-64'">
|
||||||
{% if product.image %}
|
{% if product.image %}
|
||||||
<img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover transition duration-700 ease-out group-hover:scale-105">
|
<img src="/images/{{ product.image }}" alt="{{ product.name }}" class="size-full object-cover transition duration-700 ease-out group-hover:scale-105">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<div class="flex flex-1 flex-col gap-1 p-6 pb-2">
|
<div class="flex min-w-0 flex-1 flex-col gap-1"
|
||||||
<!-- Header: Title & Price -->
|
:class="view === 'list' ? 'p-4 sm:p-5' : 'p-6 pb-2'">
|
||||||
<div class="flex justify-between gap-4">
|
<!-- Header: Title & Price (stacked so neither overflows the narrow card) -->
|
||||||
<h3 class="text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
|
<h3 class="break-words text-lg font-bold text-on-surface-strong dark:text-on-surface-dark-strong">{{ product.name }}</h3>
|
||||||
{% if product.on_sale %}
|
{% if product.on_sale %}
|
||||||
<span class="flex flex-col items-end whitespace-nowrap leading-tight">
|
<div class="flex flex-wrap items-baseline gap-x-2 leading-tight">
|
||||||
<span class="text-sm text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }} {{ product.currency }}</span>
|
<span class="text-xl font-semibold text-danger"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ product.currency }}</span>
|
||||||
<span class="text-xl font-semibold text-danger"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ product.currency }}</span>
|
<span class="text-sm text-on-surface/50 line-through dark:text-on-surface-dark/50">{{ product.regular_price }} {{ product.currency }}</span>
|
||||||
</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="whitespace-nowrap text-xl"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ product.currency }}</span>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<span class="break-words text-xl"><span class="sr-only">Price</span>{% if product.has_options %}{{ t(key="from-price", price=product.price, lang=lang | default(value='sk')) }}{% else %}{{ product.price }}{% endif %} {{ product.currency }}</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="flex flex-col gap-2 p-6 pt-0">
|
<div class="flex flex-col gap-2"
|
||||||
|
:class="view === 'list' ? 'w-full justify-center p-4 sm:w-56 sm:p-5' : 'p-6 pt-0'">
|
||||||
{% if product.has_options %}
|
{% if product.has_options %}
|
||||||
{# Multiple variants: customer must pick on the product page. #}
|
{# Multiple variants: customer must pick on the product page. #}
|
||||||
{{ ui::button(label=t(key="choose-option", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, extra="w-full") }}
|
{{ ui::button(label=t(key="choose-option", lang=lang | default(value='sk')), href="/shop/" ~ product.slug, extra="w-full") }}
|
||||||
{% elif product.stock > 0 %}
|
{% elif product.in_stock %}
|
||||||
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}</p>
|
<p class="text-xs text-on-surface/60 dark:text-on-surface-dark/60">{% if product.tracked %}{{ t(key="in-stock", lang=lang | default(value='sk')) }}: {{ product.stock }}{% else %}{{ t(key="available", lang=lang | default(value='sk')) }}{% endif %}</p>
|
||||||
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none"
|
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none"
|
||||||
hx-on::after-request="if (event.detail.successful) toast('{{ t(key='cart-added', lang=lang | default(value='sk')) }}')">
|
hx-on::after-request="if (event.detail.successful) toast('{{ t(key='cart-added', lang=lang | default(value='sk')) }}')">
|
||||||
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
<input type="hidden" name="_csrf" value="{{ csrf_token() }}">
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
hx-post="/cart/update" hx-trigger="cartchange" hx-target="#cart-body" hx-swap="innerHTML">
|
hx-post="/cart/update" hx-trigger="cartchange" hx-target="#cart-body" hx-swap="innerHTML">
|
||||||
{{ ui::csrf_field() }}
|
{{ ui::csrf_field() }}
|
||||||
<input type="hidden" name="variant_id" value="{{ item.id }}">
|
<input type="hidden" name="variant_id" value="{{ item.id }}">
|
||||||
<input type="number" name="quantity" min="0" max="{{ item.stock }}" value="{{ item.quantity }}"
|
<input type="number" name="quantity" min="0" {% if item.stock %}max="{{ item.stock }}"{% endif %} value="{{ item.quantity }}"
|
||||||
@change="
|
@change="
|
||||||
if (parseInt($el.value || '0') <= 0 && !window.confirm('{{ t(key='cart-remove-confirm', lang=lang | default(value='sk')) }}')) {
|
if (parseInt($el.value || '0') <= 0 && !window.confirm('{{ t(key='cart-remove-confirm', lang=lang | default(value='sk')) }}')) {
|
||||||
$el.value = '{{ item.quantity }}';
|
$el.value = '{{ item.quantity }}';
|
||||||
|
|||||||
39
assets/views/shop/_product_grid.html
Normal file
39
assets/views/shop/_product_grid.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{# Product collection with a grid / list view toggle.
|
||||||
|
The chosen view is held in Alpine and persisted to localStorage so it
|
||||||
|
survives navigation; `_card.html` reads the same `view` state to switch
|
||||||
|
its own layout between a vertical card and a horizontal row. #}
|
||||||
|
<div x-data="{ view: localStorage.getItem('shopView') === 'list' ? 'list' : 'grid' }"
|
||||||
|
x-init="$watch('view', v => localStorage.setItem('shopView', v))"
|
||||||
|
class="space-y-4">
|
||||||
|
<!-- View toggle -->
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<div class="inline-flex gap-0.5 rounded-radius border border-outline p-0.5 dark:border-outline-dark" role="group"
|
||||||
|
aria-label="{{ t(key='view-grid', lang=lang | default(value='sk')) }} / {{ t(key='view-list', lang=lang | default(value='sk')) }}">
|
||||||
|
<button type="button" @click="view = 'grid'" :aria-pressed="view === 'grid'"
|
||||||
|
class="inline-flex size-8 items-center justify-center rounded-radius transition"
|
||||||
|
:class="view === 'grid' ? 'bg-primary text-on-primary dark:bg-primary-dark dark:text-on-primary-dark' : 'text-on-surface hover:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark'"
|
||||||
|
aria-label="{{ t(key='view-grid', lang=lang | default(value='sk')) }}"
|
||||||
|
title="{{ t(key='view-grid', lang=lang | default(value='sk')) }}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-4">
|
||||||
|
<path d="M3 3h6v6H3V3Zm8 0h6v6h-6V3ZM3 11h6v6H3v-6Zm8 0h6v6h-6v-6Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" @click="view = 'list'" :aria-pressed="view === 'list'"
|
||||||
|
class="inline-flex size-8 items-center justify-center rounded-radius transition"
|
||||||
|
:class="view === 'list' ? 'bg-primary text-on-primary dark:bg-primary-dark dark:text-on-primary-dark' : 'text-on-surface hover:text-primary dark:text-on-surface-dark dark:hover:text-primary-dark'"
|
||||||
|
aria-label="{{ t(key='view-list', lang=lang | default(value='sk')) }}"
|
||||||
|
title="{{ t(key='view-list', lang=lang | default(value='sk')) }}">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true" class="size-4">
|
||||||
|
<path d="M3 4h14v2.5H3V4Zm0 4.75h14v2.5H3v-2.5ZM3 13.5h14V16H3v-2.5Z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Products -->
|
||||||
|
<div :class="view === 'list' ? 'flex flex-col gap-5' : 'grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4'">
|
||||||
|
{% for product in products %}
|
||||||
|
{% include "shop/_card.html" %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
13
assets/views/shop/_results.html
Normal file
13
assets/views/shop/_results.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
{# 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>
|
||||||
|
</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 %}
|
||||||
@@ -29,11 +29,7 @@
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
{% if products | length > 0 %}
|
{% if products | length > 0 %}
|
||||||
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4">
|
{% include "shop/_product_grid.html" %}
|
||||||
{% for product in products %}
|
|
||||||
{% include "shop/_card.html" %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% else %}
|
{% 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">
|
<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')) }}
|
{{ t(key="shop-empty", lang=lang | default(value='sk')) }}
|
||||||
|
|||||||
@@ -5,21 +5,41 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="space-y-8">
|
<div class="space-y-8">
|
||||||
<header class="space-y-2">
|
<header class="space-y-4">
|
||||||
<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>
|
<div class="space-y-2">
|
||||||
<p class="text-on-surface/70 dark:text-on-surface-dark/70">{{ t(key="shop-subtitle", lang=lang | default(value='sk')) }}</p>
|
<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>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{% if products | length > 0 %}
|
<div id="shop-results">
|
||||||
<div class="grid grid-cols-2 gap-5 sm:grid-cols-3 xl:grid-cols-4">
|
{% include "shop/_results.html" %}
|
||||||
{% for product in products %}
|
|
||||||
{% include "shop/_card.html" %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</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 %}
|
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
|||||||
@@ -60,6 +60,19 @@
|
|||||||
|
|
||||||
<template x-if="current">
|
<template x-if="current">
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
|
<!-- option picker (only when there's a real choice); first option is
|
||||||
|
selected by default and switching it updates the price + buy form -->
|
||||||
|
<template x-if="variants.length > 1">
|
||||||
|
<div class="max-w-sm space-y-1.5">
|
||||||
|
<label for="variant-select" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="choose-option", lang=lang | default(value='sk')) }}</label>
|
||||||
|
<select id="variant-select" x-model.number="sel" class="{{ fld }}">
|
||||||
|
<template x-for="(v, i) in variants" :key="v.id">
|
||||||
|
<option :value="i" x-text="(v.label || '—') + ' · ' + v.price + ' {{ product.currency }}' + (v.in_stock ? '' : ' ({{ t(key='out-of-stock', lang=lang | default(value='sk')) }})')"></option>
|
||||||
|
</template>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<div class="flex items-baseline gap-3">
|
<div class="flex items-baseline gap-3">
|
||||||
<p class="text-2xl font-semibold" :class="current.on_sale ? 'text-danger' : 'text-primary dark:text-primary-dark'">
|
<p class="text-2xl font-semibold" :class="current.on_sale ? 'text-danger' : 'text-primary dark:text-primary-dark'">
|
||||||
<span x-text="current.price"></span> {{ product.currency }}
|
<span x-text="current.price"></span> {{ product.currency }}
|
||||||
@@ -73,18 +86,6 @@
|
|||||||
<div class="whitespace-pre-line leading-relaxed text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description }}</div>
|
<div class="whitespace-pre-line leading-relaxed text-on-surface/80 dark:text-on-surface-dark/80">{{ product.description }}</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<!-- variant picker (only when there's a real choice) -->
|
|
||||||
<template x-if="variants.length > 1">
|
|
||||||
<div class="max-w-sm space-y-1.5">
|
|
||||||
<label for="variant-select" class="text-sm font-medium text-on-surface-strong dark:text-on-surface-dark-strong">{{ t(key="choose-option", lang=lang | default(value='sk')) }}</label>
|
|
||||||
<select id="variant-select" x-model.number="sel" class="{{ fld }}">
|
|
||||||
<template x-for="(v, i) in variants" :key="v.id">
|
|
||||||
<option :value="i" x-text="(v.label || '—') + ' · ' + v.price + ' {{ product.currency }}' + (v.in_stock ? '' : ' ({{ t(key='out-of-stock', lang=lang | default(value='sk')) }})')"></option>
|
|
||||||
</template>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<template x-if="current.in_stock">
|
<template x-if="current.in_stock">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none" class="flex flex-wrap items-end gap-3"
|
<form method="post" action="/cart/add" hx-post="/cart/add" hx-swap="none" class="flex flex-wrap items-end gap-3"
|
||||||
@@ -97,7 +98,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<button type="submit" class="{{ btn }}">{{ t(key="add-to-cart", lang=lang | default(value='sk')) }}</button>
|
<button type="submit" class="{{ btn }}">{{ t(key="add-to-cart", lang=lang | default(value='sk')) }}</button>
|
||||||
</form>
|
</form>
|
||||||
<p class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">{{ t(key="in-stock", lang=lang | default(value='sk')) }}: <span x-text="current.stock"></span></p>
|
<p class="text-sm text-on-surface/60 dark:text-on-surface-dark/60">
|
||||||
|
<template x-if="current.tracked">
|
||||||
|
<span>{{ t(key="in-stock", lang=lang | default(value='sk')) }}: <span x-text="current.stock"></span></span>
|
||||||
|
</template>
|
||||||
|
<template x-if="!current.tracked">
|
||||||
|
<span>{{ t(key="available", lang=lang | default(value='sk')) }}</span>
|
||||||
|
</template>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template x-if="!current.in_stock">
|
<template x-if="!current.in_stock">
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ mod m20260621_000003_discount_profiles;
|
|||||||
mod m20260621_000004_add_business_sale_price_to_products;
|
mod m20260621_000004_add_business_sale_price_to_products;
|
||||||
mod m20260622_000001_audience_discount_profiles;
|
mod m20260622_000001_audience_discount_profiles;
|
||||||
mod m20260622_000002_product_variants;
|
mod m20260622_000002_product_variants;
|
||||||
|
mod m20260622_000003_variant_stock_nullable;
|
||||||
|
mod m20260622_000004_product_search;
|
||||||
|
mod m20260622_000005_product_search_aggregate;
|
||||||
pub struct Migrator;
|
pub struct Migrator;
|
||||||
|
|
||||||
#[async_trait::async_trait]
|
#[async_trait::async_trait]
|
||||||
@@ -86,6 +89,9 @@ impl MigratorTrait for Migrator {
|
|||||||
Box::new(m20260621_000004_add_business_sale_price_to_products::Migration),
|
Box::new(m20260621_000004_add_business_sale_price_to_products::Migration),
|
||||||
Box::new(m20260622_000001_audience_discount_profiles::Migration),
|
Box::new(m20260622_000001_audience_discount_profiles::Migration),
|
||||||
Box::new(m20260622_000002_product_variants::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),
|
||||||
// inject-above (do not remove this comment)
|
// inject-above (do not remove this comment)
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
36
migration/src/m20260622_000003_variant_stock_nullable.rs
Normal file
36
migration/src/m20260622_000003_variant_stock_nullable.rs
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
//! Make `product_variants.stock` nullable: a NULL stock means the variant is
|
||||||
|
//! "available" but not inventory-tracked — always purchasable, no quantity cap,
|
||||||
|
//! and never decremented on order. A numeric stock is tracked/capped as before.
|
||||||
|
|
||||||
|
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#"
|
||||||
|
ALTER TABLE product_variants ALTER COLUMN stock DROP DEFAULT;
|
||||||
|
ALTER TABLE product_variants ALTER COLUMN stock DROP NOT NULL;
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn down(&self, m: &SchemaManager) -> Result<(), DbErr> {
|
||||||
|
m.get_connection()
|
||||||
|
.execute_unprepared(
|
||||||
|
r#"
|
||||||
|
UPDATE product_variants SET stock = 0 WHERE stock IS NULL;
|
||||||
|
ALTER TABLE product_variants ALTER COLUMN stock SET DEFAULT 0;
|
||||||
|
ALTER TABLE product_variants ALTER COLUMN stock SET NOT NULL;
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
92
migration/src/m20260622_000004_product_search.rs
Normal file
92
migration/src/m20260622_000004_product_search.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
232
migration/src/m20260622_000005_product_search_aggregate.rs
Normal file
232
migration/src/m20260622_000005_product_search_aggregate.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -198,7 +198,7 @@ async fn create(
|
|||||||
guard::current_admin(auth, &ctx).await?;
|
guard::current_admin(auth, &ctx).await?;
|
||||||
let form = read_multipart_form(multipart).await?;
|
let form = read_multipart_form(multipart).await?;
|
||||||
let fields = parse_category_fields(&ctx, &form, None).await?;
|
let fields = parse_category_fields(&ctx, &form, None).await?;
|
||||||
let image_id = match form.image {
|
let image_id = match form.single_image() {
|
||||||
Some(data) => Some(store_image(&ctx, data).await?),
|
Some(data) => Some(store_image(&ctx, data).await?),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
@@ -252,7 +252,7 @@ async fn update(
|
|||||||
category.position = Set(fields.position);
|
category.position = Set(fields.position);
|
||||||
category.published = Set(fields.published);
|
category.published = Set(fields.published);
|
||||||
category.parent_id = Set(fields.parent_id);
|
category.parent_id = Set(fields.parent_id);
|
||||||
if let Some(data) = form.image {
|
if let Some(data) = form.single_image() {
|
||||||
category.image_id = Set(Some(store_image(&ctx, data).await?));
|
category.image_id = Set(Some(store_image(&ctx, data).await?));
|
||||||
}
|
}
|
||||||
category.update(&ctx.db).await?;
|
category.update(&ctx.db).await?;
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
//! Multipart form handling shared by the product and category admin forms.
|
//! Multipart form handling shared by the product and category admin forms.
|
||||||
//!
|
//!
|
||||||
//! Both forms submit a mix of text fields and an optional `image` file part;
|
//! Both forms submit a mix of text fields and `image` file part(s); this
|
||||||
//! this collects them into an easy-to-query [`MultipartForm`] and stores any
|
//! collects them into an easy-to-query [`MultipartForm`] and stores any
|
||||||
//! uploaded image through the configured storage driver.
|
//! uploaded image through the configured storage driver. The product form can
|
||||||
|
//! upload several images at once and submits a unified gallery order as
|
||||||
|
//! repeated `image_order` fields — each either an existing image's id or the
|
||||||
|
//! literal `new` (a placeholder consumed, in order, from the uploaded files).
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
@@ -18,11 +21,24 @@ fn normalize_empty(value: Option<String>) -> Option<String> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Collected multipart form: text fields keyed by name, plus the raw bytes of
|
/// One slot in the unified gallery order submitted by the product form.
|
||||||
/// an `image` file part if one was uploaded (an empty file input is ignored).
|
#[derive(Debug, Clone)]
|
||||||
|
pub(crate) enum ImageSlot {
|
||||||
|
/// An existing image kept in the gallery.
|
||||||
|
Existing(i32),
|
||||||
|
/// A placeholder for one newly-uploaded file, consumed from [`MultipartForm::images`]
|
||||||
|
/// in the order these slots appear.
|
||||||
|
New,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collected multipart form: text fields keyed by name, the raw bytes of every
|
||||||
|
/// `image` file part uploaded (empty file inputs are ignored, submission order
|
||||||
|
/// preserved), and the full gallery order as repeated `image_order` fields —
|
||||||
|
/// each either an existing image's id or the literal `new`.
|
||||||
pub(crate) struct MultipartForm {
|
pub(crate) struct MultipartForm {
|
||||||
fields: HashMap<String, String>,
|
fields: HashMap<String, String>,
|
||||||
pub(crate) image: Option<Vec<u8>>,
|
pub(crate) images: Vec<Vec<u8>>,
|
||||||
|
pub(crate) image_order: Vec<ImageSlot>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MultipartForm {
|
impl MultipartForm {
|
||||||
@@ -31,6 +47,12 @@ impl MultipartForm {
|
|||||||
normalize_empty(self.fields.get(key).cloned())
|
normalize_empty(self.fields.get(key).cloned())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The single uploaded image, for forms (like categories) that accept only
|
||||||
|
/// one. Consumes the first uploaded part; any extras are ignored.
|
||||||
|
pub(crate) fn single_image(self) -> Option<Vec<u8>> {
|
||||||
|
self.images.into_iter().next()
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether a checkbox-style field is checked.
|
/// Whether a checkbox-style field is checked.
|
||||||
pub(crate) fn checked(&self, key: &str) -> bool {
|
pub(crate) fn checked(&self, key: &str) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
@@ -59,7 +81,8 @@ impl MultipartForm {
|
|||||||
|
|
||||||
pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<MultipartForm> {
|
pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<MultipartForm> {
|
||||||
let mut fields = HashMap::new();
|
let mut fields = HashMap::new();
|
||||||
let mut image = None;
|
let mut images = Vec::new();
|
||||||
|
let mut image_order = Vec::new();
|
||||||
|
|
||||||
while let Some(mut field) = multipart
|
while let Some(mut field) = multipart
|
||||||
.next_field()
|
.next_field()
|
||||||
@@ -82,8 +105,20 @@ pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<Mult
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// An empty file part (no file chosen in a slot) is ignored.
|
||||||
if !data.is_empty() {
|
if !data.is_empty() {
|
||||||
image = Some(data);
|
images.push(data);
|
||||||
|
}
|
||||||
|
} else if name == "image_order" {
|
||||||
|
let value = field
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|err| Error::BadRequest(format!("invalid multipart field: {err}")))?;
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed == "new" {
|
||||||
|
image_order.push(ImageSlot::New);
|
||||||
|
} else if let Ok(id) = trimmed.parse::<i32>() {
|
||||||
|
image_order.push(ImageSlot::Existing(id));
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
let value = field
|
let value = field
|
||||||
@@ -94,7 +129,11 @@ pub(crate) async fn read_multipart_form(mut multipart: Multipart) -> Result<Mult
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(MultipartForm { fields, image })
|
Ok(MultipartForm {
|
||||||
|
fields,
|
||||||
|
images,
|
||||||
|
image_order,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Store an uploaded image's bytes and return its generated filename.
|
/// Store an uploaded image's bytes and return its generated filename.
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use serde_json::json;
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
controllers::{
|
controllers::{
|
||||||
admin_form::{read_multipart_form, store_image, MultipartForm},
|
admin_form::{read_multipart_form, store_image, ImageSlot, MultipartForm},
|
||||||
i18n::current_lang,
|
i18n::current_lang,
|
||||||
media::IMAGE_MAX_BYTES,
|
media::IMAGE_MAX_BYTES,
|
||||||
},
|
},
|
||||||
@@ -102,15 +102,15 @@ struct VariantInput {
|
|||||||
id: Option<i32>,
|
id: Option<i32>,
|
||||||
label: String,
|
label: String,
|
||||||
sku: Option<String>,
|
sku: Option<String>,
|
||||||
stock: i32,
|
/// `None` = available but not inventory-tracked.
|
||||||
|
stock: Option<i32>,
|
||||||
price_cents: i64,
|
price_cents: i64,
|
||||||
sale_cents: Option<i64>,
|
|
||||||
business_sale_cents: Option<i64>,
|
business_sale_cents: Option<i64>,
|
||||||
position: i32,
|
position: i32,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// An optional price field on a variant row (sale / business sale): blank means
|
/// The optional business-sale price field on a variant row: blank means "no
|
||||||
/// "no quick-sale", a value must parse and be below the regular price.
|
/// business quick-sale", a value must parse and be below the regular price.
|
||||||
fn parse_optional_sale(
|
fn parse_optional_sale(
|
||||||
form: &MultipartForm,
|
form: &MultipartForm,
|
||||||
i: usize,
|
i: usize,
|
||||||
@@ -157,12 +157,17 @@ fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let sku = form.text(&format!("variants[{i}][sku]"));
|
let sku = form.text(&format!("variants[{i}][sku]"));
|
||||||
let stock = form
|
// Stock is optional: blank means "available, not tracked". A value must
|
||||||
.text(&format!("variants[{i}][stock]"))
|
// be a non-negative integer.
|
||||||
.and_then(|s| s.parse::<i32>().ok())
|
let stock = match form.text(&format!("variants[{i}][stock]")) {
|
||||||
.filter(|n| *n >= 0)
|
None => None,
|
||||||
.unwrap_or(0);
|
Some(raw) => Some(
|
||||||
let sale_cents = parse_optional_sale(form, i, "sale", price_cents)?;
|
raw.parse::<i32>()
|
||||||
|
.ok()
|
||||||
|
.filter(|n| *n >= 0)
|
||||||
|
.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 business_sale_cents = parse_optional_sale(form, i, "business_sale", price_cents)?;
|
||||||
let id = form
|
let id = form
|
||||||
.text(&format!("variants[{i}][id]"))
|
.text(&format!("variants[{i}][id]"))
|
||||||
@@ -174,7 +179,6 @@ fn parse_variants(form: &MultipartForm) -> Result<Vec<VariantInput>> {
|
|||||||
sku,
|
sku,
|
||||||
stock,
|
stock,
|
||||||
price_cents,
|
price_cents,
|
||||||
sale_cents,
|
|
||||||
business_sale_cents,
|
business_sale_cents,
|
||||||
position: out.len() as i32,
|
position: out.len() as i32,
|
||||||
});
|
});
|
||||||
@@ -193,7 +197,8 @@ fn apply_variant(active: &mut product_variants::ActiveModel, input: &VariantInpu
|
|||||||
active.sku = Set(input.sku.clone());
|
active.sku = Set(input.sku.clone());
|
||||||
active.stock = Set(input.stock);
|
active.stock = Set(input.stock);
|
||||||
active.price_cents = Set(input.price_cents);
|
active.price_cents = Set(input.price_cents);
|
||||||
active.sale_price_cents = Set(input.sale_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);
|
active.business_sale_price_cents = Set(input.business_sale_cents);
|
||||||
active.position = Set(input.position);
|
active.position = Set(input.position);
|
||||||
}
|
}
|
||||||
@@ -252,7 +257,6 @@ fn variant_form_json(variant: &product_variants::Model) -> serde_json::Value {
|
|||||||
"sku": variant.sku,
|
"sku": variant.sku,
|
||||||
"stock": variant.stock,
|
"stock": variant.stock,
|
||||||
"price": format_price(variant.price_cents),
|
"price": format_price(variant.price_cents),
|
||||||
"sale": variant.sale_price_cents.map(format_price),
|
|
||||||
"business_sale": variant.business_sale_price_cents.map(format_price),
|
"business_sale": variant.business_sale_price_cents.map(format_price),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -318,12 +322,22 @@ async fn index(
|
|||||||
let category_name = product
|
let category_name = product
|
||||||
.category_id
|
.category_id
|
||||||
.and_then(|id| category_name.get(&id).cloned());
|
.and_then(|id| category_name.get(&id).cloned());
|
||||||
let total_stock: i32 = variants.iter().map(|v| v.stock).sum();
|
// Stock column: total across tracked variants, or "∞" when any option is
|
||||||
|
// untracked (always available).
|
||||||
|
let stock_display = if variants.iter().any(|v| !v.tracked()) {
|
||||||
|
"∞".to_string()
|
||||||
|
} else {
|
||||||
|
variants
|
||||||
|
.iter()
|
||||||
|
.filter_map(|v| v.stock)
|
||||||
|
.sum::<i32>()
|
||||||
|
.to_string()
|
||||||
|
};
|
||||||
rows.push(product_row(
|
rows.push(product_row(
|
||||||
product,
|
product,
|
||||||
priced,
|
priced,
|
||||||
variants.len(),
|
variants.len(),
|
||||||
total_stock,
|
stock_display,
|
||||||
image,
|
image,
|
||||||
category_name,
|
category_name,
|
||||||
));
|
));
|
||||||
@@ -352,7 +366,7 @@ fn product_row(
|
|||||||
product: &products::Model,
|
product: &products::Model,
|
||||||
effective: &pricing::PricedProduct,
|
effective: &pricing::PricedProduct,
|
||||||
variant_count: usize,
|
variant_count: usize,
|
||||||
total_stock: i32,
|
stock_display: String,
|
||||||
image: Option<String>,
|
image: Option<String>,
|
||||||
category_name: Option<String>,
|
category_name: Option<String>,
|
||||||
) -> serde_json::Value {
|
) -> serde_json::Value {
|
||||||
@@ -361,7 +375,7 @@ fn product_row(
|
|||||||
"name": product.name,
|
"name": product.name,
|
||||||
"slug": product.slug,
|
"slug": product.slug,
|
||||||
"currency": product.currency,
|
"currency": product.currency,
|
||||||
"stock": total_stock,
|
"stock": stock_display,
|
||||||
"variant_count": variant_count,
|
"variant_count": variant_count,
|
||||||
"has_options": variant_count > 1,
|
"has_options": variant_count > 1,
|
||||||
"published": product.published,
|
"published": product.published,
|
||||||
@@ -444,24 +458,73 @@ async fn create(
|
|||||||
.insert(&txn)
|
.insert(&txn)
|
||||||
.await?;
|
.await?;
|
||||||
sync_variants(&txn, product.id, &variants).await?;
|
sync_variants(&txn, product.id, &variants).await?;
|
||||||
|
sync_images(&ctx, &txn, product.id, &form.image_order, &form.images).await?;
|
||||||
if let Some(data) = form.image {
|
|
||||||
let filename = store_image(&ctx, data).await?;
|
|
||||||
product_images::ActiveModel {
|
|
||||||
product_id: Set(product.id),
|
|
||||||
image_id: Set(filename),
|
|
||||||
position: Set(0),
|
|
||||||
alt: Set(None),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
.insert(&txn)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
txn.commit().await?;
|
txn.commit().await?;
|
||||||
|
|
||||||
format::redirect("/admin/catalog/products")
|
format::redirect("/admin/catalog/products")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Reconcile a product's images inside `txn` with the submitted unified
|
||||||
|
/// `image_order`: for each [`ImageSlot::Existing`] entry re-number the
|
||||||
|
/// corresponding image to its slot position; for each [`ImageSlot::New`]
|
||||||
|
/// consume the next freshly uploaded file from `new_images`, storing and
|
||||||
|
/// inserting it at that position. Any existing image not referenced in
|
||||||
|
/// `image_order` is deleted.
|
||||||
|
async fn sync_images<C: ConnectionTrait>(
|
||||||
|
ctx: &AppContext,
|
||||||
|
txn: &C,
|
||||||
|
product_id: i32,
|
||||||
|
image_order: &[ImageSlot],
|
||||||
|
new_images: &[Vec<u8>],
|
||||||
|
) -> Result<()> {
|
||||||
|
let existing = product_images::for_product(txn, product_id).await?;
|
||||||
|
let by_id: HashMap<i32, product_images::Model> =
|
||||||
|
existing.iter().map(|m| (m.id, m.clone())).collect();
|
||||||
|
let keep: HashSet<i32> = image_order
|
||||||
|
.iter()
|
||||||
|
.filter_map(|slot| match slot {
|
||||||
|
ImageSlot::Existing(id) => Some(*id),
|
||||||
|
ImageSlot::New => None,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for image in &existing {
|
||||||
|
if !keep.contains(&image.id) {
|
||||||
|
image.clone().delete(txn).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut new_iter = new_images.iter();
|
||||||
|
let mut position = 0i32;
|
||||||
|
for slot in image_order {
|
||||||
|
match slot {
|
||||||
|
ImageSlot::Existing(id) => {
|
||||||
|
if let Some(model) = by_id.get(id) {
|
||||||
|
let mut active = model.clone().into_active_model();
|
||||||
|
active.position = Set(position);
|
||||||
|
active.update(txn).await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ImageSlot::New => {
|
||||||
|
if let Some(data) = new_iter.next() {
|
||||||
|
let filename = store_image(ctx, data.clone()).await?;
|
||||||
|
product_images::ActiveModel {
|
||||||
|
product_id: Set(product_id),
|
||||||
|
image_id: Set(filename),
|
||||||
|
position: Set(position),
|
||||||
|
alt: Set(None),
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
.insert(txn)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
position += 1;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn edit(
|
async fn edit(
|
||||||
auth: auth::JWT,
|
auth: auth::JWT,
|
||||||
@@ -472,10 +535,10 @@ async fn edit(
|
|||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
guard::current_admin(auth, &ctx).await?;
|
guard::current_admin(auth, &ctx).await?;
|
||||||
let product = product_by_id(&ctx, id).await?;
|
let product = product_by_id(&ctx, id).await?;
|
||||||
let image = product_images::first_for(&ctx, id).await?;
|
let images = product_images::for_product(&ctx.db, id).await?;
|
||||||
let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
|
let variants = product_variants::Entity::for_product(&ctx.db, id).await?;
|
||||||
let mut context = form_context(&ctx, &jar).await?;
|
let mut context = form_context(&ctx, &jar).await?;
|
||||||
context["product"] = view::product_form(&product, image);
|
context["product"] = view::product_form(&product, &images);
|
||||||
context["variants"] = json!(variants.iter().map(variant_form_json).collect::<Vec<_>>());
|
context["variants"] = json!(variants.iter().map(variant_form_json).collect::<Vec<_>>());
|
||||||
format::view(&v, "admin/catalog/product_form.html", context)
|
format::view(&v, "admin/catalog/product_form.html", context)
|
||||||
}
|
}
|
||||||
@@ -509,20 +572,7 @@ async fn update(
|
|||||||
}
|
}
|
||||||
product.update(&txn).await?;
|
product.update(&txn).await?;
|
||||||
sync_variants(&txn, id, &variants).await?;
|
sync_variants(&txn, id, &variants).await?;
|
||||||
|
sync_images(&ctx, &txn, id, &form.image_order, &form.images).await?;
|
||||||
if let Some(data) = form.image {
|
|
||||||
let filename = store_image(&ctx, data).await?;
|
|
||||||
let next_position = product_images::count_for(&ctx, id).await?;
|
|
||||||
product_images::ActiveModel {
|
|
||||||
product_id: Set(id),
|
|
||||||
image_id: Set(filename),
|
|
||||||
position: Set(next_position),
|
|
||||||
alt: Set(None),
|
|
||||||
..Default::default()
|
|
||||||
}
|
|
||||||
.insert(&txn)
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
txn.commit().await?;
|
txn.commit().await?;
|
||||||
|
|
||||||
format::redirect("/admin/catalog/products")
|
format::redirect("/admin/catalog/products")
|
||||||
@@ -685,7 +735,9 @@ async fn sync_profiles(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn routes() -> Routes {
|
pub fn routes() -> Routes {
|
||||||
let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES + 1024 * 1024);
|
// Several images may be uploaded in one submission; allow a generous total
|
||||||
|
// (per-file size is still capped at IMAGE_MAX_BYTES while reading).
|
||||||
|
let image_limit = DefaultBodyLimit::max(IMAGE_MAX_BYTES * 12 + 1024 * 1024);
|
||||||
Routes::new()
|
Routes::new()
|
||||||
.add("/admin/catalog/products", get(index))
|
.add("/admin/catalog/products", get(index))
|
||||||
.add("/admin/catalog/products/new", get(new))
|
.add("/admin/catalog/products/new", get(new))
|
||||||
|
|||||||
@@ -97,9 +97,9 @@ async fn add(
|
|||||||
let mut items = parse_cart(&jar);
|
let mut items = parse_cart(&jar);
|
||||||
let add_qty = form.quantity.unwrap_or(1).max(1);
|
let add_qty = form.quantity.unwrap_or(1).max(1);
|
||||||
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == variant.id) {
|
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == variant.id) {
|
||||||
entry.1 = (entry.1 + add_qty).min(variant.stock);
|
entry.1 = variant.cap(entry.1 + add_qty);
|
||||||
} else {
|
} else {
|
||||||
items.push((variant.id, add_qty.min(variant.stock)));
|
items.push((variant.id, variant.cap(add_qty)));
|
||||||
}
|
}
|
||||||
items.retain(|(_, qty)| *qty > 0);
|
items.retain(|(_, qty)| *qty > 0);
|
||||||
|
|
||||||
@@ -128,13 +128,14 @@ async fn update(
|
|||||||
headers: HeaderMap,
|
headers: HeaderMap,
|
||||||
Form(form): Form<UpdateForm>,
|
Form(form): Form<UpdateForm>,
|
||||||
) -> Result<Response> {
|
) -> Result<Response> {
|
||||||
let stock = published_variant(&ctx, form.variant_id)
|
// Clamp the requested quantity to what's available (no cap for untracked
|
||||||
.await?
|
// variants); a removed variant clamps to 0 and drops out below.
|
||||||
.map(|(v, _)| v.stock)
|
let clamped = match published_variant(&ctx, form.variant_id).await? {
|
||||||
.unwrap_or(0);
|
Some((variant, _)) => variant.cap(form.quantity),
|
||||||
|
None => 0,
|
||||||
|
};
|
||||||
|
|
||||||
let mut items = parse_cart(&jar);
|
let mut items = parse_cart(&jar);
|
||||||
let clamped = form.quantity.clamp(0, stock);
|
|
||||||
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.variant_id) {
|
if let Some(entry) = items.iter_mut().find(|(id, _)| *id == form.variant_id) {
|
||||||
entry.1 = clamped;
|
entry.1 = clamped;
|
||||||
}
|
}
|
||||||
@@ -208,7 +209,7 @@ pub(crate) async fn resolve_cart(
|
|||||||
let Some((variant, product)) = published_variant(ctx, id).await? else {
|
let Some((variant, product)) = published_variant(ctx, id).await? else {
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
let qty = qty.clamp(0, variant.stock);
|
let qty = variant.cap(qty);
|
||||||
if qty == 0 {
|
if qty == 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
//! Public storefront: product listings, product detail, category pages and the
|
//! Public storefront: product listings, product detail, category pages and the
|
||||||
//! lazily-loaded category sidebar.
|
//! lazily-loaded category sidebar.
|
||||||
|
|
||||||
|
use axum::extract::Query;
|
||||||
|
use axum::http::HeaderMap;
|
||||||
use axum_extra::extract::cookie::CookieJar;
|
use axum_extra::extract::cookie::CookieJar;
|
||||||
use loco_rs::prelude::*;
|
use loco_rs::prelude::*;
|
||||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set};
|
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set};
|
||||||
@@ -13,6 +15,12 @@ use crate::{
|
|||||||
views::shop as view,
|
views::shop as view,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// Query string for the storefront search box.
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct SearchParams {
|
||||||
|
q: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
/// Shape a list of products into card rows for `user` (None = public). Each card
|
/// 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
|
/// shows the resolved price of the product's representative (first) variant; the
|
||||||
/// `variant_count` lets the template render "from {price}" for multi-variant
|
/// `variant_count` lets the template render "from {price}" for multi-variant
|
||||||
@@ -101,6 +109,7 @@ async fn index(
|
|||||||
"shop/index.html",
|
"shop/index.html",
|
||||||
json!({
|
json!({
|
||||||
"products": product_rows(&ctx, user.as_ref(), list).await?,
|
"products": product_rows(&ctx, user.as_ref(), list).await?,
|
||||||
|
"query": "",
|
||||||
"logged_in_admin": c.logged_in_admin,
|
"logged_in_admin": c.logged_in_admin,
|
||||||
"logged_in_customer": c.logged_in_customer,
|
"logged_in_customer": c.logged_in_customer,
|
||||||
"customer_name": c.customer_name,
|
"customer_name": c.customer_name,
|
||||||
@@ -110,6 +119,59 @@ async fn index(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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;
|
||||||
|
/// 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 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 lang = current_lang(&jar);
|
||||||
|
|
||||||
|
if headers.contains_key("HX-Request") {
|
||||||
|
return format::view(
|
||||||
|
&v,
|
||||||
|
"shop/_results.html",
|
||||||
|
json!({ "products": rows, "query": q, "lang": lang }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[debug_handler]
|
#[debug_handler]
|
||||||
async fn show(
|
async fn show(
|
||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
@@ -240,6 +302,9 @@ async fn category(
|
|||||||
pub fn routes() -> Routes {
|
pub fn routes() -> Routes {
|
||||||
Routes::new()
|
Routes::new()
|
||||||
.add("/shop", get(index))
|
.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("/shop/{slug}", get(show))
|
||||||
.add("/category/{slug}", get(category))
|
.add("/category/{slug}", get(category))
|
||||||
.add("/partials/categories", get(category_sidebar))
|
.add("/partials/categories", get(category_sidebar))
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ pub struct Model {
|
|||||||
pub label: String,
|
pub label: String,
|
||||||
pub position: i32,
|
pub position: i32,
|
||||||
pub sku: Option<String>,
|
pub sku: Option<String>,
|
||||||
pub stock: i32,
|
pub stock: Option<i32>,
|
||||||
pub price_cents: i64,
|
pub price_cents: i64,
|
||||||
pub sale_price_cents: Option<i64>,
|
pub sale_price_cents: Option<i64>,
|
||||||
pub business_sale_price_cents: Option<i64>,
|
pub business_sale_price_cents: Option<i64>,
|
||||||
|
|||||||
@@ -65,11 +65,15 @@ pub async fn place(
|
|||||||
.one(&txn)
|
.one(&txn)
|
||||||
.await?
|
.await?
|
||||||
.ok_or_else(|| Error::BadRequest("a product is no longer available".to_string()))?;
|
.ok_or_else(|| Error::BadRequest("a product is no longer available".to_string()))?;
|
||||||
if variant.stock < *qty {
|
// Tracked variants can't oversell; untracked ones (stock = None) are
|
||||||
return Err(Error::BadRequest(format!(
|
// always available and never decremented.
|
||||||
"not enough stock for {}",
|
if let Some(on_hand) = variant.stock {
|
||||||
product.name
|
if on_hand < *qty {
|
||||||
)));
|
return Err(Error::BadRequest(format!(
|
||||||
|
"not enough stock for {}",
|
||||||
|
product.name
|
||||||
|
)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
currency = product.currency.clone();
|
currency = product.currency.clone();
|
||||||
// Snapshot the price the buyer actually pays — public sale or, for a
|
// Snapshot the price the buyer actually pays — public sale or, for a
|
||||||
@@ -78,9 +82,11 @@ pub async fn place(
|
|||||||
let unit_price_cents = pricing::price_variant(ctx, &variant, user).await?.price_cents;
|
let unit_price_cents = pricing::price_variant(ctx, &variant, user).await?.price_cents;
|
||||||
subtotal += unit_price_cents * i64::from(*qty);
|
subtotal += unit_price_cents * i64::from(*qty);
|
||||||
|
|
||||||
let mut active = variant.clone().into_active_model();
|
if let Some(on_hand) = variant.stock {
|
||||||
active.stock = Set(variant.stock - *qty);
|
let mut active = variant.clone().into_active_model();
|
||||||
active.update(&txn).await?;
|
active.stock = Set(Some(on_hand - *qty));
|
||||||
|
active.update(&txn).await?;
|
||||||
|
}
|
||||||
|
|
||||||
snapshots.push((product.id, variant.id, product.name, variant.label, unit_price_cents, *qty));
|
snapshots.push((product.id, variant.id, product.name, variant.label, unit_price_cents, *qty));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,11 +37,15 @@ pub async fn first_for(ctx: &AppContext, product_id: i32) -> Result<Option<Strin
|
|||||||
.map(|image| image.image_id))
|
.map(|image| image.image_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Number of images already attached to a product, used to position new uploads.
|
/// All of a product's images in display order (lowest position first). Takes a
|
||||||
pub async fn count_for(ctx: &AppContext, product_id: i32) -> Result<i32> {
|
/// connection so it can run inside the update transaction.
|
||||||
use sea_orm::PaginatorTrait;
|
pub async fn for_product<C: ConnectionTrait>(
|
||||||
|
db: &C,
|
||||||
|
product_id: i32,
|
||||||
|
) -> Result<Vec<Model>> {
|
||||||
Ok(Entity::find()
|
Ok(Entity::find()
|
||||||
.filter(Column::ProductId.eq(product_id))
|
.filter(Column::ProductId.eq(product_id))
|
||||||
.count(&ctx.db)
|
.order_by_asc(Column::Position)
|
||||||
.await? as i32)
|
.all(db)
|
||||||
|
.await?)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,30 @@ impl Model {
|
|||||||
pub fn business_on_sale(&self) -> bool {
|
pub fn business_on_sale(&self) -> bool {
|
||||||
matches!(self.business_sale_price_cents, Some(sale) if sale < self.price_cents)
|
matches!(self.business_sale_price_cents, Some(sale) if sale < self.price_cents)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether the variant's inventory is tracked. A `None` stock means
|
||||||
|
/// "available, not tracked" (always purchasable, unlimited).
|
||||||
|
#[must_use]
|
||||||
|
pub fn tracked(&self) -> bool {
|
||||||
|
self.stock.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the variant can currently be bought: untracked variants are always
|
||||||
|
/// available; tracked ones need a positive quantity on hand.
|
||||||
|
#[must_use]
|
||||||
|
pub fn in_stock(&self) -> bool {
|
||||||
|
self.stock.map_or(true, |s| s > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clamp a desired quantity to what's available: capped at the tracked stock,
|
||||||
|
/// or left as-is (only floored at 0) when untracked.
|
||||||
|
#[must_use]
|
||||||
|
pub fn cap(&self, qty: i32) -> i32 {
|
||||||
|
match self.stock {
|
||||||
|
Some(s) => qty.clamp(0, s),
|
||||||
|
None => qty.max(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// implement your write-oriented logic here
|
// implement your write-oriented logic here
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
use sea_orm::entity::prelude::*;
|
use sea_orm::entity::prelude::*;
|
||||||
|
use sea_orm::{ConnectionTrait, Statement};
|
||||||
pub use crate::models::_entities::products::{ActiveModel, Column, Entity, Model};
|
pub use crate::models::_entities::products::{ActiveModel, Column, Entity, Model};
|
||||||
pub type Products = Entity;
|
pub type Products = Entity;
|
||||||
|
|
||||||
@@ -25,4 +26,50 @@ impl Model {}
|
|||||||
impl ActiveModel {}
|
impl ActiveModel {}
|
||||||
|
|
||||||
// implement your custom finders, selectors oriented logic here
|
// 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.
|
||||||
|
pub async fn search<C: ConnectionTrait>(db: &C, query: &str) -> 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.
|
||||||
|
let sql = 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 (
|
||||||
|
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 60
|
||||||
|
"#;
|
||||||
|
|
||||||
|
Entity::find()
|
||||||
|
.from_raw_sql(Statement::from_sql_and_values(
|
||||||
|
db.get_database_backend(),
|
||||||
|
sql,
|
||||||
|
[q.into()],
|
||||||
|
))
|
||||||
|
.all(db)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ pub async fn seed_catalog(ctx: &AppContext) -> Result<()> {
|
|||||||
label: Set(String::new()),
|
label: Set(String::new()),
|
||||||
position: Set(0),
|
position: Set(0),
|
||||||
sku: Set(item.sku.map(|s| s.to_string())),
|
sku: Set(item.sku.map(|s| s.to_string())),
|
||||||
stock: Set(item.stock),
|
stock: Set(Some(item.stock)),
|
||||||
price_cents: Set(item.price_cents),
|
price_cents: Set(item.price_cents),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::models::_entities::{categories, product_variants, products};
|
use crate::models::_entities::{categories, product_images, product_variants, products};
|
||||||
use crate::shared::money::format_price;
|
use crate::shared::money::format_price;
|
||||||
use crate::shared::pricing::PricedProduct;
|
use crate::shared::pricing::PricedProduct;
|
||||||
|
|
||||||
@@ -34,6 +34,8 @@ pub fn product_card(
|
|||||||
"currency": product.currency,
|
"currency": product.currency,
|
||||||
"sku": representative.sku,
|
"sku": representative.sku,
|
||||||
"stock": representative.stock,
|
"stock": representative.stock,
|
||||||
|
"tracked": representative.tracked(),
|
||||||
|
"in_stock": representative.in_stock(),
|
||||||
"variant_count": variant_count,
|
"variant_count": variant_count,
|
||||||
"has_options": variant_count > 1,
|
"has_options": variant_count > 1,
|
||||||
"published": product.published,
|
"published": product.published,
|
||||||
@@ -49,7 +51,8 @@ pub fn variant_option(variant: &product_variants::Model, priced: &PricedProduct)
|
|||||||
"label": variant.label,
|
"label": variant.label,
|
||||||
"sku": variant.sku,
|
"sku": variant.sku,
|
||||||
"stock": variant.stock,
|
"stock": variant.stock,
|
||||||
"in_stock": variant.stock > 0,
|
"tracked": variant.tracked(),
|
||||||
|
"in_stock": variant.in_stock(),
|
||||||
"price": format_price(priced.price_cents),
|
"price": format_price(priced.price_cents),
|
||||||
"on_sale": priced.is_reduced(),
|
"on_sale": priced.is_reduced(),
|
||||||
"regular_price": format_price(priced.regular_cents),
|
"regular_price": format_price(priced.regular_cents),
|
||||||
@@ -58,9 +61,9 @@ pub fn variant_option(variant: &product_variants::Model, priced: &PricedProduct)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Shape used to pre-fill the admin product form (exposes `category_id` rather
|
/// Shape used to pre-fill the admin product form (exposes `category_id` rather
|
||||||
/// than a resolved name, and the current primary image). Variants are supplied
|
/// than a resolved name, and the current images in display order — first is the
|
||||||
/// separately by the controller.
|
/// main one). Variants are supplied separately by the controller.
|
||||||
pub fn product_form(product: &products::Model, image: Option<String>) -> Value {
|
pub fn product_form(product: &products::Model, images: &[product_images::Model]) -> Value {
|
||||||
json!({
|
json!({
|
||||||
"id": product.id,
|
"id": product.id,
|
||||||
"name": product.name,
|
"name": product.name,
|
||||||
@@ -69,7 +72,10 @@ pub fn product_form(product: &products::Model, image: Option<String>) -> Value {
|
|||||||
"currency": product.currency,
|
"currency": product.currency,
|
||||||
"published": product.published,
|
"published": product.published,
|
||||||
"category_id": product.category_id,
|
"category_id": product.category_id,
|
||||||
"image": image,
|
"images": images
|
||||||
|
.iter()
|
||||||
|
.map(|im| json!({ "id": im.id, "image_id": im.image_id }))
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user