//! 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, category_name: Option, 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::>(), }) } /// 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 { 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 = 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=`) 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], ) -> Vec { use std::collections::HashMap; let mut direct: HashMap = 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 = 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> { match selected { "all" => None, "none" => Some(std::collections::HashSet::new()), s => s.parse::().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>, category_id: Option, ) -> 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)), } }