Compare commits

..

22 Commits

Author SHA1 Message Date
masoodafar-web 58037f0b49 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 5m9s
2026-08-24 00:10:52 +03:30
masoodafar-web aae4b2693d fix(checkout): add missing JSInterop import for enhanced interactivity
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 3m47s
- Included the Microsoft.JSInterop namespace in the Checkout.razor.cs file to enable JavaScript interop functionality.
- This addition prepares the Checkout component for future enhancements that may require interaction with JavaScript libraries or functions.

These changes improve the potential for interactive features in the checkout process, enhancing user experience.
2026-08-23 23:46:35 +03:30
masoodafar-web 61fd805c75 feat(profile): enhance withdrawal request processing with IBAN normalization
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 4m8s
- Implemented a new method to normalize Iranian IBANs, ensuring they are formatted correctly before processing withdrawal requests.
- Added validation to provide user feedback if the IBAN format is invalid, improving the user experience during withdrawal submissions.
- Updated the withdrawal request logic to utilize the normalized IBAN, enhancing the reliability of the withdrawal process.

These changes improve the accuracy and usability of the withdrawal request feature, ensuring users can submit valid IBANs effectively.
2026-08-23 23:01:39 +03:30
masoodafar-web ef6d233bbc refactor(checkout): simplify address management and improve package selection logic
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 2m45s
- Removed the address selection UI and related logic from the Checkout page to streamline the user experience.
- Updated the payment validation to only require package selection, enhancing clarity for users.
- Adjusted the loading of addresses in related components based on user authentication status, ensuring addresses are only loaded when available.

These changes improve the overall usability of the checkout process by focusing on essential selections and reducing unnecessary complexity.
2026-08-23 22:32:47 +03:30
masoodafar-web 3d84e726db Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 6m27s
2026-08-09 02:33:27 +03:30
masoodafar-web 13091849cf feat(wallet): update ChargeCreditWallet component for improved magic ceiling consent handling
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 8m1s
- Enhanced the user interface to provide clearer instructions and options for charging without a magic multiplier when the magic wallet ceiling is full.
- Removed the previous consent prompting logic and replaced it with direct buttons for user actions.
- Updated methods to streamline the consent process and improve navigation based on user choices.

These changes enhance user experience by simplifying the consent flow and providing immediate options for wallet charging.
2026-08-09 00:11:49 +03:30
masoodafar-web 8e98c50bdf feat(wallet): enhance ChargeCreditWallet and Wallet components with magic ceiling consent logic
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 5m29s
- Added new logic to handle user consent for charging without a magic multiplier when the magic wallet ceiling is full.
- Updated the ChargeCreditWallet component to display alerts and prompts based on the user's magic wallet status and consent.
- Enhanced the Wallet component to conditionally render buttons and alerts related to the magic wallet ceiling status.
- Improved user feedback in the InsufficientCreditDialog to guide users on charging options based on their wallet status.

These changes enhance the user experience by providing clear instructions and options for charging wallets under specific conditions.
2026-08-08 23:59:36 +03:30
masoodafar-web 6123e47968 feat(wallet): enhance ChargeCreditWallet and Wallet components with magic wallet functionality
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 5m28s
- Added logic to handle redirection and alerts for users with a magic wallet status in the ChargeCreditWallet component.
- Updated the Wallet component to conditionally render buttons and alerts based on the user's magic wallet status.
- Implemented a method to check the magic wallet status and provide user feedback regarding wallet charging options.

These changes improve user experience by clearly guiding users on the appropriate wallet charging methods based on their current wallet status.
2026-08-08 22:22:03 +03:30
masoodafar-web acad1a719f feat(index): add conditional rendering for testimonials section
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 6m31s
- Implemented a check to display the testimonials section only when there are testimonials available, enhancing the user experience by preventing empty sections.
- Updated the logic for loading testimonials from settings to handle cases where the testimonials list may be null, ensuring robustness in data handling.

These changes improve the layout and presentation of the Index page, providing users with relevant content based on available testimonials.
2026-08-07 18:39:49 +03:30
masoodafar-web c7e6d01c11 feat(orders): enhance order display and interaction for improved user experience
- Refactored the Orders page to improve the layout and presentation of order information, including a new responsive design for mobile and desktop views.
- Added a button to navigate to the store when no orders are present, enhancing user engagement.
- Updated the order details display to include payment and delivery status with color-coded chips for better visual feedback.
- Introduced helper methods for formatting prices and dates, improving code readability and maintainability.

These changes significantly enhance the usability and clarity of the order management interface, providing users with a more intuitive experience.
2026-08-07 18:28:38 +03:30
masoodafar-web c764614d26 feat(order-tracking): enhance tracking steps logic and delivery status handling
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 5m52s
- Refactored the BuildTrackingSteps method to improve the logic for displaying order tracking steps based on various delivery statuses.
- Added handling for new delivery statuses: "آماده تحویل در دفتر" and "مرجوع شده".
- Updated the payment status step to consolidate descriptions based on payment completion.
- Enhanced the overall user experience by providing clearer tracking information for different order states.

These changes improve the clarity and functionality of the order tracking feature, ensuring users receive accurate updates on their order status.
2026-08-07 17:35:05 +03:30
masoodafar-web 9203dc7924 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 6m24s
2026-08-07 14:38:22 +03:30
masoodafar-web b64d99c5b3 feat(profile): enhance ChargeCreditWallet and Wallet components with club membership checks
Build and Deploy to Kubernetes / build-and-deploy (push) Successful in 4m42s
- Added logic to display alerts and buttons based on club membership status in the ChargeCreditWallet and Wallet components.
- Implemented user authentication checks to determine if the user is a club member and has purchased a package before allowing wallet charging.
- Updated UI to guide users on purchasing packages or signing club contracts if they are not eligible to charge their wallet.

These changes improve user experience by providing clear instructions and preventing unauthorized wallet charges.
2026-08-06 23:59:42 +03:30
masoodafar-web 4989cd00e0 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Failing after 16m3s
2026-07-26 00:52:30 +03:30
masoodafar-web a708727241 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.
2026-07-26 00:17:55 +03:30
masoodafar-web bed35935ad Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Failing after 17m17s
2026-07-15 22:02:31 +03:30
masoodafar-web 2339ca99b4 fix: correct Persian labels in OrganizationChart component
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- Updated the labels for left and right legs in the OrganizationChart component from "پای چپ" and "پای راست" to "سازمان چپ" and "سازمان راست" for improved clarity and accuracy.
- Removed unnecessary whitespace in the FrontOffice.Main.csproj file.

These changes enhance the user interface by ensuring the terminology used in the OrganizationChart is more appropriate for the context.
2026-07-15 21:51:38 +03:30
masoodafar-web ceaaac3e23 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Failing after 18m42s
2026-07-03 06:04:56 +03:30
masoodafar-web 7f5a4f2c37 feat: enhance SEO and sitemap functionality
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- Updated the sitemap generation to return a byte array instead of a string for improved performance.
- Added new SEO metadata for categories and store chooser routes to enhance search engine visibility.
- Implemented redirects for legacy wishlist URLs to the homepage, improving user experience.
- Updated robots.txt to disallow indexing of specific paths, ensuring better control over search engine crawling.

These changes significantly improve the SEO capabilities and sitemap handling of the application, enhancing both performance and user navigation.
2026-07-03 06:04:44 +03:30
masoodafar-web db35814ce3 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Has been cancelled
2026-07-03 05:43:14 +03:30
masoodafar-web 06100ca6dd fix: update sitemap generation to use XmlWriter for improved XML handling
Build and Deploy to Kubernetes / build-and-deploy (push) Has been cancelled
- Changed the encoding in the XML declaration from "UTF-8" to "utf-8" for consistency.
- Replaced StringWriter with XmlWriter to enhance XML document writing and ensure proper encoding settings.
- Updated the return statement to convert the MemoryStream to a UTF-8 string, improving the efficiency of the sitemap generation process.

