feat: enhance product listing and navigation functionality
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled

- 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.
This commit is contained in:
masoodafar-web
2026-07-26 00:17:55 +03:30
parent 2339ca99b4
commit a708727241
6 changed files with 409 additions and 90 deletions
@@ -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);
}