Files
FrontOffice/src/FrontOffice.Main/Utilities/ProductService.cs
T
masoodafar-web bed1ad7c66
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
feat: lazy load products in both stores (infinite scroll)
- ProductService: add GetProductsPagedAsync with PaginationState support
- Store/Products: replace load-all with page-by-page lazy loading (12 per page)
- DiscountStore/Products: replace pagination with lazy load (load more button)
- Both stores show 'load more' button + loading indicator + 'all shown' text
2026-02-18 01:15:48 +03:30

306 lines
9.2 KiB
C#
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.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using CMSMicroservice.Protobuf.Protos.Products;
using Google.Protobuf.WellKnownTypes;
namespace FrontOffice.Main.Utilities;
public record Product(
long Id,
string Title,
string Description,
string FullInformation,
string ImageUrl,
long Price,
int Discount = 1,
int Rate = 0,
int RemainingCount = 0)
{
public IReadOnlyList<ProductGalleryImage> Gallery { get; init; }
= [];
public IReadOnlyList<ProductCategoryPathInfo> Categories { get; init; }
= [];
}
public record ProductListResult(
List<Product> Products,
int TotalCount,
int TotalPages,
int CurrentPage,
bool HasNext);
public record ProductGalleryImage(
long ProductGalleryId,
long ProductImageId,
string Title,
string ImageUrl,
string ThumbnailUrl);
public record ProductCategoryNodeInfo(
long Id,
string Title,
long? ParentId);
public record ProductCategoryPathInfo(
long CategoryId,
string Title,
IReadOnlyList<ProductCategoryNodeInfo> Nodes)
{
public string DisplayLabel => string.Join(" ", Nodes.Select(node => node.Title));
public ProductCategoryNodeInfo Leaf => Nodes.Last();
}
public class ProductService
{
private readonly ConcurrentDictionary<long, CacheEntry> _cache = new();
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(1);
private readonly CMSMicroservice.Protobuf.Protos.Products.ProductsContract.ProductsContractClient _client;
public ProductService(CMSMicroservice.Protobuf.Protos.Products.ProductsContract.ProductsContractClient client)
{
_client = client;
}
public async Task<List<Product>> GetProductsAsync(string? query = null, long? categoryId = null, string? sortBy = null)
{
var result = await GetProductsPagedAsync(query: query, categoryId: categoryId, sortBy: sortBy, page: 1, pageSize: 500);
return result.Products;
}
public async Task<ProductListResult> GetProductsPagedAsync(
string? query = null, long? categoryId = null, string? sortBy = null,
int page = 1, int pageSize = 12)
{
try
{
var request = new GetAllProductsByFilterRequest
{
PaginationState = new CMSMicroservice.Protobuf.Protos.PaginationState
{
PageNumber = page,
PageSize = pageSize
},
Filter = new GetAllProductsByFilterFilter
{
Title = query ?? string.Empty,
Description = query ?? string.Empty,
ShortInfomation = query ?? string.Empty,
FullInformation = query ?? string.Empty
}
};
if (categoryId is { } value)
{
request.Filter.CategoryId = value;
}
if (!string.IsNullOrEmpty(sortBy))
{
request.SortBy = sortBy;
}
var resp = await _client.GetAllProductsByFilterAsync(request);
var products = MapAndCache(resp.Models);
var totalCount = (int)(resp.MetaData?.TotalCount ?? 0);
var totalPages = (int)(resp.MetaData?.TotalPage ?? 0);
var hasNext = resp.MetaData?.HasNext ?? false;
return new ProductListResult(products, totalCount, totalPages, page, hasNext);
}
catch
{
IEnumerable<Product> list = GetValidCachedProducts();
if (!string.IsNullOrWhiteSpace(query))
{
var q = query.Trim();
list = list.Where(p =>
p.Title.Contains(q, StringComparison.OrdinalIgnoreCase) ||
p.Description.Contains(q, StringComparison.OrdinalIgnoreCase));
}
var all = list.OrderBy(p => p.Id).ToList();
return new ProductListResult(all, all.Count, 1, 1, false);
}
}
public async Task<Product?> GetByIdAsync(long id)
{
if (TryGetCachedProduct(id, out var cached) && HasDetailedData(cached))
{
return cached;
}
try
{
var resp = await _client.GetProductsAsync(new GetProductsRequest { Id = id });
if (resp == null)
{
return null;
}
return MapAndCache(resp);
}
catch
{
if (cached is not null)
{
return cached;
}
TryGetCachedProduct(id, out var result);
return result;
}
}
private List<Product> MapAndCache(
Google.Protobuf.Collections.RepeatedField<GetAllProductsByFilterResponseModel> models)
{
var list = new List<Product>();
foreach (var m in models)
{
var p = new Product(
Id: m.Id,
Title: m.Title ?? string.Empty,
Description: m.Description ?? string.Empty,
FullInformation: m.FullInformation ?? string.Empty,
ImageUrl: m.ImagePath,
Price: m.Price,
Discount: m.Discount,
Rate: m.Rate,
RemainingCount: m.RemainingCount
);
p = PreserveCachedDetails(p);
CacheProduct(p);
list.Add(p);
}
return list;
}
private Product PreserveCachedDetails(Product product)
{
if (_cache.TryGetValue(product.Id, out var entry))
{
var cached = entry.Product;
var hasGallery = cached.Gallery.Count > 0;
var hasCategories = cached.Categories.Count > 0;
if (hasGallery || hasCategories)
{
product = product with
{
Gallery = hasGallery ? cached.Gallery : product.Gallery,
Categories = hasCategories ? cached.Categories : product.Categories
};
}
}
return product;
}
private Product MapAndCache(GetProductsResponse model)
{
var gallery = model.Gallery
.Select(item => new ProductGalleryImage(
ProductGalleryId: item.ProductGalleryId,
ProductImageId: item.ProductImageId,
Title: item.Title ?? string.Empty,
ImageUrl: BuildUrl(item.ImagePath),
ThumbnailUrl: BuildUrl(item.ImageThumbnailPath)))
.ToList();
var product = new Product(
Id: model.Id,
Title: model.Title ?? string.Empty,
Description: model.Description ?? string.Empty,
FullInformation: model.FullInformation ?? string.Empty,
ImageUrl: BuildUrl(model.ImagePath),
Price: model.Price,
Discount: model.Discount,
Rate: model.Rate,
RemainingCount: model.RemainingCount)
{
Gallery = gallery,
Categories = MapCategoryPaths(model.Categories)
};
CacheProduct(product);
return product;
}
private void CacheProduct(Product product)
{
var entry = new CacheEntry(product, DateTime.UtcNow.Add(CacheDuration));
_cache.AddOrUpdate(product.Id, entry, (_, _) => entry);
}
private bool TryGetCachedProduct(long id, [NotNullWhen(true)] out Product? product)
{
if (_cache.TryGetValue(id, out var entry))
{
if (entry.Expiration > DateTime.UtcNow)
{
product = entry.Product;
return true;
}
_cache.TryRemove(id, out _);
}
product = null;
return false;
}
private IEnumerable<Product> GetValidCachedProducts()
{
var now = DateTime.UtcNow;
return _cache.Values
.Where(entry => entry.Expiration > now)
.Select(entry => entry.Product);
}
private static bool HasDetailedData(Product product)
=> product.Gallery.Count > 0 || product.Categories.Count > 0;
private sealed record CacheEntry(Product Product, DateTime Expiration);
private static string BuildUrl(string? path) =>
path ?? string.Empty;
private static IReadOnlyList<ProductCategoryPathInfo> MapCategoryPaths(IEnumerable<ProductCategoryPath>? categories)
{
if (categories is null)
{
return Array.Empty<ProductCategoryPathInfo>();
}
var result = new List<ProductCategoryPathInfo>();
foreach (var category in categories)
{
var nodes = category.Path
.Select(node => new ProductCategoryNodeInfo(
Id: node.Id,
Title: node.Title ?? string.Empty,
ParentId: node.ParentId
))
.ToList();
if (nodes.Count == 0)
{
continue;
}
result.Add(new ProductCategoryPathInfo(
CategoryId: category.CategoryId,
Title: category.Title ?? string.Empty,
Nodes: nodes));
}
return result;
}
}