Files
kompress_eshop/src/views/shop.rs
2026-06-25 12:13:30 +02:00

199 lines
7.6 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! JSON shaping for storefront and catalog-admin templates.
use serde_json::{json, Value};
use crate::models::_entities::{categories, product_images, product_variants, products};
use crate::shared::currency::Currency;
use crate::shared::pricing::PricedProduct;
/// Card/list shape for a product: model fields plus the viewer's resolved price
/// for its representative (first) variant, the variant count (so the template can
/// render "from {price}" for multi-variant products), its optional primary image
/// and category. `on_sale` means "render the price as reduced" — driven by the
/// resolved price, so it covers both public sales and business deals;
/// `is_business` flags the latter. `representative` is the variant whose price is
/// shown and whose stock/sku the card exposes.
pub fn product_card(
product: &products::Model,
representative: &product_variants::Model,
priced: &PricedProduct,
variant_count: usize,
image: Option<String>,
category_name: Option<String>,
cur: &Currency,
) -> Value {
// Whole-percent discount for the card's sale badge (e.g. "15 %"). Only
// meaningful when the resolved price is actually reduced below the regular.
let percent_off = if priced.is_reduced() && priced.regular_cents > priced.price_cents {
(((priced.regular_cents - priced.price_cents) as f64 / priced.regular_cents as f64) * 100.0)
.round() as i64
} else {
0
};
json!({
"id": product.id,
"variant_id": representative.id,
"name": product.name,
"slug": product.slug,
"description": product.description,
"short_description": product.short_description,
"price": cur.format(priced.price_cents),
"on_sale": priced.is_reduced(),
"percent_off": percent_off,
"is_business": priced.is_business,
"regular_price": cur.format(priced.regular_cents),
"sku": representative.sku,
"stock": representative.stock,
"tracked": representative.tracked(),
"in_stock": representative.in_stock(),
"variant_count": variant_count,
"has_options": variant_count > 1,
"published": product.published,
"image": image,
"category_name": category_name,
})
}
/// One priced variant row for the product detail page's option picker.
pub fn variant_option(
variant: &product_variants::Model,
priced: &PricedProduct,
cur: &Currency,
) -> Value {
json!({
"id": variant.id,
"label": variant.label,
"sku": variant.sku,
"stock": variant.stock,
"tracked": variant.tracked(),
"in_stock": variant.in_stock(),
"price": cur.format(priced.price_cents),
"on_sale": priced.is_reduced(),
"regular_price": cur.format(priced.regular_cents),
"is_business": priced.is_business,
})
}
/// Shape used to pre-fill the admin product form (exposes `category_id` rather
/// than a resolved name, and the current images in display order — first is the
/// main one). Variants are supplied separately by the controller.
pub fn product_form(product: &products::Model, images: &[product_images::Model]) -> Value {
json!({
"id": product.id,
"name": product.name,
"slug": product.slug,
"description": product.description,
"short_description": product.short_description,
"published": product.published,
"category_id": product.category_id,
"images": images
.iter()
.map(|im| json!({ "id": im.id, "image_id": im.image_id }))
.collect::<Vec<_>>(),
})
}
/// Two-level grouping for the storefront sidebar menu: each top-level category
/// as `{ name, slug, children: [{ name, slug }] }`, with its direct
/// subcategories nested under `children` (empty when the category has none).
/// Siblings are ordered by position then name on both levels.
pub fn sidebar_groups(categories: &[categories::Model]) -> Vec<Value> {
let mut top: Vec<&categories::Model> = categories
.iter()
.filter(|c| c.parent_id.is_none())
.collect();
top.sort_by(|a, b| a.position.cmp(&b.position).then_with(|| a.name.cmp(&b.name)));
top.into_iter()
.map(|category| {
let children: Vec<Value> = crate::models::categories::children_of(categories, category.id)
.into_iter()
.map(|child| json!({ "name": child.name, "slug": child.slug }))
.collect();
json!({ "name": category.name, "slug": category.slug, "children": children })
})
.collect()
}
/// Admin category-filter tree for product listings: like [`sidebar_groups`] but
/// each node carries its `id` (links filter by `?category=<id>`) and a `count`
/// of matching products — the node's own products plus every descendant's, so a
/// parent's count covers its whole subtree. `category_ids` is each product's
/// `category_id` (`None` = uncategorized), taken over the full unfiltered list.
pub fn admin_category_groups(
categories: &[categories::Model],
category_ids: &[Option<i32>],
) -> Vec<Value> {
use std::collections::HashMap;
let mut direct: HashMap<i32, usize> = HashMap::new();
for id in category_ids.iter().flatten() {
*direct.entry(*id).or_default() += 1;
}
let subtree_count = |id: i32| -> usize {
let mut n = direct.get(&id).copied().unwrap_or(0);
for d in crate::models::categories::descendant_ids(categories, id) {
n += direct.get(&d).copied().unwrap_or(0);
}
n
};
let mut top: Vec<&categories::Model> = categories
.iter()
.filter(|c| c.parent_id.is_none())
.collect();
top.sort_by(|a, b| a.position.cmp(&b.position).then_with(|| a.name.cmp(&b.name)));
top.into_iter()
.map(|category| {
let children: Vec<Value> = crate::models::categories::children_of(categories, category.id)
.into_iter()
.map(|child| {
json!({ "id": child.id, "name": child.name, "count": subtree_count(child.id) })
})
.collect();
json!({
"id": category.id,
"name": category.name,
"count": subtree_count(category.id),
"children": children,
})
})
.collect()
}
/// Resolve the `?category=` filter param against the category forest into the
/// set of `category_id`s a product may have to be shown. Returns:
/// - `None` for "all" (no filtering) or an unknown value,
/// - `Some(empty set)` for "none" (uncategorized — match products with no
/// category; callers treat an empty set as "uncategorized only"),
/// - `Some({id} descendants)` for a numeric category id.
pub fn category_filter_ids(
categories: &[categories::Model],
selected: &str,
) -> Option<std::collections::HashSet<i32>> {
match selected {
"all" => None,
"none" => Some(std::collections::HashSet::new()),
s => s.parse::<i32>().ok().map(|id| {
let mut set = crate::models::categories::descendant_ids(categories, id);
set.insert(id);
set
}),
}
}
/// Whether a product with `category_id` passes the filter from
/// [`category_filter_ids`]: `None` keeps everything, an empty set keeps only
/// uncategorized products, a non-empty set keeps products in those categories.
pub fn category_filter_keep(
filter: &Option<std::collections::HashSet<i32>>,
category_id: Option<i32>,
) -> bool {
match filter {
None => true,
Some(set) if set.is_empty() => category_id.is_none(),
Some(set) => category_id.is_some_and(|id| set.contains(&id)),
}
}