199 lines
7.6 KiB
Rust
199 lines
7.6 KiB
Rust
//! 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)),
|
||
}
|
||
}
|