These changes enhance the reliability and correctness of the sitemap generation, ensuring better compliance with XML standards.
2026-07-03 05:30:23 +03:30
masoodafar-web 7a5a2110c6 feat: add SEO configuration for production environment
Build and Deploy to Production / build-and-deploy (push) Failing after 19m58s
- Introduced SEO settings in appsettings.Production.json, including SiteUrl, SiteName, and DefaultDescription.
- These additions enhance the application's search engine optimization capabilities, improving visibility and user engagement.
2026-07-01 21:48:49 +03:30
37 changed files with 1277 additions and 424 deletions
+1
View File
@@ -1,3 +1,4 @@
# Docs moved to totalDoc # Docs moved to totalDoc
See [totalDoc/INDEX.md](../../totalDoc/INDEX.md) for all documentation. See [totalDoc/INDEX.md](../../totalDoc/INDEX.md) for all documentation.
+1 -1
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web"> <Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
+1 -75
View File
@@ -71,80 +71,6 @@
</MudStack> </MudStack>
} }
</MudPaper> </MudPaper>
<!-- Address Selection -->
<MudPaper Elevation="2" Class="pa-4 rounded-lg">
<MudText Typo="Typo.h5" Class="mb-4">انتخاب آدرس</MudText>
@if (_isLoadingAddresses)
{
<MudStack AlignItems="AlignItems.Center" Class="py-4">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Medium" />
<MudText Typo="Typo.body2" Class="mud-text-secondary mt-2">بارگذاری آدرس‌ها...</MudText>
</MudStack>
}
else if (_addresses.Any())
{
<MudStack Spacing="2">
@foreach (var address in _addresses)
{
<MudPaper Outlined="@(_selectedAddress?.Id != address.Id)"
Elevation="@(_selectedAddress?.Id == address.Id ? 4 : 0)"
Class="pa-3 rounded-xl cursor-pointer"
Style="@(_selectedAddress?.Id == address.Id ? "border: 2px solid var(--mud-palette-primary);" : "")"
@onclick="() => SetAddressAsDefault(address.Id)">
<MudStack Spacing="1">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudCheckBox @bind-Value="address.IsDefault"
Disabled="true"
Color="Color.Primary" />
<MudText Typo="Typo.subtitle2">@(address.Title)</MudText>
@if (address.IsDefault)
{
<MudChip T="string" Color="Color.Success" Variant="Variant.Filled" Size="Size.Small">پیش‌فرض</MudChip>
}
@if (!address.IsDefault)
{
@if (_isSettingDefaultAddress && _settingDefaultAddressId == address.Id)
{
<MudProgressCircular Size="Size.Small" Color="Color.Primary" Indeterminate="true" />
}
else
{
<MudButton Variant="Variant.Text"
Color="Color.Primary"
Size="Size.Small"
OnClick="() => SetAddressAsDefault(address.Id)">
تنظیم به عنوان پیش‌فرض
</MudButton>
}
}
</MudStack>
<MudStack Row="true">
<MudText Typo="Typo.body2" Class="mud-text-secondary">@(address.Address)</MudText>
<MudSpacer />
<MudText Typo="Typo.caption" Class="mud-text-secondary">کد پستی: @(address.PostalCode)</MudText>
</MudStack>
</MudStack>
</MudPaper>
}
</MudStack>
}
else
{
<MudStack AlignItems="AlignItems.Center" Class="py-4">
<MudIcon Icon="@Icons.Material.Filled.LocationOff" Size="Size.Large" Color="Color.Default" />
<MudText Typo="Typo.body2" Class="mud-text-secondary mt-2">آدرسی ثبت نشده است.</MudText>
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="() => Navigation.NavigateTo(RouteConstants.Profile.Index)"
Class="mt-2">
افزودن آدرس
</MudButton>
</MudStack>
}
</MudPaper>
</MudStack> </MudStack>
</MudItem> </MudItem>
@@ -241,7 +167,7 @@
@if (!CanProceedToPayment) @if (!CanProceedToPayment)
{ {
<MudText Typo="Typo.caption" Color="Color.Error" Align="Align.Center"> <MudText Typo="Typo.caption" Color="Color.Error" Align="Align.Center">
لطفاً پکیج و آدرس را انتخاب کنید. لطفاً پکیج را انتخاب کنید.
</MudText> </MudText>
} }
</MudStack> </MudStack>
+6 -71
View File
@@ -1,6 +1,4 @@
using CMSMicroservice.Protobuf.Protos.Package; using CMSMicroservice.Protobuf.Protos.Package;
using CMSMicroservice.Protobuf.Protos.UserAddress;
using FrontOffice.Main.Utilities; using FrontOffice.Main.Utilities;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop; using Microsoft.JSInterop;
@@ -13,14 +11,10 @@ public partial class Checkout
{ {
[Inject] private PackageService PackageService { get; set; } = default!; [Inject] private PackageService PackageService { get; set; } = default!;
[Inject] private PackageContract.PackageContractClient PackageClient { get; set; } = default!; [Inject] private PackageContract.PackageContractClient PackageClient { get; set; } = default!;
[Inject] private UserAddressContract.UserAddressContractClient UserAddressContract { get; set; } = default!;
[Parameter] public long? PackageId { get; set; } [Parameter] public long? PackageId { get; set; }
private Pack? _selectedPackage; private Pack? _selectedPackage;
private List<CustomerAddressModel> _addresses = new();
private CustomerAddressModel? _selectedAddress;
private bool _isLoadingAddresses;
private bool _isProcessingPayment; private bool _isProcessingPayment;
// Discount code // Discount code
@@ -31,16 +25,11 @@ public partial class Checkout
private long _discountAmount; private long _discountAmount;
private long _finalPrice; private long _finalPrice;
// Address management private bool CanProceedToPayment => _selectedPackage != null;
private bool _isSettingDefaultAddress;
private long? _settingDefaultAddressId;
private bool CanProceedToPayment => _selectedPackage != null && _selectedAddress != null;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
await LoadPackageDetails(); await LoadPackageDetails();
await LoadAddresses();
} }
private async Task LoadPackageDetails() private async Task LoadPackageDetails()
@@ -72,60 +61,6 @@ public partial class Checkout
} }
} }
private async Task LoadAddresses()
{
_isLoadingAddresses = true;
try
{
var response = await UserAddressContract.GetCustomerAddressesAsync(request: new());
if (response?.Models?.Any() == true)
{
_addresses = response.Models.ToList();
// Select default address if available
_selectedAddress = _addresses.FirstOrDefault(a => a.IsDefault) ?? _addresses.First();
}
else
{
_addresses = new List<CustomerAddressModel>();
}
}
catch (Exception ex)
{
Snackbar.Add($"خطا در بارگذاری آدرس‌ها: {ex.Message}", Severity.Error);
_addresses = new List<CustomerAddressModel>();
}
finally
{
_isLoadingAddresses = false;
await InvokeAsync(StateHasChanged);
}
}
private async Task SetAddressAsDefault(long addressId)
{
if (_isSettingDefaultAddress) return;
_isSettingDefaultAddress = true;
_settingDefaultAddressId = addressId;
try
{
await UserAddressContract.SetAddressAsDefaultAsync(new() { Id = addressId });
await LoadAddresses(); // Reload addresses to reflect the change
Snackbar.Add("آدرس پیش‌فرض با موفقیت تغییر یافت.", Severity.Success);
}
catch (Exception ex)
{
Snackbar.Add($"خطا در تغییر آدرس پیش‌فرض: {ex.Message}", Severity.Error);
}
finally
{
_isSettingDefaultAddress = false;
_settingDefaultAddressId = null;
await InvokeAsync(StateHasChanged);
}
}
private async Task ApplyDiscountCode() private async Task ApplyDiscountCode()
{ {
if (string.IsNullOrWhiteSpace(_discountCode)) if (string.IsNullOrWhiteSpace(_discountCode))
@@ -157,7 +92,7 @@ public partial class Checkout
_finalPrice = _selectedPackage!.Price; _finalPrice = _selectedPackage!.Price;
} }
} }
catch (Exception ex) catch (Exception)
{ {
_discountMessage = "خطا در اعمال کد تخفیف."; _discountMessage = "خطا در اعمال کد تخفیف.";
_discountApplied = false; _discountApplied = false;
@@ -175,9 +110,9 @@ public partial class Checkout
{ {
if (_isProcessingPayment) return; if (_isProcessingPayment) return;
if (!CanProceedToPayment || _selectedPackage == null || _selectedAddress == null) if (!CanProceedToPayment || _selectedPackage == null)
{ {
Snackbar.Add("لطفاً پکیج و آدرس را انتخاب کنید.", Severity.Warning); Snackbar.Add("لطفاً پکیج را انتخاب کنید.", Severity.Warning);
return; return;
} }
@@ -219,9 +154,9 @@ public partial class Checkout
private async Task DayaLoanPayment() private async Task DayaLoanPayment()
{ {
if (_selectedAddress == null) if (_selectedPackage == null)
{ {
Snackbar.Add("لطفاً آدرس را انتخاب کنید.", Severity.Warning); Snackbar.Add("لطفاً پکیج را انتخاب کنید.", Severity.Warning);
return; return;
} }
@@ -40,7 +40,11 @@ public partial class Checkout
} }
await VAT.LoadAsync(); await VAT.LoadAsync();
await DiscountCart.EnsureInitializedAsync(); await DiscountCart.EnsureInitializedAsync();
var userInfo = await AuthService.GetUserAuthInfo();
if (userInfo.HasAddress)
await LoadAddresses(); await LoadAddresses();
else
_addresses = new();
} }
private async Task LoadAddresses() private async Task LoadAddresses()
@@ -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();
} }
@@ -72,28 +140,28 @@ public partial class Products : ComponentBase, IDisposable
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;
} }
} }
+3
View File
@@ -429,6 +429,8 @@ else
@* ═══════════════════════════════════════════════ @* ═══════════════════════════════════════════════
6. TESTIMONIALS 6. TESTIMONIALS
═══════════════════════════════════════════════ *@ ═══════════════════════════════════════════════ *@
@if (_testimonials.Count > 0)
{
<section class="section-landing"> <section class="section-landing">
<MudContainer MaxWidth="MaxWidth.Large"> <MudContainer MaxWidth="MaxWidth.Large">
<div class="text-center mb-8 fade-in-up"> <div class="text-center mb-8 fade-in-up">
@@ -465,6 +467,7 @@ else
</MudGrid> </MudGrid>
</MudContainer> </MudContainer>
</section> </section>
}
@* ═══════════════════════════════════════════════ @* ═══════════════════════════════════════════════
7. FAQ 7. FAQ
+3 -2
View File
@@ -193,9 +193,10 @@ public partial class Index : IDisposable
} }
// ── Testimonials ── // ── Testimonials ──
if (_settings?.Testimonials?.Any() == true) // اگر تنظیمات از CMS لود شده، لیست خالی یعنی ادمین عمداً حذف کرده — بدون fallback hardcode
if (_settings is not null)
{ {
_testimonials = _settings.Testimonials _testimonials = (_settings.Testimonials ?? [])
.Select((t, i) => new TestimonialItem(t.Quote ?? "", t.Name ?? "", t.Role ?? "", i * 150)) .Select((t, i) => new TestimonialItem(t.Quote ?? "", t.Name ?? "", t.Role ?? "", i * 150))
.ToList(); .ToList();
} }
@@ -1,4 +1,4 @@
using CMSMicroservice.Protobuf.Protos.UserAddress; using FrontOffice.Main.Utilities;
using CMSMicroservice.Protobuf.Protos.UserAddress; using CMSMicroservice.Protobuf.Protos.UserAddress;
using FrontOffice.Main.Pages.Profile.Components; using FrontOffice.Main.Pages.Profile.Components;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
@@ -9,6 +9,7 @@ namespace FrontOffice.Main.Pages.Profile;
public partial class Addresses : ComponentBase public partial class Addresses : ComponentBase
{ {
[Inject] private UserAddressContract.UserAddressContractClient UserAddressContract { get; set; } = default!; [Inject] private UserAddressContract.UserAddressContractClient UserAddressContract { get; set; } = default!;
[Inject] private AuthService AuthService { get; set; } = default!;
private List<CustomerAddressModel> _addresses = new(); private List<CustomerAddressModel> _addresses = new();
private bool _isLoadingAddresses; private bool _isLoadingAddresses;
@@ -42,7 +43,10 @@ public partial class Addresses : ComponentBase
var dialog = await DialogService.ShowAsync<AddAddressDialog>("افزودن آدرس جدید"); var dialog = await DialogService.ShowAsync<AddAddressDialog>("افزودن آدرس جدید");
var result = await dialog.Result; var result = await dialog.Result;
if (result is not null && !result.Canceled) if (result is not null && !result.Canceled)
{
await LoadAddresses(); await LoadAddresses();
await AuthService.RefreshTokenAsync();
}
} }
private async Task OpenEditAddressDialog(CustomerAddressModel address) private async Task OpenEditAddressDialog(CustomerAddressModel address)
@@ -80,6 +84,7 @@ public partial class Addresses : ComponentBase
await UserAddressContract.DeleteCustomerAddressAsync(new() { Id = id }); await UserAddressContract.DeleteCustomerAddressAsync(new() { Id = id });
Snackbar.Add("آدرس حذف شد.", Severity.Success); Snackbar.Add("آدرس حذف شد.", Severity.Success);
await LoadAddresses(); await LoadAddresses();
await AuthService.RefreshTokenAsync();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -35,12 +35,97 @@
</MudAlert> </MudAlert>
} }
@if (_authLoaded && _redirectingToMagic)
{
<MudAlert Severity="MudBlazor.Severity.Info" Class="rounded-lg" Variant="Variant.Outlined">
<MudStack Spacing="2" AlignItems="AlignItems.Center">
<MudProgressCircular Size="Size.Small" Indeterminate="true" Color="Color.Primary" />
<MudText>
کیف پول شما در حالت جادویی است. در حال انتقال به صفحه شارژ کیف پول جادویی...
</MudText>
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
Href="@RouteConstants.Profile.MagicWallet"
StartIcon="@Icons.Material.Filled.AutoAwesome">
رفتن به شارژ کیف جادویی
</MudButton>
</MudStack>
</MudAlert>
}
else if (_authLoaded && !_isClubMemberActive)
{
<MudAlert Severity="MudBlazor.Severity.Warning" Class="rounded-lg" Variant="Variant.Outlined">
<MudStack Spacing="2">
<MudText>
برای شارژ کیف پول اصلی ابتدا باید پکیج را خریداری کرده و قرارداد باشگاه مشتریان را امضا کنید.
</MudText>
<MudStack Row="true" Spacing="1" Class="flex-wrap">
@if (!_hasPurchasedPackage)
{
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
Href="@RouteConstants.Package.List"
StartIcon="@Icons.Material.Filled.CardGiftcard">
مشاهده پکیج‌ها
</MudButton>
}
else
{
<MudButton Size="Size.Small" Variant="Variant.Filled" Color="Color.Primary"
Href="@RouteConstants.Club.Membership"
StartIcon="@Icons.Material.Filled.Handshake">
امضای قرارداد باشگاه
</MudButton>
}
<MudButton Size="Size.Small" Variant="Variant.Text"
Href="@RouteConstants.Profile.Wallet">
بازگشت به کیف پول
</MudButton>
</MudStack>
</MudStack>
</MudAlert>
}
else if (_authLoaded && _needsMagicCeilingConsent && !_allowNormalDespiteMagic && !_redirectingToMagic)
{
<MudAlert Severity="MudBlazor.Severity.Warning" Class="rounded-lg" Variant="Variant.Outlined">
<MudStack Spacing="3">
<MudText Typo="Typo.subtitle1">
سقف شارژ کیف پول جادویی در این دور پر شده است.
</MudText>
<MudText Typo="Typo.body2">
شارژ از این صفحه بدون ضریب (۱:۱) به موجودی اضافه می‌شود و ضریب جادویی اعمال نمی‌شود.
برای ادامه، تأیید کنید.
</MudText>
<MudStack Row="true" Spacing="1" Class="flex-wrap">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Check"
OnClick="AcceptMagicCeilingConsent">
ادامه شارژ بدون ضریب
</MudButton>
<MudButton Variant="Variant.Outlined"
Color="Color.Secondary"
StartIcon="@Icons.Material.Filled.AutoAwesome"
OnClick="DeclineMagicCeilingConsent">
بازگشت به کیف جادویی
</MudButton>
</MudStack>
</MudStack>
</MudAlert>
}
else if (ShowChargeForm)
{
<MudPaper Elevation="2" Class="pa-5 rounded-lg"> <MudPaper Elevation="2" Class="pa-5 rounded-lg">
<MudText Typo="Typo.h6" Class="mb-3"> <MudText Typo="Typo.h6" Class="mb-3">
<MudIcon Icon="@Icons.Material.Filled.AccountBalanceWallet" Class="ml-1" /> <MudIcon Icon="@Icons.Material.Filled.AccountBalanceWallet" Class="ml-1" />
شارژ موجودی اصلی شارژ موجودی اصلی
</MudText> </MudText>
@if (_allowNormalDespiteMagic)
{
<MudAlert Severity="MudBlazor.Severity.Warning" Dense="true" Class="mb-3" Icon="@Icons.Material.Filled.Warning">
این شارژ بدون ضریب جادویی است (۱:۱). سقف واریز جادویی شما در این دور پر شده است.
</MudAlert>
}
<MudAlert Severity="MudBlazor.Severity.Info" Dense="true" Class="mb-3" Icon="@Icons.Material.Filled.Info"> <MudAlert Severity="MudBlazor.Severity.Info" Dense="true" Class="mb-3" Icon="@Icons.Material.Filled.Info">
مبلغ وارد شده (به تومان) از طریق درگاه پرداخت به موجودی اصلی شما اضافه می‌شود. مبلغ وارد شده (به تومان) از طریق درگاه پرداخت به موجودی اصلی شما اضافه می‌شود.
از موجودی اصلی برای خرید از فروشگاه عادی و پرداخت سفارش استفاده می‌شود. از موجودی اصلی برای خرید از فروشگاه عادی و پرداخت سفارش استفاده می‌شود.
@@ -122,5 +207,6 @@
</MudList> </MudList>
</MudExpansionPanel> </MudExpansionPanel>
</MudExpansionPanels> </MudExpansionPanels>
}
</MudStack> </MudStack>
</MudContainer> </MudContainer>
@@ -7,10 +7,18 @@ namespace FrontOffice.Main.Pages.Profile;
public partial class ChargeCreditWallet : ComponentBase public partial class ChargeCreditWallet : ComponentBase
{ {
[Inject] private AuthService AuthService { get; set; } = default!;
private bool _isProcessing; private bool _isProcessing;
private long _chargeAmount; private long _chargeAmount;
private string? _paymentResult; private string? _paymentResult;
private string? _pendingReturnUrl; private string? _pendingReturnUrl;
private bool _isClubMemberActive;
private bool _hasPurchasedPackage;
private bool _authLoaded;
private bool _redirectingToMagic;
private bool _allowNormalDespiteMagic;
private bool _needsMagicCeilingConsent;
private readonly long[] _presetAmounts = { 500_000, 1_000_000, 5_000_000, 10_000_000, 20_000_000, 50_000_000 }; private readonly long[] _presetAmounts = { 500_000, 1_000_000, 5_000_000, 10_000_000, 20_000_000, 50_000_000 };
@@ -23,7 +31,13 @@ public partial class ChargeCreditWallet : ComponentBase
[SupplyParameterFromQuery(Name = "returnUrl")] [SupplyParameterFromQuery(Name = "returnUrl")]
public string? ReturnUrlQueryParam { get; set; } public string? ReturnUrlQueryParam { get; set; }
protected override void OnInitialized() private bool ShowChargeForm =>
_authLoaded
&& _isClubMemberActive
&& !_redirectingToMagic
&& (!_needsMagicCeilingConsent || _allowNormalDespiteMagic);
protected override async Task OnInitializedAsync()
{ {
_paymentResult = PaymentQueryParam; _paymentResult = PaymentQueryParam;
@@ -32,20 +46,63 @@ public partial class ChargeCreditWallet : ComponentBase
if (CreditChargeNavigation.IsValidReturnUrl(ReturnUrlQueryParam)) if (CreditChargeNavigation.IsValidReturnUrl(ReturnUrlQueryParam))
_pendingReturnUrl = ReturnUrlQueryParam; _pendingReturnUrl = ReturnUrlQueryParam;
try
{
var userInfo = await AuthService.GetUserAuthInfo();
_isClubMemberActive = userInfo.IsClubMemberActive;
_hasPurchasedPackage = userInfo.HasPurchasedPackage;
}
catch
{
_isClubMemberActive = false;
_hasPurchasedPackage = false;
}
finally
{
_authLoaded = true;
}
if (_isClubMemberActive)
await EvaluateMagicWalletGateAsync();
} }
protected override async Task OnAfterRenderAsync(bool firstRender) protected override async Task OnAfterRenderAsync(bool firstRender)
{ {
if (firstRender && !string.IsNullOrEmpty(_pendingReturnUrl)) if (firstRender && !string.IsNullOrEmpty(_pendingReturnUrl) && _isClubMemberActive && !_redirectingToMagic)
{
await CreditChargeNavigation.SaveReturnUrlAsync(JSRuntime, _pendingReturnUrl); await CreditChargeNavigation.SaveReturnUrlAsync(JSRuntime, _pendingReturnUrl);
}
await base.OnAfterRenderAsync(firstRender); await base.OnAfterRenderAsync(firstRender);
} }
private void AcceptMagicCeilingConsent()
{
_allowNormalDespiteMagic = true;
_redirectingToMagic = false;
}
private void DeclineMagicCeilingConsent()
{
_redirectingToMagic = true;
Navigation.NavigateTo(RouteConstants.Profile.MagicWallet);
}
private async Task StartCreditCharge() private async Task StartCreditCharge()
{ {
if (!_isClubMemberActive)
{
Snackbar.Add(
"برای شارژ کیف پول اصلی ابتدا باید پکیج را خریداری کرده و قرارداد باشگاه مشتریان را امضا کنید.",
Severity.Warning);
return;
}
if (await EvaluateMagicWalletGateAsync())
return;
if (_needsMagicCeilingConsent && !_allowNormalDespiteMagic)
return;
if (_chargeAmount <= 0 || _isProcessing) return; if (_chargeAmount <= 0 || _isProcessing) return;
_isProcessing = true; _isProcessing = true;
@@ -84,6 +141,50 @@ public partial class ChargeCreditWallet : ComponentBase
} }
} }
/// <summary>
/// Magic با ظرفیت باقی‌مانده → ریدایرکت به MagicWallet.
/// Magic با سقف پر → نمایش تأیید روی صفحه برای شارژ ۱:۱.
/// true یعنی مسیر شارژ فعلی باید متوقف شود.
/// </summary>
private async Task<bool> EvaluateMagicWalletGateAsync()
{
if (_allowNormalDespiteMagic)
return false;
try
{
var status = await WalletService.GetMagicWalletStatusAsync();
if (status.WalletMode != 1)
{
_needsMagicCeilingConsent = false;
return false;
}
if (status.MagicRemainingDeposit > 0)
{
var multiplier = status.MagicMultiplier > 0 ? status.MagicMultiplier : 2.5;
var multiplierText = multiplier.ToString("0.#");
_redirectingToMagic = true;
_needsMagicCeilingConsent = false;
Snackbar.Add(
$"کیف پول شما در حالت جادویی است. شارژ از این صفحه موجودی را چندبرابر نمی‌کند. برای دریافت ضریب جادویی (×{multiplierText}) به صفحه شارژ کیف پول جادویی هدایت می‌شوید.",
Severity.Info);
Navigation.NavigateTo(RouteConstants.Profile.MagicWallet);
return true;
}
_needsMagicCeilingConsent = true;
return !_allowNormalDespiteMagic;
}
catch
{
// در صورت خطا در خواندن وضعیت، مانع شارژ عادی نشو
return false;
}
}
private async Task ContinueShoppingAsync() private async Task ContinueShoppingAsync()
{ {
var returnUrl = await CreditChargeNavigation.GetReturnUrlAsync(JSRuntime); var returnUrl = await CreditChargeNavigation.GetReturnUrlAsync(JSRuntime);
@@ -61,11 +61,11 @@
<span class="stat-value">@_statistics.TotalMembers</span> <span class="stat-value">@_statistics.TotalMembers</span>
</div> </div>
<div class="stat-item"> <div class="stat-item">
<span class="stat-label">پای چپ:</span> <span class="stat-label">سازمان چپ:</span>
<span class="stat-value left">@_statistics.LeftLegCount</span> <span class="stat-value left">@_statistics.LeftLegCount</span>
</div> </div>
<div class="stat-item"> <div class="stat-item">
<span class="stat-label">پای راست:</span> <span class="stat-label">سازمان راست:</span>
<span class="stat-value right">@_statistics.RightLegCount</span> <span class="stat-value right">@_statistics.RightLegCount</span>
</div> </div>
<div class="stat-item"> <div class="stat-item">
@@ -1,4 +1,4 @@
using FrontOffice.Main.Utilities; using FrontOffice.Main.Utilities;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop; using Microsoft.JSInterop;
@@ -333,6 +333,7 @@ public partial class Index
if (!result.Canceled) if (!result.Canceled)
{ {
await LoadAddresses(); await LoadAddresses();
await AuthService.RefreshTokenAsync();
} }
} }
@@ -385,6 +386,7 @@ public partial class Index
}); });
Snackbar.Add("آدرس با موفقیت حذف شد.", Severity.Success); Snackbar.Add("آدرس با موفقیت حذف شد.", Severity.Success);
await LoadAddresses(); await LoadAddresses();
await AuthService.RefreshTokenAsync();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -491,11 +493,6 @@ public partial class Index
Snackbar.Add("لطفا اطلاعات شخصی خود را تکمیل کنید. (تاریخ تولد وارد نشده)", Severity.Error); Snackbar.Add("لطفا اطلاعات شخصی خود را تکمیل کنید. (تاریخ تولد وارد نشده)", Severity.Error);
return; return;
} }
if (!_addresses.Any())
{
Snackbar.Add("آدرس محل سکونت شما الزامی است!", Severity.Error);
return;
}
var url = "https://dayadiamond.ir/profile/creditpurchase/?merchantcode=56146364"; var url = "https://dayadiamond.ir/profile/creditpurchase/?merchantcode=56146364";
await JSRuntime.InvokeVoidAsync("open", url, "_blank"); await JSRuntime.InvokeVoidAsync("open", url, "_blank");
@@ -166,8 +166,23 @@
else else
{ {
<MudAlert Severity="MudBlazor.Severity.Warning" Class="rounded-lg"> <MudAlert Severity="MudBlazor.Severity.Warning" Class="rounded-lg">
<MudStack Spacing="2">
<MudText>
سقف شارژ جادویی در این دور پر شده است. سقف شارژ جادویی در این دور پر شده است.
با خرج کردن موجودی و خرید مجدد پکیج، سقف ریست می‌شود. با خرج کردن موجودی و خرید مجدد پکیج، سقف ریست می‌شود.
</MudText>
<MudText Typo="Typo.body2">
اگر برای تکمیل خرید به موجودی بیشتری نیاز دارید، می‌توانید به‌صورت استثنایی کیف اصلی را بدون ضریب (۱:۱) شارژ کنید.
</MudText>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.AccountBalanceWallet"
Href="@RouteConstants.Profile.ChargeCreditWallet"
Class="rounded-lg align-self-start">
شارژ عادی بدون ضریب
</MudButton>
</MudStack>
</MudAlert> </MudAlert>
} }
@@ -38,7 +38,44 @@
درخواست‌های برداشت درخواست‌های برداشت
</MudButton> </MudButton>
<!-- دکمه شارژ کیف‌پول اصلی --> <!-- دکمه شارژ کیف‌پول اصلی / جادویی -->
@if (_isClubMemberActive)
{
@if (_isMagicWallet)
{
<MudButton Variant="Variant.Filled"
Color="Color.Secondary"
Size="Size.Large"
FullWidth="true"
StartIcon="@Icons.Material.Filled.AutoAwesome"
Href="@RouteConstants.Profile.MagicWallet"
Class="rounded-lg">
شارژ کیف‌پول جادویی
</MudButton>
@if (_magicCeilingFull)
{
<MudAlert Severity="Severity.Warning" Dense="true" Variant="Variant.Outlined">
سقف شارژ جادویی این دور پر است. در صورت نیاز می‌توانید به‌صورت استثنایی بدون ضریب شارژ کنید.
</MudAlert>
<MudButton Variant="Variant.Outlined"
Color="Color.Primary"
Size="Size.Large"
FullWidth="true"
StartIcon="@Icons.Material.Filled.AccountBalanceWallet"
Href="@RouteConstants.Profile.ChargeCreditWallet"
Class="rounded-lg">
شارژ عادی بدون ضریب
</MudButton>
}
else
{
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined">
کیف پول شما در حالت جادویی است؛ شارژ از مسیر جادویی با ضریب چندبرابر اعمال می‌شود.
</MudAlert>
}
}
else
{
<MudButton Variant="Variant.Filled" <MudButton Variant="Variant.Filled"
Color="Color.Primary" Color="Color.Primary"
Size="Size.Large" Size="Size.Large"
@@ -48,6 +85,45 @@
Class="rounded-lg"> Class="rounded-lg">
شارژ کیف‌پول اصلی شارژ کیف‌پول اصلی
</MudButton> </MudButton>
}
}
else
{
<MudPaper Elevation="0" Class="pa-3 rounded-lg" Outlined="true">
<MudStack Spacing="2">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Size="Size.Large"
FullWidth="true"
StartIcon="@Icons.Material.Filled.AccountBalanceWallet"
Disabled="true"
Class="rounded-lg">
شارژ کیف‌پول اصلی
</MudButton>
<MudAlert Severity="Severity.Warning" Dense="true" Variant="Variant.Outlined">
برای شارژ کیف پول اصلی ابتدا باید پکیج را خریداری کرده و قرارداد باشگاه مشتریان را امضا کنید.
</MudAlert>
<MudStack Row="true" Spacing="1" Class="flex-wrap">
@if (!_hasPurchasedPackage)
{
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
Href="@RouteConstants.Package.List"
StartIcon="@Icons.Material.Filled.CardGiftcard">
مشاهده پکیج‌ها
</MudButton>
}
else
{
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
Href="@RouteConstants.Club.Membership"
StartIcon="@Icons.Material.Filled.Handshake">
امضای قرارداد باشگاه
</MudButton>
}
</MudStack>
</MudStack>
</MudPaper>
}
<!-- دکمه شارژ کیف‌پول اعتباری --> <!-- دکمه شارژ کیف‌پول اعتباری -->
<MudButton Variant="Variant.Filled" <MudButton Variant="Variant.Filled"
@@ -6,13 +6,43 @@ namespace FrontOffice.Main.Pages.Profile;
public partial class Wallet : ComponentBase public partial class Wallet : ComponentBase
{ {
[Inject] private AuthService AuthService { get; set; } = default!;
private (string Credit, string Discount, string Network) _balances = ("-", "-", "-"); private (string Credit, string Discount, string Network) _balances = ("-", "-", "-");
private List<WalletTransaction> _txs = new(); private List<WalletTransaction> _txs = new();
private string? _filterReferenceId; private string? _filterReferenceId;
private string _filterType = "all"; private string _filterType = "all";
private bool _isClubMemberActive;
private bool _hasPurchasedPackage;
private bool _isMagicWallet;
private bool _magicCeilingFull;
protected override async Task OnInitializedAsync() protected override async Task OnInitializedAsync()
{ {
try
{
var userInfo = await AuthService.GetUserAuthInfo();
_isClubMemberActive = userInfo.IsClubMemberActive;
_hasPurchasedPackage = userInfo.HasPurchasedPackage;
}
catch
{
_isClubMemberActive = false;
_hasPurchasedPackage = false;
}
try
{
var magicStatus = await WalletService.GetMagicWalletStatusAsync();
_isMagicWallet = magicStatus.WalletMode == 1;
_magicCeilingFull = _isMagicWallet && magicStatus.MagicRemainingDeposit <= 0;
}
catch
{
_isMagicWallet = false;
_magicCeilingFull = false;
}
var b = await WalletService.GetBalancesAsync(); var b = await WalletService.GetBalancesAsync();
_balances = (FormatPrice(b.CreditBalance), FormatPrice(b.DiscountBalance), FormatPrice(b.NetworkBalance)); _balances = (FormatPrice(b.CreditBalance), FormatPrice(b.DiscountBalance), FormatPrice(b.NetworkBalance));
_txs = await WalletService.GetTransactionsAsync(); _txs = await WalletService.GetTransactionsAsync();
@@ -58,8 +58,19 @@ public partial class WithdrawalRequests : ComponentBase
try try
{ {
_isSubmittingWithdrawal = true; _isSubmittingWithdrawal = true;
_withdrawIban=_withdrawIban!.Trim().ToUpper().Replace("IR", "").Replace(" ", "");
await WalletService.RequestWithdrawalAsync(_selectedPayout.Id, _withdrawMethod, "IR"+_withdrawIban); string? normalizedIban = null;
if (_withdrawMethod == WithdrawalMethodClient.Cash)
{
normalizedIban = NormalizeIranianIban(_withdrawIban);
if (normalizedIban is null)
{
Snackbar.Add("فرمت شماره شبا معتبر نیست. باید IR و ۲۴ رقم باشد (خط تیره اختیاری است).", Severity.Warning);
return;
}
}
await WalletService.RequestWithdrawalAsync(_selectedPayout.Id, _withdrawMethod, normalizedIban);
Snackbar.Add("درخواست برداشت ثبت شد.", Severity.Success); Snackbar.Add("درخواست برداشت ثبت شد.", Severity.Success);
// بروزرسانی لیست // بروزرسانی لیست
@@ -125,4 +136,26 @@ public partial class WithdrawalRequests : ComponentBase
1 => "الماس", 1 => "الماس",
_ => "-" _ => "-"
}; };
/// <summary>
/// فاصله و خط تیره را حذف می‌کند و شبا را به شکل IR + ۲۴ رقم برمی‌گرداند.
/// </summary>
private static string? NormalizeIranianIban(string? iban)
{
if (string.IsNullOrWhiteSpace(iban))
return null;
var normalized = iban.Trim().ToUpperInvariant()
.Replace(" ", "", StringComparison.Ordinal)
.Replace("-", "", StringComparison.Ordinal);
if (normalized.StartsWith("IR", StringComparison.Ordinal))
normalized = normalized[2..];
normalized = "IR" + normalized;
return System.Text.RegularExpressions.Regex.IsMatch(normalized, @"^IR\d{24}$")
? normalized
: null;
}
} }
@@ -35,7 +35,11 @@ public partial class CheckoutSummary : ComponentBase
await AuthDialogService.ShowAuthDialogAsync(); await AuthDialogService.ShowAuthDialogAsync();
} }
await Cart.EnsureInitializedAsync(); await Cart.EnsureInitializedAsync();
var userInfo = await AuthService.GetUserAuthInfo();
if (userInfo.HasAddress)
await LoadAddresses(); await LoadAddresses();
else
_addresses = new();
await LoadWalletBalance(); await LoadWalletBalance();
} }
@@ -125,11 +129,42 @@ public partial class CheckoutSummary : ComponentBase
return false; return false;
var shortfall = totalRequired - walletBalance; var shortfall = totalRequired - walletBalance;
var allowCharge = false;
var hasPurchasedPackage = false;
var isMagicWallet = false;
var magicCeilingFull = false;
try
{
var userInfo = await AuthService.GetUserAuthInfo();
allowCharge = userInfo.IsClubMemberActive;
hasPurchasedPackage = userInfo.HasPurchasedPackage;
}
catch
{
allowCharge = false;
}
try
{
var magicStatus = await WalletService.GetMagicWalletStatusAsync();
isMagicWallet = magicStatus.WalletMode == 1;
magicCeilingFull = isMagicWallet && magicStatus.MagicRemainingDeposit <= 0;
}
catch
{
isMagicWallet = false;
magicCeilingFull = false;
}
var parameters = new DialogParameters<InsufficientCreditDialog> var parameters = new DialogParameters<InsufficientCreditDialog>
{ {
{ x => x.CurrentBalance, walletBalance }, { x => x.CurrentBalance, walletBalance },
{ x => x.RequiredAmount, totalRequired }, { x => x.RequiredAmount, totalRequired },
{ x => x.ShortfallAmount, shortfall } { x => x.ShortfallAmount, shortfall },
{ x => x.AllowChargeCredit, allowCharge },
{ x => x.HasPurchasedPackage, hasPurchasedPackage },
{ x => x.IsMagicWallet, isMagicWallet },
{ x => x.MagicCeilingFull, magicCeilingFull }
}; };
var dialog = await DialogService.ShowAsync<InsufficientCreditDialog>( var dialog = await DialogService.ShowAsync<InsufficientCreditDialog>(
@@ -143,7 +178,7 @@ public partial class CheckoutSummary : ComponentBase
}); });
var result = await dialog.Result; var result = await dialog.Result;
if (result is { Canceled: false } && result.Data is long chargeShortfall) if (allowCharge && result is { Canceled: false } && result.Data is long chargeShortfall)
{ {
var chargeAmount = CreditChargeNavigation.NormalizeChargeAmount(chargeShortfall); var chargeAmount = CreditChargeNavigation.NormalizeChargeAmount(chargeShortfall);
Navigation.NavigateTo(CreditChargeNavigation.BuildChargeUrl( Navigation.NavigateTo(CreditChargeNavigation.BuildChargeUrl(
@@ -47,11 +47,18 @@ public partial class OrderTracking : ComponentBase
{ {
if (_order is null) return; if (_order is null) return;
// Build tracking steps based on PaymentStatus
var isPaid = _order.PaymentStatus == Messages.PaymentStatus.Success; var isPaid = _order.PaymentStatus == Messages.PaymentStatus.Success;
// Domain/public_messages: None=0, Pending=1, InTransit=2, Delivered=3, Returned=4, Cancelled=5, ReadyForOfficePickup=6
var delivery = (int)_order.DeliveryStatus;
var isCancelled = delivery == 5;
var isReturned = delivery == 4;
var isOfficePickup = delivery == 6;
var isInTransit = delivery == 2;
var isDelivered = delivery == 3;
var isPendingDelivery = delivery is 0 or 1;
_trackingSteps = new List<TrackingStep> _trackingSteps =
{ [
new() new()
{ {
Title = "ثبت سفارش", Title = "ثبت سفارش",
@@ -61,45 +68,77 @@ public partial class OrderTracking : ComponentBase
}, },
new() new()
{ {
Title = "در انتظار پرداخت", Title = "پرداخت",
Description = "منتظر تایید پرداخت هستیم", Description = isPaid ? "پرداخت شما تایید شد" : "منتظر تایید پرداخت هستیم",
IsCompleted = isPaid, IsCompleted = isPaid,
IsCurrent = !isPaid, IsCurrent = !isPaid && !isCancelled,
Date = isPaid ? FormatDate(_order.PaymentDate) : "" Date = isPaid ? FormatDate(_order.PaymentDate) : ""
}, }
new() ];
if (isCancelled)
{ {
Title = "تایید پرداخت", _trackingSteps.Add(new()
Description = "پرداخت شما تایید شد",
IsCompleted = isPaid,
IsCurrent = false,
Date = isPaid ? FormatDate(_order.PaymentDate) : ""
},
new()
{ {
Title = "در حال پردازش", Title = "لغو شده",
Description = "سفارش شما در حال آماده‌سازی است", Description = "ارسال این سفارش لغو شده است",
IsCompleted = false, IsCompleted = true,
IsCurrent = isPaid, IsCurrent = true,
Date = "" Date = ""
}, });
new() return;
}
if (isReturned)
{
_trackingSteps.Add(new()
{
Title = "مرجوع شده",
Description = "سفارش مرجوع شده است",
IsCompleted = true,
IsCurrent = true,
Date = ""
});
return;
}
if (isOfficePickup)
{
_trackingSteps.Add(new()
{
Title = "آماده تحویل در دفتر",
Description = "سفارش برای تحویل حضوری در دفتر آماده است",
IsCompleted = true,
IsCurrent = true,
Date = ""
});
return;
}
_trackingSteps.Add(new()
{
Title = "در حال آماده‌سازی",
Description = "سفارش شما در حال آماده‌سازی است",
IsCompleted = isInTransit || isDelivered,
IsCurrent = isPaid && isPendingDelivery,
Date = ""
});
_trackingSteps.Add(new()
{ {
Title = "ارسال شده", Title = "ارسال شده",
Description = "سفارش شما به پست تحویل داده شد", Description = "سفارش شما به پست تحویل داده شد",
IsCompleted = false, IsCompleted = isDelivered,
IsCurrent = false, IsCurrent = isInTransit,
Date = "" Date = ""
}, });
new() _trackingSteps.Add(new()
{ {
Title = "تحویل داده شده", Title = "تحویل داده شده",
Description = "سفارش به دست شما رسید", Description = "سفارش به دست شما رسید",
IsCompleted = false, IsCompleted = isDelivered,
IsCurrent = false, IsCurrent = false,
Date = "" Date = ""
} });
};
} }
private static string FormatDate(Google.Protobuf.WellKnownTypes.Timestamp? timestamp) private static string FormatDate(Google.Protobuf.WellKnownTypes.Timestamp? timestamp)
+57 -35
View File
@@ -1,75 +1,96 @@
@attribute [Route(RouteConstants.Store.Orders)] @attribute [Route(RouteConstants.Store.Orders)]
@* Injection is handled in code-behind *@
<PageTitle>سفارشات من</PageTitle> <PageTitle>سفارشات من</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="py-6"> <MudContainer MaxWidth="MaxWidth.Large" Class="py-6">
<MudStack Spacing="3">
<PageHeader Title="سفارشات من" BackHref="@RouteConstants.Store.Products" /> <PageHeader Title="سفارشات من" BackHref="@RouteConstants.Store.Products" />
@if (_loading) @if (_loading)
{ {
<LoadingState /> <LoadingState />
} }
else if (_orders.Count == 0) else if (_orders.Count == 0)
{ {
<MudAlert Severity="Severity.Info">هنوز سفارشی ثبت نکرده‌اید.</MudAlert> <MudAlert Severity="Severity.Info">هنوز سفارشی ثبت نشده است.</MudAlert>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Store"
Href="@RouteConstants.Store.Products">
مشاهده فروشگاه
</MudButton>
} }
else else
{ {
<!-- Desktop Table -->
<MudHidden Breakpoint="Breakpoint.MdAndUp" Invert="true"> <MudHidden Breakpoint="Breakpoint.MdAndUp" Invert="true">
<MudTable Items="_orders" Dense="true"> <MudPaper Elevation="1" Class="pa-4 rounded-lg">
<MudTable Items="_orders">
<HeaderContent> <HeaderContent>
<MudTh>شناسه</MudTh> <MudTh>شماره سفارش</MudTh>
<MudTh>تعداد اقلام</MudTh>
<MudTh>مبلغ کل</MudTh>
<MudTh>وضعیت پرداخت</MudTh>
<MudTh>وضعیت ارسال</MudTh>
<MudTh>تاریخ</MudTh> <MudTh>تاریخ</MudTh>
<MudTh>وضعیت</MudTh>
<MudTh>مبلغ</MudTh>
<MudTh></MudTh> <MudTh></MudTh>
</HeaderContent> </HeaderContent>
<RowTemplate> <RowTemplate>
<MudTd>@context.Id</MudTd> <MudTd>@context.Id</MudTd>
<MudTd>@GetItemsCount(context)</MudTd>
<MudTd>@FormatPrice(GetTotalAmount(context))</MudTd>
<MudTd> <MudTd>
@if (context.PaymentDate != null) <MudChip T="string" Size="Size.Small"
{ Color="@GetPaymentStatusColor(context.PaymentStatus)"
@context.PaymentDate.ToDateTime().MiladiToJalaliWithTime() Variant="Variant.Filled">
} @GetPaymentStatusText(context.PaymentStatus)
else </MudChip>
{
<text>در انتظار پرداخت</text>
}
</MudTd> </MudTd>
<MudTd>@GetStatusText(context.PaymentStatus)</MudTd>
<MudTd>@FormatPrice(context.FactorDetails.Sum(s=>(s.UnitPrice ?? 0) * (s.Count ?? 0)))</MudTd>
<MudTd> <MudTd>
<MudButton Variant="Variant.Text" Href="@($"{RouteConstants.Store.OrderDetail}{context.Id}")" StartIcon="@Icons.Material.Filled.Receipt">جزئیات</MudButton> <MudChip T="string" Size="Size.Small"
Color="@GetDeliveryStatusColor((int)context.DeliveryStatus)"
Variant="Variant.Outlined">
@GetDeliveryStatusText((int)context.DeliveryStatus)
</MudChip>
</MudTd>
<MudTd>@FormatDate(context)</MudTd>
<MudTd>
<MudButton Size="Size.Small" Variant="Variant.Outlined" Color="Color.Primary"
OnClick="() => ViewOrder(context.Id)">
جزئیات
</MudButton>
</MudTd> </MudTd>
</RowTemplate> </RowTemplate>
</MudTable> </MudTable>
</MudPaper>
</MudHidden> </MudHidden>
<!-- Mobile Cards -->
<MudHidden Breakpoint="Breakpoint.MdAndUp"> <MudHidden Breakpoint="Breakpoint.MdAndUp">
<MudStack Spacing="2"> <MudStack Spacing="2">
@foreach (var o in _orders) @foreach (var order in _orders)
{ {
<MudPaper Class="pa-3 rounded-lg" Outlined="true"> <MudPaper Class="pa-3 rounded-lg cursor-pointer" Outlined="true"
@onclick="() => ViewOrder(order.Id)">
<MudStack Spacing="1"> <MudStack Spacing="1">
<MudStack Row="true" Justify="Justify.SpaceBetween"> <MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText>سفارش #@o.Id</MudText> <MudText Typo="Typo.subtitle2">سفارش #@order.Id</MudText>
<MudText Class="mud-text-secondary"> <MudChip T="string" Size="Size.Small"
@if (o.PaymentDate != null) Color="@GetPaymentStatusColor(order.PaymentStatus)"
{ Variant="Variant.Filled">
@o.PaymentDate.ToDateTime().MiladiToJalaliWithTime() @GetPaymentStatusText(order.PaymentStatus)
} </MudChip>
else
{
<text>در انتظار پرداخت</text>
}
</MudText>
</MudStack> </MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween"> <MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText>وضعیت: @GetStatusText(o.PaymentStatus)</MudText> <MudText Typo="Typo.caption" Class="mud-text-secondary">@FormatDate(order)</MudText>
<MudText Color="Color.Primary">@FormatPrice(o.FactorDetails.Sum(s=>(s.UnitPrice ?? 0) * (s.Count ?? 0)))</MudText> <MudChip T="string" Size="Size.Small"
Color="@GetDeliveryStatusColor((int)order.DeliveryStatus)"
Variant="Variant.Outlined">
@GetDeliveryStatusText((int)order.DeliveryStatus)
</MudChip>
</MudStack> </MudStack>
<MudStack Row="true" Justify="Justify.FlexEnd"> <MudDivider />
<MudButton Size="Size.Small" Variant="Variant.Outlined" Href="@($"{RouteConstants.Store.OrderDetail}{o.Id}")" StartIcon="@Icons.Material.Filled.Receipt">جزئیات</MudButton> <MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2">@GetItemsCount(order) قلم</MudText>
<MudText Typo="Typo.body2" Class="fw-semibold">@FormatPrice(GetTotalAmount(order)) تومان</MudText>
</MudStack> </MudStack>
</MudStack> </MudStack>
</MudPaper> </MudPaper>
@@ -77,4 +98,5 @@
</MudStack> </MudStack>
</MudHidden> </MudHidden>
} }
</MudStack>
</MudContainer> </MudContainer>
@@ -1,5 +1,5 @@
using System;
using CMSMicroservice.Protobuf.Protos.UserOrder; using CMSMicroservice.Protobuf.Protos.UserOrder;
using DateTimeConverterCL;
using FrontOffice.Main.Utilities; using FrontOffice.Main.Utilities;
using Microsoft.AspNetCore.Components; using Microsoft.AspNetCore.Components;
using Messages = CMSMicroservice.Protobuf.Protos; using Messages = CMSMicroservice.Protobuf.Protos;
@@ -10,7 +10,6 @@ namespace FrontOffice.Main.Pages.Store;
public partial class Orders : ComponentBase public partial class Orders : ComponentBase
{ {
[Inject] private OrderService OrderService { get; set; } = default!; [Inject] private OrderService OrderService { get; set; } = default!;
[Inject] private VATService VAT { get; set; } = default!;
private List<GetUserOrderResponse> _orders = new(); private List<GetUserOrderResponse> _orders = new();
private bool _loading; private bool _loading;
@@ -33,28 +32,81 @@ public partial class Orders : ComponentBase
} }
} }
protected override async Task OnAfterRenderAsync(bool firstRender) private void ViewOrder(long orderId)
{ {
if (firstRender) Navigation.NavigateTo($"{RouteConstants.Store.OrderDetail}{orderId}");
{
// بارگذاری نرخ VAT
await VAT.LoadAsync();
}
await base.OnAfterRenderAsync(firstRender);
} }
private static string FormatPrice(long price) => string.Format("{0:N0} تومان", price); private static string FormatPrice(long price) => $"{price:N0}";
private static string FormatDate(GetUserOrderResponse order)
private string GetStatusText(Messages.PaymentStatus contextPaymentStatus)
{ {
return contextPaymentStatus switch try
{
var timestamp = order.PaymentDate;
if (timestamp is null)
return "در انتظار پرداخت";
return timestamp.ToDateTime().ToLocalTime().MiladiToJalaliWithTime();
}
catch
{
return "—";
}
}
private static int GetItemsCount(GetUserOrderResponse order) =>
order.FactorDetails.Sum(f => f.Count ?? 0);
private static long GetTotalAmount(GetUserOrderResponse order)
{
if (order.VatInfo is not null && order.VatInfo.TotalAmount > 0)
return order.VatInfo.TotalAmount;
if (order.Amount > 0)
return order.Amount;
return order.FactorDetails.Sum(f => (f.UnitPrice ?? 0) * (f.Count ?? 0));
}
private static string GetPaymentStatusText(Messages.PaymentStatus status) => status switch
{ {
Messages.PaymentStatus.Pending => "در انتظار پرداخت", Messages.PaymentStatus.Pending => "در انتظار پرداخت",
Messages.PaymentStatus.Success => "پرداخت شده", Messages.PaymentStatus.Success => "پرداخت شده",
Messages.PaymentStatus.Reject => "پرداخت ناموفق", Messages.PaymentStatus.Reject => "پرداخت ناموفق",
_ => "نامشخص", _ => "نامشخص"
}; };
}
}
private static Color GetPaymentStatusColor(Messages.PaymentStatus status) => status switch
{
Messages.PaymentStatus.Pending => Color.Warning,
Messages.PaymentStatus.Success => Color.Success,
Messages.PaymentStatus.Reject => Color.Error,
_ => Color.Default
};
// Domain/public_messages: None=0, Pending=1, InTransit=2, Delivered=3, Returned=4, Cancelled=5, ReadyForOfficePickup=6
private static string GetDeliveryStatusText(int status) => status switch
{
0 => "بدون ارسال",
1 => "در انتظار ارسال",
2 => "ارسال شده",
3 => "تحویل داده شده",
4 => "مرجوع شده",
5 => "لغو شده",
6 => "آماده تحویل در دفتر",
_ => "نامشخص"
};
private static Color GetDeliveryStatusColor(int status) => status switch
{
0 => Color.Default,
1 => Color.Warning,
2 => Color.Primary,
3 => Color.Success,
4 => Color.Error,
5 => Color.Dark,
6 => Color.Primary,
_ => Color.Default
};
}
@@ -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,36 +38,98 @@ 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);
for (var page = 1; page <= pagesToLoad; page++)
{
var result = await ProductService.GetProductsPagedAsync(
_query, _activeCategoryId, sortBy, page, PageSize);
if (page == 1)
{
_products = result.Products; _products = result.Products;
_hasMore = result.HasNext;
_totalCount = result.TotalCount; _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;
@@ -82,42 +138,32 @@ public partial class Products : ComponentBase, IDisposable
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); _ignoreNextLocationChange = false;
if (QueryHelpers.ParseQuery(uri.Query).TryGetValue("category", out var values) &&
long.TryParse(values.FirstOrDefault(), out var categoryId))
{
_activeCategoryId = categoryId;
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();
} }
} }
+6 -2
View File
@@ -60,10 +60,14 @@ webApp.MapBlazorHub();
webApp.MapGet("/sitemap.xml", async (SitemapGenerator sitemapGenerator, CancellationToken cancellationToken) => webApp.MapGet("/sitemap.xml", async (SitemapGenerator sitemapGenerator, CancellationToken cancellationToken) =>
{ {
var xml = await sitemapGenerator.GenerateAsync(cancellationToken); var bytes = await sitemapGenerator.GenerateBytesAsync(cancellationToken);
return Results.Content(xml, "application/xml; charset=utf-8"); return Results.File(bytes, "application/xml; charset=utf-8");
}); });
// Legacy URLs from old site / common e-commerce paths — redirect to homepage
webApp.MapGet("/wishlist", () => Results.Redirect("/", permanent: true));
webApp.MapGet("/wishlist/", () => Results.Redirect("/", permanent: true));
webApp.MapFallbackToPage("/_Host"); webApp.MapFallbackToPage("/_Host");
@@ -1,3 +1,5 @@
@using FrontOffice.Main.Utilities
<MudDialog> <MudDialog>
<TitleContent> <TitleContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2"> <MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
@@ -7,9 +9,34 @@
</TitleContent> </TitleContent>
<DialogContent> <DialogContent>
<MudStack Spacing="2"> <MudStack Spacing="2">
@if (AllowChargeCredit)
{
@if (IsMagicWallet && !MagicCeilingFull)
{
<MudText Typo="Typo.body2" Class="mud-text-secondary">
برای تکمیل این سفارش موجودی شما کافی نیست. کیف پول شما در حالت جادویی است؛ برای دریافت ضریب، از مسیر شارژ جادویی اقدام کنید.
</MudText>
}
else if (IsMagicWallet && MagicCeilingFull)
{
<MudAlert Severity="Severity.Warning" Dense="true" Variant="Variant.Outlined">
سقف شارژ جادویی شما در این دور پر است. می‌توانید به‌صورت استثنایی کیف اصلی را بدون ضریب (۱:۱) شارژ کنید.
</MudAlert>
}
else
{
<MudText Typo="Typo.body2" Class="mud-text-secondary"> <MudText Typo="Typo.body2" Class="mud-text-secondary">
برای تکمیل این سفارش، موجودی اصلی شما کافی نیست. می‌توانید کیف پول را شارژ کنید و سپس سفارش را ثبت کنید. برای تکمیل این سفارش، موجودی اصلی شما کافی نیست. می‌توانید کیف پول را شارژ کنید و سپس سفارش را ثبت کنید.
</MudText> </MudText>
}
}
else
{
<MudAlert Severity="Severity.Warning" Dense="true" Variant="Variant.Outlined">
برای شارژ کیف پول اصلی ابتدا باید پکیج را خریداری کرده و قرارداد باشگاه مشتریان را امضا کنید.
</MudAlert>
}
<MudPaper Outlined="true" Class="pa-3 rounded-lg"> <MudPaper Outlined="true" Class="pa-3 rounded-lg">
<MudStack Spacing="1"> <MudStack Spacing="1">
<MudStack Row="true" Justify="Justify.SpaceBetween"> <MudStack Row="true" Justify="Justify.SpaceBetween">
@@ -31,12 +58,33 @@
</DialogContent> </DialogContent>
<DialogActions> <DialogActions>
<MudButton OnClick="Cancel">بستن</MudButton> <MudButton OnClick="Cancel">بستن</MudButton>
@if (AllowChargeCredit)
{
<MudButton Variant="Variant.Filled"
Color="@(IsMagicWallet && !MagicCeilingFull ? Color.Secondary : Color.Primary)"
StartIcon="@(IsMagicWallet && !MagicCeilingFull ? Icons.Material.Filled.AutoAwesome : Icons.Material.Filled.Payment)"
OnClick="GoToCharge">
@ChargeButtonLabel
</MudButton>
}
else if (!HasPurchasedPackage)
{
<MudButton Variant="Variant.Filled" <MudButton Variant="Variant.Filled"
Color="Color.Primary" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Payment" StartIcon="@Icons.Material.Filled.CardGiftcard"
OnClick="GoToCharge"> OnClick="GoToPackages">
شارژ حساب اصلی مشاهده پکیج‌ها
</MudButton> </MudButton>
}
else
{
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Handshake"
OnClick="GoToClubMembership">
امضای قرارداد باشگاه
</MudButton>
}
</DialogActions> </DialogActions>
</MudDialog> </MudDialog>
@@ -53,9 +101,52 @@
[Parameter] [Parameter]
public long ShortfallAmount { get; set; } public long ShortfallAmount { get; set; }
/// <summary>اگر false باشد، به صفحه شارژ هدایت نمی‌شود.</summary>
[Parameter]
public bool AllowChargeCredit { get; set; } = true;
[Parameter]
public bool HasPurchasedPackage { get; set; }
[Parameter]
public bool IsMagicWallet { get; set; }
/// <summary>سقف واریز جادویی این دور پر شده است.</summary>
[Parameter]
public bool MagicCeilingFull { get; set; }
private string ChargeButtonLabel =>
IsMagicWallet && !MagicCeilingFull
? "شارژ کیف جادویی"
: IsMagicWallet && MagicCeilingFull
? "شارژ عادی بدون ضریب (سقف جادویی پر است)"
: "شارژ حساب اصلی";
private void Cancel() => MudDialog.Cancel(); private void Cancel() => MudDialog.Cancel();
private void GoToCharge() => MudDialog.Close(DialogResult.Ok(ShortfallAmount)); private void GoToCharge()
{
if (IsMagicWallet && !MagicCeilingFull)
{
MudDialog.Cancel();
Navigation.NavigateTo(RouteConstants.Profile.MagicWallet);
return;
}
MudDialog.Close(DialogResult.Ok(ShortfallAmount));
}
private void GoToPackages()
{
MudDialog.Cancel();
Navigation.NavigateTo(RouteConstants.Package.List);
}
private void GoToClubMembership()
{
MudDialog.Cancel();
Navigation.NavigateTo(RouteConstants.Club.Membership);
}
private static string FormatPrice(long price) => string.Format("{0:N0} تومان", price); private static string FormatPrice(long price) => string.Format("{0:N0} تومان", price);
} }
@@ -79,6 +79,7 @@ public class AuthService
var isSignMainContractStr = claims.FirstOrDefault(c => c.key == "IsSignMainContract").value; var isSignMainContractStr = claims.FirstOrDefault(c => c.key == "IsSignMainContract").value;
var hasPurchasedPackageStr = claims.FirstOrDefault(c => c.key == "HasPurchasedPackage").value; var hasPurchasedPackageStr = claims.FirstOrDefault(c => c.key == "HasPurchasedPackage").value;
var isClubMemberActiveStr = claims.FirstOrDefault(c => c.key == "IsClubMemberActive").value; var isClubMemberActiveStr = claims.FirstOrDefault(c => c.key == "IsClubMemberActive").value;
var hasAddressStr = claims.FirstOrDefault(c => c.key == "HasAddress").value;
_userAuthInfo.UserId = long.TryParse(userIdStr, out var userId) ? userId : 0; _userAuthInfo.UserId = long.TryParse(userIdStr, out var userId) ? userId : 0;
_userAuthInfo.FirstName = firstName ?? string.Empty; _userAuthInfo.FirstName = firstName ?? string.Empty;
@@ -88,6 +89,7 @@ public class AuthService
_userAuthInfo.IsSignMainContract = bool.TryParse(isSignMainContractStr, out var isSignMainContract) && isSignMainContract; _userAuthInfo.IsSignMainContract = bool.TryParse(isSignMainContractStr, out var isSignMainContract) && isSignMainContract;
_userAuthInfo.HasPurchasedPackage = bool.TryParse(hasPurchasedPackageStr, out var hasPurchased) && hasPurchased; _userAuthInfo.HasPurchasedPackage = bool.TryParse(hasPurchasedPackageStr, out var hasPurchased) && hasPurchased;
_userAuthInfo.IsClubMemberActive = bool.TryParse(isClubMemberActiveStr, out var isClubActive) && isClubActive; _userAuthInfo.IsClubMemberActive = bool.TryParse(isClubMemberActiveStr, out var isClubActive) && isClubActive;
_userAuthInfo.HasAddress = bool.TryParse(hasAddressStr, out var hasAddress) && hasAddress;
} }
private async Task<List<(string key, string value)>?> GetTokenClaimsAsync() private async Task<List<(string key, string value)>?> GetTokenClaimsAsync()
@@ -219,6 +219,8 @@ public class DiscountOrderService
2 => "ارسال شده", 2 => "ارسال شده",
3 => "تحویل داده شده", 3 => "تحویل داده شده",
4 => "لغو شده", 4 => "لغو شده",
5 => "آماده تحویل در دفتر",
6 => "مرجوع شده",
_ => "نامشخص" _ => "نامشخص"
}; };
@@ -228,7 +230,9 @@ public class DiscountOrderService
1 => Color.Info, 1 => Color.Info,
2 => Color.Primary, 2 => Color.Primary,
3 => Color.Success, 3 => Color.Success,
4 => Color.Error, 4 => Color.Dark,
5 => Color.Primary,
6 => Color.Error,
_ => Color.Default _ => Color.Default
}; };
@@ -1,3 +1,4 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace FrontOffice.Main.Utilities.Seo; namespace FrontOffice.Main.Utilities.Seo;
@@ -30,6 +31,9 @@ public class SeoMetadataProvider
[RouteConstants.Store.Products] = ( [RouteConstants.Store.Products] = (
"محصولات | کارابازار سلامت", "محصولات | کارابازار سلامت",
"فروشگاه محصولات سلامت کارابازار — خرید آنلاین با پاداش باشگاه مشتریان."), "فروشگاه محصولات سلامت کارابازار — خرید آنلاین با پاداش باشگاه مشتریان."),
[RouteConstants.Store.Categories] = (
"دسته‌بندی‌ها | کارابازار سلامت",
"دسته‌بندی محصولات فروشگاه کارابازار سلامت."),
[RouteConstants.Package.List] = ( [RouteConstants.Package.List] = (
"پکیج‌ها | کارابازار سلامت", "پکیج‌ها | کارابازار سلامت",
"پکیج‌های عضویت باشگاه مشتریان کارابازار سلامت."), "پکیج‌های عضویت باشگاه مشتریان کارابازار سلامت."),
@@ -45,6 +49,18 @@ public class SeoMetadataProvider
[RouteConstants.Registration.Wizard] = ( [RouteConstants.Registration.Wizard] = (
"ثبت‌نام | کارابازار سلامت", "ثبت‌نام | کارابازار سلامت",
"ثبت‌نام سریع در باشگاه مشتریان کارابازار سلامت."), "ثبت‌نام سریع در باشگاه مشتریان کارابازار سلامت."),
[RouteConstants.Gateway.StoreChooser] = (
"فروشگاه‌ها | کارابازار سلامت",
"انتخاب فروشگاه اصلی یا اعتباری در کارابازار سلامت."),
};
private static readonly HashSet<string> NoIndexPaths = new(StringComparer.OrdinalIgnoreCase)
{
"/wishlist",
"/wp-admin",
"/wp-login.php",
"/feed",
"/xmlrpc.php",
}; };
private static readonly HashSet<string> NoIndexPrefixes = new(StringComparer.OrdinalIgnoreCase) private static readonly HashSet<string> NoIndexPrefixes = new(StringComparer.OrdinalIgnoreCase)
@@ -62,8 +78,20 @@ public class SeoMetadataProvider
"/discount-store/order/", "/discount-store/order/",
RouteConstants.Gateway.OrdersChooser, RouteConstants.Gateway.OrdersChooser,
RouteConstants.Gateway.CartChooser, RouteConstants.Gateway.CartChooser,
RouteConstants.Commission.Dashboard,
"/commission/",
RouteConstants.Network.Statistics,
"/network/",
RouteConstants.Package.MyPackages,
"/wp-content/",
"/wp-includes/",
}; };
private static readonly Regex ProductDetailPattern = new(@"^/product/\d+$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static readonly Regex DiscountProductPattern = new(@"^/discount-store/product/\d+$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static readonly Regex PackageDetailPattern = new(@"^/package/\d+$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static readonly Regex CheckoutPackagePattern = new(@"^/checkout/\d+$", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
public SeoMetadataProvider(BlogPostService blogPostService, IOptions<SeoSettings> settings) public SeoMetadataProvider(BlogPostService blogPostService, IOptions<SeoSettings> settings)
{ {
_blogPostService = blogPostService; _blogPostService = blogPostService;
@@ -121,6 +149,8 @@ public class SeoMetadataProvider
}; };
} }
if (IsIndexableDynamicRoute(normalizedPath))
{
return new SeoPageMetadata return new SeoPageMetadata
{ {
Title = _settings.SiteName, Title = _settings.SiteName,
@@ -129,6 +159,21 @@ public class SeoMetadataProvider
}; };
} }
return new SeoPageMetadata
{
Title = _settings.SiteName,
Description = _settings.DefaultDescription,
CanonicalUrl = $"{baseUrl}{normalizedPath}",
NoIndex = true
};
}
private static bool IsIndexableDynamicRoute(string path) =>
ProductDetailPattern.IsMatch(path)
|| DiscountProductPattern.IsMatch(path)
|| PackageDetailPattern.IsMatch(path)
|| CheckoutPackagePattern.IsMatch(path);
private static string NormalizePath(string path) private static string NormalizePath(string path)
{ {
if (string.IsNullOrWhiteSpace(path)) if (string.IsNullOrWhiteSpace(path))
@@ -146,6 +191,9 @@ public class SeoMetadataProvider
private static bool IsNoIndexPath(string path) private static bool IsNoIndexPath(string path)
{ {
if (NoIndexPaths.Contains(path))
return true;
foreach (var prefix in NoIndexPrefixes) foreach (var prefix in NoIndexPrefixes)
{ {
if (path.Equals(prefix, StringComparison.OrdinalIgnoreCase) if (path.Equals(prefix, StringComparison.OrdinalIgnoreCase)
@@ -1,4 +1,5 @@
using System.Text; using System.Text;
using System.Xml;
using System.Xml.Linq; using System.Xml.Linq;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
@@ -71,16 +72,29 @@ public class SitemapGenerator
} }
var document = new XDocument( var document = new XDocument(
new XDeclaration("1.0", "UTF-8", null), new XDeclaration("1.0", "utf-8", null),
new XElement(ns + "urlset", urls)); new XElement(ns + "urlset", urls));
var builder = new StringBuilder(); var settings = new XmlWriterSettings
using (var writer = new StringWriter(builder)) {
Encoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false),
Indent = true,
OmitXmlDeclaration = false
};
using var stream = new MemoryStream();
using (var writer = XmlWriter.Create(stream, settings))
{ {
document.Save(writer); document.Save(writer);
} }
return builder.ToString(); return Encoding.UTF8.GetString(stream.ToArray());
}
public async Task<byte[]> GenerateBytesAsync(CancellationToken cancellationToken = default)
{
var xml = await GenerateAsync(cancellationToken);
return Encoding.UTF8.GetBytes(xml);
} }
private static XElement CreateUrlElement( private static XElement CreateUrlElement(
@@ -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);
}
@@ -11,4 +11,5 @@ public class UserAuthInfo
public bool IsSignMainContract { get; set; } public bool IsSignMainContract { get; set; }
public bool HasPurchasedPackage { get; set; } public bool HasPurchasedPackage { get; set; }
public bool IsClubMemberActive { get; set; } public bool IsClubMemberActive { get; set; }
public bool HasAddress { get; set; }
} }
@@ -14,5 +14,10 @@
"Microsoft.AspNetCore": "Warning" "Microsoft.AspNetCore": "Warning"
} }
}, },
"Seo": {
"SiteUrl": "https://kbs2.ir",
"SiteName": "کارابازار سلامت",
"DefaultDescription": "باشگاه مشتریان کارابازار سلامت (KBS) — فروشگاه محصولات سلامت، پاداش شفاف، کیف پول و شبکه فروش."
},
"AllowedHosts": "*" "AllowedHosts": "*"
} }
+4
View File
@@ -13,5 +13,9 @@ Disallow: /discount-store/orders
Disallow: /discount-store/order/ Disallow: /discount-store/order/
Disallow: /my-orders Disallow: /my-orders
Disallow: /my-cart Disallow: /my-cart
Disallow: /wishlist
Disallow: /commission/
Disallow: /network/
Disallow: /my-packages
Sitemap: https://kbs2.ir/sitemap.xml Sitemap: https://kbs2.ir/sitemap.xml