feat: add top-seller product sections to landing page with guest browsing support
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 3m10s

- Add two sections to landing page (Index.razor) below the hero:
  - Top 6 best-selling regular products (3-per-row grid)
  - Top 6 best-selling discount store products (3-per-row grid)
  - Each section has a 'More' button linking to /products and /discount-store
- Add GuestActionGate utility service: wraps auth-required actions; shows
  login modal for guests and resumes the original action after successful login
- Register GuestActionGate as scoped service in ConfigureServices
- Add GetTopSellingAsync(count) to ProductService and DiscountProductService
- Add optional sortBy parameter to DiscountProductService.GetProductsAsync
- Hybridize all product pages for guest browsing:
  - Store/Products, Store/ProductDetail: wrap AddToCart with GuestActionGate
  - DiscountStore/Products, DiscountStore/ProductDetail: wrap AddToCart with GuestActionGate
  - Store/Cart, DiscountStore/Cart, Store/CheckoutSummary: soft auth gate on page init
- Fix MembershipPage membership benefit text to be package-agnostic

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
masoodafar-web
2026-05-13 19:48:14 +03:30
parent a1d67c5865
commit 231da2cbaa
15 changed files with 299 additions and 10 deletions
@@ -56,6 +56,7 @@ public static class ConfigureServices
services.AddSingleton<UserAuthInfo>();
services.AddScoped<AuthService>();
services.AddScoped<AuthDialogService>();
services.AddScoped<GuestActionGate>();
// Storefront services
services.AddScoped<CartService>();
services.AddScoped<ProductService>();
@@ -70,7 +70,7 @@
<MudIcon Icon="@Icons.Material.Filled.AccountBalanceWallet" Color="Color.Success" Size="Size.Large" />
<MudStack Spacing="0">
<MudText Typo="Typo.subtitle1">شارژ کیف پول فروشگاه اعتباری</MudText>
<MudText Typo="Typo.caption" Color="Color.Default">شارژ ۵۶ میلیون تومان کیف پول فروشگاه اعتباری</MudText>
<MudText Typo="Typo.caption" Color="Color.Default">شارژ برابر ارزش پکیج فعال در کیف پول فروشگاه اعتباری</MudText>
</MudStack>
</MudStack>
<MudDivider />
@@ -7,9 +7,15 @@ public partial class Cart : IDisposable
{
[Inject] private DiscountCartService DiscountCart { get; set; } = default!;
[Inject] private VATService VAT { get; set; } = default!;
[Inject] private AuthDialogService AuthDialogService { get; set; } = default!;
[Inject] private AuthService AuthService { get; set; } = default!;
protected override async Task OnInitializedAsync()
{
if (!await AuthService.IsAuthenticatedAsync())
{
await AuthDialogService.ShowAuthDialogAsync();
}
await DiscountCart.EnsureInitializedAsync();
DiscountCart.OnChange += StateHasChanged;
}
@@ -11,6 +11,8 @@ public partial class Checkout
[Inject] private DiscountOrderService DiscountOrderService { get; set; } = default!;
[Inject] private UserAddressContract.UserAddressContractClient UserAddressContract { get; set; } = default!;
[Inject] private VATService VAT { get; set; } = default!;
[Inject] private AuthDialogService AuthDialogService { get; set; } = default!;
[Inject] private AuthService AuthService { get; set; } = default!;
private List<CustomerAddressModel> _addresses = new();
private CustomerAddressModel? _selectedAddress;
@@ -32,6 +34,10 @@ public partial class Checkout
protected override async Task OnInitializedAsync()
{
if (!await AuthService.IsAuthenticatedAsync())
{
await AuthDialogService.ShowAuthDialogAsync();
}
await VAT.LoadAsync();
await DiscountCart.EnsureInitializedAsync();
await LoadAddresses();
@@ -10,6 +10,7 @@ public partial class ProductDetail
[Inject] private DiscountProductService DiscountProductService { get; set; } = default!;
[Inject] private DiscountCartService DiscountCartService { get; set; } = default!;
[Inject] private VATService VAT { get; set; } = default!;
[Inject] private GuestActionGate GuestGate { get; set; } = default!;
private DiscountProductDetail? _product;
private string _selectedImage = string.Empty;
@@ -59,8 +60,14 @@ public partial class ProductDetail
_addingToCart = true;
try
{
await DiscountCartService.AddAsync(_product.Id, _quantity);
Snackbar.Add($"{_product.Title} به سبد خرید اضافه شد", MudBlazor.Severity.Success);
var productId = _product.Id;
var qty = _quantity;
var title = _product.Title;
await GuestGate.RunAsync(async () =>
{
await DiscountCartService.AddAsync(productId, qty);
Snackbar.Add($"{title} به سبد خرید اضافه شد", MudBlazor.Severity.Success);
});
}
catch
{
@@ -8,6 +8,7 @@ public partial class Products : ComponentBase, IDisposable
{
[Inject] private DiscountProductService ProductService { get; set; } = default!;
[Inject] private DiscountCartService DiscountCart { get; set; } = default!;
[Inject] private GuestActionGate GuestGate { get; set; } = default!;
private string _search = string.Empty;
private long? _selectedCategoryId;
@@ -94,7 +95,7 @@ public partial class Products : ComponentBase, IDisposable
private async Task AddToCart(DiscountProductCard p)
{
await DiscountCart.AddAsync(p.Id);
await GuestGate.RunAsync(() => DiscountCart.AddAsync(p.Id));
}
private void NavigateToProduct(long id)
+155
View File
@@ -93,6 +93,161 @@
</section>
}
@* ═══════════════════════════════════════════════
1c. TOP-SELLING REGULAR PRODUCTS
═══════════════════════════════════════════════ *@
@if (_loadingTopProducts)
{
<section class="section-landing" style="background:var(--mud-palette-background-gray);">
<MudContainer MaxWidth="MaxWidth.Large">
<MudStack AlignItems="AlignItems.Center" Class="py-4">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Small" />
</MudStack>
</MudContainer>
</section>
}
else
{
@if (_topRegularProducts.Any())
{
<section class="section-landing" style="background:var(--mud-palette-background-gray);">
<MudContainer MaxWidth="MaxWidth.Large">
<div class="text-center mb-6 fade-in-up">
<MudText Typo="Typo.h3">پرخریدترین محصولات فروشگاه</MudText>
<MudText Typo="Typo.body1" Class="mud-text-secondary mt-2">
محبوب‌ترین محصولات کارا بازار سلامت
</MudText>
</div>
<MudGrid Spacing="2" Justify="Justify.FlexStart">
@foreach (var p in _topRegularProducts)
{
<MudItem xs="6" sm="6" md="4">
<MudCard Class="rounded-lg h-100 d-flex flex-column overflow-hidden landing-product-card"
Style="cursor:pointer;"
@onclick="() => NavigateToRegularProduct(p.Id)">
<MudCardContent Class="d-flex flex-column pa-1 h-100">
<div style="aspect-ratio:1/1;width:100%;background-image:url('@p.ImageUrl');background-size:cover;background-position:center;border-radius:0.5rem;position:relative;">
@if (p.RemainingCount <= 0)
{
<div style="position:absolute;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;border-radius:0.5rem;">
<MudChip T="string" Color="Color.Error" Variant="Variant.Filled" Size="Size.Small">ناموجود</MudChip>
</div>
}
else if (p.RemainingCount <= 5)
{
<MudChip T="string" Color="Color.Warning" Variant="Variant.Filled" Size="Size.Small"
Style="position:absolute;top:8px;right:8px;">
فقط @p.RemainingCount عدد
</MudChip>
}
</div>
<div class="pa-1 flex-grow-1 d-flex flex-column justify-space-between">
<MudText Typo="Typo.subtitle1" Style="display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">@p.Title</MudText>
<MudText Typo="Typo.subtitle2" Color="Color.Primary">@FormatPrice(p.Price)</MudText>
</div>
</MudCardContent>
<MudCardActions Class="mt-auto pa-2" @onclick:stopPropagation="true">
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="true"
StartIcon="@Icons.Material.Filled.AddShoppingCart"
Disabled="@(p.RemainingCount <= 0)"
OnClick="@(() => AddRegularToCart(p))">
@(p.RemainingCount <= 0 ? "ناموجود" : "افزودن به سبد")
</MudButton>
</MudCardActions>
</MudCard>
</MudItem>
}
</MudGrid>
<div class="text-center mt-6">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Class="rounded-pill"
Href="@RouteConstants.Store.Products"
EndIcon="@Icons.Material.Filled.ArrowBack">
مشاهده همه محصولات
</MudButton>
</div>
</MudContainer>
</section>
}
@* ═══════════════════════════════════════════════
1d. TOP-SELLING DISCOUNT STORE PRODUCTS
═══════════════════════════════════════════════ *@
@if (_topDiscountProducts.Any())
{
<section class="section-landing">
<MudContainer MaxWidth="MaxWidth.Large">
<div class="text-center mb-6 fade-in-up">
<MudText Typo="Typo.h3">پرخریدترین محصولات فروشگاه اعتباری</MudText>
<MudText Typo="Typo.body1" Class="mud-text-secondary mt-2">
محصولات ویژه اعضای باشگاه با پرداخت اعتباری
</MudText>
</div>
<MudGrid Spacing="2" Justify="Justify.FlexStart">
@foreach (var p in _topDiscountProducts)
{
<MudItem xs="6" sm="6" md="4">
<MudCard Class="rounded-lg h-100 d-flex flex-column overflow-hidden landing-product-card"
Style="cursor:pointer;"
@onclick="() => NavigateToDiscountProduct(p.Id)">
<MudCardContent Class="d-flex flex-column pa-1 h-100">
<div style="aspect-ratio:1/1;width:100%;background-image:url('@(string.IsNullOrWhiteSpace(p.ThumbnailUrl) ? p.ImageUrl : p.ThumbnailUrl)');background-size:cover;background-position:center;border-radius:0.5rem;position:relative;">
@if (p.RemainingCount <= 0)
{
<div style="position:absolute;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;border-radius:0.5rem;">
<MudChip T="string" Color="Color.Error" Variant="Variant.Filled" Size="Size.Small">ناموجود</MudChip>
</div>
}
else if (p.RemainingCount <= 5)
{
<MudChip T="string" Color="Color.Warning" Variant="Variant.Filled" Size="Size.Small"
Style="position:absolute;top:8px;right:8px;">
فقط @p.RemainingCount عدد
</MudChip>
}
@if (p.MaxDiscountPercent > 0)
{
<MudChip T="string" Color="Color.Secondary" Variant="Variant.Filled" Size="Size.Small"
Style="position:absolute;top:8px;left:8px;">
@p.MaxDiscountPercent% اعتبار
</MudChip>
}
</div>
<div class="pa-1 flex-grow-1 d-flex flex-column justify-space-between">
<MudText Typo="Typo.subtitle1" Style="display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">@p.Title</MudText>
<div>
<MudText Typo="Typo.subtitle2" Color="Color.Primary">@FormatDiscountPrice(p.Price)</MudText>
<MudText Typo="Typo.overline" Class="mud-text-secondary" Style="font-size:0.6rem;line-height:1;">(+ ارزش افزوده)</MudText>
</div>
</div>
</MudCardContent>
<MudCardActions Class="mt-auto pa-2" @onclick:stopPropagation="true">
<MudButton Variant="Variant.Filled" Color="Color.Secondary" FullWidth="true"
StartIcon="@Icons.Material.Filled.AddShoppingCart"
Disabled="@(p.RemainingCount <= 0)"
OnClick="@(() => AddDiscountToCart(p))">
@(p.RemainingCount <= 0 ? "ناموجود" : "افزودن به سبد")
</MudButton>
</MudCardActions>
</MudCard>
</MudItem>
}
</MudGrid>
<div class="text-center mt-6">
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" Class="rounded-pill"
Href="@RouteConstants.DiscountStore.Products"
EndIcon="@Icons.Material.Filled.ArrowBack">
مشاهده همه محصولات اعتباری
</MudButton>
</div>
</MudContainer>
</section>
}
}
@* ═══════════════════════════════════════════════
2. HOW IT WORKS — 3 numbered steps
═══════════════════════════════════════════════ *@
+46 -1
View File
@@ -10,11 +10,22 @@ public partial class Index : IDisposable
[Inject] private BlogPostService BlogPostService { get; set; } = default!;
[Inject] private AuthService AuthService { get; set; } = default!;
[Inject] private SitePageSettingsService PageSettingsService { get; set; } = default!;
[Inject] private ProductService ProductService { get; set; } = default!;
[Inject] private DiscountProductService DiscountProductService { get; set; } = default!;
[Inject] private CartService Cart { get; set; } = default!;
[Inject] private DiscountCartService DiscountCart { get; set; } = default!;
[Inject] private GuestActionGate GuestGate { get; set; } = default!;
[Inject] private VATService VAT { get; set; } = default!;
// ── CMS page data ──
private PageSettingsDto? _pageData;
private LandingSettings? _settings;
// ── Top-selling product sections ──
private List<Product> _topRegularProducts = new();
private List<DiscountProductCard> _topDiscountProducts = new();
private bool _loadingTopProducts = true;
// ── Latest blog posts (loaded from CMS) ──
private List<BlogPostCardDto> _latestPosts = new();
@@ -51,10 +62,29 @@ public partial class Index : IDisposable
PopulateFromSettings();
_dataLoaded = true;
// Load top-selling products and blog posts in parallel
var topRegTask = ProductService.GetTopSellingAsync(6);
var topDiscTask = DiscountProductService.GetTopSellingAsync(6);
var featuredPostsTask = BlogPostService.GetFeaturedPostsAsync(2);
try
{
await Task.WhenAll(topRegTask, topDiscTask, featuredPostsTask);
_topRegularProducts = topRegTask.Result.Products;
_topDiscountProducts = topDiscTask.Result.Products;
}
catch
{
// Fallback: sections remain empty
}
_loadingTopProducts = false;
// Load latest published blog posts
try
{
_latestPosts = await BlogPostService.GetFeaturedPostsAsync(2);
_latestPosts = featuredPostsTask.IsCompletedSuccessfully
? featuredPostsTask.Result
: await BlogPostService.GetFeaturedPostsAsync(2);
if (_latestPosts.Count < 2)
{
@@ -267,6 +297,21 @@ public partial class Index : IDisposable
Navigation.NavigateTo($"/blog/{slug}");
}
private void NavigateToRegularProduct(long id)
=> Navigation.NavigateTo(RouteConstants.Store.ProductDetail + id);
private void NavigateToDiscountProduct(long id)
=> Navigation.NavigateTo(RouteConstants.DiscountStore.ProductDetail + id);
private async Task AddRegularToCart(Product p)
=> await GuestGate.RunAsync(() => Cart.Add(p, 1));
private async Task AddDiscountToCart(DiscountProductCard p)
=> await GuestGate.RunAsync(() => DiscountCart.AddAsync(p.Id));
private string FormatPrice(long price) => $"{VAT.AddVAT(price):N0} تومان";
private string FormatDiscountPrice(long price) => $"{price:N0} تومان";
private async void OnStateChanged()
{
await InvokeAsync(StateHasChanged);
@@ -8,11 +8,17 @@ public partial class Cart : ComponentBase, IDisposable
{
[Inject] private CartService CartService { get; set; } = default!;
[Inject] private VATService VAT { get; set; } = default!;
[Inject] private AuthDialogService AuthDialogService { get; set; } = default!;
[Inject] private AuthService AuthService { get; set; } = default!;
// Navigation and Snackbar are available via _Imports.razor
private CartService CartData => CartService;
protected override async Task OnInitializedAsync()
{
if (!await AuthService.IsAuthenticatedAsync())
{
await AuthDialogService.ShowAuthDialogAsync();
}
// لود سبد خرید (فقط اگر کاربر لاگین کرده باشد)
await CartService.EnsureInitializedAsync();
CartService.OnChange += StateHasChanged;
@@ -14,6 +14,8 @@ public partial class CheckoutSummary : ComponentBase
[Inject] private VATService VAT { get; set; } = default!;
[Inject] private UserAddressContract.UserAddressContractClient UserAddressContract { get; set; } = default!;
[Inject] private UserOrderContract.UserOrderContractClient UserOrderContract { get; set; } = default!;
[Inject] private AuthDialogService AuthDialogService { get; set; } = default!;
[Inject] private AuthService AuthService { get; set; } = default!;
// Snackbar and Navigation are injected via _Imports.razor
private List<CustomerAddressModel> _addresses = new();
@@ -27,6 +29,10 @@ public partial class CheckoutSummary : ComponentBase
protected override async Task OnInitializedAsync()
{
if (!await AuthService.IsAuthenticatedAsync())
{
await AuthDialogService.ShowAuthDialogAsync();
}
// لود سبد خرید (فقط اگر کاربر لاگین کرده باشد)
await Cart.EnsureInitializedAsync();
await LoadAddresses();
@@ -12,6 +12,7 @@ public partial class ProductDetail : ComponentBase, IDisposable
[Inject] private ProductService ProductService { get; set; } = default!;
[Inject] private CartService Cart { get; set; } = default!;
[Inject] private VATService VAT { get; set; } = default!;
[Inject] private GuestActionGate GuestGate { get; set; } = default!;
[Parameter] public long id { get; set; }
@@ -92,14 +93,18 @@ public partial class ProductDetail : ComponentBase, IDisposable
private async Task AddToCart()
{
if (_product is null) return;
await Cart.Add(_product, 1);
var product = _product;
await GuestGate.RunAsync(() => Cart.Add(product, 1));
}
private async Task RemoveFromCart()
{
if (_product is null) return;
_qty--;
await Cart.UpdateQuantity(CurrentCartItem.ProductId, _qty);
await GuestGate.RunAsync(async () =>
{
_qty--;
await Cart.UpdateQuantity(CurrentCartItem!.ProductId, _qty);
});
}
private void IncreaseLocalQty()
@@ -21,6 +21,7 @@ public partial class Products : ComponentBase, IDisposable
[Inject] private CategoryService CategoryService { get; set; } = default!;
[Inject] private CartService Cart { get; set; } = default!;
[Inject] private VATService VAT { get; set; } = default!;
[Inject] private GuestActionGate GuestGate { get; set; } = default!;
private string _query = string.Empty;
private bool _loading;
@@ -112,7 +113,7 @@ public partial class Products : ComponentBase, IDisposable
private async Task AddToCart(Product p)
{
await Cart.Add(p, 1);
await GuestGate.RunAsync(() => Cart.Add(p, 1));
}
private string FormatPrice(long price) => $"{VAT.AddVAT(price):N0} تومان";
@@ -80,7 +80,7 @@ public class DiscountProductService
public async Task<DiscountProductListResult> GetProductsAsync(
int page = 1, int pageSize = 12,
string? search = null, long? categoryId = null,
bool? inStock = null)
bool? inStock = null, string? sortBy = null)
{
try
{
@@ -97,6 +97,8 @@ public class DiscountProductService
request.CategoryId = categoryId.Value;
if (inStock.HasValue)
request.InStock = inStock.Value;
if (!string.IsNullOrWhiteSpace(sortBy))
request.SortBy = sortBy;
var response = await _productClient.GetDiscountProductsAsync(request);
@@ -125,6 +127,9 @@ public class DiscountProductService
}
}
public Task<DiscountProductListResult> GetTopSellingAsync(int count = 6)
=> GetProductsAsync(page: 1, pageSize: count, sortBy: "SaleCount desc");
public async Task<DiscountProductDetail?> GetByIdAsync(long productId)
{
try
@@ -0,0 +1,42 @@
namespace FrontOffice.Main.Utilities;
/// <summary>
/// هر action ای که نیاز به لاگین دارد را از طریق این سرویس اجرا کنید.
/// اگر کاربر لاگین نباشد، مودال ورود نشان داده می‌شود.
/// پس از ورود موفق، action به صورت خودکار اجرا می‌شود.
/// </summary>
public class GuestActionGate
{
private readonly AuthService _authService;
private readonly AuthDialogService _authDialogService;
public GuestActionGate(AuthService authService, AuthDialogService authDialogService)
{
_authService = authService;
_authDialogService = authDialogService;
}
/// <summary>
/// اگر کاربر لاگین باشد action را اجرا می‌کند.
/// در غیر این صورت مودال لاگین را باز کرده و پس از ورود موفق، action را اجرا می‌کند.
/// </summary>
/// <returns>true اگر action اجرا شد، false اگر کاربر لاگین نکرد.</returns>
public async Task<bool> RunAsync(Func<Task> action)
{
if (await _authService.IsAuthenticatedAsync())
{
await action();
return true;
}
await _authDialogService.ShowAuthDialogAsync();
if (await _authService.IsAuthenticatedAsync())
{
await action();
return true;
}
return false;
}
}
@@ -71,6 +71,9 @@ public class ProductService
return result.Products;
}
public Task<ProductListResult> GetTopSellingAsync(int count = 6)
=> GetProductsPagedAsync(sortBy: "SaleCount desc", page: 1, pageSize: count);
public async Task<ProductListResult> GetProductsPagedAsync(
string? query = null, long? categoryId = null, string? sortBy = null,
int page = 1, int pageSize = 12)