//! Public storefront: product listings, product detail, category pages and the //! lazily-loaded category sidebar. 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, models::{categories, product_images, products}, views::shop as view, }; /// Shape a list of products into card rows, loading each one's primary image. async fn product_rows(ctx: &AppContext, list: Vec) -> Result> { let mut rows = Vec::with_capacity(list.len()); for product in list { let image = product_images::first_for(ctx, product.id).await?; rows.push(view::product_card(&product, 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, limit: u64, ) -> Result> { 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, 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, State(ctx): State, ) -> Result { let published = categories::published(&ctx).await?; format::view( &v, "shop/_sidebar.html", json!({ "category_groups": view::sidebar_groups(&published), "lang": current_lang(&jar), }), ) } #[debug_handler] async fn index( jar: CookieJar, ViewEngine(v): ViewEngine, State(ctx): State, ) -> Result { let list = products::Entity::find() .filter(products::Column::Published.eq(true)) .order_by_desc(products::Column::PublishedAt) .all(&ctx.db) .await?; format::view( &v, "shop/index.html", json!({ "products": product_rows(&ctx, list).await?, "logged_in_admin": guard::logged_in(&ctx, &jar).await, "lang": current_lang(&jar), }), ) } #[debug_handler] async fn show( jar: CookieJar, ViewEngine(v): ViewEngine, Path(slug): Path, State(ctx): State, ) -> Result { 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, }; format::view( &v, "shop/show.html", json!({ "product": view::product_card(&product, None, category.as_ref().map(|c| c.name.clone())), "images": images.iter().map(|i| i.image_id.clone()).collect::>(), "category": category, "logged_in_admin": guard::logged_in(&ctx, &jar).await, "lang": current_lang(&jar), }), ) } #[debug_handler] async fn category( jar: CookieJar, ViewEngine(v): ViewEngine, Path(slug): Path, State(ctx): State, ) -> Result { 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); // Products listed here span this category and all of its descendants, so a // parent category is never empty just because its products live in leaves. let mut category_ids: Vec = categories::descendant_ids(&published, category.id) .into_iter() .collect(); category_ids.push(category.id); let list = products::Entity::find() .filter(products::Column::CategoryId.is_in(category_ids)) .filter(products::Column::Published.eq(true)) .order_by_desc(products::Column::PublishedAt) .all(&ctx.db) .await?; format::view( &v, "shop/category.html", json!({ "category": category, "breadcrumbs": breadcrumbs, "children": children, "products": product_rows(&ctx, list).await?, "logged_in_admin": guard::logged_in(&ctx, &jar).await, "lang": current_lang(&jar), }), ) } pub fn routes() -> Routes { Routes::new() .add("/shop", get(index)) .add("/shop/{slug}", get(show)) .add("/category/{slug}", get(category)) .add("/partials/categories", get(category_sidebar)) }