feat: full FrontOffice updates - discount store, blog, payment gateway, UI improvements
Build and Deploy to Kubernetes / build-and-deploy (push) Failing after 4m55s

- Discount store pages (products, cart, orders, order detail)
- Blog pages and services
- Payment gateway callback page
- AppImage component, EmptyState, LoadingState, PageHeader
- DiscountCartService, DiscountOrderService, DiscountProductService
- BlogCategoryService, BlogPostService, SitePageService, ImageCacheService
- PhoneVerifyForm component
- Profile Hub page
- UI/UX improvements across all pages
- landing.js for homepage
- Config and routing updates
This commit is contained in:
masoodafar-web
2026-02-16 00:51:39 +03:30
parent f0d1156457
commit 6db83ccd90
104 changed files with 7668 additions and 1370 deletions
+101
View File
@@ -0,0 +1,101 @@
@inject ImageCacheService ImageCache
@if (_loading)
{
<MudSkeleton SkeletonType="SkeletonType.Rectangle"
Width="@(ImgWidth > 0 ? $"{ImgWidth}px" : SkeletonWidth)"
Height="@(ImgHeight > 0 ? $"{ImgHeight}px" : SkeletonHeight)"
Class="@Class" Style="@Style" />
}
else if (!string.IsNullOrWhiteSpace(_resolvedSrc))
{
<MudImage Src="@_resolvedSrc"
Alt="@Alt"
ObjectFit="@ObjectFit"
ObjectPosition="@ObjectPosition"
Elevation="@Elevation"
Fluid="@Fluid"
Width="@ImgWidth"
Height="@ImgHeight"
Style="@Style"
Class="@Class" />
}
else if (!string.IsNullOrWhiteSpace(Fallback))
{
<div class="@($"d-flex align-center justify-center {Class}")" style="@Style">
<MudIcon Icon="@Fallback" Size="Size.Large" Style="color:rgba(0,0,0,.15);" />
</div>
}
@code {
/// <summary>مسیر تصویر (نسبی، data-URI، یا http)</summary>
[Parameter] public string? Path { get; set; }
/// <summary>متن جایگزین</summary>
[Parameter] public string? Alt { get; set; }
/// <summary>نحوه جایگیری تصویر</summary>
[Parameter] public ObjectFit ObjectFit { get; set; } = ObjectFit.Cover;
/// <summary>موقعیت تصویر</summary>
[Parameter] public ObjectPosition ObjectPosition { get; set; } = ObjectPosition.Center;
/// <summary>سایه تصویر</summary>
[Parameter] public int Elevation { get; set; }
/// <summary>تصویر سیال (100% عرض)</summary>
[Parameter] public bool Fluid { get; set; }
/// <summary>استایل inline</summary>
[Parameter] public string? Style { get; set; }
/// <summary>کلاس CSS</summary>
[Parameter] public string? Class { get; set; }
/// <summary>عرض تصویر (پیکسل) — مانند MudImage</summary>
[Parameter] public int? ImgWidth { get; set; }
/// <summary>ارتفاع تصویر (پیکسل) — مانند MudImage</summary>
[Parameter] public int? ImgHeight { get; set; }
/// <summary>عرض placeholder skeleton (مقدار CSS)</summary>
[Parameter] public string? SkeletonWidth { get; set; }
/// <summary>ارتفاع placeholder skeleton (مقدار CSS)</summary>
[Parameter] public string? SkeletonHeight { get; set; }
/// <summary>آیکون fallback هنگام عدم وجود تصویر</summary>
[Parameter] public string? Fallback { get; set; }
private string? _resolvedSrc;
private bool _loading;
private string? _lastPath;
protected override async Task OnParametersSetAsync()
{
// فقط وقتی Path تغییر کرده، resolve مجدد انجام شود
if (Path == _lastPath) return;
_lastPath = Path;
if (string.IsNullOrWhiteSpace(Path))
{
_resolvedSrc = null;
_loading = false;
return;
}
// اگر قبلاً base64 هست → نمایش فوری
if (Path.StartsWith("data:", StringComparison.Ordinal))
{
_resolvedSrc = Path;
_loading = false;
return;
}
_loading = true;
StateHasChanged();
_resolvedSrc = await ImageCache.ResolveAsync(Path);
_loading = false;
}
}
+91 -158
View File
@@ -1,168 +1,101 @@
@if (InlineMode)
{
@* Inline rendering without MudDialog wrapper *@
<MudStack Spacing="2">
<MudText Typo="Typo.h5" Align="Align.Center">@GetDialogTitle()</MudText>
@PhoneOrVerifyContent()
</MudStack>
<div class="auth-dialog-content">
<MudStack Spacing="3" AlignItems="AlignItems.Center" Class="mb-4">
<MudAvatar Size="Size.Large" Color="Color.Primary" Variant="Variant.Filled">
<MudIcon Icon="@(_currentStep == AuthStep.Phone ? Icons.Material.Outlined.PhoneAndroid : Icons.Material.Outlined.LockOpen)" Size="Size.Large" />
</MudAvatar>
<MudText Typo="Typo.h5">@GetDialogTitle()</MudText>
</MudStack>
<PhoneVerifyForm @ref="_phoneVerifyForm"
CurrentStep="_currentStep"
PhoneRequest="_phoneRequest"
VerifyRequest="_verifyRequest"
CaptchaCode="@_captchaCode"
CaptchaInput="@_captchaInput"
CaptchaInputChanged="@(v => _captchaInput = v)"
OnRefreshCaptcha="GenerateCaptcha"
IsBusy="_isBusy"
ErrorMessage="@_errorMessage"
InfoMessage="@_infoMessage"
PhoneNumber="@_phoneNumber"
ResendRemaining="_resendRemaining"
OnChangePhone="ChangePhoneAsync"
OnResendOtp="ResendOtpAsync" />
</div>
}
else
{
<MudDialog>
<TitleContent>
<MudText Typo="Typo.h4" Align="Align.Center">@GetDialogTitle()</MudText>
</TitleContent>
<MudDialog Class="auth-dialog-wrapper">
<DialogContent>
@PhoneOrVerifyContent()
<div class="auth-dialog-content">
<MudStack Spacing="2" AlignItems="AlignItems.Center" Class="mb-4 pt-2">
<MudAvatar Size="Size.Large" Color="Color.Primary" Variant="Variant.Filled">
<MudIcon Icon="@(_currentStep == AuthStep.Phone ? Icons.Material.Outlined.PhoneAndroid : Icons.Material.Outlined.LockOpen)" Size="Size.Large" />
</MudAvatar>
<MudText Typo="Typo.h5" Align="Align.Center">@GetDialogTitle()</MudText>
</MudStack>
<PhoneVerifyForm @ref="_phoneVerifyForm"
CurrentStep="_currentStep"
PhoneRequest="_phoneRequest"
VerifyRequest="_verifyRequest"
CaptchaCode="@_captchaCode"
CaptchaInput="@_captchaInput"
CaptchaInputChanged="@(v => _captchaInput = v)"
OnRefreshCaptcha="GenerateCaptcha"
IsBusy="_isBusy"
ErrorMessage="@_errorMessage"
InfoMessage="@_infoMessage"
PhoneNumber="@_phoneNumber"
ResendRemaining="_resendRemaining"
OnChangePhone="ChangePhoneAsync"
OnResendOtp="ResendOtpAsync" />
<MudStack Class="mt-4" Spacing="2">
@if (_currentStep == AuthStep.Phone)
{
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="SendOtpAsync"
Disabled="_isBusy"
Class="rounded-lg"
FullWidth="true"
Size="Size.Large">
@if (_isBusy)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="me-2" />
}
ارسال رمز پویا
</MudButton>
}
else if (_currentStep == AuthStep.Verify)
{
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="VerifyOtpAsync"
Disabled="_isBusy || IsVerificationLocked"
Class="rounded-lg"
FullWidth="true"
Size="Size.Large">
@if (_isBusy)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="me-2" />
}
تأیید و ورود
</MudButton>
}
@if (!HideCancelButton)
{
<MudButton Variant="Variant.Text"
OnClick="Cancel"
Disabled="_isBusy"
FullWidth="true">لغو
</MudButton>
}
</MudStack>
</div>
</DialogContent>
<DialogActions>
@if (!HideCancelButton)
{
<MudButton Variant="Variant.Text"
OnClick="Cancel"
Disabled="_isBusy">لغو
</MudButton>
}
@if (_currentStep == AuthStep.Phone)
{
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="SendOtpAsync"
Disabled="_isBusy"
FullWidth="true">
ارسال رمز پویا
</MudButton>
}
else if (_currentStep == AuthStep.Verify)
{
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
OnClick="VerifyOtpAsync"
Disabled="_isBusy || IsVerificationLocked"
FullWidth="true">
تأیید و ورود
</MudButton>
}
</DialogActions>
</MudDialog>
}
@code {
private RenderFragment PhoneOrVerifyContent() => __builder =>
{
if (_currentStep == AuthStep.Phone)
{
// Phone Step
__builder.OpenComponent(0, typeof(MudText));
__builder.AddAttribute(1, "Typo", Typo.body2);
__builder.AddAttribute(2, "Class", "mb-4");
__builder.AddAttribute(3, "Align", Align.Center);
__builder.AddContent(4, "لطفاً شماره موبایل خود را وارد کنید تا رمز پویا ارسال شود.");
__builder.CloseComponent();
<MudForm @ref="_phoneForm" Model="_phoneRequest">
<MudTextField @bind-Value="_phoneRequest.Mobile"
For="@(() => _phoneRequest.Mobile)"
Label="شماره موبایل"
InputType="InputType.Telephone"
InputMode="InputMode.tel"
Variant="Variant.Outlined"
Immediate="true"
Required="true"
RequiredError="وارد کردن شماره موبایل الزامی است."
HelperText="مثال: 09121234567"
Class="mb-2"/>
@* @if (EnableCaptcha) *@
@* { *@
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1" Class="mt-2 mb-2">
<MudItem xs="7" md="12">
<MudTextField xs="4" Label="کد کپچا" Placeholder="کد نمایش داده شده" Immediate="true"
Variant="Variant.Outlined" @bind-Value="_captchaInput" Required="true"
RequiredError="لطفاً کد کپچا را وارد کنید."/>
</MudItem>
<MudItem xs="4" >
<MudPaper Elevation="1" Class="captcha-box d-flex align-center justify-center"
Style="min-width:100px;min-height:48px;">
<MudText Typo="Typo.h5">@_captchaCode</MudText>
</MudPaper>
</MudItem>
<MudItem xs="1" >
<MudButton Variant="Variant.Text" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Refresh" Disabled="_isBusy"
OnClick="GenerateCaptcha">
</MudButton>
</MudItem>
</MudStack>
@* } *@
@* *@
<MudCheckBox T="bool" Required="true"
Label="شرایط و قوانین را می‌پذیرم"
RequiredError="برای ادامه باید شرایط و قوانین را بپذیرید."
Class="mb-1"/>
@if (!string.IsNullOrWhiteSpace(_errorMessage))
{
<MudAlert Severity="Severity.Error" Dense="true" Elevation="0"
Class="mb-2">@_errorMessage</MudAlert>
}
</MudForm>
}
else if (_currentStep == AuthStep.Verify)
{
// Verify Step
<MudText Typo="Typo.body2" Class="mb-4" Align="Align.Center">رمز پویا شش رقمی ارسال ‌شده به @_phoneNumber را
وارد کنید.
</MudText>
<MudForm @ref="_verifyForm" Model="_verifyRequest">
<MudTextField @bind-Value="_verifyRequest.Code"
For="@(() => _verifyRequest.Code)"
Label="رمز پویا"
InputType="InputType.Telephone"
InputMode="InputMode.tel"
Variant="Variant.Outlined"
Immediate="true"
Required="true"
RequiredError="وارد کردن رمز پویا الزامی است."
HelperText="کد ۶ رقمی"
Class="mb-2"
MaxLength="6"/>
@if (!string.IsNullOrWhiteSpace(_errorMessage))
{
<MudAlert Severity="Severity.Error" Dense="true" Elevation="0"
Class="mb-2">@(_errorMessage)</MudAlert>
}
@if (!string.IsNullOrWhiteSpace(_infoMessage))
{
<MudAlert Severity="Severity.Success" Dense="true" Elevation="0"
Class="mb-2">@(_infoMessage)</MudAlert>
}
<MudStack Spacing="2">
<MudButton Variant="Variant.Text" Color="Color.Secondary" Disabled="_isBusy"
OnClick="ChangePhoneAsync">تغییر شماره
</MudButton>
</MudStack>
<MudDivider Class="my-2"/>
@if (_resendRemaining > 0)
{
<MudText Typo="Typo.body2" Align="Align.Center" Class="mud-text-secondary">امکان ارسال مجدد
تا @_resendRemaining ثانیه دیگر
</MudText>
}
else
{
<MudButton Variant="Variant.Text" Color="Color.Primary" Disabled="_isBusy" OnClick="ResendOtpAsync">
ارسال مجدد رمز پویا
</MudButton>
}
</MudForm>
}
};
}
@@ -25,10 +25,9 @@ public partial class AuthDialog : IDisposable
public AuthStep _currentStep = AuthStep.Phone;
private readonly CreateNewOtpTokenRequest _phoneRequest = new();
private MudForm? _phoneForm;
private PhoneVerifyForm? _phoneVerifyForm;
private readonly VerifyOtpTokenRequest _verifyRequest = new();
private MudForm? _verifyForm;
private bool _isBusy;
private string? _phoneNumber;
@@ -79,11 +78,12 @@ public partial class AuthDialog : IDisposable
public async Task SendOtpAsync()
{
_errorMessage = null;
if (_phoneForm is null)
var phoneForm = _phoneVerifyForm?.GetPhoneForm();
if (phoneForm is null)
return;
await _phoneForm.Validate();
if (!_phoneForm.IsValid)
await phoneForm.Validate();
if (!phoneForm.IsValid)
return;
// if (EnableCaptcha)
@@ -154,11 +154,12 @@ public partial class AuthDialog : IDisposable
_errorMessage = null;
_infoMessage = null;
if (_verifyForm is null)
var verifyForm = _phoneVerifyForm?.GetVerifyForm();
if (verifyForm is null)
return false;
await _verifyForm.Validate();
if (!_verifyForm.IsValid)
await verifyForm.Validate();
if (!verifyForm.IsValid)
return false;
if (IsVerificationLocked)
@@ -236,6 +237,10 @@ public partial class AuthDialog : IDisposable
return true;
}
// اگه سرور RemainingAttempts برگردونده، از اون استفاده کن
if (response.RemainingAttempts > 0)
_attemptsLeft = response.RemainingAttempts;
RegisterFailedAttempt(string.IsNullOrWhiteSpace(response.Message) ? "کد نادرست است." : response.Message);
}
catch (RpcException rpcEx)
@@ -135,7 +135,8 @@
new CMSMicroservice.Protobuf.Protos.OtpToken.CreateNewOtpTokenRequest
{
Mobile = await GetUserMobileAsync(),
Purpose = "ClubContract"
Purpose = "signClubContract",
SignGuid = _signGuid.ToString()
});
if (response.Success)
@@ -166,11 +167,9 @@
_isLoading = true;
try
{
var userId = await GetCurrentUserIdAsync();
var response = await ClubMembershipClient.AcceptClubMembershipContractAsync(
new CMSMicroservice.Protobuf.Protos.ClubMembership.AcceptClubMembershipContractRequest
{
UserId = userId,
OtpCode = _otpCode,
SignGuid = _signGuid.ToString(),
ContractHtml = GetClubContractHtml()
@@ -0,0 +1,67 @@
@inject IJSRuntime JS
@* ═══════════════════════════════════════════════
EmptyState — Unified empty/not-found placeholder
Usage: <EmptyState Title="سفارشی ثبت نشده" />
<EmptyState Title="آدرسی یافت نشد"
Icon="@Icons.Material.Filled.LocationOff"
Description="ابتدا یک آدرس اضافه کنید."
ActionText="افزودن آدرس"
OnAction="OpenAddDialog" />
═══════════════════════════════════════════════ *@
<MudStack AlignItems="AlignItems.Center" Class="py-12" Spacing="3">
<MudIcon Icon="@Icon" Size="Size.Large" Color="Color.Default"
Class="mud-text-disabled" Style="font-size:3rem;" />
<MudText Typo="Typo.h6" Class="mud-text-secondary">@Title</MudText>
@if (!string.IsNullOrWhiteSpace(Description))
{
<MudText Typo="Typo.body2" Class="mud-text-secondary text-center"
Style="max-width:400px;">@Description</MudText>
}
@if (ChildContent is not null)
{
@ChildContent
}
else if (!string.IsNullOrWhiteSpace(ActionText))
{
@if (!string.IsNullOrWhiteSpace(ActionHref))
{
<MudButton Variant="Variant.Filled" Color="Color.Primary"
OnClick="GoBack" Class="mt-2">@ActionText</MudButton>
}
else
{
<MudButton Variant="Variant.Filled" Color="Color.Primary"
OnClick="OnAction" Class="mt-2">@ActionText</MudButton>
}
}
</MudStack>
@code {
/// <summary>Material icon string.</summary>
[Parameter] public string Icon { get; set; } = Icons.Material.Filled.Inbox;
/// <summary>Main heading text.</summary>
[Parameter] public string Title { get; set; } = "موردی یافت نشد";
/// <summary>Optional secondary description.</summary>
[Parameter] public string? Description { get; set; }
/// <summary>Optional CTA button text.</summary>
[Parameter] public string? ActionText { get; set; }
/// <summary>If set, CTA becomes a link instead of callback.</summary>
[Parameter] public string? ActionHref { get; set; }
/// <summary>CTA button callback.</summary>
[Parameter] public EventCallback OnAction { get; set; }
/// <summary>Optional custom content (replaces ActionText button).</summary>
[Parameter] public RenderFragment? ChildContent { get; set; }
private async Task GoBack()
{
await JS.InvokeVoidAsync("history.back");
}
}
@@ -0,0 +1,18 @@
@* ═══════════════════════════════════════════════
LoadingState — Unified loading indicator
Usage: <LoadingState />
<LoadingState Message="در حال دریافت سفارشات..." />
═══════════════════════════════════════════════ *@
<MudStack AlignItems="AlignItems.Center" Class="py-16">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Large" />
@if (!string.IsNullOrWhiteSpace(Message))
{
<MudText Typo="Typo.body1" Class="mud-text-secondary mt-2">@Message</MudText>
}
</MudStack>
@code {
/// <summary>Optional loading message displayed below spinner.</summary>
[Parameter] public string Message { get; set; } = "در حال بارگذاری...";
}
+180 -38
View File
@@ -9,10 +9,10 @@
<MudSnackbarProvider/>
<MudLayout>
<!-- AppBar -->
<MudAppBar Elevation="0" Color="Color.Transparent" Dense="true" Class="py-2" id="top">
<MudAppBar Elevation="1" Color="Color.Surface" Dense="true" Class="py-2" id="top">
<MudContainer MaxWidth="MaxWidth.Large" Class="d-flex align-center justify-space-between w-100">
<MudHidden Breakpoint="Breakpoint.SmAndUp">
<MudHidden Breakpoint="Breakpoint.MdAndUp">
<MudIconButton Class="d-inline"
Icon="@Icons.Material.Filled.Menu"
OnClick="@ToggleDrawer"/>
@@ -21,7 +21,7 @@
<div class="d-flex align-center gap-2">
<MudLink Href="@(RouteConstants.Main.MainPage)" Underline="Underline.None">
<MudStack Row="true" Spacing="3" AlignItems="AlignItems.Center">
<MudHidden Breakpoint="Breakpoint.SmAndUp" Invert="true">
<MudHidden Breakpoint="Breakpoint.MdAndUp" Invert="true">
<MudImage ObjectFit="ObjectFit.Cover"
ObjectPosition="ObjectPosition.Center"
Width="32"
@@ -32,37 +32,66 @@
<MudText Typo="Typo.h6">کارا بازار سلامت</MudText>
</MudStack>
</MudLink>
@* ── Store context badge (desktop) ── *@
@if (IsInRegularStore)
{
<MudChip T="string" Color="Color.Success" Variant="Variant.Filled" Size="Size.Small"
Class="d-none d-md-flex" Icon="@Icons.Material.Filled.Storefront">فروشگاه</MudChip>
}
else if (IsInDiscountStore)
{
<MudChip T="string" Color="Color.Error" Variant="Variant.Filled" Size="Size.Small"
Class="d-none d-md-flex" Icon="@Icons.Material.Filled.Loyalty">فروشگاه تخفیفی</MudChip>
}
</div>
<div class="d-none d-md-flex align-center gap-10">
@if (_isAuthenticated)
@if (IsInRegularStore && _isAuthenticated)
{
<MudLink Href="@(RouteConstants.Store.Products)" Typo="Typo.subtitle1" Class="mud-link">
محصولات
</MudLink>
<MudLink Href="@(RouteConstants.Store.Cart)" Typo="Typo.subtitle1" Class="mud-link">سبد خرید
</MudLink>
<MudLink Href="@(RouteConstants.Store.Orders)" Typo="Typo.subtitle1" Class="mud-link">سفارشات
من
</MudLink>
@* ── Regular store nav ── *@
<MudLink Href="@(RouteConstants.Store.Products)" Typo="Typo.subtitle1" Class="mud-link">محصولات</MudLink>
<MudLink Href="@(RouteConstants.Store.Cart)" Typo="Typo.subtitle1" Class="mud-link">سبد خرید</MudLink>
<MudLink Href="@(RouteConstants.Store.Orders)" Typo="Typo.subtitle1" Class="mud-link">سفارشات من</MudLink>
}
<MudLink Href="@(RouteConstants.FAQ.Index)" Typo="Typo.subtitle1" Class="mud-link">سوالات متداول
</MudLink>
<MudLink Href="@(RouteConstants.Contact.Index)" Typo="Typo.subtitle1" Class="mud-link">ارتباط با
ما
</MudLink>
<MudLink Href="@(RouteConstants.About.Index)" Typo="Typo.subtitle1" Class="mud-link">درباره ما
</MudLink>
else if (IsInDiscountStore && _isAuthenticated)
{
@* ── Discount store nav ── *@
<MudLink Href="@(RouteConstants.DiscountStore.Products)" Typo="Typo.subtitle1" Class="mud-link">محصولات تخفیفی</MudLink>
<MudLink Href="@(RouteConstants.DiscountStore.Cart)" Typo="Typo.subtitle1" Class="mud-link">سبد خرید</MudLink>
<MudLink Href="@(RouteConstants.DiscountStore.Orders)" Typo="Typo.subtitle1" Class="mud-link">سفارشات من</MudLink>
}
else
{
@* ── Default global nav (not inside any store) ── *@
@if (_isAuthenticated)
{
<MudLink Href="@(RouteConstants.Gateway.StoreChooser)" Typo="Typo.subtitle1" Class="mud-link">فروشگاه‌ها</MudLink>
}
}
<MudLink Href="@(RouteConstants.FAQ.Index)" Typo="Typo.subtitle1" Class="mud-link">سوالات متداول</MudLink>
<MudLink Href="@(RouteConstants.Blog.Index)" Typo="Typo.subtitle1" Class="mud-link">بلاگ</MudLink>
<MudLink Href="@(RouteConstants.Contact.Index)" Typo="Typo.subtitle1" Class="mud-link">ارتباط با ما</MudLink>
<MudLink Href="@(RouteConstants.About.Index)" Typo="Typo.subtitle1" Class="mud-link">درباره ما</MudLink>
</div>
<div class="d-flex align-center gap-2">
@if (_isAuthenticated)
{
@* <MudBadge Content="@_cartCount" Color="Color.Error" Overlap="true" Visible="@(_cartCount > 0)"> *@
@* *@
@* </MudBadge> *@
<MudIconButton Icon="@Icons.Material.Filled.ShoppingCart" Color="@(_cartCount > 0?Color.Success:Color.Inherit)"
Href="@(RouteConstants.Store.Cart)" />
@* ── Cart icon: only visible when inside a store ── *@
@if (IsInRegularStore)
{
<MudIconButton Icon="@Icons.Material.Filled.ShoppingCart"
Color="@(_cartCount > 0 ? Color.Success : Color.Inherit)"
Href="@(RouteConstants.Store.Cart)" />
}
else if (IsInDiscountStore)
{
<MudIconButton Icon="@Icons.Material.Filled.ShoppingCart"
Color="@(_discountCartCount > 0 ? Color.Error : Color.Inherit)"
Href="@(RouteConstants.DiscountStore.Cart)" />
}
@* دکمه بروزرسانی *@
@if (_hasUpdate)
{
@@ -74,7 +103,10 @@
</MudBadge>
}
<MudMenu Icon="@Icons.Material.Filled.Person" Color="Color.Inherit" Size="Size.Medium">
<MudMenuItem OnClick="NavigateToProfile" Disabled="@(!AuthService.IsCompleteRegister())">
<MudMenuItem OnClick="NavigateToProfile" Disabled="@(!_isCompleteRegister)" Icon="@Icons.Material.Outlined.Dashboard">
داشبورد
</MudMenuItem>
<MudMenuItem OnClick="NavigateToHub" Disabled="@(!_isCompleteRegister)" Icon="@Icons.Material.Outlined.Person">
پروفایل
</MudMenuItem>
<MudDivider/>
@@ -110,19 +142,49 @@
<MudStack Spacing="2">
@if (_isAuthenticated)
{
<MudLink Href="@(RouteConstants.Store.Products)" Typo="Typo.subtitle1"
OnClick="() => _drawerOpen=false">محصولات
</MudLink>
<MudLink Href="@(RouteConstants.Store.Cart)" Typo="Typo.subtitle1"
OnClick="() => _drawerOpen=false">سبد خرید
</MudLink>
<MudLink Href="@(RouteConstants.Store.Orders)" Typo="Typo.subtitle1"
OnClick="() => _drawerOpen=false">سفارشات من
</MudLink>
@if (IsInRegularStore)
{
<MudText Typo="Typo.overline" Class="mud-text-secondary">فروشگاه</MudText>
<MudLink Href="@(RouteConstants.Store.Products)" Typo="Typo.subtitle1"
OnClick="() => _drawerOpen=false">محصولات</MudLink>
<MudLink Href="@(RouteConstants.Store.Cart)" Typo="Typo.subtitle1"
OnClick="() => _drawerOpen=false">سبد خرید</MudLink>
<MudLink Href="@(RouteConstants.Store.Orders)" Typo="Typo.subtitle1"
OnClick="() => _drawerOpen=false">سفارشات من</MudLink>
<MudDivider Class="my-1" />
<MudLink Href="@(RouteConstants.Gateway.StoreChooser)" Typo="Typo.subtitle1"
OnClick="() => _drawerOpen=false" Class="mud-text-secondary">
← بازگشت به فروشگاه‌ها
</MudLink>
}
else if (IsInDiscountStore)
{
<MudText Typo="Typo.overline" Style="color:var(--mud-palette-error);">فروشگاه تخفیفی</MudText>
<MudLink Href="@(RouteConstants.DiscountStore.Products)" Typo="Typo.subtitle1"
OnClick="() => _drawerOpen=false">محصولات تخفیفی</MudLink>
<MudLink Href="@(RouteConstants.DiscountStore.Cart)" Typo="Typo.subtitle1"
OnClick="() => _drawerOpen=false">سبد خرید</MudLink>
<MudLink Href="@(RouteConstants.DiscountStore.Orders)" Typo="Typo.subtitle1"
OnClick="() => _drawerOpen=false">سفارشات من</MudLink>
<MudDivider Class="my-1" />
<MudLink Href="@(RouteConstants.Gateway.StoreChooser)" Typo="Typo.subtitle1"
OnClick="() => _drawerOpen=false" Class="mud-text-secondary">
← بازگشت به فروشگاه‌ها
</MudLink>
}
else
{
<MudLink Href="@(RouteConstants.Gateway.StoreChooser)" Typo="Typo.subtitle1"
OnClick="() => _drawerOpen=false">فروشگاه‌ها</MudLink>
}
<MudDivider Class="my-1" />
}
<MudLink Href="@(RouteConstants.FAQ.Index)" Typo="Typo.subtitle1" OnClick="() => _drawerOpen=false">
سوالات متداول
</MudLink>
<MudLink Href="@(RouteConstants.Blog.Index)" Typo="Typo.subtitle1" OnClick="() => _drawerOpen=false">
بلاگ
</MudLink>
<MudLink Href="@(RouteConstants.About.Index)" Typo="Typo.subtitle1" OnClick="() => _drawerOpen=false">
درباره ما
</MudLink>
@@ -165,13 +227,93 @@
</MudStack>
</MudDrawer>
<MudMainContent>
<MudMainContent Class="main-content-wrapper">
@Body
@if (DeviceDetector.IsDesktop())
{
<MudHidden Breakpoint="Breakpoint.SmAndDown">
<Footer/>
}
</MudHidden>
</MudMainContent>
<!-- Mobile Bottom Navigation -->
<MudHidden Breakpoint="Breakpoint.MdAndUp">
<MudPaper Class="bottom-nav" Elevation="8">
<MudStack Row="true" Justify="Justify.SpaceAround" AlignItems="AlignItems.Center" Class="py-1">
@if (IsInRegularStore && _isAuthenticated)
{
@* ── Regular store bottom nav ── *@
<MudLink Href="@(RouteConstants.Store.Products)" Class="bottom-nav-item">
<MudIcon Icon="@Icons.Material.Outlined.Storefront" Size="Size.Medium" />
<MudText Typo="Typo.caption">محصولات</MudText>
</MudLink>
<MudLink Href="@(RouteConstants.Store.Cart)" Class="bottom-nav-item">
<MudIcon Icon="@Icons.Material.Outlined.ShoppingCart" Size="Size.Medium" Color="@(_cartCount > 0 ? Color.Success : Color.Default)" />
<MudText Typo="Typo.caption">سبد خرید</MudText>
</MudLink>
<MudLink Href="@(RouteConstants.Store.Orders)" Class="bottom-nav-item">
<MudIcon Icon="@Icons.Material.Outlined.Receipt" Size="Size.Medium" />
<MudText Typo="Typo.caption">سفارشات</MudText>
</MudLink>
<MudLink Href="@(RouteConstants.Profile.Index)" Class="bottom-nav-item">
<MudIcon Icon="@Icons.Material.Outlined.Home" Size="Size.Medium" />
<MudText Typo="Typo.caption">داشبورد</MudText>
</MudLink>
}
else if (IsInDiscountStore && _isAuthenticated)
{
@* ── Discount store bottom nav ── *@
<MudLink Href="@(RouteConstants.DiscountStore.Products)" Class="bottom-nav-item">
<MudIcon Icon="@Icons.Material.Outlined.Loyalty" Size="Size.Medium" Color="Color.Error" />
<MudText Typo="Typo.caption">محصولات</MudText>
</MudLink>
<MudLink Href="@(RouteConstants.DiscountStore.Cart)" Class="bottom-nav-item">
<MudIcon Icon="@Icons.Material.Outlined.ShoppingCart" Size="Size.Medium" Color="@(_discountCartCount > 0 ? Color.Error : Color.Default)" />
<MudText Typo="Typo.caption">سبد خرید</MudText>
</MudLink>
<MudLink Href="@(RouteConstants.DiscountStore.Orders)" Class="bottom-nav-item">
<MudIcon Icon="@Icons.Material.Outlined.Receipt" Size="Size.Medium" />
<MudText Typo="Typo.caption">سفارشات</MudText>
</MudLink>
<MudLink Href="@(RouteConstants.Profile.Index)" Class="bottom-nav-item">
<MudIcon Icon="@Icons.Material.Outlined.Home" Size="Size.Medium" />
<MudText Typo="Typo.caption">داشبورد</MudText>
</MudLink>
}
else
{
@* ── Default bottom nav (outside stores) ── *@
<MudLink Href="@(RouteConstants.Profile.Index)" Class="bottom-nav-item">
<MudIcon Icon="@Icons.Material.Outlined.Home" Size="Size.Medium" />
<MudText Typo="Typo.caption">خانه</MudText>
</MudLink>
@if (_isAuthenticated)
{
<MudLink Href="@(RouteConstants.Gateway.StoreChooser)" Class="bottom-nav-item">
<MudIcon Icon="@Icons.Material.Outlined.Store" Size="Size.Medium" />
<MudText Typo="Typo.caption">فروشگاه‌ها</MudText>
</MudLink>
}
<MudLink Href="@(RouteConstants.Blog.Index)" Class="bottom-nav-item">
<MudIcon Icon="@Icons.Material.Outlined.Article" Size="Size.Medium" />
<MudText Typo="Typo.caption">بلاگ</MudText>
</MudLink>
@if (_isAuthenticated)
{
<MudLink Href="@(RouteConstants.Profile.Hub)" Class="bottom-nav-item">
<MudIcon Icon="@Icons.Material.Outlined.Person" Size="Size.Medium" />
<MudText Typo="Typo.caption">پروفایل</MudText>
</MudLink>
}
else
{
<MudLink Class="bottom-nav-item" @onclick="OpenAuthDialog">
<MudIcon Icon="@Icons.Material.Outlined.Login" Size="Size.Medium" />
<MudText Typo="Typo.caption">ورود</MudText>
</MudLink>
}
}
</MudStack>
</MudPaper>
</MudHidden>
</MudLayout>
</MudRTLProvider>
+116 -1
View File
@@ -1,6 +1,7 @@
using Blazored.LocalStorage;
using FrontOffice.Main.Utilities;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.JSInterop;
using MudBlazor;
using Microsoft.AspNetCore.Components.Authorization;
@@ -16,6 +17,15 @@ public partial class MainLayout : IDisposable
private bool _isAuthenticated;
private string? _email;
private int _cartCount;
private int _discountCartCount;
private bool _isCompleteRegister;
// ── Store Context ──
private enum StoreContext { None, Regular, Discount }
private StoreContext _storeCtx = StoreContext.None;
private bool IsInRegularStore => _storeCtx == StoreContext.Regular;
private bool IsInDiscountStore => _storeCtx == StoreContext.Discount;
private bool IsInAnyStore => _storeCtx != StoreContext.None;
// متغیرهای بروزرسانی
private bool _hasUpdate;
@@ -26,6 +36,7 @@ public partial class MainLayout : IDisposable
[Inject] private AuthService AuthService { get; set; } = default!;
[Inject] private AuthDialogService AuthDialogService { get; set; } = default!;
[Inject] private CartService CartService { get; set; } = default!;
[Inject] private DiscountCartService DiscountCartService { get; set; } = default!;
[Inject] private AppVersionService AppVersionService { get; set; } = default!;
private void ToggleTheme() => _isDark = !_isDark;
@@ -35,20 +46,34 @@ public partial class MainLayout : IDisposable
await JSRuntime.InvokeVoidAsync("history.back");
}
protected override void OnInitialized()
{
Navigation.LocationChanged += OnLocationChanged;
UpdateStoreContext();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JSRuntime.InvokeVoidAsync("changeNavBgOnBodyScroll", "top", null, 1);
await CheckAuthStatus();
await EnforceAuthGuardAsync();
if (_isAuthenticated)
{
// بررسی وضعیت ثبت‌نام کامل
_isCompleteRegister = await AuthService.IsCompleteRegisterAsync();
// لود سبد خرید فقط برای کاربر لاگین شده
await CartService.EnsureInitializedAsync();
CartService.OnChange += OnCartChanged;
_cartCount = CartService.Count;
await DiscountCartService.EnsureInitializedAsync();
DiscountCartService.OnChange += OnDiscountCartChanged;
_discountCartCount = DiscountCartService.Count;
// چک کردن بروزرسانی (بدون نمایش popup - فقط برای نشون دادن آیکون)
await CheckForUpdateSilentlyAsync();
}
@@ -57,6 +82,83 @@ public partial class MainLayout : IDisposable
}
}
private void OnLocationChanged(object? sender, LocationChangedEventArgs e)
{
UpdateStoreContext();
_ = InvokeAsync(async () =>
{
await EnforceAuthGuardAsync();
StateHasChanged();
});
}
/// <summary>
/// If the current route requires authentication and the user is not logged in, redirect to home.
/// </summary>
private async Task EnforceAuthGuardAsync()
{
if (_isAuthenticated) return;
// Re-check in case token was set after initial render
_isAuthenticated = await AuthService.IsAuthenticatedAsync();
if (_isAuthenticated) return;
var path = new Uri(Navigation.Uri).AbsolutePath.ToLowerInvariant();
if (IsProtectedRoute(path))
{
Navigation.NavigateTo(RouteConstants.Main.MainPage, replace: true);
}
}
private static bool IsProtectedRoute(string path)
{
// Public routes — no auth needed
if (path is "/" or "/register" or "/about" or "/faq" or "/contact"
or "/packages" or "/stores" or "/products" or "/categories")
return false;
if (path.StartsWith("/blog")) return false;
if (path.StartsWith("/package/")) return false;
if (path.StartsWith("/product/")) return false;
if (path == "/discount-store" || path.StartsWith("/discount-store/product/")) return false;
// Everything else is protected
if (path.StartsWith("/profile")) return true;
if (path.StartsWith("/commission")) return true;
if (path.StartsWith("/network")) return true;
if (path.StartsWith("/club")) return true;
if (path.StartsWith("/checkout")) return true;
if (path.StartsWith("/cart")) return true;
if (path.StartsWith("/orders") || path.StartsWith("/order/") || path.StartsWith("/order-tracking/")) return true;
if (path.StartsWith("/my-packages") || path.StartsWith("/my-orders") || path.StartsWith("/my-cart")) return true;
if (path.StartsWith("/discount-store/cart") || path.StartsWith("/discount-store/checkout")
|| path.StartsWith("/discount-store/orders") || path.StartsWith("/discount-store/order/")) return true;
return false;
}
private void UpdateStoreContext()
{
var path = new Uri(Navigation.Uri).AbsolutePath.ToLowerInvariant();
// Discount store routes all start with /discount-store
if (path.StartsWith("/discount-store"))
{
_storeCtx = StoreContext.Discount;
}
// Regular store routes
else if (path.StartsWith("/products") || path.StartsWith("/product/") ||
path.StartsWith("/cart") || path.StartsWith("/checkout-summary") ||
path.StartsWith("/orders") || path.StartsWith("/order/") ||
path.StartsWith("/order-tracking/") || path.StartsWith("/categories"))
{
_storeCtx = StoreContext.Regular;
}
else
{
_storeCtx = StoreContext.None;
}
}
/// <summary>
/// چک کردن بروزرسانی بدون نمایش popup
/// </summary>
@@ -131,6 +233,12 @@ public partial class MainLayout : IDisposable
InvokeAsync(StateHasChanged);
}
private void OnDiscountCartChanged()
{
_discountCartCount = DiscountCartService.Count;
InvokeAsync(StateHasChanged);
}
private async Task CheckAuthStatus()
{
_isAuthenticated = await AuthService.IsAuthenticatedAsync();
@@ -154,6 +262,11 @@ public partial class MainLayout : IDisposable
Navigation.NavigateTo(RouteConstants.Profile.Index);
}
private void NavigateToHub()
{
Navigation.NavigateTo(RouteConstants.Profile.Hub);
}
private async Task Logout()
{
await AuthService.LogoutAsync();
@@ -165,5 +278,7 @@ public partial class MainLayout : IDisposable
public void Dispose()
{
CartService.OnChange -= OnCartChanged;
DiscountCartService.OnChange -= OnDiscountCartChanged;
Navigation.LocationChanged -= OnLocationChanged;
}
}
@@ -0,0 +1,40 @@
@inject NavigationManager Navigation
@inject IJSRuntime JS
<div class="page-header">
<MudStack Spacing="0">
<MudText Typo="Typo.h5">@Title</MudText>
@if (!string.IsNullOrWhiteSpace(Subtitle))
{
<MudText Typo="Typo.body2" Class="mud-text-secondary">@Subtitle</MudText>
}
</MudStack>
@if (ShowBack)
{
<MudButton Variant="Variant.Text" StartIcon="@Icons.Material.Filled.ArrowBack" OnClick="GoBack">بازگشت</MudButton>
}
</div>
@code {
[Parameter] public string Title { get; set; } = string.Empty;
/// <summary>
/// Optional subtitle text displayed below the title.
/// </summary>
[Parameter] public string? Subtitle { get; set; }
/// <summary>
/// Fallback URL if there's no browser history. Uses history.back() first.
/// </summary>
[Parameter] public string? BackHref { get; set; }
/// <summary>
/// Whether to show the back button. Default is true.
/// </summary>
[Parameter] public bool ShowBack { get; set; } = true;
private async Task GoBack()
{
await JS.InvokeVoidAsync("history.back");
}
}
@@ -0,0 +1,96 @@
@using static FrontOffice.Main.Shared.AuthDialog
@if (CurrentStep == AuthStep.Phone)
{
<MudText Typo="Typo.body2" Class="mb-4 mud-text-secondary" Align="Align.Center">
لطفاً شماره موبایل خود را وارد کنید تا رمز پویا ارسال شود.
</MudText>
<MudForm @ref="_phoneForm" Model="PhoneRequest">
<MudTextField @bind-Value="PhoneRequest.Mobile"
Label="شماره موبایل"
Variant="Variant.Outlined"
Immediate="true"
Required="true"
Placeholder="شماره موبایل خود را وارد کنید"
RequiredError="وارد کردن شماره موبایل الزامی است."
Class="mb-3" />
<div class="captcha-row mb-3">
<MudTextField Label="کد کپچا" Placeholder="کد نمایش داده شده" Immediate="true"
Variant="Variant.Outlined" Value="CaptchaInput"
ValueChanged="@((string v) => CaptchaInputChanged.InvokeAsync(v))"
Required="true"
RequiredError="لطفاً کد کپچا را وارد کنید." />
<MudPaper Elevation="0" Class="captcha-box d-flex align-center justify-center">
<MudText Typo="Typo.h5">@CaptchaCode</MudText>
</MudPaper>
<MudIconButton Icon="@Icons.Material.Outlined.Refresh" Color="Color.Primary"
Disabled="IsBusy" OnClick="OnRefreshCaptcha" Size="Size.Medium" />
</div>
<MudCheckBox T="bool" Required="true"
Label="شرایط و قوانین را می‌پذیرم"
RequiredError="برای ادامه باید شرایط و قوانین را بپذیرید."
Color="Color.Primary"
Class="mb-2" />
@if (!string.IsNullOrWhiteSpace(ErrorMessage))
{
<MudAlert Severity="Severity.Error" Dense="true" Elevation="0"
Class="mb-2 rounded-lg">@ErrorMessage</MudAlert>
}
</MudForm>
}
else if (CurrentStep == AuthStep.Verify)
{
<MudText Typo="Typo.body2" Class="mb-4 mud-text-secondary" Align="Align.Center">
رمز پویا شش رقمی ارسال شده به <strong>@PhoneNumber</strong> را وارد کنید.
</MudText>
<MudForm @ref="_verifyForm" Model="VerifyRequest">
<MudTextField @bind-Value="VerifyRequest.Code"
Label="رمز پویا"
Variant="Variant.Outlined"
Immediate="true"
Required="true"
RequiredError="وارد کردن رمز پویا الزامی است."
Class="mb-3"
MaxLength="6" />
@if (!string.IsNullOrWhiteSpace(ErrorMessage))
{
<MudAlert Severity="Severity.Error" Dense="true" Elevation="0"
Class="mb-2 rounded-lg">@ErrorMessage</MudAlert>
}
@if (!string.IsNullOrWhiteSpace(InfoMessage))
{
<MudAlert Severity="Severity.Success" Dense="true" Elevation="0"
Class="mb-2 rounded-lg">@InfoMessage</MudAlert>
}
<MudDivider Class="my-3" />
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudButton Variant="Variant.Text" Color="Color.Secondary" Disabled="IsBusy"
OnClick="OnChangePhone" Size="Size.Small">
<MudIcon Icon="@Icons.Material.Outlined.Edit" Size="Size.Small" Class="me-1" /> تغییر شماره
</MudButton>
@if (ResendRemaining > 0)
{
<MudText Typo="Typo.caption" Class="mud-text-secondary">
ارسال مجدد تا @ResendRemaining ثانیه
</MudText>
}
else
{
<MudButton Variant="Variant.Text" Color="Color.Primary" Disabled="IsBusy"
OnClick="OnResendOtp" Size="Size.Small">
ارسال مجدد
</MudButton>
}
</MudStack>
</MudForm>
}
@@ -0,0 +1,42 @@
using CMSMicroservice.Protobuf.Protos.User;
using Microsoft.AspNetCore.Components;
using MudBlazor;
using static FrontOffice.Main.Shared.AuthDialog;
namespace FrontOffice.Main.Shared;
public partial class PhoneVerifyForm
{
// ── Step state ──
[Parameter, EditorRequired] public AuthStep CurrentStep { get; set; }
// ── Phone step ──
[Parameter, EditorRequired] public CreateNewOtpTokenRequest PhoneRequest { get; set; } = default!;
// ── Verify step ──
[Parameter, EditorRequired] public VerifyOtpTokenRequest VerifyRequest { get; set; } = default!;
// ── Captcha ──
[Parameter] public string? CaptchaCode { get; set; }
[Parameter] public string? CaptchaInput { get; set; }
[Parameter] public EventCallback<string?> CaptchaInputChanged { get; set; }
[Parameter] public EventCallback OnRefreshCaptcha { get; set; }
// ── Shared state ──
[Parameter] public bool IsBusy { get; set; }
[Parameter] public string? ErrorMessage { get; set; }
[Parameter] public string? InfoMessage { get; set; }
[Parameter] public string? PhoneNumber { get; set; }
[Parameter] public int ResendRemaining { get; set; }
// ── Verify actions ──
[Parameter] public EventCallback OnChangePhone { get; set; }
[Parameter] public EventCallback OnResendOtp { get; set; }
// ── Internal form refs (exposed via public methods) ──
private MudForm? _phoneForm;
private MudForm? _verifyForm;
public MudForm? GetPhoneForm() => _phoneForm;
public MudForm? GetVerifyForm() => _verifyForm;
}