//! Public storefront: product listings, product detail, category pages and the //! lazily-loaded category sidebar. use std::collections::HashMap; use axum::extract::Query; use axum::http::HeaderMap; use axum_extra::extract::cookie::CookieJar; use loco_rs::prelude::*; use sea_orm::{ColumnTrait, EntityTrait, QueryFilter, QueryOrder, QuerySelect, Set}; use serde_json::json; use crate::{ controllers::i18n::current_lang, shared::{ guard, money::{format_price, parse_price_to_cents}, pricing, }, models::{categories, product_images, product_variants, products, users}, views::shop as view, }; /// Results per page in the storefront listing/search. const PER_PAGE: usize = 24; /// Hard cap on candidates a single text search considers before faceting; well /// above any realistic page of results for this catalog. const SEARCH_CAP: u64 = 1000; /// All storefront listing controls: free-text query, category, price band, /// stock, sort and page. Everything is optional so `/shop` and `/search` share /// one shape. #[derive(Debug, Default, serde::Deserialize)] struct SearchParams { q: Option, category: Option, min_price: Option, max_price: Option, in_stock: Option, sort: Option, page: Option, } /// 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) -> 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 { 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 = if q_trim.is_empty() { products::Entity::find() .filter(products::Column::Published.eq(true)) .order_by_desc(products::Column::PublishedAt) .all(&ctx.db) .await? } else { products::Entity::search(&ctx.db, &q_trim, SEARCH_CAP, true).await? }; // 2. Attach representative variant + resolved price to each (drop products // with no purchasable variant). let ids: Vec = 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 = staged.iter().map(|(_, r, _, _)| r.clone()).collect(); let priced = pricing::price_variants(ctx, &reps, user).await?; let mut items: Vec = 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> = 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 = all_categories.iter().map(|c| (c.id, c.name.clone())).collect(); // 5. Apply the category filter. let selected_category = params .category .clone() .filter(|s| !s.is_empty()) .unwrap_or_else(|| "all".to_string()); let filter = view::category_filter_ids(&all_categories, &selected_category); items.retain(|i| view::category_filter_keep(&filter, i.product.category_id)); // 6. Sort. Newest-first is the default; relevance (the ranked search order) // is available explicitly via the sort control. let sort = params .sort .clone() .filter(|s| !s.is_empty()) .unwrap_or_else(|| "newest".to_string()); match sort.as_str() { "price_asc" => items.sort_by(|a, b| a.priced.price_cents.cmp(&b.priced.price_cents)), "price_desc" => items.sort_by(|a, b| b.priced.price_cents.cmp(&a.priced.price_cents)), "name_asc" => items.sort_by(|a, b| { a.product.name.to_lowercase().cmp(&b.product.name.to_lowercase()) }), "name_desc" => items.sort_by(|a, b| { b.product.name.to_lowercase().cmp(&a.product.name.to_lowercase()) }), "newest" => items.sort_by(|a, b| b.product.published_at.cmp(&a.product.published_at)), // "relevance" and anything unknown: original search rank. _ => items.sort_by_key(|i| i.rank), } // 7. Paginate. let total = items.len(); let pages = total.div_ceil(PER_PAGE).max(1); let page = params.page.unwrap_or(1).clamp(1, pages as u32); let start = (page as usize - 1) * PER_PAGE; // 8. Render only the current page's cards (images fetched per row). let mut rows = Vec::new(); for item in items.iter().skip(start).take(PER_PAGE) { let image = product_images::first_for(ctx, item.product.id).await?; let cat_name = item.product.category_id.and_then(|id| category_name.get(&id).cloned()); rows.push(view::product_card( &item.product, &item.rep, &item.priced, item.count, image, cat_name, )); } Ok(json!({ "products": rows, "query": q, "category_groups": category_groups, "selected_category": selected_category, // Numeric form so the