Add category browsing and product filtering with breadcrumbs
This commit is contained in:
@@ -5,6 +5,7 @@ using Grpc.Net.Client;
|
|||||||
using MudBlazor.Services;
|
using MudBlazor.Services;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
|
using FrontOffice.BFF.Category.Protobuf.Protos.Category;
|
||||||
using FrontOffice.BFF.Package.Protobuf.Protos.Package;
|
using FrontOffice.BFF.Package.Protobuf.Protos.Package;
|
||||||
using FrontOffice.BFF.Transaction.Protobuf.Protos.Transaction;
|
using FrontOffice.BFF.Transaction.Protobuf.Protos.Transaction;
|
||||||
using FrontOffice.BFF.User.Protobuf.Protos.User;
|
using FrontOffice.BFF.User.Protobuf.Protos.User;
|
||||||
@@ -42,6 +43,7 @@ public static class ConfigureServices
|
|||||||
// Storefront services
|
// Storefront services
|
||||||
services.AddScoped<CartService>();
|
services.AddScoped<CartService>();
|
||||||
services.AddScoped<ProductService>();
|
services.AddScoped<ProductService>();
|
||||||
|
services.AddScoped<CategoryService>();
|
||||||
services.AddScoped<OrderService>();
|
services.AddScoped<OrderService>();
|
||||||
services.AddScoped<WalletService>();
|
services.AddScoped<WalletService>();
|
||||||
// Device detection: very light, dependency-free
|
// Device detection: very light, dependency-free
|
||||||
@@ -78,6 +80,7 @@ public static class ConfigureServices
|
|||||||
services.AddScoped(CreateAuthenticatedClient<UserAddressContract.UserAddressContractClient>);
|
services.AddScoped(CreateAuthenticatedClient<UserAddressContract.UserAddressContractClient>);
|
||||||
services.AddScoped(CreateAuthenticatedClient<UserOrderContract.UserOrderContractClient>);
|
services.AddScoped(CreateAuthenticatedClient<UserOrderContract.UserOrderContractClient>);
|
||||||
services.AddScoped(CreateAuthenticatedClient<UserWalletContract.UserWalletContractClient>);
|
services.AddScoped(CreateAuthenticatedClient<UserWalletContract.UserWalletContractClient>);
|
||||||
|
services.AddScoped(CreateAuthenticatedClient<CategoryContract.CategoryContractClient>);
|
||||||
// Products gRPC
|
// Products gRPC
|
||||||
services.AddScoped(CreateAuthenticatedClient<FrontOffice.BFF.Products.Protobuf.Protos.Products.ProductsContract.ProductsContractClient>);
|
services.AddScoped(CreateAuthenticatedClient<FrontOffice.BFF.Products.Protobuf.Protos.Products.ProductsContract.ProductsContractClient>);
|
||||||
services.AddScoped(CreateAuthenticatedClient<TransactionContract.TransactionContractClient>);
|
services.AddScoped(CreateAuthenticatedClient<TransactionContract.TransactionContractClient>);
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="../../../FrontOffice.BFF/src/Protobufs/FrontOffice.BFF.Products.Protobuf/FrontOffice.BFF.Products.Protobuf.csproj" />
|
<ProjectReference Include="../../../FrontOffice.BFF/src/Protobufs/FrontOffice.BFF.Products.Protobuf/FrontOffice.BFF.Products.Protobuf.csproj" />
|
||||||
|
<ProjectReference Include="../../../FrontOffice.BFF/src/Protobufs/FrontOffice.BFF.Category.Protobuf/FrontOffice.BFF.Category.Protobuf.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
66
src/FrontOffice.Main/Pages/Store/Categories.razor
Normal file
66
src/FrontOffice.Main/Pages/Store/Categories.razor
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
@page "/categories"
|
||||||
|
@using MudBlazor
|
||||||
|
@* Project-level imports provide Navigation, services, and MudBlazor *@
|
||||||
|
|
||||||
|
<PageTitle>دستهبندیها</PageTitle>
|
||||||
|
|
||||||
|
<MudContainer MaxWidth="MaxWidth.Large" Class="py-6">
|
||||||
|
<MudPaper Elevation="1" Class="pa-4 mb-4 rounded-lg">
|
||||||
|
<MudStack Spacing="1">
|
||||||
|
<MudText Typo="Typo.h5">دستهبندی محصولات</MudText>
|
||||||
|
<MudText Typo="Typo.body2" Class="mud-text-secondary">
|
||||||
|
از بین درخت دستهبندیها انتخاب کنید تا محصولات آن دسته را مشاهده نمایید.
|
||||||
|
</MudText>
|
||||||
|
</MudStack>
|
||||||
|
<MudStack Row="true" Class="mt-4" AlignItems="AlignItems.Center" Spacing="1">
|
||||||
|
<MudButton Variant="Variant.Text" Color="Color.Secondary" StartIcon="@Icons.Material.Filled.ArrowBack"
|
||||||
|
OnClick="() => Navigation.NavigateTo(RouteConstants.Store.Products)">
|
||||||
|
بازگشت به محصولات
|
||||||
|
</MudButton>
|
||||||
|
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Href="@RouteConstants.Store.Products">
|
||||||
|
مشاهده محصولات بدون فیلتر
|
||||||
|
</MudButton>
|
||||||
|
</MudStack>
|
||||||
|
</MudPaper>
|
||||||
|
|
||||||
|
<MudPaper Elevation="1" Class="pa-4 rounded-lg">
|
||||||
|
@if (_loading)
|
||||||
|
{
|
||||||
|
<MudStack AlignItems="AlignItems.Center">
|
||||||
|
<MudProgressCircular Indeterminate="true" Color="Color.Primary" />
|
||||||
|
<MudText Class="mt-2 mud-text-secondary">در حال بارگذاری دستهبندیها...</MudText>
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
|
else if (_rows.Count == 0)
|
||||||
|
{
|
||||||
|
<MudAlert Severity="Severity.Info">هنوز دستهبندیای ثبت نشده است.</MudAlert>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<MudTable Items="_rows" Elevation="0" Bordered="true" Hover="true">
|
||||||
|
<HeaderContent>
|
||||||
|
<MudTh>عنوان</MudTh>
|
||||||
|
<MudTh>مسیر دستهبندی</MudTh>
|
||||||
|
<MudTh>اقدامات</MudTh>
|
||||||
|
</HeaderContent>
|
||||||
|
<RowTemplate>
|
||||||
|
<MudTd>
|
||||||
|
<MudStack Row="true" AlignItems="AlignItems.Center" Class="d-flex" Style="@($"padding-left:{context.Depth * 1.5}rem")">
|
||||||
|
<MudText Typo="Typo.subtitle2">@context.Category.Title</MudText>
|
||||||
|
@if (!string.IsNullOrWhiteSpace(context.Category.ImagePath))
|
||||||
|
{
|
||||||
|
<MudAvatar Size="Size.Small" Image="@context.Category.ImagePath" Class="me-2" />
|
||||||
|
}
|
||||||
|
</MudStack>
|
||||||
|
</MudTd>
|
||||||
|
<MudTd>@context.Path</MudTd>
|
||||||
|
<MudTd>
|
||||||
|
<MudButton Variant="Variant.Text" Color="Color.Primary" OnClick="() => NavigateToCategory(context.Category.Id)">
|
||||||
|
مشاهده محصولات
|
||||||
|
</MudButton>
|
||||||
|
</MudTd>
|
||||||
|
</RowTemplate>
|
||||||
|
</MudTable>
|
||||||
|
}
|
||||||
|
</MudPaper>
|
||||||
|
</MudContainer>
|
||||||
67
src/FrontOffice.Main/Pages/Store/Categories.razor.cs
Normal file
67
src/FrontOffice.Main/Pages/Store/Categories.razor.cs
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using FrontOffice.Main.Utilities;
|
||||||
|
|
||||||
|
namespace FrontOffice.Main.Pages.Store;
|
||||||
|
|
||||||
|
public partial class Categories : ComponentBase
|
||||||
|
{
|
||||||
|
[Inject] private CategoryService CategoryService { get; set; } = default!;
|
||||||
|
|
||||||
|
private bool _loading;
|
||||||
|
private List<CategoryItem> _items = new();
|
||||||
|
private List<CategoryRow> _rows = new();
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
_loading = true;
|
||||||
|
_items = await CategoryService.GetAllAsync();
|
||||||
|
BuildRows();
|
||||||
|
_loading = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void BuildRows()
|
||||||
|
{
|
||||||
|
var lookup = _items.ToDictionary(item => item.Id);
|
||||||
|
var rows = new List<CategoryRow>();
|
||||||
|
foreach (var item in _items)
|
||||||
|
{
|
||||||
|
var path = BuildPath(item, lookup);
|
||||||
|
var pathLabel = path.Count > 0 ? string.Join(" › ", path.Select(x => x.Title)) : item.Title;
|
||||||
|
rows.Add(new CategoryRow(item, pathLabel, Math.Max(0, path.Count - 1)));
|
||||||
|
}
|
||||||
|
|
||||||
|
_rows = rows.OrderBy(row => row.Depth).ThenBy(row => row.Category.Title).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<CategoryItem> BuildPath(CategoryItem category, IReadOnlyDictionary<long, CategoryItem> lookup)
|
||||||
|
{
|
||||||
|
var path = new List<CategoryItem>();
|
||||||
|
var current = category;
|
||||||
|
var visited = new HashSet<long>();
|
||||||
|
while (current is not null && visited.Add(current.Id))
|
||||||
|
{
|
||||||
|
path.Add(current);
|
||||||
|
if (current.ParentId is { } parentId && lookup.TryGetValue(parentId, out var parent))
|
||||||
|
{
|
||||||
|
current = parent;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
path.Reverse();
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NavigateToCategory(long categoryId)
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo($"{RouteConstants.Store.Products}?category={categoryId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record CategoryRow(CategoryItem Category, string Path, int Depth);
|
||||||
|
}
|
||||||
@@ -23,6 +23,17 @@ else if (_product is null)
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
<MudContainer MaxWidth="MaxWidth.Large" Class="py-6">
|
<MudContainer MaxWidth="MaxWidth.Large" Class="py-6">
|
||||||
|
@if (_product is not null)
|
||||||
|
{
|
||||||
|
<MudBreadcrumbs SeparatorIcon="@Icons.Material.Filled.ChevronRight" Class="mb-4">
|
||||||
|
<MudBreadcrumbItem Href="@RouteConstants.Main.MainPage">دیجی کالا</MudBreadcrumbItem>
|
||||||
|
@foreach (var node in _breadcrumbNodes)
|
||||||
|
{
|
||||||
|
<MudBreadcrumbItem Href="@($"{RouteConstants.Store.Products}?category={node.Id}")">@node.Title</MudBreadcrumbItem>
|
||||||
|
}
|
||||||
|
<MudBreadcrumbItem Disabled="true">@_product.Title</MudBreadcrumbItem>
|
||||||
|
</MudBreadcrumbs>
|
||||||
|
}
|
||||||
<MudGrid Spacing="3">
|
<MudGrid Spacing="3">
|
||||||
<MudItem xs="12" md="6">
|
<MudItem xs="12" md="6">
|
||||||
<MudPaper Class="rounded-lg pa-2">
|
<MudPaper Class="rounded-lg pa-2">
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ public partial class ProductDetail : ComponentBase, IDisposable
|
|||||||
private const int MaxQty = 20;
|
private const int MaxQty = 20;
|
||||||
private IReadOnlyList<ProductGalleryImage> _galleryItems = Array.Empty<ProductGalleryImage>();
|
private IReadOnlyList<ProductGalleryImage> _galleryItems = Array.Empty<ProductGalleryImage>();
|
||||||
private IReadOnlyList<ProductCategoryPathInfo> _categoryPaths = Array.Empty<ProductCategoryPathInfo>();
|
private IReadOnlyList<ProductCategoryPathInfo> _categoryPaths = Array.Empty<ProductCategoryPathInfo>();
|
||||||
|
private IReadOnlyList<ProductCategoryNodeInfo> _breadcrumbNodes = Array.Empty<ProductCategoryNodeInfo>();
|
||||||
private ProductGalleryImage? _selectedGalleryImage;
|
private ProductGalleryImage? _selectedGalleryImage;
|
||||||
private long TotalPrice => (_product?.Price ?? 0) * _qty;
|
private long TotalPrice => (_product?.Price ?? 0) * _qty;
|
||||||
private bool HasDiscount => _product is { Discount: > 0 and < 100 };
|
private bool HasDiscount => _product is { Discount: > 0 and < 100 };
|
||||||
@@ -52,12 +53,14 @@ public partial class ProductDetail : ComponentBase, IDisposable
|
|||||||
_galleryItems = BuildGalleryItems(_product);
|
_galleryItems = BuildGalleryItems(_product);
|
||||||
_selectedGalleryImage = _galleryItems.FirstOrDefault();
|
_selectedGalleryImage = _galleryItems.FirstOrDefault();
|
||||||
_categoryPaths = _product.Categories;
|
_categoryPaths = _product.Categories;
|
||||||
|
UpdateBreadcrumb();
|
||||||
_qty = Math.Clamp(CurrentCartItem?.Quantity ?? _qty, MinQty, MaxQty);
|
_qty = Math.Clamp(CurrentCartItem?.Quantity ?? _qty, MinQty, MaxQty);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_galleryItems = Array.Empty<ProductGalleryImage>();
|
_galleryItems = Array.Empty<ProductGalleryImage>();
|
||||||
_categoryPaths = Array.Empty<ProductCategoryPathInfo>();
|
_categoryPaths = Array.Empty<ProductCategoryPathInfo>();
|
||||||
|
_breadcrumbNodes = Array.Empty<ProductCategoryNodeInfo>();
|
||||||
}
|
}
|
||||||
|
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
@@ -172,6 +175,13 @@ public partial class ProductDetail : ComponentBase, IDisposable
|
|||||||
_selectedGalleryImage = item;
|
_selectedGalleryImage = item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void UpdateBreadcrumb()
|
||||||
|
{
|
||||||
|
_breadcrumbNodes = _categoryPaths
|
||||||
|
.OrderByDescending(path => path.Nodes.Count)
|
||||||
|
.FirstOrDefault()?.Nodes ?? Array.Empty<ProductCategoryNodeInfo>();
|
||||||
|
}
|
||||||
|
|
||||||
private void NavigateToCategory(ProductCategoryPathInfo path)
|
private void NavigateToCategory(ProductCategoryPathInfo path)
|
||||||
{
|
{
|
||||||
var target = $"{RouteConstants.Store.Products}?category={path.CategoryId}";
|
var target = $"{RouteConstants.Store.Products}?category={path.CategoryId}";
|
||||||
|
|||||||
@@ -15,13 +15,25 @@
|
|||||||
OnKeyUp="OnQueryChanged"
|
OnKeyUp="OnQueryChanged"
|
||||||
Class="w-100"/>
|
Class="w-100"/>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
<MudItem xs="12" md="4" Class="d-flex justify-end">
|
<MudItem xs="12" md="4" Class="d-flex justify-end flex-wrap gap-2">
|
||||||
|
<MudButton Class="w-100-mobile" Variant="Variant.Outlined" Color="Color.Primary"
|
||||||
|
StartIcon="@Icons.Material.Filled.ViewList" Href="@RouteConstants.Store.Categories">
|
||||||
|
دستهبندیها
|
||||||
|
</MudButton>
|
||||||
<MudButton Class="w-100-mobile" Variant="Variant.Filled" Color="Color.Primary"
|
<MudButton Class="w-100-mobile" Variant="Variant.Filled" Color="Color.Primary"
|
||||||
StartIcon="@Icons.Material.Filled.ShoppingCart" Href="@RouteConstants.Store.Cart">
|
StartIcon="@Icons.Material.Filled.ShoppingCart" Href="@RouteConstants.Store.Cart">
|
||||||
سبد خرید (@Cart.Count)
|
سبد خرید (@Cart.Count)
|
||||||
</MudButton>
|
</MudButton>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
</MudGrid>
|
</MudGrid>
|
||||||
|
@if (_activeCategoryId.HasValue)
|
||||||
|
{
|
||||||
|
<MudStack Spacing="1" Class="mt-3">
|
||||||
|
<MudChip T="string" Color="Color.Info" Variant="Variant.Filled" Closeable="true" OnClose="ClearCategoryFilter">
|
||||||
|
@(_activeCategoryTitle ?? $"دستهبندی #{_activeCategoryId}")
|
||||||
|
</MudChip>
|
||||||
|
</MudStack>
|
||||||
|
}
|
||||||
</MudPaper>
|
</MudPaper>
|
||||||
|
|
||||||
@if (_loading)
|
@if (_loading)
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
|
using System.Linq;
|
||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.Components.Routing;
|
||||||
using Microsoft.AspNetCore.Components.Web;
|
using Microsoft.AspNetCore.Components.Web;
|
||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
using FrontOffice.Main.Utilities;
|
using FrontOffice.Main.Utilities;
|
||||||
|
|
||||||
namespace FrontOffice.Main.Pages.Store;
|
namespace FrontOffice.Main.Pages.Store;
|
||||||
@@ -7,22 +10,30 @@ namespace FrontOffice.Main.Pages.Store;
|
|||||||
public partial class Products : ComponentBase, IDisposable
|
public partial class Products : ComponentBase, IDisposable
|
||||||
{
|
{
|
||||||
[Inject] private ProductService ProductService { get; set; } = default!;
|
[Inject] private ProductService ProductService { get; set; } = default!;
|
||||||
|
[Inject] private CategoryService CategoryService { get; set; } = default!;
|
||||||
[Inject] private CartService Cart { get; set; } = default!;
|
[Inject] private CartService Cart { get; set; } = default!;
|
||||||
|
|
||||||
private string _query = string.Empty;
|
private string _query = string.Empty;
|
||||||
private bool _loading;
|
private bool _loading;
|
||||||
private List<Product> _products = new();
|
private List<Product> _products = new();
|
||||||
|
private long? _activeCategoryId;
|
||||||
|
private string? _activeCategoryTitle;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
Cart.OnChange += StateHasChanged;
|
Cart.OnChange += StateHasChanged;
|
||||||
|
Navigation.LocationChanged += HandleLocationChanged;
|
||||||
await Load();
|
await Load();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task Load()
|
private async Task Load()
|
||||||
{
|
{
|
||||||
_loading = true;
|
_loading = true;
|
||||||
_products = await ProductService.GetProductsAsync(_query);
|
UpdateCategoryFilterFromUri();
|
||||||
|
_products = await ProductService.GetProductsAsync(_query, _activeCategoryId);
|
||||||
|
_activeCategoryTitle = _activeCategoryId is { } categoryId
|
||||||
|
? (await CategoryService.GetByIdAsync(categoryId))?.Title
|
||||||
|
: null;
|
||||||
_loading = false;
|
_loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,5 +52,29 @@ public partial class Products : ComponentBase, IDisposable
|
|||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
Cart.OnChange -= StateHasChanged;
|
Cart.OnChange -= StateHasChanged;
|
||||||
|
Navigation.LocationChanged -= HandleLocationChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void HandleLocationChanged(object? sender, LocationChangedEventArgs args)
|
||||||
|
{
|
||||||
|
_ = InvokeAsync(Load);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateCategoryFilterFromUri()
|
||||||
|
{
|
||||||
|
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
|
||||||
|
if (QueryHelpers.ParseQuery(uri.Query).TryGetValue("category", out var values) &&
|
||||||
|
long.TryParse(values.FirstOrDefault(), out var categoryId))
|
||||||
|
{
|
||||||
|
_activeCategoryId = categoryId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_activeCategoryId = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearCategoryFilter()
|
||||||
|
{
|
||||||
|
Navigation.NavigateTo(RouteConstants.Store.Products);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
45
src/FrontOffice.Main/Utilities/CategoryService.cs
Normal file
45
src/FrontOffice.Main/Utilities/CategoryService.cs
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using FrontOffice.BFF.Category.Protobuf.Protos.Category;
|
||||||
|
|
||||||
|
namespace FrontOffice.Main.Utilities;
|
||||||
|
|
||||||
|
public sealed record CategoryItem(long Id, string Title, long? ParentId, string? ImagePath, bool IsActive);
|
||||||
|
|
||||||
|
public class CategoryService
|
||||||
|
{
|
||||||
|
private readonly CategoryContract.CategoryContractClient _client;
|
||||||
|
private List<CategoryItem>? _cache;
|
||||||
|
|
||||||
|
public CategoryService(CategoryContract.CategoryContractClient client)
|
||||||
|
{
|
||||||
|
_client = client;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<CategoryItem>> GetAllAsync(bool forceReload = false)
|
||||||
|
{
|
||||||
|
if (!forceReload && _cache is { Count: > 0 })
|
||||||
|
{
|
||||||
|
return _cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = await _client.GetAllCategoriesAsync(new GetCategoriesRequest());
|
||||||
|
_cache = response.Categories
|
||||||
|
.Select(dto => new CategoryItem(
|
||||||
|
Id: dto.Id,
|
||||||
|
Title: dto.Title ?? dto.Name ?? string.Empty,
|
||||||
|
ParentId: dto.ParentId,
|
||||||
|
ImagePath: dto.ImagePath,
|
||||||
|
IsActive: dto.IsActive))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return _cache;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<CategoryItem?> GetByIdAsync(long id)
|
||||||
|
{
|
||||||
|
var categories = await GetAllAsync();
|
||||||
|
return categories.FirstOrDefault(c => c.Id == id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
|
using System;
|
||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using FrontOffice.BFF.Products.Protobuf.Protos.Products;
|
using FrontOffice.BFF.Products.Protobuf.Protos.Products;
|
||||||
|
using Google.Protobuf.WellKnownTypes;
|
||||||
|
|
||||||
namespace FrontOffice.Main.Utilities;
|
namespace FrontOffice.Main.Utilities;
|
||||||
|
|
||||||
@@ -46,7 +49,8 @@ public record ProductCategoryPathInfo(
|
|||||||
|
|
||||||
public class ProductService
|
public class ProductService
|
||||||
{
|
{
|
||||||
private readonly ConcurrentDictionary<long, Product> _cache = new();
|
private readonly ConcurrentDictionary<long, CacheEntry> _cache = new();
|
||||||
|
private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(1);
|
||||||
private readonly ProductsContract.ProductsContractClient _client;
|
private readonly ProductsContract.ProductsContractClient _client;
|
||||||
|
|
||||||
public ProductService(ProductsContract.ProductsContractClient client)
|
public ProductService(ProductsContract.ProductsContractClient client)
|
||||||
@@ -54,11 +58,11 @@ public class ProductService
|
|||||||
_client = client;
|
_client = client;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<Product>> GetProductsAsync(string? query = null)
|
public async Task<List<Product>> GetProductsAsync(string? query = null, long? categoryId = null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var resp = await _client.GetAllProductsByFilterAsync(new GetAllProductsByFilterRequest
|
var request = new GetAllProductsByFilterRequest
|
||||||
{
|
{
|
||||||
Filter = new GetAllProductsByFilterFilter
|
Filter = new GetAllProductsByFilterFilter
|
||||||
{
|
{
|
||||||
@@ -67,12 +71,19 @@ public class ProductService
|
|||||||
ShortInfomation = query ?? string.Empty,
|
ShortInfomation = query ?? string.Empty,
|
||||||
FullInformation = query ?? string.Empty
|
FullInformation = query ?? string.Empty
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
if (categoryId is { } value)
|
||||||
|
{
|
||||||
|
request.Filter.CategoryId = value ;
|
||||||
|
}
|
||||||
|
|
||||||
|
var resp = await _client.GetAllProductsByFilterAsync(request);
|
||||||
return MapAndCache(resp.Models);
|
return MapAndCache(resp.Models);
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
IEnumerable<Product> list = _cache.Values;
|
IEnumerable<Product> list = GetValidCachedProducts();
|
||||||
if (!string.IsNullOrWhiteSpace(query))
|
if (!string.IsNullOrWhiteSpace(query))
|
||||||
{
|
{
|
||||||
var q = query.Trim();
|
var q = query.Trim();
|
||||||
@@ -87,7 +98,7 @@ public class ProductService
|
|||||||
|
|
||||||
public async Task<Product?> GetByIdAsync(long id)
|
public async Task<Product?> GetByIdAsync(long id)
|
||||||
{
|
{
|
||||||
if (_cache.TryGetValue(id, out var cached)) return cached;
|
if (TryGetCachedProduct(id, out var cached)) return cached;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -101,7 +112,7 @@ public class ProductService
|
|||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
{
|
{
|
||||||
_cache.TryGetValue(id, out var result);
|
TryGetCachedProduct(id, out var result);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -123,9 +134,7 @@ public class ProductService
|
|||||||
Rate: m.Rate,
|
Rate: m.Rate,
|
||||||
RemainingCount: m.RemainingCount
|
RemainingCount: m.RemainingCount
|
||||||
);
|
);
|
||||||
if (_cache.All(a => a.Key != p.Id))
|
CacheProduct(p);
|
||||||
_cache[p.Id] = p;
|
|
||||||
|
|
||||||
|
|
||||||
list.Add(p);
|
list.Add(p);
|
||||||
}
|
}
|
||||||
@@ -159,10 +168,42 @@ public class ProductService
|
|||||||
Categories = MapCategoryPaths(model.Categories)
|
Categories = MapCategoryPaths(model.Categories)
|
||||||
};
|
};
|
||||||
|
|
||||||
_cache[product.Id] = product;
|
CacheProduct(product);
|
||||||
return 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 sealed record CacheEntry(Product Product, DateTime Expiration);
|
||||||
private static string BuildUrl(string? path) =>
|
private static string BuildUrl(string? path) =>
|
||||||
string.IsNullOrWhiteSpace(path) ? string.Empty : UrlUtility.DownloadUrl + path;
|
string.IsNullOrWhiteSpace(path) ? string.Empty : UrlUtility.DownloadUrl + path;
|
||||||
|
|
||||||
|
|||||||
@@ -55,5 +55,6 @@ public static class RouteConstants
|
|||||||
public const string CheckoutSummary = "/checkout-summary";
|
public const string CheckoutSummary = "/checkout-summary";
|
||||||
public const string Orders = "/orders";
|
public const string Orders = "/orders";
|
||||||
public const string OrderDetail = "/order/"; // usage: /order/{id}
|
public const string OrderDetail = "/order/"; // usage: /order/{id}
|
||||||
|
public const string Categories = "/categories";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user