From 231da2cbaaec7f232d9fbfaa7ea0bef8ff8f9fca Mon Sep 17 00:00:00 2001 From: masoodafar-web Date: Wed, 13 May 2026 19:48:14 +0330 Subject: [PATCH] feat: add top-seller product sections to landing page with guest browsing support - 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 --- src/FrontOffice.Main/ConfigureServices.cs | 1 + .../Pages/Club/MembershipPage.razor | 2 +- .../Pages/DiscountStore/Cart.razor.cs | 6 + .../Pages/DiscountStore/Checkout.razor.cs | 6 + .../DiscountStore/ProductDetail.razor.cs | 11 +- .../Pages/DiscountStore/Products.razor.cs | 3 +- src/FrontOffice.Main/Pages/Index.razor | 155 ++++++++++++++++++ src/FrontOffice.Main/Pages/Index.razor.cs | 47 +++++- .../Pages/Store/Cart.razor.cs | 6 + .../Pages/Store/CheckoutSummary.razor.cs | 6 + .../Pages/Store/ProductDetail.razor.cs | 11 +- .../Pages/Store/Products.razor.cs | 3 +- .../Utilities/DiscountProductService.cs | 7 +- .../Utilities/GuestActionGate.cs | 42 +++++ .../Utilities/ProductService.cs | 3 + 15 files changed, 299 insertions(+), 10 deletions(-) create mode 100644 src/FrontOffice.Main/Utilities/GuestActionGate.cs diff --git a/src/FrontOffice.Main/ConfigureServices.cs b/src/FrontOffice.Main/ConfigureServices.cs index f71e4db..fa7c3f9 100644 --- a/src/FrontOffice.Main/ConfigureServices.cs +++ b/src/FrontOffice.Main/ConfigureServices.cs @@ -56,6 +56,7 @@ public static class ConfigureServices services.AddSingleton(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Storefront services services.AddScoped(); services.AddScoped(); diff --git a/src/FrontOffice.Main/Pages/Club/MembershipPage.razor b/src/FrontOffice.Main/Pages/Club/MembershipPage.razor index 7cc4835..00b918a 100644 --- a/src/FrontOffice.Main/Pages/Club/MembershipPage.razor +++ b/src/FrontOffice.Main/Pages/Club/MembershipPage.razor @@ -70,7 +70,7 @@ شارژ کیف پول فروشگاه اعتباری - شارژ ۵۶ میلیون تومان کیف پول فروشگاه اعتباری + شارژ برابر ارزش پکیج فعال در کیف پول فروشگاه اعتباری diff --git a/src/FrontOffice.Main/Pages/DiscountStore/Cart.razor.cs b/src/FrontOffice.Main/Pages/DiscountStore/Cart.razor.cs index d53ea46..bdc7b0f 100644 --- a/src/FrontOffice.Main/Pages/DiscountStore/Cart.razor.cs +++ b/src/FrontOffice.Main/Pages/DiscountStore/Cart.razor.cs @@ -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; } diff --git a/src/FrontOffice.Main/Pages/DiscountStore/Checkout.razor.cs b/src/FrontOffice.Main/Pages/DiscountStore/Checkout.razor.cs index 4036725..50cb81b 100644 --- a/src/FrontOffice.Main/Pages/DiscountStore/Checkout.razor.cs +++ b/src/FrontOffice.Main/Pages/DiscountStore/Checkout.razor.cs @@ -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 _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(); diff --git a/src/FrontOffice.Main/Pages/DiscountStore/ProductDetail.razor.cs b/src/FrontOffice.Main/Pages/DiscountStore/ProductDetail.razor.cs index 5d09884..84d4d74 100644 --- a/src/FrontOffice.Main/Pages/DiscountStore/ProductDetail.razor.cs +++ b/src/FrontOffice.Main/Pages/DiscountStore/ProductDetail.razor.cs @@ -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 { diff --git a/src/FrontOffice.Main/Pages/DiscountStore/Products.razor.cs b/src/FrontOffice.Main/Pages/DiscountStore/Products.razor.cs index 9bdf7e9..183d8a4 100644 --- a/src/FrontOffice.Main/Pages/DiscountStore/Products.razor.cs +++ b/src/FrontOffice.Main/Pages/DiscountStore/Products.razor.cs @@ -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) diff --git a/src/FrontOffice.Main/Pages/Index.razor b/src/FrontOffice.Main/Pages/Index.razor index 4ba4291..7a76f5f 100644 --- a/src/FrontOffice.Main/Pages/Index.razor +++ b/src/FrontOffice.Main/Pages/Index.razor @@ -93,6 +93,161 @@ } +@* ═══════════════════════════════════════════════ + 1c. TOP-SELLING REGULAR PRODUCTS + ═══════════════════════════════════════════════ *@ +@if (_loadingTopProducts) +{ +
+ + + + + +
+} +else +{ + @if (_topRegularProducts.Any()) + { +
+ +
+ پرخریدترین محصولات فروشگاه + + محبوب‌ترین محصولات کارا بازار سلامت + +
+ + + @foreach (var p in _topRegularProducts) + { + + + +
+ @if (p.RemainingCount <= 0) + { +
+ ناموجود +
+ } + else if (p.RemainingCount <= 5) + { + + فقط @p.RemainingCount عدد + + } +
+
+ @p.Title + @FormatPrice(p.Price) +
+
+ + + @(p.RemainingCount <= 0 ? "ناموجود" : "افزودن به سبد") + + +
+
+ } +
+ +
+ + مشاهده همه محصولات + +
+
+
+ } + + @* ═══════════════════════════════════════════════ + 1d. TOP-SELLING DISCOUNT STORE PRODUCTS + ═══════════════════════════════════════════════ *@ + @if (_topDiscountProducts.Any()) + { +
+ +
+ پرخریدترین محصولات فروشگاه اعتباری + + محصولات ویژه اعضای باشگاه با پرداخت اعتباری + +
+ + + @foreach (var p in _topDiscountProducts) + { + + + +
+ @if (p.RemainingCount <= 0) + { +
+ ناموجود +
+ } + else if (p.RemainingCount <= 5) + { + + فقط @p.RemainingCount عدد + + } + @if (p.MaxDiscountPercent > 0) + { + + @p.MaxDiscountPercent% اعتبار + + } +
+
+ @p.Title +
+ @FormatDiscountPrice(p.Price) + (+ ارزش افزوده) +
+
+
+ + + @(p.RemainingCount <= 0 ? "ناموجود" : "افزودن به سبد") + + +
+
+ } +
+ +
+ + مشاهده همه محصولات اعتباری + +
+
+
+ } +} + @* ═══════════════════════════════════════════════ 2. HOW IT WORKS — 3 numbered steps ═══════════════════════════════════════════════ *@ diff --git a/src/FrontOffice.Main/Pages/Index.razor.cs b/src/FrontOffice.Main/Pages/Index.razor.cs index 88fb346..c819d94 100644 --- a/src/FrontOffice.Main/Pages/Index.razor.cs +++ b/src/FrontOffice.Main/Pages/Index.razor.cs @@ -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 _topRegularProducts = new(); + private List _topDiscountProducts = new(); + private bool _loadingTopProducts = true; + // ── Latest blog posts (loaded from CMS) ── private List _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); diff --git a/src/FrontOffice.Main/Pages/Store/Cart.razor.cs b/src/FrontOffice.Main/Pages/Store/Cart.razor.cs index ee5adbd..f2d5bd2 100644 --- a/src/FrontOffice.Main/Pages/Store/Cart.razor.cs +++ b/src/FrontOffice.Main/Pages/Store/Cart.razor.cs @@ -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; diff --git a/src/FrontOffice.Main/Pages/Store/CheckoutSummary.razor.cs b/src/FrontOffice.Main/Pages/Store/CheckoutSummary.razor.cs index 7ca7aad..6a50925 100644 --- a/src/FrontOffice.Main/Pages/Store/CheckoutSummary.razor.cs +++ b/src/FrontOffice.Main/Pages/Store/CheckoutSummary.razor.cs @@ -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 _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(); diff --git a/src/FrontOffice.Main/Pages/Store/ProductDetail.razor.cs b/src/FrontOffice.Main/Pages/Store/ProductDetail.razor.cs index 2c08830..a42c25e 100644 --- a/src/FrontOffice.Main/Pages/Store/ProductDetail.razor.cs +++ b/src/FrontOffice.Main/Pages/Store/ProductDetail.razor.cs @@ -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() diff --git a/src/FrontOffice.Main/Pages/Store/Products.razor.cs b/src/FrontOffice.Main/Pages/Store/Products.razor.cs index 4394fa2..7fa0517 100644 --- a/src/FrontOffice.Main/Pages/Store/Products.razor.cs +++ b/src/FrontOffice.Main/Pages/Store/Products.razor.cs @@ -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} تومان"; diff --git a/src/FrontOffice.Main/Utilities/DiscountProductService.cs b/src/FrontOffice.Main/Utilities/DiscountProductService.cs index c324b1c..149fedc 100644 --- a/src/FrontOffice.Main/Utilities/DiscountProductService.cs +++ b/src/FrontOffice.Main/Utilities/DiscountProductService.cs @@ -80,7 +80,7 @@ public class DiscountProductService public async Task 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 GetTopSellingAsync(int count = 6) + => GetProductsAsync(page: 1, pageSize: count, sortBy: "SaleCount desc"); + public async Task GetByIdAsync(long productId) { try diff --git a/src/FrontOffice.Main/Utilities/GuestActionGate.cs b/src/FrontOffice.Main/Utilities/GuestActionGate.cs new file mode 100644 index 0000000..6946867 --- /dev/null +++ b/src/FrontOffice.Main/Utilities/GuestActionGate.cs @@ -0,0 +1,42 @@ +namespace FrontOffice.Main.Utilities; + +/// +/// هر action ای که نیاز به لاگین دارد را از طریق این سرویس اجرا کنید. +/// اگر کاربر لاگین نباشد، مودال ورود نشان داده می‌شود. +/// پس از ورود موفق، action به صورت خودکار اجرا می‌شود. +/// +public class GuestActionGate +{ + private readonly AuthService _authService; + private readonly AuthDialogService _authDialogService; + + public GuestActionGate(AuthService authService, AuthDialogService authDialogService) + { + _authService = authService; + _authDialogService = authDialogService; + } + + /// + /// اگر کاربر لاگین باشد action را اجرا می‌کند. + /// در غیر این صورت مودال لاگین را باز کرده و پس از ورود موفق، action را اجرا می‌کند. + /// + /// true اگر action اجرا شد، false اگر کاربر لاگین نکرد. + public async Task RunAsync(Func action) + { + if (await _authService.IsAuthenticatedAsync()) + { + await action(); + return true; + } + + await _authDialogService.ShowAuthDialogAsync(); + + if (await _authService.IsAuthenticatedAsync()) + { + await action(); + return true; + } + + return false; + } +} diff --git a/src/FrontOffice.Main/Utilities/ProductService.cs b/src/FrontOffice.Main/Utilities/ProductService.cs index 0640bee..ca09d3e 100644 --- a/src/FrontOffice.Main/Utilities/ProductService.cs +++ b/src/FrontOffice.Main/Utilities/ProductService.cs @@ -71,6 +71,9 @@ public class ProductService return result.Products; } + public Task GetTopSellingAsync(int count = 6) + => GetProductsPagedAsync(sortBy: "SaleCount desc", page: 1, pageSize: count); + public async Task GetProductsPagedAsync( string? query = null, long? categoryId = null, string? sortBy = null, int page = 1, int pageSize = 12)