search with parameters
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
//! Public storefront: product listings, product detail, category pages and the
|
||||
//! lazily-loaded category sidebar.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use axum::extract::Query;
|
||||
use axum::http::HeaderMap;
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
@@ -10,15 +12,224 @@ use serde_json::json;
|
||||
|
||||
use crate::{
|
||||
controllers::i18n::current_lang,
|
||||
shared::{guard, pricing},
|
||||
shared::{
|
||||
guard,
|
||||
money::{format_price, parse_price_to_cents},
|
||||
pricing,
|
||||
},
|
||||
models::{categories, product_images, product_variants, products, users},
|
||||
views::shop as view,
|
||||
};
|
||||
|
||||
/// Query string for the storefront search box.
|
||||
#[derive(Debug, serde::Deserialize)]
|
||||
/// Results per page in the storefront listing/search.
|
||||
const PER_PAGE: usize = 24;
|
||||
/// Hard cap on candidates a single text search considers before faceting; well
|
||||
/// above any realistic page of results for this catalog.
|
||||
const SEARCH_CAP: u64 = 1000;
|
||||
|
||||
/// All storefront listing controls: free-text query, category, price band,
|
||||
/// stock, sort and page. Everything is optional so `/shop` and `/search` share
|
||||
/// one shape.
|
||||
#[derive(Debug, Default, serde::Deserialize)]
|
||||
struct SearchParams {
|
||||
q: Option<String>,
|
||||
category: Option<String>,
|
||||
min_price: Option<String>,
|
||||
max_price: Option<String>,
|
||||
in_stock: Option<String>,
|
||||
sort: Option<String>,
|
||||
page: Option<u32>,
|
||||
}
|
||||
|
||||
/// A candidate product with everything the listing needs to filter, sort and
|
||||
/// render it: its representative (first) variant, the resolved price for the
|
||||
/// viewer, stock, variant count and original search rank (for relevance order).
|
||||
struct Candidate {
|
||||
product: products::Model,
|
||||
rep: product_variants::Model,
|
||||
priced: pricing::PricedProduct,
|
||||
in_stock: bool,
|
||||
count: usize,
|
||||
rank: usize,
|
||||
}
|
||||
|
||||
/// Whether a checkbox-style param is on (present and not an explicit "off"/"0").
|
||||
fn is_on(v: &Option<String>) -> bool {
|
||||
matches!(v.as_deref(), Some(s) if !s.is_empty() && s != "0" && s != "false" && s != "off")
|
||||
}
|
||||
|
||||
/// Rebuild the query string from `params` minus `page`, so pagination links can
|
||||
/// preserve the active query + filters + sort.
|
||||
fn query_base(params: &SearchParams) -> String {
|
||||
let mut ser = form_urlencoded::Serializer::new(String::new());
|
||||
if let Some(q) = params.q.as_deref().filter(|s| !s.is_empty()) {
|
||||
ser.append_pair("q", q);
|
||||
}
|
||||
if let Some(c) = params.category.as_deref().filter(|s| !s.is_empty() && *s != "all") {
|
||||
ser.append_pair("category", c);
|
||||
}
|
||||
if let Some(p) = params.min_price.as_deref().filter(|s| !s.is_empty()) {
|
||||
ser.append_pair("min_price", p);
|
||||
}
|
||||
if let Some(p) = params.max_price.as_deref().filter(|s| !s.is_empty()) {
|
||||
ser.append_pair("max_price", p);
|
||||
}
|
||||
if is_on(¶ms.in_stock) {
|
||||
ser.append_pair("in_stock", "1");
|
||||
}
|
||||
if let Some(s) = params.sort.as_deref().filter(|s| !s.is_empty()) {
|
||||
ser.append_pair("sort", s);
|
||||
}
|
||||
ser.finish()
|
||||
}
|
||||
|
||||
/// Run the full faceted listing pipeline for `params` and shape the template
|
||||
/// context (results page + facet data + pagination). Reused by `/shop` and
|
||||
/// `/search`; the caller adds chrome and picks the template.
|
||||
async fn run_search(
|
||||
ctx: &AppContext,
|
||||
user: Option<&users::Model>,
|
||||
params: &SearchParams,
|
||||
) -> Result<serde_json::Value> {
|
||||
let q = params.q.clone().unwrap_or_default();
|
||||
let q_trim = q.trim().to_string();
|
||||
|
||||
// 1. Base candidates: ranked search hits, or the full published listing.
|
||||
let base: Vec<products::Model> = if q_trim.is_empty() {
|
||||
products::Entity::find()
|
||||
.filter(products::Column::Published.eq(true))
|
||||
.order_by_desc(products::Column::PublishedAt)
|
||||
.all(&ctx.db)
|
||||
.await?
|
||||
} else {
|
||||
products::Entity::search(&ctx.db, &q_trim, SEARCH_CAP).await?
|
||||
};
|
||||
|
||||
// 2. Attach representative variant + resolved price to each (drop products
|
||||
// with no purchasable variant).
|
||||
let ids: Vec<i32> = base.iter().map(|p| p.id).collect();
|
||||
let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &ids).await?;
|
||||
let mut staged: Vec<(products::Model, product_variants::Model, usize, usize)> = Vec::new();
|
||||
for (rank, product) in base.into_iter().enumerate() {
|
||||
if let Some(vs) = grouped.get(&product.id) {
|
||||
if let Some(rep) = vs.first() {
|
||||
staged.push((product, rep.clone(), vs.len(), rank));
|
||||
}
|
||||
}
|
||||
}
|
||||
let reps: Vec<product_variants::Model> = staged.iter().map(|(_, r, _, _)| r.clone()).collect();
|
||||
let priced = pricing::price_variants(ctx, &reps, user).await?;
|
||||
let mut items: Vec<Candidate> = staged
|
||||
.into_iter()
|
||||
.zip(priced.iter())
|
||||
.map(|((product, rep, count, rank), p)| Candidate {
|
||||
in_stock: rep.in_stock(),
|
||||
product,
|
||||
rep,
|
||||
priced: *p,
|
||||
count,
|
||||
rank,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Price band bounds across all matches, to hint the filter UI.
|
||||
let price_floor = items.iter().map(|i| i.priced.price_cents).min().unwrap_or(0);
|
||||
let price_ceil = items.iter().map(|i| i.priced.price_cents).max().unwrap_or(0);
|
||||
|
||||
// 3. Non-category filters: price band + in-stock.
|
||||
let min_c = params.min_price.as_deref().and_then(|s| parse_price_to_cents(s).ok());
|
||||
let max_c = params.max_price.as_deref().and_then(|s| parse_price_to_cents(s).ok());
|
||||
let in_stock_only = is_on(¶ms.in_stock);
|
||||
items.retain(|i| {
|
||||
min_c.is_none_or(|m| i.priced.price_cents >= m)
|
||||
&& max_c.is_none_or(|m| i.priced.price_cents <= m)
|
||||
&& (!in_stock_only || i.in_stock)
|
||||
});
|
||||
|
||||
// 4. Category facets: counts computed over the price/stock-filtered set
|
||||
// (i.e. before applying the category choice itself).
|
||||
let all_categories = categories::published(ctx).await?;
|
||||
let cat_ids: Vec<Option<i32>> = items.iter().map(|i| i.product.category_id).collect();
|
||||
let category_groups = view::admin_category_groups(&all_categories, &cat_ids);
|
||||
let uncategorized_count = cat_ids.iter().filter(|c| c.is_none()).count();
|
||||
let category_name: HashMap<i32, String> =
|
||||
all_categories.iter().map(|c| (c.id, c.name.clone())).collect();
|
||||
|
||||
// 5. Apply the category filter.
|
||||
let selected_category = params
|
||||
.category
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| "all".to_string());
|
||||
let filter = view::category_filter_ids(&all_categories, &selected_category);
|
||||
items.retain(|i| view::category_filter_keep(&filter, i.product.category_id));
|
||||
|
||||
// 6. Sort. Relevance keeps the search rank (newest when there is no query).
|
||||
let default_sort = if q_trim.is_empty() { "newest" } else { "relevance" };
|
||||
let sort = params
|
||||
.sort
|
||||
.clone()
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| default_sort.to_string());
|
||||
match sort.as_str() {
|
||||
"price_asc" => items.sort_by(|a, b| a.priced.price_cents.cmp(&b.priced.price_cents)),
|
||||
"price_desc" => items.sort_by(|a, b| b.priced.price_cents.cmp(&a.priced.price_cents)),
|
||||
"name_asc" => items.sort_by(|a, b| {
|
||||
a.product.name.to_lowercase().cmp(&b.product.name.to_lowercase())
|
||||
}),
|
||||
"name_desc" => items.sort_by(|a, b| {
|
||||
b.product.name.to_lowercase().cmp(&a.product.name.to_lowercase())
|
||||
}),
|
||||
"newest" => items.sort_by(|a, b| b.product.published_at.cmp(&a.product.published_at)),
|
||||
// "relevance" and anything unknown: original search rank.
|
||||
_ => items.sort_by_key(|i| i.rank),
|
||||
}
|
||||
|
||||
// 7. Paginate.
|
||||
let total = items.len();
|
||||
let pages = total.div_ceil(PER_PAGE).max(1);
|
||||
let page = params.page.unwrap_or(1).clamp(1, pages as u32);
|
||||
let start = (page as usize - 1) * PER_PAGE;
|
||||
|
||||
// 8. Render only the current page's cards (images fetched per row).
|
||||
let mut rows = Vec::new();
|
||||
for item in items.iter().skip(start).take(PER_PAGE) {
|
||||
let image = product_images::first_for(ctx, item.product.id).await?;
|
||||
let cat_name = item.product.category_id.and_then(|id| category_name.get(&id).cloned());
|
||||
rows.push(view::product_card(
|
||||
&item.product,
|
||||
&item.rep,
|
||||
&item.priced,
|
||||
item.count,
|
||||
image,
|
||||
cat_name,
|
||||
));
|
||||
}
|
||||
|
||||
Ok(json!({
|
||||
"products": rows,
|
||||
"query": q,
|
||||
"category_groups": category_groups,
|
||||
"selected_category": selected_category,
|
||||
// Numeric form so the <select> can mark the active option (Tera can't
|
||||
// compare a string param against a numeric category id).
|
||||
"selected_category_id": selected_category.parse::<i32>().unwrap_or(-1),
|
||||
"uncategorized_count": uncategorized_count,
|
||||
"sort": sort,
|
||||
"in_stock": in_stock_only,
|
||||
"min_price": params.min_price.clone().unwrap_or_default(),
|
||||
"max_price": params.max_price.clone().unwrap_or_default(),
|
||||
"price_floor": format_price(price_floor),
|
||||
"price_ceil": format_price(price_ceil),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pages": pages,
|
||||
"has_prev": page > 1,
|
||||
"has_next": (page as usize) < pages,
|
||||
"prev_page": page.saturating_sub(1).max(1),
|
||||
"next_page": page + 1,
|
||||
"query_base": query_base(params),
|
||||
}))
|
||||
}
|
||||
|
||||
/// Shape a list of products into card rows for `user` (None = public). Each card
|
||||
@@ -90,39 +301,36 @@ async fn category_sidebar(
|
||||
)
|
||||
}
|
||||
|
||||
/// Fold the page chrome (login state, names) and language into a `run_search`
|
||||
/// context so the full page can render the layout.
|
||||
fn add_chrome(ctx_value: &mut serde_json::Value, c: &guard::Chrome, lang: &str) {
|
||||
if let Some(map) = ctx_value.as_object_mut() {
|
||||
map.insert("logged_in_admin".into(), json!(c.logged_in_admin));
|
||||
map.insert("logged_in_customer".into(), json!(c.logged_in_customer));
|
||||
map.insert("customer_name".into(), json!(c.customer_name));
|
||||
map.insert("customer_account_type".into(), json!(c.customer_account_type));
|
||||
map.insert("lang".into(), json!(lang));
|
||||
}
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
async fn index(
|
||||
jar: CookieJar,
|
||||
ViewEngine(v): ViewEngine<TeraView>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let list = products::Entity::find()
|
||||
.filter(products::Column::Published.eq(true))
|
||||
.order_by_desc(products::Column::PublishedAt)
|
||||
.all(&ctx.db)
|
||||
.await?;
|
||||
|
||||
let user = guard::current_user(&ctx, &jar).await;
|
||||
let mut context = run_search(&ctx, user.as_ref(), &SearchParams::default()).await?;
|
||||
let c = guard::chrome_from(&ctx, user.as_ref());
|
||||
format::view(
|
||||
&v,
|
||||
"shop/index.html",
|
||||
json!({
|
||||
"products": product_rows(&ctx, user.as_ref(), list).await?,
|
||||
"query": "",
|
||||
"logged_in_admin": c.logged_in_admin,
|
||||
"logged_in_customer": c.logged_in_customer,
|
||||
"customer_name": c.customer_name,
|
||||
"customer_account_type": c.customer_account_type,
|
||||
"lang": current_lang(&jar),
|
||||
}),
|
||||
)
|
||||
add_chrome(&mut context, &c, ¤t_lang(&jar));
|
||||
format::view(&v, "shop/index.html", context)
|
||||
}
|
||||
|
||||
/// Storefront search. Reuses the shop listing's card shaping, ranking results by
|
||||
/// the hybrid full-text + fuzzy query in [`products::Entity::search`]. A blank
|
||||
/// query falls back to the full published listing (so clearing the box restores
|
||||
/// the shop). htmx requests get just the results fragment for live updates;
|
||||
/// Storefront search + faceted browse. Combines the hybrid full-text/fuzzy query
|
||||
/// ([`products::Entity::search`]) with category, price-band, in-stock and sort
|
||||
/// filters, ranked and paginated by [`run_search`]. A blank query falls back to
|
||||
/// the full published listing, so the same endpoint powers both "browse" and
|
||||
/// "search". htmx requests get just the results fragment (for live updates);
|
||||
/// direct navigation (or no-JS) renders the whole page.
|
||||
#[debug_handler]
|
||||
async fn search(
|
||||
@@ -132,44 +340,20 @@ async fn search(
|
||||
Query(params): Query<SearchParams>,
|
||||
State(ctx): State<AppContext>,
|
||||
) -> Result<Response> {
|
||||
let q = params.q.unwrap_or_default();
|
||||
let trimmed = q.trim();
|
||||
let list = if trimmed.is_empty() {
|
||||
products::Entity::find()
|
||||
.filter(products::Column::Published.eq(true))
|
||||
.order_by_desc(products::Column::PublishedAt)
|
||||
.all(&ctx.db)
|
||||
.await?
|
||||
} else {
|
||||
products::Entity::search(&ctx.db, trimmed).await?
|
||||
};
|
||||
|
||||
let user = guard::current_user(&ctx, &jar).await;
|
||||
let rows = product_rows(&ctx, user.as_ref(), list).await?;
|
||||
let mut context = run_search(&ctx, user.as_ref(), ¶ms).await?;
|
||||
let lang = current_lang(&jar);
|
||||
|
||||
if headers.contains_key("HX-Request") {
|
||||
return format::view(
|
||||
&v,
|
||||
"shop/_results.html",
|
||||
json!({ "products": rows, "query": q, "lang": lang }),
|
||||
);
|
||||
if let Some(map) = context.as_object_mut() {
|
||||
map.insert("lang".into(), json!(lang));
|
||||
}
|
||||
return format::view(&v, "shop/_results.html", context);
|
||||
}
|
||||
|
||||
let c = guard::chrome_from(&ctx, user.as_ref());
|
||||
format::view(
|
||||
&v,
|
||||
"shop/index.html",
|
||||
json!({
|
||||
"products": rows,
|
||||
"query": q,
|
||||
"logged_in_admin": c.logged_in_admin,
|
||||
"logged_in_customer": c.logged_in_customer,
|
||||
"customer_name": c.customer_name,
|
||||
"customer_account_type": c.customer_account_type,
|
||||
"lang": lang,
|
||||
}),
|
||||
)
|
||||
add_chrome(&mut context, &c, &lang);
|
||||
format::view(&v, "shop/index.html", context)
|
||||
}
|
||||
|
||||
#[debug_handler]
|
||||
|
||||
@@ -37,7 +37,11 @@ impl Entity {
|
||||
/// `f_unaccent`, so diacritics never matter. Results are ranked by full-text
|
||||
/// rank, then trigram closeness of the name, then recency. An empty/blank
|
||||
/// query returns nothing — callers fall back to the plain listing.
|
||||
pub async fn search<C: ConnectionTrait>(db: &C, query: &str) -> Result<Vec<Model>, DbErr> {
|
||||
pub async fn search<C: ConnectionTrait>(
|
||||
db: &C,
|
||||
query: &str,
|
||||
limit: u64,
|
||||
) -> Result<Vec<Model>, DbErr> {
|
||||
let q = query.trim();
|
||||
if q.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
@@ -45,7 +49,7 @@ impl Entity {
|
||||
|
||||
// Only the model's own columns are selected; the generated `search_vector`
|
||||
// is left out so the row maps cleanly back onto `Model`. `$1` is reused
|
||||
// for every occurrence of the query term.
|
||||
// for every occurrence of the query term; `$2` caps the result set.
|
||||
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
|
||||
@@ -60,14 +64,14 @@ impl Entity {
|
||||
ts_rank(p.search_vector, websearch_to_tsquery('sk_unaccent', $1)) DESC,
|
||||
word_similarity(f_unaccent($1), f_unaccent(p.name)) DESC,
|
||||
p.published_at DESC NULLS LAST
|
||||
LIMIT 60
|
||||
LIMIT $2
|
||||
"#;
|
||||
|
||||
Entity::find()
|
||||
.from_raw_sql(Statement::from_sql_and_values(
|
||||
db.get_database_backend(),
|
||||
sql,
|
||||
[q.into()],
|
||||
[q.into(), (limit as i64).into()],
|
||||
))
|
||||
.all(db)
|
||||
.await
|
||||
|
||||
Reference in New Issue
Block a user