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