Add category browsing and product filtering with breadcrumbs

This commit is contained in:
masoodafar-web
2025-11-28 11:15:58 +03:30
parent fe5f7bd9b9
commit 6ab835f7e9
11 changed files with 305 additions and 13 deletions

View 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>

View 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);
}

View File

@@ -23,6 +23,17 @@ else if (_product is null)
else
{
<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">
<MudItem xs="12" md="6">
<MudPaper Class="rounded-lg pa-2">

View File

@@ -20,6 +20,7 @@ public partial class ProductDetail : ComponentBase, IDisposable
private const int MaxQty = 20;
private IReadOnlyList<ProductGalleryImage> _galleryItems = Array.Empty<ProductGalleryImage>();
private IReadOnlyList<ProductCategoryPathInfo> _categoryPaths = Array.Empty<ProductCategoryPathInfo>();
private IReadOnlyList<ProductCategoryNodeInfo> _breadcrumbNodes = Array.Empty<ProductCategoryNodeInfo>();
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<ProductGalleryImage>();
_categoryPaths = Array.Empty<ProductCategoryPathInfo>();
_breadcrumbNodes = Array.Empty<ProductCategoryNodeInfo>();
}
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<ProductCategoryNodeInfo>();
}
private void NavigateToCategory(ProductCategoryPathInfo path)
{
var target = $"{RouteConstants.Store.Products}?category={path.CategoryId}";

View File

@@ -15,13 +15,25 @@
OnKeyUp="OnQueryChanged"
Class="w-100"/>
</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"
StartIcon="@Icons.Material.Filled.ShoppingCart" Href="@RouteConstants.Store.Cart">
سبد خرید (@Cart.Count)
</MudButton>
</MudItem>
</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>
@if (_loading)

View File

@@ -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<Product> _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);
}
}