488 lines
19 KiB
Rust
488 lines
19 KiB
Rust
//! 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<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, true).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. 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 <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
|
|
/// shows the resolved price of the product's representative (first) variant; the
|
|
/// `variant_count` lets the template render "from {price}" for multi-variant
|
|
/// products. Products with no variants are skipped (not purchasable).
|
|
async fn product_rows(
|
|
ctx: &AppContext,
|
|
user: Option<&users::Model>,
|
|
list: Vec<products::Model>,
|
|
) -> Result<Vec<serde_json::Value>> {
|
|
let ids: Vec<i32> = list.iter().map(|p| p.id).collect();
|
|
let grouped = product_variants::Entity::grouped_for_products(&ctx.db, &ids).await?;
|
|
|
|
// Representative (first) variant per product, in list order, dropping any
|
|
// product that has no variants.
|
|
let mut entries: Vec<(&products::Model, product_variants::Model, usize)> = Vec::new();
|
|
for product in &list {
|
|
if let Some(variants) = grouped.get(&product.id) {
|
|
if let Some(first) = variants.first() {
|
|
entries.push((product, first.clone(), variants.len()));
|
|
}
|
|
}
|
|
}
|
|
|
|
let reps: Vec<product_variants::Model> = entries.iter().map(|(_, v, _)| v.clone()).collect();
|
|
let priced = pricing::price_variants(ctx, &reps, user).await?;
|
|
|
|
let mut rows = Vec::with_capacity(entries.len());
|
|
for ((product, rep, count), priced) in entries.iter().zip(priced.iter()) {
|
|
let image = product_images::first_for(ctx, product.id).await?;
|
|
rows.push(view::product_card(product, rep, priced, *count, image, None));
|
|
}
|
|
Ok(rows)
|
|
}
|
|
|
|
/// Latest published products (with primary image), shaped for templates. Reused
|
|
/// by the home-page landing grid.
|
|
pub(crate) async fn featured_products(
|
|
ctx: &AppContext,
|
|
user: Option<&users::Model>,
|
|
limit: u64,
|
|
) -> Result<Vec<serde_json::Value>> {
|
|
let list = products::Entity::find()
|
|
.filter(products::Column::Published.eq(true))
|
|
.order_by_desc(products::Column::PublishedAt)
|
|
.limit(limit)
|
|
.all(&ctx.db)
|
|
.await?;
|
|
product_rows(ctx, user, list).await
|
|
}
|
|
|
|
/// The site-wide category sidebar, loaded lazily via htmx by the base layout so
|
|
/// every page gets it without each handler having to supply category data.
|
|
#[debug_handler]
|
|
async fn category_sidebar(
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
let published = categories::published(&ctx).await?;
|
|
format::view(
|
|
&v,
|
|
"shop/_sidebar.html",
|
|
json!({
|
|
"category_groups": view::sidebar_groups(&published),
|
|
"lang": current_lang(&jar),
|
|
}),
|
|
)
|
|
}
|
|
|
|
/// 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 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());
|
|
add_chrome(&mut context, &c, ¤t_lang(&jar));
|
|
format::view(&v, "shop/index.html", context)
|
|
}
|
|
|
|
/// 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(
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
headers: HeaderMap,
|
|
Query(params): Query<SearchParams>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
let user = guard::current_user(&ctx, &jar).await;
|
|
let mut context = run_search(&ctx, user.as_ref(), ¶ms).await?;
|
|
let lang = current_lang(&jar);
|
|
|
|
if headers.contains_key("HX-Request") {
|
|
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());
|
|
add_chrome(&mut context, &c, &lang);
|
|
format::view(&v, "shop/index.html", context)
|
|
}
|
|
|
|
#[debug_handler]
|
|
async fn show(
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
Path(slug): Path<String>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
let product = products::Entity::find()
|
|
.filter(products::Column::Slug.eq(slug))
|
|
.filter(products::Column::Published.eq(true))
|
|
.one(&ctx.db)
|
|
.await?
|
|
.ok_or_else(|| Error::NotFound)?;
|
|
|
|
let mut active = product.clone().into_active_model();
|
|
active.view_count = Set(product.view_count + 1);
|
|
let product = active.update(&ctx.db).await?;
|
|
|
|
let images = product_images::Entity::find()
|
|
.filter(product_images::Column::ProductId.eq(product.id))
|
|
.order_by_asc(product_images::Column::Position)
|
|
.all(&ctx.db)
|
|
.await?;
|
|
let category = match product.category_id {
|
|
Some(id) => categories::Entity::find_by_id(id).one(&ctx.db).await?,
|
|
None => None,
|
|
};
|
|
|
|
let user = guard::current_user(&ctx, &jar).await;
|
|
let variants = product_variants::Entity::for_product(&ctx.db, product.id).await?;
|
|
let variant_prices = pricing::price_variants(&ctx, &variants, user.as_ref()).await?;
|
|
let options: Vec<serde_json::Value> = variants
|
|
.iter()
|
|
.zip(variant_prices.iter())
|
|
.map(|(variant, priced)| view::variant_option(variant, priced))
|
|
.collect();
|
|
// The card header uses the representative (first) variant for its headline
|
|
// price; the picker below lets the customer switch.
|
|
let representative = variants.first();
|
|
let priced = variant_prices.first().copied();
|
|
let card = match (representative, priced) {
|
|
(Some(rep), Some(priced)) => view::product_card(
|
|
&product,
|
|
rep,
|
|
&priced,
|
|
variants.len(),
|
|
None,
|
|
category.as_ref().map(|c| c.name.clone()),
|
|
),
|
|
// A product with no variants isn't purchasable; show it without a price.
|
|
_ => serde_json::json!({
|
|
"id": product.id,
|
|
"name": product.name,
|
|
"slug": product.slug,
|
|
"description": product.description,
|
|
"currency": product.currency,
|
|
"variant_count": 0,
|
|
"has_options": false,
|
|
}),
|
|
};
|
|
let c = guard::chrome_from(&ctx, user.as_ref());
|
|
format::view(
|
|
&v,
|
|
"shop/show.html",
|
|
json!({
|
|
"product": card,
|
|
"variants": options,
|
|
"images": images.iter().map(|i| i.image_id.clone()).collect::<Vec<_>>(),
|
|
"category": category,
|
|
"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),
|
|
}),
|
|
)
|
|
}
|
|
|
|
/// Category page: the same faceted search as the shop, but with this category
|
|
/// preselected as the default filter (plus breadcrumbs and subcategory chips).
|
|
/// Any other filters/sort/query on the URL are honoured; the category itself is
|
|
/// always forced to this page's category. Interacting with the toolbar navigates
|
|
/// to `/search` (the category stays selected there too).
|
|
#[debug_handler]
|
|
async fn category(
|
|
jar: CookieJar,
|
|
ViewEngine(v): ViewEngine<TeraView>,
|
|
Path(slug): Path<String>,
|
|
Query(params): Query<SearchParams>,
|
|
State(ctx): State<AppContext>,
|
|
) -> Result<Response> {
|
|
let published = categories::published(&ctx).await?;
|
|
let category = published
|
|
.iter()
|
|
.find(|c| c.slug == slug)
|
|
.cloned()
|
|
.ok_or_else(|| Error::NotFound)?;
|
|
|
|
let breadcrumbs = categories::ancestors(&published, category.parent_id);
|
|
let children = categories::children_of(&published, category.id);
|
|
|
|
// Force the category filter to this page's category, keeping any other params.
|
|
let params = SearchParams {
|
|
category: Some(category.id.to_string()),
|
|
..params
|
|
};
|
|
|
|
let user = guard::current_user(&ctx, &jar).await;
|
|
let mut context = run_search(&ctx, user.as_ref(), ¶ms).await?;
|
|
if let Some(map) = context.as_object_mut() {
|
|
map.insert("category".into(), serde_json::to_value(&category)?);
|
|
map.insert("breadcrumbs".into(), serde_json::to_value(&breadcrumbs)?);
|
|
map.insert("children".into(), serde_json::to_value(&children)?);
|
|
}
|
|
let c = guard::chrome_from(&ctx, user.as_ref());
|
|
add_chrome(&mut context, &c, ¤t_lang(&jar));
|
|
format::view(&v, "shop/category.html", context)
|
|
}
|
|
|
|
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))
|
|
}
|