Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Failing after 16m3s
Build and Deploy to Production / build-and-deploy (push) Failing after 16m3s
This commit is contained in:
@@ -76,6 +76,7 @@
|
|||||||
{
|
{
|
||||||
<MudItem xs="6" sm="6" md="3"
|
<MudItem xs="6" sm="6" md="3"
|
||||||
onclick="@(() => NavigateToProduct(p.Id))">
|
onclick="@(() => NavigateToProduct(p.Id))">
|
||||||
|
<div id="@($"shop-product-{p.Id}")" class="h-100">
|
||||||
<MudCard Class="rounded-lg h-100 d-flex flex-column overflow-hidden"
|
<MudCard Class="rounded-lg h-100 d-flex flex-column overflow-hidden"
|
||||||
Style="cursor:pointer;">
|
Style="cursor:pointer;">
|
||||||
<MudCardContent Class="d-flex flex-column pa-1 h-100">
|
<MudCardContent Class="d-flex flex-column pa-1 h-100">
|
||||||
@@ -127,6 +128,7 @@
|
|||||||
</MudButton>
|
</MudButton>
|
||||||
</MudCardActions>
|
</MudCardActions>
|
||||||
</MudCard>
|
</MudCard>
|
||||||
|
</div>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
}
|
}
|
||||||
</MudGrid>
|
</MudGrid>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
|
using Microsoft.AspNetCore.Components.Routing;
|
||||||
using Microsoft.AspNetCore.Components.Web;
|
using Microsoft.AspNetCore.Components.Web;
|
||||||
|
using Microsoft.JSInterop;
|
||||||
using FrontOffice.Main.Utilities;
|
using FrontOffice.Main.Utilities;
|
||||||
|
|
||||||
namespace FrontOffice.Main.Pages.DiscountStore;
|
namespace FrontOffice.Main.Pages.DiscountStore;
|
||||||
@@ -10,6 +12,7 @@ public partial class Products : ComponentBase, IDisposable
|
|||||||
[Inject] private DiscountCartService DiscountCart { get; set; } = default!;
|
[Inject] private DiscountCartService DiscountCart { get; set; } = default!;
|
||||||
[Inject] private GuestActionGate GuestGate { get; set; } = default!;
|
[Inject] private GuestActionGate GuestGate { get; set; } = default!;
|
||||||
[Inject] private AuthService AuthService { get; set; } = default!;
|
[Inject] private AuthService AuthService { get; set; } = default!;
|
||||||
|
[Inject] private IJSRuntime Js { get; set; } = default!;
|
||||||
|
|
||||||
private bool _isAuthenticated;
|
private bool _isAuthenticated;
|
||||||
private string _search = string.Empty;
|
private string _search = string.Empty;
|
||||||
@@ -20,42 +23,107 @@ public partial class Products : ComponentBase, IDisposable
|
|||||||
private bool _hasMore = true;
|
private bool _hasMore = true;
|
||||||
private int _totalCount;
|
private int _totalCount;
|
||||||
private const int PageSize = 12;
|
private const int PageSize = 12;
|
||||||
|
private const string DefaultSortBy = "price desc";
|
||||||
|
|
||||||
private List<DiscountProductCard> _products = new();
|
private List<DiscountProductCard> _products = new();
|
||||||
private List<DiscountCategoryNode> _categories = new();
|
private List<DiscountCategoryNode> _categories = new();
|
||||||
|
private bool _ignoreNextLocationChange;
|
||||||
|
private bool _pendingScrollRestore;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
_isAuthenticated = await AuthService.IsAuthenticatedAsync();
|
_isAuthenticated = await AuthService.IsAuthenticatedAsync();
|
||||||
_loading = true;
|
|
||||||
await DiscountCart.EnsureInitializedAsync();
|
await DiscountCart.EnsureInitializedAsync();
|
||||||
DiscountCart.OnChange += StateHasChanged;
|
DiscountCart.OnChange += StateHasChanged;
|
||||||
var categoriesTask = ProductService.GetCategoriesAsync();
|
Navigation.LocationChanged += HandleLocationChanged;
|
||||||
var productsTask = ProductService.GetProductsAsync(page: 1, pageSize: PageSize);
|
|
||||||
await Task.WhenAll(categoriesTask, productsTask);
|
_categories = await ProductService.GetCategoriesAsync();
|
||||||
_categories = categoriesTask.Result;
|
ApplyStateFromUri();
|
||||||
var result = productsTask.Result;
|
_loading = true;
|
||||||
_products = result.Products;
|
await LoadPages(_currentPage);
|
||||||
_totalCount = result.TotalCount;
|
|
||||||
_hasMore = result.CurrentPage < result.TotalPages;
|
|
||||||
_loading = false;
|
_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;
|
_loading = true;
|
||||||
_currentPage = 1;
|
_currentPage = 1;
|
||||||
_products.Clear();
|
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
|
await LoadPages(1);
|
||||||
var result = await ProductService.GetProductsAsync(
|
SyncUrl();
|
||||||
page: 1,
|
|
||||||
pageSize: PageSize,
|
|
||||||
search: string.IsNullOrWhiteSpace(_search) ? null : _search,
|
|
||||||
categoryId: _selectedCategoryId);
|
|
||||||
_products = result.Products;
|
|
||||||
_totalCount = result.TotalCount;
|
|
||||||
_hasMore = result.CurrentPage < result.TotalPages;
|
|
||||||
_loading = false;
|
_loading = false;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
@@ -63,37 +131,37 @@ public partial class Products : ComponentBase, IDisposable
|
|||||||
private async Task LoadMore()
|
private async Task LoadMore()
|
||||||
{
|
{
|
||||||
if (_loadingMore || !_hasMore) return;
|
if (_loadingMore || !_hasMore) return;
|
||||||
|
|
||||||
_loadingMore = true;
|
_loadingMore = true;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
|
|
||||||
_currentPage++;
|
_currentPage++;
|
||||||
var result = await ProductService.GetProductsAsync(
|
var result = await ProductService.GetProductsAsync(
|
||||||
page: _currentPage,
|
page: _currentPage,
|
||||||
pageSize: PageSize,
|
pageSize: PageSize,
|
||||||
search: string.IsNullOrWhiteSpace(_search) ? null : _search,
|
search: string.IsNullOrWhiteSpace(_search) ? null : _search,
|
||||||
categoryId: _selectedCategoryId);
|
categoryId: _selectedCategoryId,
|
||||||
|
sortBy: DefaultSortBy);
|
||||||
_products.AddRange(result.Products);
|
_products.AddRange(result.Products);
|
||||||
_hasMore = result.CurrentPage < result.TotalPages;
|
_hasMore = result.CurrentPage < result.TotalPages;
|
||||||
|
SyncUrl();
|
||||||
|
|
||||||
_loadingMore = false;
|
_loadingMore = false;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task SearchProducts() => await LoadInitial();
|
private async Task SearchProducts() => await ReloadFromFilters();
|
||||||
|
|
||||||
private async Task OnSearchKeyUp(KeyboardEventArgs e)
|
private async Task OnSearchKeyUp(KeyboardEventArgs e)
|
||||||
{
|
{
|
||||||
if (e.Key == "Enter")
|
if (e.Key == "Enter")
|
||||||
{
|
await ReloadFromFilters();
|
||||||
await LoadInitial();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task OnCategoryChanged(long? value)
|
private async Task OnCategoryChanged(long? value)
|
||||||
{
|
{
|
||||||
_selectedCategoryId = value;
|
_selectedCategoryId = value;
|
||||||
await LoadInitial();
|
await ReloadFromFilters();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task AddToCart(DiscountProductCard p)
|
private async Task AddToCart(DiscountProductCard p)
|
||||||
@@ -101,15 +169,44 @@ public partial class Products : ComponentBase, IDisposable
|
|||||||
await GuestGate.RunAsync(() => DiscountCart.AddAsync(p.Id));
|
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}");
|
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} تومان";
|
private static string FormatPrice(long price) => $"{price:N0} تومان";
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
DiscountCart.OnChange -= StateHasChanged;
|
DiscountCart.OnChange -= StateHasChanged;
|
||||||
|
Navigation.LocationChanged -= HandleLocationChanged;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -118,7 +118,8 @@
|
|||||||
@foreach (var p in _products)
|
@foreach (var p in _products)
|
||||||
{
|
{
|
||||||
<MudItem xs="6" sm="6" md="3"
|
<MudItem xs="6" sm="6" md="3"
|
||||||
onclick="@(() => Navigation.NavigateTo($"{RouteConstants.Store.ProductDetail}{p.Id}"))">
|
onclick="@(() => OpenProduct(p.Id))">
|
||||||
|
<div id="@($"shop-product-{p.Id}")" class="h-100">
|
||||||
<MudCard Class="rounded-lg h-100 d-flex flex-column overflow-hidden"
|
<MudCard Class="rounded-lg h-100 d-flex flex-column overflow-hidden"
|
||||||
Style="cursor:pointer;">
|
Style="cursor:pointer;">
|
||||||
|
|
||||||
@@ -164,6 +165,7 @@
|
|||||||
</MudButton>
|
</MudButton>
|
||||||
</MudCardActions>
|
</MudCardActions>
|
||||||
</MudCard>
|
</MudCard>
|
||||||
|
</div>
|
||||||
</MudItem>
|
</MudItem>
|
||||||
}
|
}
|
||||||
</MudGrid>
|
</MudGrid>
|
||||||
|
|||||||
@@ -2,19 +2,11 @@ using System.Linq;
|
|||||||
using Microsoft.AspNetCore.Components;
|
using Microsoft.AspNetCore.Components;
|
||||||
using Microsoft.AspNetCore.Components.Routing;
|
using Microsoft.AspNetCore.Components.Routing;
|
||||||
using Microsoft.AspNetCore.Components.Web;
|
using Microsoft.AspNetCore.Components.Web;
|
||||||
using Microsoft.AspNetCore.WebUtilities;
|
using Microsoft.JSInterop;
|
||||||
using FrontOffice.Main.Utilities;
|
using FrontOffice.Main.Utilities;
|
||||||
|
|
||||||
namespace FrontOffice.Main.Pages.Store;
|
namespace FrontOffice.Main.Pages.Store;
|
||||||
|
|
||||||
public enum ProductSortOption
|
|
||||||
{
|
|
||||||
PriceDesc, // گرانترین (پیشفرض)
|
|
||||||
PriceAsc, // ارزانترین
|
|
||||||
Newest, // جدیدترین
|
|
||||||
Title // الفبایی
|
|
||||||
}
|
|
||||||
|
|
||||||
public partial class Products : ComponentBase, IDisposable
|
public partial class Products : ComponentBase, IDisposable
|
||||||
{
|
{
|
||||||
[Inject] private ProductService ProductService { get; set; } = default!;
|
[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 VATService VAT { get; set; } = default!;
|
||||||
[Inject] private GuestActionGate GuestGate { get; set; } = default!;
|
[Inject] private GuestActionGate GuestGate { get; set; } = default!;
|
||||||
[Inject] private AuthService AuthService { get; set; } = default!;
|
[Inject] private AuthService AuthService { get; set; } = default!;
|
||||||
|
[Inject] private IJSRuntime Js { get; set; } = default!;
|
||||||
|
|
||||||
private bool _isAuthenticated;
|
private bool _isAuthenticated;
|
||||||
private string _query = string.Empty;
|
private string _query = string.Empty;
|
||||||
@@ -35,8 +28,9 @@ public partial class Products : ComponentBase, IDisposable
|
|||||||
private List<Product> _products = new();
|
private List<Product> _products = new();
|
||||||
private long? _activeCategoryId;
|
private long? _activeCategoryId;
|
||||||
private string? _activeCategoryTitle;
|
private string? _activeCategoryTitle;
|
||||||
|
private ProductSortOption _sortOption = ProductSortOption.PriceDesc;
|
||||||
private ProductSortOption _sortOption = ProductSortOption.PriceDesc; // پیشفرض: گرانترین
|
private bool _ignoreNextLocationChange;
|
||||||
|
private bool _pendingScrollRestore;
|
||||||
|
|
||||||
protected override async Task OnInitializedAsync()
|
protected override async Task OnInitializedAsync()
|
||||||
{
|
{
|
||||||
@@ -44,80 +38,132 @@ public partial class Products : ComponentBase, IDisposable
|
|||||||
await Cart.EnsureInitializedAsync();
|
await Cart.EnsureInitializedAsync();
|
||||||
Cart.OnChange += StateHasChanged;
|
Cart.OnChange += StateHasChanged;
|
||||||
Navigation.LocationChanged += HandleLocationChanged;
|
Navigation.LocationChanged += HandleLocationChanged;
|
||||||
await LoadInitial();
|
ApplyStateFromUri();
|
||||||
|
await LoadPages(_currentPage);
|
||||||
|
_pendingScrollRestore = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task OnAfterRenderAsync(bool firstRender)
|
protected override async Task OnAfterRenderAsync(bool firstRender)
|
||||||
{
|
{
|
||||||
if (firstRender)
|
if (firstRender)
|
||||||
{
|
|
||||||
// بارگذاری نرخ VAT
|
|
||||||
await VAT.LoadAsync();
|
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);
|
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;
|
_loading = true;
|
||||||
_currentPage = 1;
|
|
||||||
_products.Clear();
|
_products.Clear();
|
||||||
UpdateCategoryFilterFromUri();
|
_hasMore = true;
|
||||||
var sortBy = GetSortByValue();
|
pagesToLoad = Math.Max(1, pagesToLoad);
|
||||||
var result = await ProductService.GetProductsPagedAsync(_query, _activeCategoryId, sortBy, _currentPage, PageSize);
|
var sortBy = ShopListQueryState.ToApiSortBy(_sortOption);
|
||||||
_products = result.Products;
|
|
||||||
_hasMore = result.HasNext;
|
for (var page = 1; page <= pagesToLoad; page++)
|
||||||
_totalCount = result.TotalCount;
|
{
|
||||||
|
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
|
_activeCategoryTitle = _activeCategoryId is { } categoryId
|
||||||
? (await CategoryService.GetByIdAsync(categoryId))?.Title
|
? (await CategoryService.GetByIdAsync(categoryId))?.Title
|
||||||
: null;
|
: null;
|
||||||
_loading = false;
|
_loading = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ReloadFromFilters()
|
||||||
|
{
|
||||||
|
_currentPage = 1;
|
||||||
|
await LoadPages(1);
|
||||||
|
SyncUrl();
|
||||||
|
StateHasChanged();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task LoadMore()
|
private async Task LoadMore()
|
||||||
{
|
{
|
||||||
if (_loadingMore || !_hasMore) return;
|
if (_loadingMore || !_hasMore) return;
|
||||||
|
|
||||||
_loadingMore = true;
|
_loadingMore = true;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
|
|
||||||
_currentPage++;
|
_currentPage++;
|
||||||
var sortBy = GetSortByValue();
|
var sortBy = ShopListQueryState.ToApiSortBy(_sortOption);
|
||||||
var result = await ProductService.GetProductsPagedAsync(_query, _activeCategoryId, sortBy, _currentPage, PageSize);
|
var result = await ProductService.GetProductsPagedAsync(
|
||||||
|
_query, _activeCategoryId, sortBy, _currentPage, PageSize);
|
||||||
_products.AddRange(result.Products);
|
_products.AddRange(result.Products);
|
||||||
_hasMore = result.HasNext;
|
_hasMore = result.HasNext;
|
||||||
|
SyncUrl();
|
||||||
|
|
||||||
_loadingMore = false;
|
_loadingMore = false;
|
||||||
StateHasChanged();
|
StateHasChanged();
|
||||||
}
|
}
|
||||||
|
|
||||||
private string GetSortByValue()
|
private async Task OnSortChanged() => await ReloadFromFilters();
|
||||||
{
|
|
||||||
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()
|
private async Task OnQueryChanged(KeyboardEventArgs _) => await ReloadFromFilters();
|
||||||
{
|
|
||||||
await LoadInitial();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task OnQueryChanged(KeyboardEventArgs _)
|
|
||||||
{
|
|
||||||
await LoadInitial();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task AddToCart(Product p)
|
private async Task AddToCart(Product p)
|
||||||
{
|
{
|
||||||
await GuestGate.RunAsync(() => Cart.Add(p, 1));
|
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} تومان";
|
private string FormatPrice(long price) => $"{VAT.AddVAT(price):N0} تومان";
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@@ -128,24 +174,33 @@ public partial class Products : ComponentBase, IDisposable
|
|||||||
|
|
||||||
private void HandleLocationChanged(object? sender, LocationChangedEventArgs args)
|
private void HandleLocationChanged(object? sender, LocationChangedEventArgs args)
|
||||||
{
|
{
|
||||||
_ = InvokeAsync(LoadInitial);
|
if (_ignoreNextLocationChange)
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
_ignoreNextLocationChange = false;
|
||||||
return;
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
using Microsoft.AspNetCore.WebUtilities;
|
||||||
|
using Microsoft.Extensions.Primitives;
|
||||||
|
|
||||||
|
namespace FrontOffice.Main.Utilities;
|
||||||
|
|
||||||
|
public enum ProductSortOption
|
||||||
|
{
|
||||||
|
PriceDesc,
|
||||||
|
PriceAsc,
|
||||||
|
Newest,
|
||||||
|
Title
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// وضعیت لیست فروشگاه در query string — فقط برای /products و /discount-store.
|
||||||
|
/// </summary>
|
||||||
|
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<string, string?>();
|
||||||
|
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<string, StringValues> q, string key) =>
|
||||||
|
q.TryGetValue(key, out var v) ? v.FirstOrDefault() : null;
|
||||||
|
|
||||||
|
private static long? GetLong(Dictionary<string, StringValues> q, string key) =>
|
||||||
|
long.TryParse(GetString(q, key), out var n) ? n : null;
|
||||||
|
|
||||||
|
private static int? GetInt(Dictionary<string, StringValues> q, string key) =>
|
||||||
|
int.TryParse(GetString(q, key), out var n) ? n : null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using Microsoft.JSInterop;
|
||||||
|
|
||||||
|
namespace FrontOffice.Main.Utilities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// اسکرول/فوکوس محصول پس از بازگشت از جزئیات — کلیدهای محدود به لیست فروشگاه (نه لندینگ).
|
||||||
|
/// </summary>
|
||||||
|
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<double>("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<ScrollPayload?> TakeAsync(IJSRuntime js, string storageKey)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var raw = await js.InvokeAsync<string?>("sessionStorage.getItem", storageKey);
|
||||||
|
await js.InvokeVoidAsync("sessionStorage.removeItem", storageKey);
|
||||||
|
if (string.IsNullOrWhiteSpace(raw))
|
||||||
|
return null;
|
||||||
|
return JsonSerializer.Deserialize<ScrollPayload>(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);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user