search implement
Some checks failed
CI / Check Style (push) Has been cancelled
CI / Run Clippy (push) Has been cancelled
CI / Run Tests (push) Has been cancelled

This commit is contained in:
Priec
2026-06-22 20:37:06 +02:00
parent e5cac27010
commit 3b9c2f7d64
12 changed files with 498 additions and 11 deletions

View File

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