Files
kompress_eshop/src/controllers/shop.rs
2026-06-17 15:11:21 +02:00

175 lines
5.4 KiB
Rust

//! 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<products::Model>) -> Result<Vec<serde_json::Value>> {
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<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, 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),
}),
)
}
#[debug_handler]
async fn index(
jar: CookieJar,
ViewEngine(v): ViewEngine<TeraView>,
State(ctx): State<AppContext>,
) -> Result<Response> {
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<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,
};
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::<Vec<_>>(),
"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<TeraView>,
Path(slug): Path<String>,
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);
// 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<i32> = 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))
}