From a708727241f479cad2f9425db880c1772cecf439 Mon Sep 17 00:00:00 2001 From: masoodafar-web Date: Sun, 26 Jul 2026 00:17:55 +0330 Subject: [PATCH] feat: enhance product listing and navigation functionality - Added a wrapper div for product items in the DiscountStore and Store pages to improve layout consistency. - Implemented state management for URL synchronization and scroll restoration in both product components. - Refactored product loading logic to streamline data retrieval and improve user experience when navigating between product pages. These changes enhance the overall functionality and user experience of the product listing pages, ensuring better state management and navigation. --- .../Pages/DiscountStore/Products.razor | 2 + .../Pages/DiscountStore/Products.razor.cs | 157 +++++++++++++--- .../Pages/Store/Products.razor | 4 +- .../Pages/Store/Products.razor.cs | 173 ++++++++++++------ .../Utilities/ShopListQueryState.cs | 94 ++++++++++ .../Utilities/ShopListScrollRestore.cs | 69 +++++++ 6 files changed, 409 insertions(+), 90 deletions(-) create mode 100644 src/FrontOffice.Main/Utilities/ShopListQueryState.cs create mode 100644 src/FrontOffice.Main/Utilities/ShopListScrollRestore.cs diff --git a/src/FrontOffice.Main/Pages/DiscountStore/Products.razor b/src/FrontOffice.Main/Pages/DiscountStore/Products.razor index b05fa5f..819e25b 100644 --- a/src/FrontOffice.Main/Pages/DiscountStore/Products.razor +++ b/src/FrontOffice.Main/Pages/DiscountStore/Products.razor @@ -76,6 +76,7 @@ { +
@@ -127,6 +128,7 @@ +
} diff --git a/src/FrontOffice.Main/Pages/DiscountStore/Products.razor.cs b/src/FrontOffice.Main/Pages/DiscountStore/Products.razor.cs index 84aef44..e99c681 100644 --- a/src/FrontOffice.Main/Pages/DiscountStore/Products.razor.cs +++ b/src/FrontOffice.Main/Pages/DiscountStore/Products.razor.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.Components; +using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.Components.Web; +using Microsoft.JSInterop; using FrontOffice.Main.Utilities; namespace FrontOffice.Main.Pages.DiscountStore; @@ -10,6 +12,7 @@ public partial class Products : ComponentBase, IDisposable [Inject] private DiscountCartService DiscountCart { get; set; } = default!; [Inject] private GuestActionGate GuestGate { get; set; } = default!; [Inject] private AuthService AuthService { get; set; } = default!; + [Inject] private IJSRuntime Js { get; set; } = default!; private bool _isAuthenticated; private string _search = string.Empty; @@ -20,42 +23,107 @@ public partial class Products : ComponentBase, IDisposable private bool _hasMore = true; private int _totalCount; private const int PageSize = 12; + private const string DefaultSortBy = "price desc"; private List _products = new(); private List _categories = new(); + private bool _ignoreNextLocationChange; + private bool _pendingScrollRestore; protected override async Task OnInitializedAsync() { _isAuthenticated = await AuthService.IsAuthenticatedAsync(); - _loading = true; await DiscountCart.EnsureInitializedAsync(); DiscountCart.OnChange += StateHasChanged; - var categoriesTask = ProductService.GetCategoriesAsync(); - var productsTask = ProductService.GetProductsAsync(page: 1, pageSize: PageSize); - await Task.WhenAll(categoriesTask, productsTask); - _categories = categoriesTask.Result; - var result = productsTask.Result; - _products = result.Products; - _totalCount = result.TotalCount; - _hasMore = result.CurrentPage < result.TotalPages; + Navigation.LocationChanged += HandleLocationChanged; + + _categories = await ProductService.GetCategoriesAsync(); + ApplyStateFromUri(); + _loading = true; + await LoadPages(_currentPage); _loading = false; + _pendingScrollRestore = true; } - private async Task LoadInitial() + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (_pendingScrollRestore && !_loading && _products.Count > 0) + { + _pendingScrollRestore = false; + var payload = await ShopListScrollRestore.TakeAsync(Js, ShopListScrollRestore.DiscountKey); + if (payload is not null) + await ShopListScrollRestore.RestoreAsync(Js, payload); + } + + await base.OnAfterRenderAsync(firstRender); + } + + private void ApplyStateFromUri() + { + var state = ShopListQueryState.Parse(Navigation.ToAbsoluteUri(Navigation.Uri)); + _search = state.Query; + _selectedCategoryId = state.CategoryId; + _currentPage = state.Pages; + } + + private ShopListQueryState CaptureState() => new() + { + Query = _search, + CategoryId = _selectedCategoryId, + Pages = Math.Max(1, _currentPage) + }; + + private void SyncUrl() + { + var target = CaptureState().ToRelativeUrl(RouteConstants.DiscountStore.Products); + var currentPathAndQuery = Navigation.ToAbsoluteUri(Navigation.Uri).PathAndQuery; + if (string.Equals(currentPathAndQuery, target, StringComparison.OrdinalIgnoreCase)) + return; + + _ignoreNextLocationChange = true; + Navigation.NavigateTo(target, replace: true); + } + + private async Task LoadPages(int pagesToLoad) + { + _products.Clear(); + _hasMore = true; + pagesToLoad = Math.Max(1, pagesToLoad); + var search = string.IsNullOrWhiteSpace(_search) ? null : _search; + + for (var page = 1; page <= pagesToLoad; page++) + { + var result = await ProductService.GetProductsAsync( + page: page, + pageSize: PageSize, + search: search, + categoryId: _selectedCategoryId, + sortBy: DefaultSortBy); + + if (page == 1) + { + _products = result.Products; + _totalCount = result.TotalCount; + } + else + { + _products.AddRange(result.Products); + } + + _currentPage = page; + _hasMore = result.CurrentPage < result.TotalPages; + if (!_hasMore) + break; + } + } + + private async Task ReloadFromFilters() { _loading = true; _currentPage = 1; - _products.Clear(); StateHasChanged(); - - var result = await ProductService.GetProductsAsync( - page: 1, - pageSize: PageSize, - search: string.IsNullOrWhiteSpace(_search) ? null : _search, - categoryId: _selectedCategoryId); - _products = result.Products; - _totalCount = result.TotalCount; - _hasMore = result.CurrentPage < result.TotalPages; + await LoadPages(1); + SyncUrl(); _loading = false; StateHasChanged(); } @@ -63,37 +131,37 @@ public partial class Products : ComponentBase, IDisposable private async Task LoadMore() { if (_loadingMore || !_hasMore) return; - + _loadingMore = true; StateHasChanged(); - + _currentPage++; var result = await ProductService.GetProductsAsync( page: _currentPage, pageSize: PageSize, search: string.IsNullOrWhiteSpace(_search) ? null : _search, - categoryId: _selectedCategoryId); + categoryId: _selectedCategoryId, + sortBy: DefaultSortBy); _products.AddRange(result.Products); _hasMore = result.CurrentPage < result.TotalPages; - + SyncUrl(); + _loadingMore = false; StateHasChanged(); } - private async Task SearchProducts() => await LoadInitial(); + private async Task SearchProducts() => await ReloadFromFilters(); private async Task OnSearchKeyUp(KeyboardEventArgs e) { if (e.Key == "Enter") - { - await LoadInitial(); - } + await ReloadFromFilters(); } private async Task OnCategoryChanged(long? value) { _selectedCategoryId = value; - await LoadInitial(); + await ReloadFromFilters(); } private async Task AddToCart(DiscountProductCard p) @@ -101,15 +169,44 @@ public partial class Products : ComponentBase, IDisposable await GuestGate.RunAsync(() => DiscountCart.AddAsync(p.Id)); } - private void NavigateToProduct(long id) + private async Task NavigateToProduct(long id) { + await ShopListScrollRestore.SaveAsync(Js, ShopListScrollRestore.DiscountKey, id); Navigation.NavigateTo($"{RouteConstants.DiscountStore.ProductDetail}{id}"); } + private void HandleLocationChanged(object? sender, LocationChangedEventArgs args) + { + if (_ignoreNextLocationChange) + { + _ignoreNextLocationChange = false; + return; + } + + var uri = Navigation.ToAbsoluteUri(args.Location); + if (!uri.AbsolutePath.Equals(RouteConstants.DiscountStore.Products, StringComparison.OrdinalIgnoreCase)) + return; + + var incoming = ShopListQueryState.Parse(uri); + if (incoming.Matches(CaptureState())) + return; + + _ = InvokeAsync(async () => + { + ApplyStateFromUri(); + _loading = true; + await LoadPages(_currentPage); + _loading = false; + _pendingScrollRestore = true; + StateHasChanged(); + }); + } + private static string FormatPrice(long price) => $"{price:N0} تومان"; public void Dispose() { DiscountCart.OnChange -= StateHasChanged; + Navigation.LocationChanged -= HandleLocationChanged; } } diff --git a/src/FrontOffice.Main/Pages/Store/Products.razor b/src/FrontOffice.Main/Pages/Store/Products.razor index cd1d3d8..9b2d21b 100644 --- a/src/FrontOffice.Main/Pages/Store/Products.razor +++ b/src/FrontOffice.Main/Pages/Store/Products.razor @@ -118,7 +118,8 @@ @foreach (var p in _products) { + onclick="@(() => OpenProduct(p.Id))"> +
@@ -164,6 +165,7 @@ +
} diff --git a/src/FrontOffice.Main/Pages/Store/Products.razor.cs b/src/FrontOffice.Main/Pages/Store/Products.razor.cs index 1c8c8c5..436357c 100644 --- a/src/FrontOffice.Main/Pages/Store/Products.razor.cs +++ b/src/FrontOffice.Main/Pages/Store/Products.razor.cs @@ -2,19 +2,11 @@ using System.Linq; using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components.Routing; using Microsoft.AspNetCore.Components.Web; -using Microsoft.AspNetCore.WebUtilities; +using Microsoft.JSInterop; using FrontOffice.Main.Utilities; namespace FrontOffice.Main.Pages.Store; -public enum ProductSortOption -{ - PriceDesc, // گران‌ترین (پیش‌فرض) - PriceAsc, // ارزان‌ترین - Newest, // جدیدترین - Title // الفبایی -} - public partial class Products : ComponentBase, IDisposable { [Inject] private ProductService ProductService { get; set; } = default!; @@ -23,6 +15,7 @@ public partial class Products : ComponentBase, IDisposable [Inject] private VATService VAT { get; set; } = default!; [Inject] private GuestActionGate GuestGate { get; set; } = default!; [Inject] private AuthService AuthService { get; set; } = default!; + [Inject] private IJSRuntime Js { get; set; } = default!; private bool _isAuthenticated; private string _query = string.Empty; @@ -35,8 +28,9 @@ public partial class Products : ComponentBase, IDisposable private List _products = new(); private long? _activeCategoryId; private string? _activeCategoryTitle; - - private ProductSortOption _sortOption = ProductSortOption.PriceDesc; // پیش‌فرض: گران‌ترین + private ProductSortOption _sortOption = ProductSortOption.PriceDesc; + private bool _ignoreNextLocationChange; + private bool _pendingScrollRestore; protected override async Task OnInitializedAsync() { @@ -44,80 +38,132 @@ public partial class Products : ComponentBase, IDisposable await Cart.EnsureInitializedAsync(); Cart.OnChange += StateHasChanged; Navigation.LocationChanged += HandleLocationChanged; - await LoadInitial(); + ApplyStateFromUri(); + await LoadPages(_currentPage); + _pendingScrollRestore = true; } protected override async Task OnAfterRenderAsync(bool firstRender) { if (firstRender) - { - // بارگذاری نرخ VAT await VAT.LoadAsync(); + + if (_pendingScrollRestore && !_loading && _products.Count > 0) + { + _pendingScrollRestore = false; + var payload = await ShopListScrollRestore.TakeAsync(Js, ShopListScrollRestore.StoreKey); + if (payload is not null) + await ShopListScrollRestore.RestoreAsync(Js, payload); } + await base.OnAfterRenderAsync(firstRender); } - private async Task LoadInitial() + private void ApplyStateFromUri() + { + var state = ShopListQueryState.Parse(Navigation.ToAbsoluteUri(Navigation.Uri)); + _query = state.Query; + _sortOption = ShopListQueryState.ParseSortOption(state.Sort); + _activeCategoryId = state.CategoryId; + _currentPage = state.Pages; + } + + private ShopListQueryState CaptureState() => new() + { + Query = _query, + Sort = ShopListQueryState.ToSortKey(_sortOption), + CategoryId = _activeCategoryId, + Pages = Math.Max(1, _currentPage) + }; + + private void SyncUrl() + { + var target = CaptureState().ToRelativeUrl(RouteConstants.Store.Products); + var currentPathAndQuery = Navigation.ToAbsoluteUri(Navigation.Uri).PathAndQuery; + if (string.Equals(currentPathAndQuery, target, StringComparison.OrdinalIgnoreCase) + || string.Equals(currentPathAndQuery, target + "/", StringComparison.OrdinalIgnoreCase)) + return; + + _ignoreNextLocationChange = true; + Navigation.NavigateTo(target, replace: true); + } + + private async Task LoadPages(int pagesToLoad) { _loading = true; - _currentPage = 1; _products.Clear(); - UpdateCategoryFilterFromUri(); - var sortBy = GetSortByValue(); - var result = await ProductService.GetProductsPagedAsync(_query, _activeCategoryId, sortBy, _currentPage, PageSize); - _products = result.Products; - _hasMore = result.HasNext; - _totalCount = result.TotalCount; + _hasMore = true; + pagesToLoad = Math.Max(1, pagesToLoad); + var sortBy = ShopListQueryState.ToApiSortBy(_sortOption); + + for (var page = 1; page <= pagesToLoad; page++) + { + var result = await ProductService.GetProductsPagedAsync( + _query, _activeCategoryId, sortBy, page, PageSize); + if (page == 1) + { + _products = result.Products; + _totalCount = result.TotalCount; + } + else + { + _products.AddRange(result.Products); + } + + _currentPage = page; + _hasMore = result.HasNext; + if (!_hasMore) + break; + } + _activeCategoryTitle = _activeCategoryId is { } categoryId ? (await CategoryService.GetByIdAsync(categoryId))?.Title : null; _loading = false; } + private async Task ReloadFromFilters() + { + _currentPage = 1; + await LoadPages(1); + SyncUrl(); + StateHasChanged(); + } + private async Task LoadMore() { if (_loadingMore || !_hasMore) return; - + _loadingMore = true; StateHasChanged(); - + _currentPage++; - var sortBy = GetSortByValue(); - var result = await ProductService.GetProductsPagedAsync(_query, _activeCategoryId, sortBy, _currentPage, PageSize); + var sortBy = ShopListQueryState.ToApiSortBy(_sortOption); + var result = await ProductService.GetProductsPagedAsync( + _query, _activeCategoryId, sortBy, _currentPage, PageSize); _products.AddRange(result.Products); _hasMore = result.HasNext; - + SyncUrl(); + _loadingMore = false; StateHasChanged(); } - private string GetSortByValue() - { - return _sortOption switch - { - ProductSortOption.PriceDesc => "price desc", - ProductSortOption.PriceAsc => "price asc", - ProductSortOption.Newest => "id desc", - ProductSortOption.Title => "title asc", - _ => "price desc" - }; - } + private async Task OnSortChanged() => await ReloadFromFilters(); - private async Task OnSortChanged() - { - await LoadInitial(); - } - - private async Task OnQueryChanged(KeyboardEventArgs _) - { - await LoadInitial(); - } + private async Task OnQueryChanged(KeyboardEventArgs _) => await ReloadFromFilters(); private async Task AddToCart(Product p) { await GuestGate.RunAsync(() => Cart.Add(p, 1)); } + private async Task OpenProduct(long productId) + { + await ShopListScrollRestore.SaveAsync(Js, ShopListScrollRestore.StoreKey, productId); + Navigation.NavigateTo($"{RouteConstants.Store.ProductDetail}{productId}"); + } + private string FormatPrice(long price) => $"{VAT.AddVAT(price):N0} تومان"; public void Dispose() @@ -128,24 +174,33 @@ public partial class Products : ComponentBase, IDisposable private void HandleLocationChanged(object? sender, LocationChangedEventArgs args) { - _ = InvokeAsync(LoadInitial); - } - - 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)) + if (_ignoreNextLocationChange) { - _activeCategoryId = categoryId; + _ignoreNextLocationChange = false; return; } - _activeCategoryId = null; + var uri = Navigation.ToAbsoluteUri(args.Location); + if (!uri.AbsolutePath.Equals(RouteConstants.Store.Products, StringComparison.OrdinalIgnoreCase)) + return; + + var incoming = ShopListQueryState.Parse(uri); + if (incoming.Matches(CaptureState())) + return; + + _ = InvokeAsync(async () => + { + ApplyStateFromUri(); + await LoadPages(_currentPage); + _pendingScrollRestore = true; + StateHasChanged(); + }); } - private void ClearCategoryFilter() + private async Task ClearCategoryFilter() { - Navigation.NavigateTo(RouteConstants.Store.Products); + _activeCategoryId = null; + _activeCategoryTitle = null; + await ReloadFromFilters(); } } diff --git a/src/FrontOffice.Main/Utilities/ShopListQueryState.cs b/src/FrontOffice.Main/Utilities/ShopListQueryState.cs new file mode 100644 index 0000000..7958e0b --- /dev/null +++ b/src/FrontOffice.Main/Utilities/ShopListQueryState.cs @@ -0,0 +1,94 @@ +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Primitives; + +namespace FrontOffice.Main.Utilities; + +public enum ProductSortOption +{ + PriceDesc, + PriceAsc, + Newest, + Title +} + +/// +/// وضعیت لیست فروشگاه در query string — فقط برای /products و /discount-store. +/// +public sealed class ShopListQueryState +{ + public string Query { get; init; } = string.Empty; + public string? Sort { get; init; } + public long? CategoryId { get; init; } + public int Pages { get; init; } = 1; + + public static ShopListQueryState Parse(Uri uri) + { + var q = QueryHelpers.ParseQuery(uri.Query); + return new ShopListQueryState + { + Query = GetString(q, "q") ?? string.Empty, + Sort = GetString(q, "sort"), + CategoryId = GetLong(q, "category"), + Pages = Math.Max(1, GetInt(q, "pages") ?? 1) + }; + } + + public string ToRelativeUrl(string path) + { + var dict = new Dictionary(); + if (!string.IsNullOrWhiteSpace(Query)) + dict["q"] = Query.Trim(); + if (!string.IsNullOrWhiteSpace(Sort) && !string.Equals(Sort, "price-desc", StringComparison.OrdinalIgnoreCase)) + dict["sort"] = Sort; + if (CategoryId is > 0) + dict["category"] = CategoryId.Value.ToString(); + if (Pages > 1) + dict["pages"] = Pages.ToString(); + + return dict.Count == 0 + ? path + : QueryHelpers.AddQueryString(path, dict!); + } + + public bool Matches(ShopListQueryState other) => + string.Equals(Query?.Trim() ?? "", other.Query?.Trim() ?? "", StringComparison.Ordinal) + && string.Equals(NormalizeSort(Sort), NormalizeSort(other.Sort), StringComparison.OrdinalIgnoreCase) + && CategoryId == other.CategoryId + && Pages == other.Pages; + + public static string NormalizeSort(string? sort) => + string.IsNullOrWhiteSpace(sort) ? "price-desc" : sort.Trim().ToLowerInvariant(); + + public static string ToSortKey(ProductSortOption option) => option switch + { + ProductSortOption.PriceAsc => "price-asc", + ProductSortOption.Newest => "newest", + ProductSortOption.Title => "title", + _ => "price-desc" + }; + + public static ProductSortOption ParseSortOption(string? sort) => NormalizeSort(sort) switch + { + "price-asc" => ProductSortOption.PriceAsc, + "newest" => ProductSortOption.Newest, + "title" => ProductSortOption.Title, + _ => ProductSortOption.PriceDesc + }; + + public static string ToApiSortBy(ProductSortOption option) => option switch + { + ProductSortOption.PriceAsc => "price asc", + ProductSortOption.Newest => "id desc", + ProductSortOption.Title => "title asc", + _ => "price desc" + }; + + private static string? GetString(Dictionary q, string key) => + q.TryGetValue(key, out var v) ? v.FirstOrDefault() : null; + + private static long? GetLong(Dictionary q, string key) => + long.TryParse(GetString(q, key), out var n) ? n : null; + + private static int? GetInt(Dictionary q, string key) => + int.TryParse(GetString(q, key), out var n) ? n : null; +} diff --git a/src/FrontOffice.Main/Utilities/ShopListScrollRestore.cs b/src/FrontOffice.Main/Utilities/ShopListScrollRestore.cs new file mode 100644 index 0000000..5d75872 --- /dev/null +++ b/src/FrontOffice.Main/Utilities/ShopListScrollRestore.cs @@ -0,0 +1,69 @@ +using System.Text.Json; +using Microsoft.JSInterop; + +namespace FrontOffice.Main.Utilities; + +/// +/// اسکرول/فوکوس محصول پس از بازگشت از جزئیات — کلیدهای محدود به لیست فروشگاه (نه لندینگ). +/// +public static class ShopListScrollRestore +{ + public const string StoreKey = "fo:store:scroll"; + public const string DiscountKey = "fo:discount:scroll"; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public static async Task SaveAsync(IJSRuntime js, string storageKey, long productId) + { + double scrollY = 0; + try + { + scrollY = await js.InvokeAsync("eval", "window.scrollY || window.pageYOffset || 0"); + } + catch + { + // ignore + } + + var payload = JsonSerializer.Serialize(new ScrollPayload(productId, scrollY), JsonOptions); + await js.InvokeVoidAsync("sessionStorage.setItem", storageKey, payload); + } + + public static async Task TakeAsync(IJSRuntime js, string storageKey) + { + try + { + var raw = await js.InvokeAsync("sessionStorage.getItem", storageKey); + await js.InvokeVoidAsync("sessionStorage.removeItem", storageKey); + if (string.IsNullOrWhiteSpace(raw)) + return null; + return JsonSerializer.Deserialize(raw, JsonOptions); + } + catch + { + return null; + } + } + + public static async Task RestoreAsync(IJSRuntime js, ScrollPayload payload) + { + try + { + await js.InvokeVoidAsync("eval", + $@"(function(){{ + var el = document.getElementById('shop-product-{payload.ProductId}'); + if (el) {{ el.scrollIntoView({{ block: 'center', behavior: 'instant' }}); return; }} + window.scrollTo(0, {payload.ScrollY.ToString(System.Globalization.CultureInfo.InvariantCulture)}); + }})()"); + } + catch + { + // ignore + } + } + + public sealed record ScrollPayload(long ProductId, double ScrollY); +}