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