diff --git a/src/FrontOffice.Main/ConfigureServices.cs b/src/FrontOffice.Main/ConfigureServices.cs index 3744901..b5499db 100644 --- a/src/FrontOffice.Main/ConfigureServices.cs +++ b/src/FrontOffice.Main/ConfigureServices.cs @@ -5,6 +5,7 @@ using Grpc.Net.Client; using MudBlazor.Services; using System.Text.Json; using System.Text.Json.Serialization; +using FrontOffice.BFF.Category.Protobuf.Protos.Category; using FrontOffice.BFF.Package.Protobuf.Protos.Package; using FrontOffice.BFF.Transaction.Protobuf.Protos.Transaction; using FrontOffice.BFF.User.Protobuf.Protos.User; @@ -42,6 +43,7 @@ public static class ConfigureServices // Storefront services services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); // Device detection: very light, dependency-free @@ -78,6 +80,7 @@ public static class ConfigureServices services.AddScoped(CreateAuthenticatedClient); services.AddScoped(CreateAuthenticatedClient); services.AddScoped(CreateAuthenticatedClient); + services.AddScoped(CreateAuthenticatedClient); // Products gRPC services.AddScoped(CreateAuthenticatedClient); services.AddScoped(CreateAuthenticatedClient); diff --git a/src/FrontOffice.Main/FrontOffice.Main.csproj b/src/FrontOffice.Main/FrontOffice.Main.csproj index 31e750d..e89524f 100644 --- a/src/FrontOffice.Main/FrontOffice.Main.csproj +++ b/src/FrontOffice.Main/FrontOffice.Main.csproj @@ -28,6 +28,7 @@ + diff --git a/src/FrontOffice.Main/Pages/Store/Categories.razor b/src/FrontOffice.Main/Pages/Store/Categories.razor new file mode 100644 index 0000000..1fac2f0 --- /dev/null +++ b/src/FrontOffice.Main/Pages/Store/Categories.razor @@ -0,0 +1,66 @@ +@page "/categories" +@using MudBlazor +@* Project-level imports provide Navigation, services, and MudBlazor *@ + +دسته‌بندی‌ها + + + + + دسته‌بندی محصولات + + از بین درخت دسته‌بندی‌ها انتخاب کنید تا محصولات آن دسته را مشاهده نمایید. + + + + + بازگشت به محصولات + + + مشاهده محصولات بدون فیلتر + + + + + + @if (_loading) + { + + + در حال بارگذاری دسته‌بندی‌ها... + + } + else if (_rows.Count == 0) + { + هنوز دسته‌بندی‌ای ثبت نشده است. + } + else + { + + + عنوان + مسیر دسته‌بندی + اقدامات + + + + + @context.Category.Title + @if (!string.IsNullOrWhiteSpace(context.Category.ImagePath)) + { + + } + + + @context.Path + + + مشاهده محصولات + + + + + } + + diff --git a/src/FrontOffice.Main/Pages/Store/Categories.razor.cs b/src/FrontOffice.Main/Pages/Store/Categories.razor.cs new file mode 100644 index 0000000..85a9785 --- /dev/null +++ b/src/FrontOffice.Main/Pages/Store/Categories.razor.cs @@ -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 _items = new(); + private List _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(); + 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 BuildPath(CategoryItem category, IReadOnlyDictionary lookup) + { + var path = new List(); + var current = category; + var visited = new HashSet(); + 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); +} diff --git a/src/FrontOffice.Main/Pages/Store/ProductDetail.razor b/src/FrontOffice.Main/Pages/Store/ProductDetail.razor index 9a2705c..ecaf4d4 100644 --- a/src/FrontOffice.Main/Pages/Store/ProductDetail.razor +++ b/src/FrontOffice.Main/Pages/Store/ProductDetail.razor @@ -23,6 +23,17 @@ else if (_product is null) else { + @if (_product is not null) + { + + دیجی کالا + @foreach (var node in _breadcrumbNodes) + { + @node.Title + } + @_product.Title + + } diff --git a/src/FrontOffice.Main/Pages/Store/ProductDetail.razor.cs b/src/FrontOffice.Main/Pages/Store/ProductDetail.razor.cs index 7c93d80..213ad6c 100644 --- a/src/FrontOffice.Main/Pages/Store/ProductDetail.razor.cs +++ b/src/FrontOffice.Main/Pages/Store/ProductDetail.razor.cs @@ -20,6 +20,7 @@ public partial class ProductDetail : ComponentBase, IDisposable private const int MaxQty = 20; private IReadOnlyList _galleryItems = Array.Empty(); private IReadOnlyList _categoryPaths = Array.Empty(); + private IReadOnlyList _breadcrumbNodes = Array.Empty(); private ProductGalleryImage? _selectedGalleryImage; private long TotalPrice => (_product?.Price ?? 0) * _qty; private bool HasDiscount => _product is { Discount: > 0 and < 100 }; @@ -52,12 +53,14 @@ public partial class ProductDetail : ComponentBase, IDisposable _galleryItems = BuildGalleryItems(_product); _selectedGalleryImage = _galleryItems.FirstOrDefault(); _categoryPaths = _product.Categories; + UpdateBreadcrumb(); _qty = Math.Clamp(CurrentCartItem?.Quantity ?? _qty, MinQty, MaxQty); } else { _galleryItems = Array.Empty(); _categoryPaths = Array.Empty(); + _breadcrumbNodes = Array.Empty(); } StateHasChanged(); @@ -172,6 +175,13 @@ public partial class ProductDetail : ComponentBase, IDisposable _selectedGalleryImage = item; } + private void UpdateBreadcrumb() + { + _breadcrumbNodes = _categoryPaths + .OrderByDescending(path => path.Nodes.Count) + .FirstOrDefault()?.Nodes ?? Array.Empty(); + } + private void NavigateToCategory(ProductCategoryPathInfo path) { var target = $"{RouteConstants.Store.Products}?category={path.CategoryId}"; diff --git a/src/FrontOffice.Main/Pages/Store/Products.razor b/src/FrontOffice.Main/Pages/Store/Products.razor index d2fce25..bb900e9 100644 --- a/src/FrontOffice.Main/Pages/Store/Products.razor +++ b/src/FrontOffice.Main/Pages/Store/Products.razor @@ -15,13 +15,25 @@ OnKeyUp="OnQueryChanged" Class="w-100"/> - + + + دسته‌بندی‌ها + سبد خرید (@Cart.Count) + @if (_activeCategoryId.HasValue) + { + + + @(_activeCategoryTitle ?? $"دسته‌بندی #{_activeCategoryId}") + + + } @if (_loading) diff --git a/src/FrontOffice.Main/Pages/Store/Products.razor.cs b/src/FrontOffice.Main/Pages/Store/Products.razor.cs index 1b33f67..da2c995 100644 --- a/src/FrontOffice.Main/Pages/Store/Products.razor.cs +++ b/src/FrontOffice.Main/Pages/Store/Products.razor.cs @@ -1,5 +1,8 @@ +using System.Linq; using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.Components.Web; +using Microsoft.AspNetCore.WebUtilities; using FrontOffice.Main.Utilities; namespace FrontOffice.Main.Pages.Store; @@ -7,22 +10,30 @@ namespace FrontOffice.Main.Pages.Store; public partial class Products : ComponentBase, IDisposable { [Inject] private ProductService ProductService { get; set; } = default!; + [Inject] private CategoryService CategoryService { get; set; } = default!; [Inject] private CartService Cart { get; set; } = default!; private string _query = string.Empty; private bool _loading; private List _products = new(); + private long? _activeCategoryId; + private string? _activeCategoryTitle; protected override async Task OnInitializedAsync() { Cart.OnChange += StateHasChanged; + Navigation.LocationChanged += HandleLocationChanged; await Load(); } private async Task Load() { _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; } @@ -41,5 +52,29 @@ public partial class Products : ComponentBase, IDisposable public void Dispose() { 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); } } diff --git a/src/FrontOffice.Main/Utilities/CategoryService.cs b/src/FrontOffice.Main/Utilities/CategoryService.cs new file mode 100644 index 0000000..f94f38b --- /dev/null +++ b/src/FrontOffice.Main/Utilities/CategoryService.cs @@ -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? _cache; + + public CategoryService(CategoryContract.CategoryContractClient client) + { + _client = client; + } + + public async Task> 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 GetByIdAsync(long id) + { + var categories = await GetAllAsync(); + return categories.FirstOrDefault(c => c.Id == id); + } +} diff --git a/src/FrontOffice.Main/Utilities/ProductService.cs b/src/FrontOffice.Main/Utilities/ProductService.cs index 468c6de..4bee96a 100644 --- a/src/FrontOffice.Main/Utilities/ProductService.cs +++ b/src/FrontOffice.Main/Utilities/ProductService.cs @@ -1,7 +1,10 @@ +using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Linq; using FrontOffice.BFF.Products.Protobuf.Protos.Products; +using Google.Protobuf.WellKnownTypes; namespace FrontOffice.Main.Utilities; @@ -46,7 +49,8 @@ public record ProductCategoryPathInfo( public class ProductService { - private readonly ConcurrentDictionary _cache = new(); + private readonly ConcurrentDictionary _cache = new(); + private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(1); private readonly ProductsContract.ProductsContractClient _client; public ProductService(ProductsContract.ProductsContractClient client) @@ -54,11 +58,11 @@ public class ProductService _client = client; } - public async Task> GetProductsAsync(string? query = null) + public async Task> GetProductsAsync(string? query = null, long? categoryId = null) { try { - var resp = await _client.GetAllProductsByFilterAsync(new GetAllProductsByFilterRequest + var request = new GetAllProductsByFilterRequest { Filter = new GetAllProductsByFilterFilter { @@ -67,12 +71,19 @@ public class ProductService ShortInfomation = 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); } catch { - IEnumerable list = _cache.Values; + IEnumerable list = GetValidCachedProducts(); if (!string.IsNullOrWhiteSpace(query)) { var q = query.Trim(); @@ -87,7 +98,7 @@ public class ProductService public async Task GetByIdAsync(long id) { - if (_cache.TryGetValue(id, out var cached)) return cached; + if (TryGetCachedProduct(id, out var cached)) return cached; try { @@ -101,7 +112,7 @@ public class ProductService } catch { - _cache.TryGetValue(id, out var result); + TryGetCachedProduct(id, out var result); return result; } } @@ -123,9 +134,7 @@ public class ProductService Rate: m.Rate, RemainingCount: m.RemainingCount ); - if (_cache.All(a => a.Key != p.Id)) - _cache[p.Id] = p; - + CacheProduct(p); list.Add(p); } @@ -159,10 +168,42 @@ public class ProductService Categories = MapCategoryPaths(model.Categories) }; - _cache[product.Id] = product; + 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 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) => string.IsNullOrWhiteSpace(path) ? string.Empty : UrlUtility.DownloadUrl + path; diff --git a/src/FrontOffice.Main/Utilities/RouteConstants.cs b/src/FrontOffice.Main/Utilities/RouteConstants.cs index c3d1950..d152efb 100644 --- a/src/FrontOffice.Main/Utilities/RouteConstants.cs +++ b/src/FrontOffice.Main/Utilities/RouteConstants.cs @@ -55,5 +55,6 @@ public static class RouteConstants public const string CheckoutSummary = "/checkout-summary"; public const string Orders = "/orders"; public const string OrderDetail = "/order/"; // usage: /order/{id} + public const string Categories = "/categories"; } }