175 lines
5.4 KiB
Rust
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))
|
|
}
|