search implement
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
//! Public storefront: product listings, product detail, category pages and the
|
||||
//! lazily-loaded category sidebar.
|
||||
|
||||
use axum::extract::Query;
|
||||
use axum::http::HeaderMap;
|
||||
use axum_extra::extract::cookie::CookieJar;
|
||||
use loco_rs::prelude::*;
|
||||
use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set};
|
||||
@@ -13,6 +15,12 @@ use crate::{
|
||||
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
|
||||
/// shows the resolved price of the product's representative (first) variant; the
|
||||
/// `variant_count` lets the template render "from {price}" for multi-variant
|
||||
@@ -101,6 +109,7 @@ async fn index(
|
||||
"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,
|
||||
@@ -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]
|
||||
async fn show(
|
||||
jar: CookieJar,
|
||||
@@ -240,6 +302,9 @@ async fn category(
|
||||
pub fn routes() -> Routes {
|
||||
Routes::new()
|
||||
.add("/shop", get(index))
|
||||
// Top-level path (not /shop/search) so it never collides with the
|
||||
// /shop/{slug} product route.
|
||||
.add("/search", get(search))
|
||||
.add("/shop/{slug}", get(show))
|
||||
.add("/category/{slug}", get(category))
|
||||
.add("/partials/categories", get(category_sidebar))
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use sea_orm::entity::prelude::*;
|
||||
use sea_orm::{ConnectionTrait, Statement};
|
||||
pub use crate::models::_entities::products::{ActiveModel, Column, Entity, Model};
|
||||
pub type Products = Entity;
|
||||
|
||||
@@ -25,4 +26,50 @@ impl Model {}
|
||||
impl ActiveModel {}
|
||||
|
||||
// implement your custom finders, selectors oriented logic here
|
||||
impl Entity {}
|
||||
impl Entity {
|
||||
/// Published products matching a free-text query, best matches first.
|
||||
///
|
||||
/// Hybrid Postgres search. The `tsvector` full-text match covers the whole
|
||||
/// purchasable surface — name, description, tags, variant labels and SKUs
|
||||
/// (kept in sync by triggers; see the `product_search_aggregate` migration) —
|
||||
/// OR a `pg_trgm` `word_similarity` match on name/description for typo
|
||||
/// tolerance ("komprsor" still finds "kompresor"). Both sides run through
|
||||
/// `f_unaccent`, so diacritics never matter. Results are ranked by full-text
|
||||
/// rank, then trigram closeness of the name, then recency. An empty/blank
|
||||
/// query returns nothing — callers fall back to the plain listing.
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user