Compare commits

..

7 Commits

Author SHA1 Message Date
Masoud 1ea928118d fix: update references from تخفیفی to اعتباری across multiple files
Build and Deploy to Production / build-and-deploy (push) Successful in 5m46s
2026-02-17 15:26:09 +03:30
Masoud 8f2d739d1f fix: update references from تخفیفی to اعتباری across multiple files
Build and Deploy to Production / build-and-deploy (push) Has been cancelled
2026-02-17 15:24:13 +03:30
masoodafar-web 8e89d5e064 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 5m43s
2026-02-17 01:59:40 +03:30
masoodafar-web 8bd961ade1 Fix production workflow: use SERVER_PASSWORD, add docker login, use set image for deploy
Build and Deploy to Production / build-and-deploy (push) Successful in 5m39s
2026-02-17 01:24:06 +03:30
masoodafar-web 750d4c6b97 Remove staging workflow files from production branch to prevent cross-deployment 2026-02-17 01:15:59 +03:30
masoodafar-web 276a4c20f2 Merge branch 'kub-stage' into production
Build and Deploy to Production / build-and-deploy (push) Successful in 1m12s
Features merged:
- Discount store UI improvements (100% auto-discount, VAT breakdown)
- PaymentStatus display on orders (Pending/Success/Failed/Refunded)
- Failed payment UX with re-order link
- Product discount badges show actual percent
- Various UI and bug fixes

Production workflow preserved.
2026-02-17 00:55:52 +03:30
masoodafar-web 98a68074bc chore: Update production workflow to use Nexus registry (no proxy needed)
Build and Deploy to Production / build-and-deploy (push) Failing after 0s
- Changed Dockerfile base images to use 194.5.195.53:32082 (Nexus)
- Updated workflow to use docker-sshpass image from Nexus
- Removed HTTP_PROXY/HTTPS_PROXY dependencies
- Changed deploy method to SSH instead of kubeconfig
- Registry: 194.5.195.53:30080 (Gitea internal)
- K8S Server: 45.149.79.127 (Production)
2026-01-29 22:04:31 +03:30
119 changed files with 1884 additions and 5860 deletions
-2
View File
@@ -1,2 +0,0 @@
# Keep production config from current branch during merges — never overwrite
src/FrontOffice.Main/appsettings.Production.json merge=ours
-46
View File
@@ -1,46 +0,0 @@
name: Push nuget and docker image Actions Workflow
on:
push:
branches:
- stage-new
jobs:
Deploy:
runs-on: windows
steps:
- name: Checkout
uses: https://git.afrino.co/actions/checkout@v3
- name: Setup dotnet
uses: https://git.afrino.co/actions/setup-dotnet@v3
with:
dotnet-version: 7.0.x
- name: Remove Package Source
run: dotnet nuget remove source FourSat
continue-on-error: true
- name: Add Package Source
run: dotnet nuget add source --name FourSat --username systemuser --password sZSA7PTiv3pUSQZ https://git.afrino.co/api/packages/FourSat/nuget/index.json --store-password-in-clear-text
- name: Install dependencies
run: dotnet restore ".\src\FrontOffice.Main\FrontOffice.Main.csproj"
- name: Build
run: dotnet build ".\src\FrontOffice.Main\FrontOffice.Main.csproj" --configuration Release --no-restore
- name: Test
run: dotnet test ".\src\FrontOffice.Main\FrontOffice.Main.csproj" --no-restore --verbosity normal
- name: Recycle Apppool
run: |
& "C:\Windows\System32\inetsrv\appcmd.exe" recycle apppool /apppool.name:kbs1.ir
shell: powershell
- name: Stop Website
run: |
& "C:\Windows\System32\inetsrv\appcmd.exe" stop site /site.name:kbs1.ir
shell: powershell
- name: Publish
run: dotnet publish ".\src\FrontOffice.Main\FrontOffice.Main.csproj" -c Release -o publish
- name: Copy Publish To IIS Directory
run: Get-ChildItem -Path "publish\*" | Copy-Item -Destination "E:\kbs1.ir\kbs1.ir\" -Recurse -Force
- name: Start Website
run: |
& "C:\Windows\System32\inetsrv\appcmd.exe" start site /site.name:kbs1.ir
shell: powershell
-82
View File
@@ -1,82 +0,0 @@
name: Build and Deploy to Kubernetes
on:
push:
branches:
- kub-stage
env:
REGISTRY: 194.5.195.53:30080
IMAGE_NAME: admin/frontoffice
K8S_SERVER: 194.5.195.53
jobs:
build-and-deploy:
runs-on: ubuntu-latest
container:
image: 194.5.195.53:32082/docker-sshpass:latest
options: --privileged
steps:
- name: Start Docker daemon
run: |
mkdir -p /etc/docker
cat > /etc/docker/daemon.json << 'DAEMON'
{
"insecure-registries": ["194.5.195.53:30080", "194.5.195.53:32500", "194.5.195.53:32082"]
}
DAEMON
echo "🚀 Starting Docker daemon..."
dockerd --iptables=false --ip6tables=false --bridge=none --storage-driver=vfs &
# Wait up to 3 minutes for Docker to be ready
for i in $(seq 1 90); do
if docker info >/dev/null 2>&1; then
echo "✅ Docker daemon is ready (attempt $i)"
docker version
break
else
echo "⏳ Waiting for Docker daemon... (attempt $i/90)"
sleep 2
fi
done
# Final check
if ! docker info >/dev/null 2>&1; then
echo "❌ Docker daemon failed to start after 3 minutes"
exit 1
fi
- name: Checkout code
run: |
git clone --depth 1 --branch kub-stage http://gitea-svc:3000/admin/FrontOffice.git .
- name: Login to Docker registries
run: |
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login 194.5.195.53:32082 -u admin --password-stdin
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login ${{ env.REGISTRY }} -u admin --password-stdin
- name: Build Docker Image
run: |
cd src
DOCKER_BUILDKIT=0 docker build --network host -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest -f FrontOffice.Main/Dockerfile .
- name: Push to Registry
run: |
docker push ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
- name: Deploy to Kubernetes
run: |
export SSHPASS="${{ secrets.SERVER_PASSWORD }}"
# Copy K8s manifests to server
sshpass -e scp -o StrictHostKeyChecking=no k8s/staging/frontoffice-deployment.yaml root@${{ env.K8S_SERVER }}:/tmp/frontoffice-deployment.yaml
# Apply manifests and restart
sshpass -e ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} "
kubectl apply -f /tmp/frontoffice-deployment.yaml &&
crictl rmi ${REGISTRY}/${IMAGE_NAME}:latest 2>/dev/null || true &&
kubectl rollout restart deployment/frontoffice &&
kubectl rollout status deployment/frontoffice --timeout=180s &&
rm -f /tmp/frontoffice-deployment.yaml
"
echo "✅ Deployed!"
+7 -13
View File
@@ -8,7 +8,7 @@ on:
env:
REGISTRY: 194.5.195.53:30080
IMAGE_NAME: admin/frontoffice
K8S_SERVER: 194.5.195.53
K8S_SERVER: 45.149.79.127
jobs:
build-and-deploy:
@@ -40,7 +40,7 @@ jobs:
done
if ! docker info >/dev/null 2>&1; then
echo "❌ Docker daemon failed to start after 3 minutes"
echo "❌ Docker daemon failed to start"
exit 1
fi
@@ -56,9 +56,10 @@ jobs:
- name: Build Docker Image
run: |
cd src
DOCKER_BUILDKIT=0 docker build --network host -t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
DOCKER_BUILDKIT=0 docker build --network host -f FrontOffice.Main/Dockerfile \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }} \
-t ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:prod \
-f FrontOffice.Main/Dockerfile .
.
- name: Push to Registry
run: |
@@ -68,15 +69,8 @@ jobs:
- name: Deploy to Production
run: |
export SSHPASS="${{ secrets.SERVER_PASSWORD }}"
# Copy K8s manifests to server
sshpass -e scp -o StrictHostKeyChecking=no k8s/production/frontoffice-deployment.yaml root@${{ env.K8S_SERVER }}:/tmp/frontoffice-deployment.yaml
# Apply manifests and restart
sshpass -e ssh -o StrictHostKeyChecking=no root@${{ env.K8S_SERVER }} "
kubectl apply -f /tmp/frontoffice-deployment.yaml &&
kubectl rollout restart deployment/frontoffice &&
kubectl rollout status deployment/frontoffice --timeout=300s &&
rm -f /tmp/frontoffice-deployment.yaml
kubectl set image deployment/frontoffice frontoffice=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
kubectl rollout status deployment/frontoffice --timeout=300s
"
echo "✅ Deployed to Production!"
+1 -1
View File
@@ -14,7 +14,7 @@
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
# Mono auto generated files
mono_crash.*
# Build results
+310 -3
View File
@@ -1,4 +1,311 @@
# Docs moved to totalDoc
# 📋 تاریخچه تغییرات FrontOffice — کارا بازار سلامت
See [totalDoc/INDEX.md](../../totalDoc/INDEX.md) for all documentation.
> **آخرین بروزرسانی**: اسفند ۱۴۰۴ (فوریه ۲۰۲۶)
> **فریمورک**: Blazor Server + MudBlazor 8.14 + .NET 9
---
## 🔖 نسخه ۲.۷.۰ — اسفند ۱۴۰۴ (February 17, 2026)
### ۱. اجبار تخفیف ۱۰۰٪ و حذف اسلایدر
- **حذف** `MudSlider` و `MudNumericField` از `Checkout.razor` — کاربر دیگه درصد تخفیف انتخاب نمی‌کنه
- **حذف** کامل بخش نمایش موجودی اعتباری
- بکند همیشه حداکثر تخفیف (`MaxDiscountPercent`) رو اعمال می‌کنه
### ۲. رفع متن Badge تخفیف
- **مشکل:** Badge روی محصولات "۱۰۰٪ اعتباری" نشون می‌داد — گمراه‌کننده
- **رفع:** نمایش درصد واقعی محصول (مثلاً "۳۰٪ تخفیف")
- **فایل‌ها:** `Products.razor`, `Cart.razor`, `ProductDetail.razor`
### ۳. نمایش جدول مالیات (VAT) در Checkout
- جمع کل، تخفیف، مبلغ پس از تخفیف، مالیات ۹٪، مبلغ قابل پرداخت
- استفاده از `VatCalculator` برای محاسبه VAT
### ۴. نمایش وضعیت پرداخت در سفارشات
- **فیلد جدید** `payment_status` در proto `discountorder.proto` (v0.0.179)
- **صفحه Orders:** نمایش Chip رنگی بر اساس PaymentStatus (موفق/ناموفق/در انتظار)
- **صفحه OrderDetail:** Alert برای سفارشات ناموفق + دکمه بازگشت + جزئیات وضعیت
- **Helper متدها:** `GetPaymentStatusText()`, `GetPaymentStatusColor()`, `GetDeliveryStatusText()`
### ۵. فیکس مپینگ PaymentStatus و DeliveryStatus
- **مشکل:** Domain enum مقادیر متفاوتی از Proto داشت — مستقیم cast می‌شد
- **رفع:** `MapPaymentStatus()` و `MapDeliveryStatus()` در `DiscountOrderService.cs` (WebApi)
- سفارشات ناموفق حالا `DeliveryStatus=Cancelled` دارن (بجای "در حال پردازش")
### ۶. فیکس Proto به NuGet v0.0.179
- `FrontOffice.Main.csproj`: `PackageReference` به `Foursat.CMSMicroservice.Protobuf` v0.0.179
---
## 🔖 نسخه ۲.۶.۰ — بهمن ۱۴۰۴ (February 16, 2026)
### ۱. درگاه پرداخت زرین‌پال
- **یکپارچه‌سازی ZarinPal** — پرداخت مستقیم بدون PYMS واسط
- صفحه checkout فروشگاه اعتباری: ریدایرکت به ZarinPal → callback → تأیید
- صفحه checkout فروشگاه عادی: همان جریان
- پشتیبانی از sandbox و production
### ۲. جدول PaymentTransaction
- **Entity جدید** `PaymentTransaction` — ذخیره جزئیات سطح درگاه (Authority, CardPan, CardHash, RefId)
- جدا از جدول Transaction اصلی
- Migration: `AddPaymentTransactionTable`
### ۳. فیکس نمایش وضعیت پرداخت
- **مشکل:** سفارشات اعتباری «در انتظار پرداخت» نشان می‌دادند حتی بعد از پرداخت موفق
- **علت:** Mapster نمی‌تونست `PaymentStatus` (enum) رو به `payment_completed` (bool) مپ کنه
- **رفع:** مپینگ دستی در `DiscountOrderService`
### ۴. فیکس DeliveryStatus
- **مشکل:** فروشگاه اعتباری بعد از پرداخت `InTransit` ست می‌کرد
- **رفع:** تغییر به `Pending` — ادمین باید وضعیت پست رو مشخص کنه
### ۵. تغییر به NuGet Package (Docker build fix)
- `ProjectReference` به CMS Protobuf → `PackageReference` (v0.0.178)
- Docker build context فقط `src/` داره → مسیر `../../../CMS` قابل دسترسی نیست
---
## 🔖 نسخه ۲.۵.۰ — بهمن ۱۴۰۴
### ۱. محافظت صفحات نیازمند احراز هویت (Auth Guard)
**فایل‌های تغییریافته:**
- `Shared/MainLayout.razor.cs`
**شرح:**
قبلاً هیچ محافظتی در سطح مسیریابی وجود نداشت — کاربر غیرلاگین می‌توانست مستقیماً به `/profile/*`، `/commission/*`، `/cart` و... دسترسی پیدا کند.
**تغییرات:**
- متد `EnforceAuthGuardAsync()` اضافه شد — در هر تغییر مسیر و اولین بار رندر اجرا می‌شود
- متد `IsProtectedRoute(path)` مسیرهای محافظت‌شده را تشخیص می‌دهد
- کاربر غیرلاگین → ریدایرکت به `/` (صفحه اصلی)
**مسیرهای محافظت‌شده:**
| گروه | مسیرها |
|---|---|
| پروفایل | `/profile/*` |
| کمیسیون | `/commission/*` |
| شبکه | `/network/*` |
| باشگاه | `/club/*` |
| سبد خرید | `/cart`, `/checkout*`, `/orders`, `/order/*`, `/order-tracking/*` |
| پکیج‌ها | `/my-packages` |
| دروازه | `/my-orders`, `/my-cart` |
| فروشگاه اعتباری | `/discount-store/cart`, `/discount-store/checkout`, `/discount-store/orders`, `/discount-store/order/*` |
**مسیرهای عمومی:**
`/`, `/register`, `/about`, `/faq`, `/contact`, `/blog/*`, `/packages`, `/package/*`, `/products`, `/product/*`, `/categories`, `/stores`, `/discount-store`, `/discount-store/product/*`
---
### ۲. استخراج کامپوننت PhoneVerifyForm
**فایل‌های جدید:**
- `Shared/PhoneVerifyForm.razor`
- `Shared/PhoneVerifyForm.razor.cs`
**فایل‌های تغییریافته:**
- `Shared/AuthDialog.razor`
- `Shared/AuthDialog.razor.cs`
**شرح:**
فرم تلفن + تایید OTP + کپچا که قبلاً به‌صورت `RenderFragment` با `__builder` مستقیم در `AuthDialog` نوشته شده بود، به کامپوننت مستقل `PhoneVerifyForm` استخراج شد.
**ساختار قبل:**
```
AuthDialog.razor
└── @code { PhoneOrVerifyContent() => __builder => { ... } } ← RenderFragment پیچیده
```
**ساختار بعد:**
```
AuthDialog.razor
└── <PhoneVerifyForm @ref="_phoneVerifyForm" ... /> ← کامپوننت مستقل
PhoneVerifyForm.razor ← مارکاپ فرم
PhoneVerifyForm.razor.cs ← پارامترها و فرم رف‌ها
```
**پارامترهای PhoneVerifyForm:**
| پارامتر | نوع | توضیح |
|---|---|---|
| `CurrentStep` | `AuthStep` | مرحله فعلی (Phone / Verify) |
| `PhoneRequest` | `CreateNewOtpTokenRequest` | مدل فرم تلفن |
| `VerifyRequest` | `VerifyOtpTokenRequest` | مدل فرم تایید |
| `CaptchaCode` | `string?` | کد کپچا نمایش‌داده‌شده |
| `CaptchaInput` / `CaptchaInputChanged` | `string?` + `EventCallback` | ورودی کپچا (two-way) |
| `OnRefreshCaptcha` | `EventCallback` | رفرش کپچا |
| `IsBusy` | `bool` | وضعیت بارگذاری |
| `ErrorMessage` / `InfoMessage` | `string?` | پیام‌های خطا/اطلاع |
| `PhoneNumber` | `string?` | شماره تایید‌شده |
| `ResendRemaining` | `int` | ثانیه تا ارسال مجدد |
| `OnChangePhone` / `OnResendOtp` | `EventCallback` | اکشن‌های تایید |
**نکته مهم — Two-way binding کپچا:**
فیلد کپچا با `Value` + `ValueChanged` بایند شده (نه `@bind-Value`) تا مقدار تایپ‌شده از فرزند به والد برگردد:
```razor
<MudTextField Value="CaptchaInput"
ValueChanged="@((string v) => CaptchaInputChanged.InvokeAsync(v))" ... />
```
**دسترسی به فرم‌ها از والد:**
```csharp
// AuthDialog.razor.cs
var phoneForm = _phoneVerifyForm?.GetPhoneForm();
var verifyForm = _phoneVerifyForm?.GetVerifyForm();
```
---
### ۳. بهبود لایوت مدال ورود (AuthDialog)
**فایل‌های تغییریافته:**
- `Shared/AuthDialog.razor`
- `Utilities/AuthDialogService.cs`
- `wwwroot/css/site.css`
**مشکلات قبلی:**
- دیالوگ روی موبایل `FullScreen` بود → فضای خالی بزرگ بین فرم و دکمه‌ها
- `TitleContent` و `DialogActions` جدا → گپ عمودی
- ردیف کپچا با `MudStack Row="true"` → آیتم‌ها عمودی رندر می‌شدند
**تغییرات:**
1. **حذف FullScreen**: `AuthDialogService` حالا همیشه `MaxWidth.ExtraSmall, FullWidth=true, CloseButton=true`
2. **ادغام محتوا**: آواتار + عنوان + فرم + دکمه‌ها همه داخل `DialogContent` → بدون `TitleContent` و `DialogActions`
3. **کلاس `auth-dialog-wrapper`**: CSS با `.auth-dialog-wrapper .mud-dialog-title { display: none; }` عنوان پیش‌فرض دیالوگ رو مخفی می‌کنه
4. **ردیف کپچا**: از `MudStack Row` به `div.captcha-row` با CSS flex اختصاصی
5. **حالت Inline**: بدون تغییر ساختاری — فقط از `PhoneVerifyForm` استفاده می‌کنه
**CSS جدید:**
```css
.auth-dialog-wrapper .mud-dialog-title { display: none; }
.auth-dialog-wrapper .mud-dialog-content { padding-bottom: 24px !important; }
.auth-dialog-content { max-width: 400px; margin: 0 auto; }
.captcha-row { display: flex; align-items: center; gap: 10px; }
.captcha-row > .mud-input-control { flex: 1 1 0; min-width: 0; }
.captcha-row > .mud-paper { flex: 0 0 auto; }
.captcha-row > .mud-button-root { flex: 0 0 auto; }
```
---
### ۴. صفحه لندینگ — حذف /pricing و اضافه بنر بلاگ
**فایل‌های تغییریافته:**
- `Pages/Index.razor`
- `wwwroot/css/site.css`
**تغییرات:**
1. دکمه هیرو «مشاهده پکیج‌ها» → **«آخرین اخبار»** با لینک `/blog`
2. **بنر جدیدترین مطلب** بین هیرو و «سه گام تا شروع» اضافه شد
- تصویر بندانگشتی + عنوان + خلاصه + آیکون شیشه‌ای
- هاور: `translateY(-2px)` + سایه بزرگ‌تر
**CSS جدید:**
```css
.landing-blog-banner { border: 1px solid var(--mud-palette-divider); transition: ... }
.landing-blog-banner:hover { box-shadow: var(--mud-elevation-4); transform: translateY(-2px); }
.landing-blog-thumb { width: 80px; height: 80px; border-radius: 12px; }
.landing-blog-title { -webkit-line-clamp: 1; font-weight: 600; }
.landing-blog-summary { -webkit-line-clamp: 1; }
```
---
### ۵. افزایش ارتفاع تکست‌باکس‌ها (Global)
**فایل تغییریافته:**
- `wwwroot/css/site.css`
**قبل:** `padding: 10px 14px`
**بعد:** `padding: 14px 14px` + `font-size: 1rem`
```css
.mud-input-outlined .mud-input-slot {
padding: 14px 14px !important;
font-size: 1rem;
}
```
تمام فیلدهای Outlined در کل اپلیکیشن بزرگ‌تر شدند.
---
### ۶. اصلاح RTL فیلدهای ورودی
**فایل تغییریافته:**
- `wwwroot/css/site.css`
**مشکل:** فیلدهای `type="tel"` به‌صورت پیش‌فرض مرورگر `direction: ltr` می‌گیرن — لیبل سمت راست ولی placeholder/cursor سمت چپ.
**اصلاح:**
```css
.mud-input-slot input,
.mud-input-slot textarea {
direction: rtl !important;
text-align: right !important;
}
```
---
### ۷. اصلاح captcha-box CSS
**فایل تغییریافته:**
- `wwwroot/css/site.css`
**قبل:**
```css
.captcha-box {
min-width: 120px; min-height: 56px;
background: linear-gradient(135deg, rgba(123,97,255,.12), rgba(255,140,189,.12));
}
```
**بعد:**
```css
.captcha-box {
min-width: 96px; min-height: 48px;
border-radius: var(--mud-default-borderradius);
background: rgba(99,102,241,.08);
border: 1px solid var(--mud-palette-divider);
}
```
---
## 📁 نقشه فایل‌ها
```
Shared/
├── AuthDialog.razor ← بازنویسی (کامپوننت PhoneVerifyForm جایگزین RenderFragment)
├── AuthDialog.razor.cs ← بروزرسانی (استفاده از _phoneVerifyForm)
├── PhoneVerifyForm.razor ← ✨ جدید (فرم تلفن + تایید + کپچا)
├── PhoneVerifyForm.razor.cs ← ✨ جدید (پارامترها و فرم رف‌ها)
├── MainLayout.razor.cs ← Auth Guard اضافه شد
Pages/
├── Index.razor ← /pricing → /blog + بنر بلاگ
Utilities/
├── AuthDialogService.cs ← حذف FullScreen، ثابت‌سازی سایز
wwwroot/css/
├── site.css ← captcha-row, auth-dialog, RTL fix, input height
```
---
## 🔍 وضعیت بیلد
| تاریخ | خطا | هشدار | توضیح |
|---|---|---|---|
| بهمن ۱۴۰۴ | **۰** | ۱۰۵ | MUD0002 warnings (بی‌خطر — مربوط به MudBlazor analyzer) |
-262
View File
@@ -1,262 +0,0 @@
# سرویس‌های FrontOffice (سمت مشتری)
این سند تمام سرویس‌هایی را که **FrontOffice** (فرانت مشتری) به **CMS** از طریق gRPC Gateway (`GW_URL`) فراخوانی می‌کند، به ترتیب **Use Case** فهرست می‌کند.
- **مسیر کد سرویس‌ها:** `FrontOffice/src/FrontOffice.Main/Utilities/`
- **ثبت gRPC:** `FrontOffice/src/FrontOffice.Main/ConfigureServices.cs`
- **تاریخ:** ۱۴۰۵/۰۳/۱۷
---
## ۱. احراز هویت و ثبت‌نام
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 1.1 | `AuthService``RefreshTokenAsync` | `UserContract.RefreshToken` | `/profile` (`Profile/Index`) | تمدید JWT پس از بازگشت از درگاه پرداخت |
| 1.2 | `AuthService``InitUserAuthInfo` / claims | — (local JWT parse) | سراسری | خواندن UserId، نام، کدملی، وضعیت قرارداد/پکیج/باشگاه از توکن |
| 1.3 | `UserContract.CreateNewOtpToken` | `UserContract` | `Shared/AuthDialog` | ارسال OTP ورود/ثبت‌نام با موبایل |
| 1.4 | `UserContract.VerifyOtpToken` | `UserContract` | `Shared/AuthDialog` | تأیید OTP و دریافت JWT |
| 1.5 | `UserContract.CreateNewOtpToken` (Purpose=`signContract`) | `UserContract` | `/register` (`RegisterWizard`) | ارسال OTP برای امضای قرارداد اصلی |
| 1.6 | `UserContract.AcceptContract` | `UserContract` | `/register` (`RegisterWizard`) | ثبت امضای قرارداد اصلی + دریافت توکن به‌روز |
| 1.7 | `UserContract.GetUserForCustomer` | `UserContract` | `/register`, `/profile`, `/profile/personal`, `/profile/settings` | بارگذاری پروفایل موجود کاربر |
| 1.8 | `UserContract.UpdateCustomerProfile` | `UserContract` | `/register`, `/profile`, `/profile/personal` | ذخیره نام، نام‌خانوادگی، کدملی و… |
| 1.9 | `AuthDialogService.ShowAuthDialogAsync` | — (UI) | فروشگاه، سبد، checkout | نمایش دیالوگ ورود برای مهمان |
| 1.10 | `GuestActionGate` | — (wrapper روی Auth) | `/`, `/products`, `/product/{id}`, Discount Store | جلوگیری از افزودن به سبد بدون لاگین |
---
## ۲. پروفایل و آدرس
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 2.1 | `UserContract.GetUserForCustomer` | `UserContract` | `/profile`, `/profile/personal`, `/profile/settings` | نمایش و ویرایش اطلاعات شخصی |
| 2.2 | `UserContract.GetUser` | `UserContract` | `/profile/hub`, `Club/ActivationSection` | اطلاعات کامل کاربر (هاب پروفایل / پس از فعال‌سازی باشگاه) |
| 2.3 | `UserContract.UpdateUser` | `UserContract` | `/profile/settings` | به‌روزرسانی تنظیمات حساب |
| 2.4 | `UserAddressContract.GetCustomerAddresses` | `UserAddressContract` | `/profile`, `/profile/addresses`, `/checkout`, Store/Discount checkout | لیست آدرس‌های تحویل |
| 2.5 | `UserAddressContract.CreateCustomerAddress` | `UserAddressContract` | `Profile/Components/AddAddressDialog` | ثبت آدرس جدید |
| 2.6 | `UserAddressContract.UpdateCustomerAddress` | `UserAddressContract` | `Profile/Components/EditAddressDialog` | ویرایش آدرس |
| 2.7 | `UserAddressContract.DeleteCustomerAddress` / `DeleteUserAddress` | `UserAddressContract` | `/profile/addresses`, `/profile` | حذف آدرس |
| 2.8 | `UserAddressContract.SetCustomerDefaultAddress` / `SetAddressAsDefault` | `UserAddressContract` | `/profile/addresses`, `/profile`, `/checkout` | تنظیم آدرس پیش‌فرض |
| 2.9 | `CityContract.GetAllCitiesByFilter` | `CityContract` | `AddAddressDialog`, `EditAddressDialog` | جستجو و انتخاب شهر/استان |
---
## ۳. پکیج و خرید عضویت
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 3.1 | `PackageService.GetAllPackagesAsync` | `PackageContract.GetCustomerPackages` | `/packages`, `/my-packages`, `/`, `/checkout`, `PackagePurchaseDialog`, `ClubMembershipContractDialog`, Commission pages | لیست پکیج‌های قابل خرید |
| 3.2 | `PackageService.GetPackageByIdAsync` | `PackageContract.GetCustomerPackageDetails` | (از طریق wrapper؛ صفحه جزئیات مستقیم RPC دارد) | جزئیات یک پکیج |
| 3.3 | `PackageContract.GetCustomerPackageDetails` | `PackageContract` | `/package/{id}` (`PackageDetail`) | جزئیات، ویژگی‌ها و مشخصات پکیج |
| 3.4 | `PackageService.GetUserPackageStatusAsync` | `PackageContract.GetUserPackageStatus` | `/packages`, `/my-packages`, `/profile` | وضعیت خرید پکیج، باشگاه، موجودی کیف پول |
| 3.5 | `PackageContract.CustomerPurchasePackage` | `PackageContract` | `/profile` (`ProcessDirectPayment`), `/checkout` | شروع پرداخت درگاهی (Zarinpal) برای خرید پکیج |
| 3.6 | `PackageContract.CustomerVerifyPackagePurchase` | `PackageContract` | `/profile/payment-callback?type=package` | تأیید پرداخت پکیج پس از بازگشت از درگاه |
| 3.7 | `PackagePurchaseDialog` | — (UI + `PackageService`) | `/profile`, `/my-packages` | انتخاب پکیج و روش پرداخت (درگاه / Daya) |
| 3.8 | Daya Loan (external URL) | — | `/profile` | هدایت به `dayadiamond.ir` برای خرید اقساطی (بدون RPC) |
---
## ۴. کیف پول، شارژ و برداشت
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 4.1 | `WalletService.GetBalancesAsync` | `UserWalletContract.GetCustomerWallet` | `/profile`, `/profile/wallet`, Store checkout, PaymentCallback | موجودی اصلی، تخفیفی و شبکه |
| 4.2 | `WalletService.GetTransactionsAsync` | `UserWalletContract.GetCustomerWalletHistory` | `/profile/wallet` | تاریخچه تراکنش‌های کیف پول |
| 4.3 | `WalletService.GetMagicWalletStatusAsync` | `UserWalletContract.GetMagicWalletStatus` | `/profile`, `/profile/magic-wallet`, `/my-packages` | وضعیت کیف پول جادویی (Magic) |
| 4.4 | `WalletService.InitiateMagicChargeAsync` | `UserWalletContract.InitiateMagicCharge` | `/profile/magic-wallet` | شروع شارژ کیف پول جادویی → درگاه |
| 4.5 | `WalletService.VerifyMagicChargeAsync` | `UserWalletContract.VerifyMagicCharge` | `/profile/payment-callback?type=magic-wallet` | تأیید شارژ Magic پس از درگاه |
| 4.6 | `WalletService.InitiateDiscountChargeAsync` | `UserWalletContract.InitiateDiscountCharge` | `/profile/charge-discount-wallet` | شروع شارژ کیف پول تخفیفی → درگاه |
| 4.7 | `WalletService.VerifyDiscountChargeAsync` | `UserWalletContract.VerifyDiscountCharge` | `/profile/payment-callback?type=discount-wallet` | تأیید شارژ کیف تخفیفی |
| 4.8 | `WalletService.InitiateCreditChargeAsync` | `UserWalletContract.InitiateCreditCharge` | `/profile/charge-credit-wallet` | شروع شارژ کیف پول اصلی → درگاه |
| 4.9 | `WalletService.VerifyCreditChargeAsync` | `UserWalletContract.VerifyCreditCharge` | `/profile/payment-callback?type=credit-wallet` | تأیید شارژ کیف اصلی |
| 4.10 | `WalletService.GetWithdrawalsAsync` | `UserWalletContract.GetCustomerWithdrawals` | `/profile/withdrawal-requests` | لیست درخواست‌های برداشت |
| 4.11 | `WalletService.GetWithdrawalSettingsAsync` | `UserWalletContract.GetCustomerWithdrawalSettings` | `/profile/withdrawal-requests` | حداقل مبلغ برداشت |
| 4.12 | `WalletService.RequestWithdrawalAsync` | `UserWalletContract.CustomerWithdrawBalance` | `/profile/withdrawal-requests` | ثبت درخواست برداشت پاداش (با payoutId کمیسیون) |
| 4.13 | `CommissionService.GetWithdrawablePayoutsAsync` | `CommissionContract.GetMyCommissionPayouts` | `/profile/withdrawal-requests` | پاداش‌های قابل برداشت برای انتخاب |
---
## ۵. Callback پرداخت (مشترک)
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 5.1 | `PackageContract.CustomerVerifyPackagePurchase` | `PackageContract` | `/profile/payment-callback` (type=package) | تأیید خرید پکیج |
| 5.2 | `WalletService.VerifyMagicChargeAsync` | `UserWalletContract` | `/profile/payment-callback` (type=magic-wallet) | تأیید شارژ Magic |
| 5.3 | `WalletService.VerifyDiscountChargeAsync` | `UserWalletContract` | `/profile/payment-callback` (type=discount-wallet) | تأیید شارژ تخفیفی |
| 5.4 | `WalletService.VerifyCreditChargeAsync` | `UserWalletContract` | `/profile/payment-callback` (type=credit-wallet) | تأیید شارژ کیف اصلی |
| 5.5 | `DiscountOrderService.VerifyDiscountOrderPaymentAsync` | `DiscountOrderContract.CustomerVerifyDiscountOrderPayment` | `/profile/payment-callback` (type=discount-order) | تأیید پرداخت سفارش فروشگاه تخفیفی |
| 5.6 | `UserContract.GetUserForCustomer` + `AuthService.InitUserAuthInfo` | `UserContract` | `/profile/payment-callback` | به‌روزرسانی claims کاربر پس از پرداخت موفق |
---
## ۶. فروشگاه اصلی (Store)
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 6.1 | `ProductService.GetProductsPagedAsync` | `ProductsContract.GetProductsForCustomer` | `/products` | لیست محصولات با فیلتر/صفحه‌بندی |
| 6.2 | `ProductService.GetTopSellingAsync` | `ProductsContract` | `/` (صفحه اصلی) | پرفروش‌ترین محصولات |
| 6.3 | `ProductService.GetByIdAsync` | `ProductsContract.GetProductByIdForCustomer` | `/product/{id}` | جزئیات محصول + گالری |
| 6.4 | `CategoryService.GetAllAsync` / `GetByIdAsync` | `CategoryContract.GetAllCategoriesForCustomer` | `/categories`, `/products` | درخت دسته‌بندی محصولات |
| 6.5 | `CartService` — Add/Update/Remove/Get | `UserCartsContract.*ForCustomer` | `/`, `/products`, `/product/{id}`, `/cart` | مدیریت سبد خرید (AddNew, Update, Remove, GetCustomerCart) |
| 6.6 | `UserOrderContract.SubmitShopBuyOrder` | `UserOrderContract` | `/checkout-summary` | ثبت سفارش و پرداخت از کیف پول |
| 6.7 | `OrderService.GetOrdersAsync` | `UserOrderContract.GetCustomerOrders` | `/orders` | لیست سفارش‌های کاربر |
| 6.8 | `OrderService.GetOrderAsync` | `UserOrderContract.GetCustomerOrder` | `/order/{id}`, `/order-tracking/{id}` | جزئیات یک سفارش |
| 6.10 | `VATService.GetRateAsync` | `UserOrderContract` (via scope) | Store/Discount صفحات قیمت | نرخ مالیات بر ارزش افزوده (کش روزانه) |
| 6.11 | `WalletService.GetBalancesAsync` | `UserWalletContract` | `/checkout-summary` | بررسی موجودی برای پرداخت سفارش |
---
## ۷. فروشگاه تخفیفی (Discount Store)
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 7.1 | `DiscountProductService.GetProductsAsync` | `DiscountProductContract.GetDiscountProducts` | `/discount-store` | لیست محصولات تخفیفی |
| 7.2 | `DiscountProductService.GetTopSellingAsync` | `DiscountProductContract` | `/` | پرفروش‌های فروشگاه تخفیفی |
| 7.3 | `DiscountProductService.GetByIdAsync` | `DiscountProductContract.GetDiscountProductById` + Images | `/discount-store/product/{id}` | جزئیات محصول تخفیفی |
| 7.4 | `DiscountProductService.GetCategoriesAsync` | `DiscountCategoryContract.GetDiscountCategories` | `/discount-store` | دسته‌بندی‌های فروشگاه تخفیفی |
| 7.5 | `DiscountCartService` — Add/Update/Remove/Get | `DiscountShoppingCartContract.*` | `/discount-store`, `/discount-store/cart` | سبد خرید تخفیفی |
| 7.6 | `DiscountOrderService.PlaceOrderAsync` | `DiscountOrderContract.PlaceOrder` | `/discount-store/checkout` | ثبت سفارش (کیف تخفیفی + درگاه) |
| 7.7 | `DiscountOrderService.GetUserOrdersAsync` | `DiscountOrderContract.GetUserOrders` | `/discount-store/orders` | لیست سفارش‌های تخفیفی |
| 7.8 | `DiscountOrderService.GetOrderByIdAsync` | `DiscountOrderContract.GetOrderById` | `/discount-store/order/{id}` | جزئیات سفارش تخفیفی |
| 7.9 | `UserAddressContract.GetCustomerAddresses` | `UserAddressContract` | `/discount-store/checkout` | انتخاب آدرس تحویل |
---
## ۸. باشگاه مشتریان (Club)
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 8.1 | `ClubMembershipService.GetMyMembershipAsync` | `ClubMembershipContract.GetClubMembership` | `/club/membership` | وضعیت عضویت باشگاه |
| 8.2 | `ClubConfigurationService.GetClubConfigurationAsync` | `ConfigurationContract.GetClubConfiguration` | `/club/membership` | هزینه فعال‌سازی و هدیه عضویت |
| 8.3 | `ClubConfigurationService.GetClubFeaturesAsync` | `ConfigurationContract.GetClubFeatures` | `/club/features` | لیست امکانات باشگاه |
| 8.4 | `ClubMembershipService.ActivateMembershipAsync` | `ClubMembershipContract.ActivateClubMembership` | `Club/ActivationSection` | فعال‌سازی/تمدید عضویت باشگاه |
| 8.5 | `OtpTokenContract.CreateNewOtpToken` | `OtpTokenContract` | `ClubMembershipContractDialog` | OTP برای امضای قرارداد باشگاه |
| 8.6 | `ClubMembershipContract.AcceptClubMembershipContract` | `ClubMembershipContract` | `ClubMembershipContractDialog` | ثبت امضای قرارداد باشگاه |
---
## ۹. شبکه فروش (Network / MLM)
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 9.1 | `NetworkMembershipService.GetMyNetworkTreeAsync` | `NetworkMembershipContract.GetMyNetworkTree` | `/profile/tree` (`OrganizationChart`) | درخت سازمان فروش کاربر |
| 9.2 | `NetworkMembershipService.GetSubordinateTreeAsync` | `NetworkMembershipContract.GetSubordinateTree` | `/profile/tree` | drill-down به زیرمجموعه |
| 9.3 | `NetworkMembershipService.GetMyNetworkStatisticsAsync` | `NetworkMembershipContract.GetMyNetworkStatistics` | `/profile/tree`, `/network/statistics` | آمار پا چپ/راست، تعداد اعضا |
---
## ۱۰. کمیسیون و پاداش
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 10.1 | `CommissionService.GetWeekDefinitionsAsync` | `CommissionContract.GetWeekDefinitions` | `Shared/WeekSelector`, Commission pages | لیست هفته‌های محاسبه پاداش |
| 10.2 | `CommissionService.GetMyCommissionPayoutsAsync` | `CommissionContract.GetMyCommissionPayouts` | `/commission/dashboard` | تاریخچه پرداخت پاداش‌ها |
| 10.3 | `CommissionService.GetMyWeeklyBalanceAsync` | `CommissionContract.GetMyWeeklyBalances` | `/commission/weekly-balance` | جزئیات بالانس هفتگی (چپ/راست) |
| 10.4 | `PackageService.GetAllPackagesAsync` | `PackageContract` | Commission pages | فیلتر پاداش بر اساس پکیج |
---
## ۱۱. محتوای سایت، بلاگ و صفحات ثابت
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 11.1 | `SitePageSettingsService.GetPageAsync` | `SitePageSettingsContract.GetPageSettings` | `/` (landing), `/about`, `/contact`, `/licenses` | محتوای داینامیک صفحات (hero، تصاویر، JSON تنظیمات) |
| 11.2 | `BlogPostService.GetFeaturedPostsAsync` | `BlogPostContract.GetFeaturedBlogPosts` | `/`, `/profile` | پست‌های ویژه |
| 11.3 | `BlogPostService.GetPublishedPostsAsync` | `BlogPostContract.GetPublishedBlogPosts` | `/`, `/blog` | لیست پست‌های منتشرشده |
| 11.4 | `BlogPostService.GetBySlugAsync` | `BlogPostContract.GetBlogPostBySlug` | `/blog/{slug}` | محتوای کامل یک پست |
| 11.5 | `BlogPostService.IncrementViewCountAsync` | `BlogPostContract.IncrementViewCount` | `/blog/{slug}` | افزایش شمارنده بازدید |
| 11.6 | `BlogCategoryService.GetActiveCategoriesAsync` | `BlogCategoryContract` | `/blog` | فیلتر دسته‌بندی بلاگ |
| 11.7 | FAQ | — (hardcoded) | `/faq` | سوالات متداول — **بدون فراخوانی API** |
---
## ۱۲. زیرساخت مشترک (Infrastructure)
| # | سرویس / متد | gRPC Contract | صفحه / کامپوننت | کاربرد |
|---|-------------|---------------|-----------------|--------|
| 12.1 | `ImageCacheService.ResolveAsync` | `ImageResolverContract` | `Shared/AppImage` (سراسری) | resolve مسیر تصویر CMS به data-URI |
| 12.2 | `AppVersionService.CheckVersionAsync` | `AppVersionContract` | `App.razor`, `MainLayout` | بررسی نسخه جدید اپ |
| 12.3 | `AppVersionService.ApplyUpdateAsync` / `SkipVersionAsync` | — (localStorage + reload) | `App.razor`, `MainLayout` | اعمال یا رد آپدیت |
| 12.4 | `MainService.OnChangeHandler` | — (in-memory) | `/`, `AuthDialog` | اطلاع‌رسانی تغییر state سراسری (مثلاً پس از login) |
| 12.5 | `IChromiumPdfService` | — (local endpoint `/contract/generate`) | `/register` | تولید PDF قرارداد (Chromium headless) |
---
## ۱۳. Gateway / انتخاب‌گر
| صفحه | سرویس | کاربرد |
|------|--------|--------|
| `/stores` | — | انتخاب بین فروشگاه اصلی و تخفیفی |
| `/my-orders` | — | انتخاب لیست سفارش‌ها |
| `/my-cart` | — | انتخاب سبد خرید |
این صفحات فقط مسیریابی UI هستند و مستقیماً gRPC صدا نمی‌زنند.
---
## ۱۴. سرویس‌های ثبت‌شده ولی بدون استفاده در UI
| سرویس / Contract | وضعیت |
|------------------|--------|
| `SitePageService` (`SitePageContract`) | ثبت در DI؛ **هیچ صفحه‌ای inject نمی‌کند** (جایگزین: `SitePageSettingsService`) |
| `TransactionsContract` | ثبت در DI؛ **استفاده نشده** |
| `UserWalletHistoryContract` | ثبت در DI؛ **استفاده نشده** (تاریخچه از `UserWalletContract.GetCustomerWalletHistory` می‌آید) |
| `TokenNotificationService` | ثبت در DI؛ **هنوز به Layout وصل نشده** (SignalR برای invalidation توکن) |
---
## نمودار جریان پرداخت
```mermaid
flowchart LR
subgraph initiate [شروع پرداخت]
A1[Profile / Checkout]
A2[MagicWallet / ChargeDiscount]
A3[Discount Checkout]
end
subgraph gateway [Zarinpal]
G[درگاه پرداخت]
end
subgraph callback [PaymentCallback]
C1[Verify Package]
C2[Verify Magic]
C3[Verify Discount Wallet]
C4[Verify Discount Order]
end
A1 --> G
A2 --> G
A3 --> G
G --> callback
```
---
## فهرست gRPC Contractهای فعال
| Contract | Wrapper اصلی |
|----------|----------------|
| `UserContract` | `AuthService`, صفحات Profile/Register |
| `UserAddressContract` | Profile, Checkout pages |
| `CityContract` | Address dialogs |
| `PackageContract` | `PackageService`, PaymentCallback, Checkout |
| `UserWalletContract` | `WalletService` |
| `UserOrderContract` | `OrderService`, CheckoutSummary |
| `UserCartsContract` | `CartService` |
| `ProductsContract` | `ProductService` |
| `CategoryContract` | `CategoryService` |
| `DiscountProductContract` / `DiscountCategoryContract` | `DiscountProductService` |
| `DiscountShoppingCartContract` | `DiscountCartService` |
| `DiscountOrderContract` | `DiscountOrderService` |
| `ClubMembershipContract` | `ClubMembershipService`, Dialog |
| `OtpTokenContract` | Club contract dialog |
| `ConfigurationContract` | `ClubConfigurationService` |
| `NetworkMembershipContract` | `NetworkMembershipService` |
| `CommissionContract` | `CommissionService` |
| `BlogPostContract` | `BlogPostService` |
| `BlogCategoryContract` | `BlogCategoryService` |
| `SitePageSettingsContract` | `SitePageSettingsService` |
| `AppVersionContract` | `AppVersionService` |
| `ImageResolverContract` | `ImageCacheService` |
+541 -2
View File
@@ -1,3 +1,542 @@
# Docs moved to totalDoc
# 🎨 پلن جامع یکپارچه‌سازی UI/UX — کارا بازار سلامت
See [totalDoc/INDEX.md](../../totalDoc/INDEX.md) for all documentation.
> **تاریخ**: بهمن ۱۴۰۴
> **وضعیت**: ✅ **تمام ۶ فاز + فاز ۷ (ناوبری و امنیت) تکمیل شد**
> **هدف**: یکپارچه‌سازی طراحی FrontOffice با ۲۰٪ تغییر UX و ۵۰٪ تغییر UI
> **فریمورک**: Blazor Server + MudBlazor 8.14
> **📋 تاریخچه تغییرات جزئی**: [CHANGELOG.md](CHANGELOG.md)
---
## 📊 خلاصه اجرایی
| شاخص | وضعیت قبل | وضعیت بعد |
|---|---|---|
| **صفحات کل** | ۴۱ صفحه + ۶ کامپوننت مشترک | ۴۱ صفحه + ۸ کامپوننت مشترک |
| **الگوی PageHeader** | ۱۹ صفحه از ۴۱ | ۳۲+ صفحه ✅ |
| **Inline Style سنگین** | ۱۱ صفحه 🔴 | ≤۲ صفحه (فقط gradient‌های تزئینی) ✅ |
| **Loading State** | ۲ الگوی متفاوت (Circular vs Linear) | `<LoadingState>` واحد ✅ |
| **Empty State** | ۴+ الگوی متناقض | `<EmptyState>` واحد ✅ |
| **Container Spacing** | ۶ الگوی متناقض | `py-6` استاندارد ✅ |
| **رنگ‌بندی** | رنگ‌های hardcoded در ۸+ صفحه | CSS Variable ✅ |
| **تم پالت** | Primary `#0380C0` (آبی ساده) | Indigo `#6366f1` + Full PaletteDark ✅ |
| **Elevation** | مخلوط ۲/۳/۴ | استاندارد ۰–۲ ✅ |
| **Build** | ۰ خطا | ۰ خطا ✅ |
### فازها
| فاز | عنوان | وضعیت |
|---|---|---|
| ۱ | زیرساخت دیزاین سیستم | ✅ تکمیل |
| ۲ | صفحات پروفایل | ✅ تکمیل |
| ۳ | صفحات تخصصی | ✅ تکمیل |
| ۴ | بهبود بصری فروشگاه | ✅ تکمیل |
| ۵ | صفحات عمومی | ✅ تکمیل |
| ۶ | پالیش و تست | ✅ تکمیل |
| ۷ | ناوبری، امنیت و بازسازی کامپوننت‌ها | ✅ تکمیل |
---
## 🔍 بخش ۱: تحلیل ضعف‌های جاری
### ۱.۱ ناسازگاری‌های ساختاری (Structural)
#### ❌ ۱.۱.۱ — PageHeader دوگانه
**مشکل**: نیمی از صفحات از `<PageHeader>` استفاده می‌کنند، نیم دیگر header دستی دارند.
| از `<PageHeader>` استفاده می‌کنند ✅ | Header دستی دارند ❌ |
|---|---|
| Store/* (۸ صفحه) | Profile/* (۷ صفحه) |
| DiscountStore/* (۶ صفحه) | Club/* (۲ صفحه) |
| Gateway/* (۳ صفحه) | Commission/* (۲ صفحه) |
| Package/* (۲ صفحه) | Network/* (۱ صفحه) |
| PackageDetail, Checkout | Blog/*, Index, About, Contact, FAQ |
**تأثیر**: ظاهر متفاوت دکمه بازگشت، فاصله‌بندی ناهماهنگ
#### ❌ ۱.۱.۲ — Container MaxWidth متناقض
```
MaxWidth.Large → اکثر صفحات
MaxWidth.Medium → Personal, Settings, OrderTracking, Blog/Post, Gateway/*
MaxWidth.Small → ChangePassword
ترکیبی (loading≠content) → OrderDetail, ProductDetail, PackageDetail
```
**تأثیر**: برخی صفحات پهن‌تر از حد نیاز هستند (مثلاً فرم‌های ساده با Large)
#### ❌ ۱.۱.۳ — Container Padding متناقض
```
py-6 → اکثریت (استاندارد)
py-8 → Package/Packages, Package/MyPackages
pa-2 pa-md-6 → Store/Products, DiscountStore/Products
py-6 py-md-10 → Gateway/*
py-4 py-md-6 → DiscountStore/ProductDetail
py-16 سکشنی → Index, About, Contact, FAQ
```
**قاعده پیشنهادی**: `py-6` برای صفحات داخلی، section-based برای صفحات عمومی
---
### ۱.۲ ناسازگاری‌های بصری (Visual)
#### ❌ ۱.۲.۱ — Inline Style سنگین (۱۱ صفحه)
| صفحه | نمونه مشکل‌دار |
|---|---|
| **Index.razor** | `Style="color:#fff; font-size:clamp(1.6rem,4.5vw,2.4rem);"` |
| **Profile/Index** | `Style="background:rgba(99,102,241,.12);"` |
| **Store/ProductDetail** | `style="width:100%;height:360px;background-image:url(...);"` |
| **PackageDetail** | `Style="background: radial-gradient(600px 280px..."` |
| **Checkout** | `Style="background: radial-gradient(..."` + `Elevation="4"` |
| **Blog/Index** | `Style="color:#fff; font-weight:700;"`, `Style="font-size:3.5rem;"` |
| **Blog/Post** | `Style="width:100%; height:100%;"`, `Style="font-size:4rem;"` |
| **About** | `Style="background: radial-gradient(...);"` |
| **WeeklyBalance** | `Style="background: linear-gradient(135deg, #e8f5e9..."` |
| **Gateway/**** | `Style="background:rgba(16,185,129,.12)..."`, `Style="color:#10b981;"` |
| **DiscountStore/ProductDetail** | `Style="background:rgba(16,185,129,.06)..."` |
#### ❌ ۱.۲.۲ — رنگ‌های Hardcoded
| رنگ | استفاده | باید باشد |
|---|---|---|
| `#10b981` | Gateway (سبز فروشگاه) | `var(--ds-color-store)` |
| `#ef4444` | Gateway (قرمز اعتباری) | `var(--ds-color-discount)` |
| `#6366f1`, `#818cf8`, `#a78bfa` | Hero/Banner gradients | `var(--ds-gradient-primary)` |
| `rgba(99,102,241,.12)` | Dashboard backgrounds | `var(--ds-primary-soft)` |
| `rgba(16,185,129,.06)` | Discount product highlights | `var(--ds-success-soft)` |
| `#e8f5e9`, `#c8e6c9` | WeeklyBalance stat cards | `var(--ds-success-gradient)` |
#### ❌ ۱.۲.۳ — Elevation ناهماهنگ
```
Elevation="0" → Blog cards (با border)
Elevation="1" → برخی صفحات
Elevation="2" → اکثر صفحات (استاندارد)
Elevation="3" → WeeklyBalance stat cards
Elevation="4" → Checkout.razor
```
**قاعده پیشنهادی**: `Elevation="0"` با `border` = کارت‌های اطلاعاتی، `Elevation="2"` = default
#### ❌ ۱.۲.۴ — Paper Rounding ناهماهنگ
```
rounded-lg (16px) → اکثریت
rounded-xl (20px) → Index, Profile/Index, Gateway, DiscountStore/ProductDetail
بدون class → design system !important → 12px
```
---
### ۱.۳ ناسازگاری‌های UX (تجربه کاربری)
#### ❌ ۱.۳.۱ — Loading State دوگانه
```
MudProgressCircular → Store/*, DiscountStore/*, Package/*, About, Blog, Addresses
MudProgressLinear → Club/*, Commission/*, Network/*, RegisterWizard
```
**مشکل**: کاربر دو تجربه مختلف «در حال بارگذاری» می‌بیند
#### ❌ ۱.۳.۲ — Empty State ناهماهنگ
```
MudAlert Severity.Info → Store/Orders, Categories, DiscountStore/Orders
Icon + Text + Button → Addresses, Package/MyPackages, PackageDetail
MudAlert Severity.Warning → WithdrawalRequests
Custom dashed-border paper → Blog/Index
```
#### ❌ ۱.۳.۳ — Routing Directive ناهماهنگ
```
@attribute [Route(RouteConstants...)] → ۳۹ صفحه ✅
@page "/categories" → Categories.razor ❌
@page "/blog/{Slug}" → Blog/Post.razor ❌
```
#### ❌ ۱.۳.۴ — PackageDetail Loading/Error بدون Container
Loading و Error state در `PackageDetail.razor` بدون `MudContainer` رندر می‌شوند → محتوا تمام‌عرض نمایش می‌یابد.
#### ❌ ۱.۳.۵ — تم فعلی کم‌رنگ
```csharp
// CustomMudTheme.cs فعلی
Primary = "#0380C0" // آبی ساده
// بدون Secondary، Tertiary، Info، Warning تعریف‌شده
// بدون PaletteDark
// بدون LayoutProperties
```
**مشکلات**:
- فقط Primary تعریف شده، بقیه رنگ‌ها default MudBlazor
- Dark mode بدون palette اختصاصی
- بدون `DefaultBorderRadius`، `AppbarHeight` و غیره
- تناقض بین Primary `#0380C0` و gradient‌های CSS با `#6366f1`
---
## 🎯 بخش ۲: معماری دیزاین سیستم هدف
### ۲.۱ سلسله‌مراتب صفحات
```
┌─────────────────────────────────────────────┐
│ MainLayout │
│ ├─ AppBar (fixed, transparent) │
│ ├─ MudMainContent │
│ │ ├─ [Public Pages] → Section-based │
│ │ │ (Index, About, Contact, FAQ, Blog) │
│ │ └─ [Internal Pages] → Container-based │
│ │ ├─ <PageHeader/> │
│ │ ├─ Content (MudStack/MudGrid) │
│ │ └─ </MudContainer> │
│ ├─ Footer (hidden on mobile) │
│ └─ BottomNav (mobile only) │
└─────────────────────────────────────────────┘
```
### ۲.۲ قواعد واحد (Single Source of Truth)
| قاعده | مقدار |
|---|---|
| **Container MaxWidth** | `Large` = لیست/گرید, `Medium` = فرم/جزئیات, `Small` = تک‌فرم ساده |
| **Container Spacing** | `py-6` صفحات داخلی, section-based صفحات عمومی |
| **Paper Elevation** | `0` با border = کارت اطلاعاتی, `2` = default |
| **Paper Rounding** | `rounded-lg` = default, `rounded-xl` = hero/banner |
| **Loading State** | `<LoadingState/>` component واحد |
| **Empty State** | `<EmptyState/>` component واحد |
| **Page Header** | `<PageHeader/>` در تمام صفحات داخلی |
| **Content Wrapper** | `MudStack Spacing="3"` بعد از PageHeader |
### ۲.۳ CSS Variables هدف
```css
:root {
/* ── Brand Colors ── */
--ds-brand-primary: #6366f1; /* Indigo — هویت اصلی */
--ds-brand-secondary: #8b5cf6; /* Purple */
--ds-brand-accent: #a78bfa; /* Light purple */
/* ── Semantic Colors ── */
--ds-color-store: #10b981; /* فروشگاه عادی */
--ds-color-discount: #ef4444; /* فروشگاه اعتباری */
--ds-color-success: #10b981;
--ds-color-warning: #f59e0b;
--ds-color-error: #ef4444;
--ds-color-info: #3b82f6;
/* ── Soft Backgrounds ── */
--ds-primary-soft: rgba(99,102,241,.08);
--ds-success-soft: rgba(16,185,129,.08);
--ds-error-soft: rgba(239,68,68,.08);
--ds-warning-soft: rgba(245,158,11,.08);
/* ── Gradients ── */
--ds-gradient-primary: linear-gradient(135deg, #6366f1 0%, #818cf8 50%, #a78bfa 100%);
--ds-gradient-store: linear-gradient(135deg, #10b981 0%, #34d399 100%);
--ds-gradient-discount: linear-gradient(135deg, #ef4444 0%, #f97316 50%, #f59e0b 100%);
--ds-gradient-success: linear-gradient(135deg, #d1fae5 0%, #a7f3d0 100%);
/* ── Spacing (existing) ── */
--ds-radius-sm: 8px;
--ds-radius-md: 12px;
--ds-radius-lg: 16px;
--ds-radius-xl: 20px;
--ds-transition: 0.2s ease;
--ds-shadow-sm: 0 1px 3px rgba(0,0,0,.06);
--ds-shadow-md: 0 4px 12px rgba(0,0,0,.08);
--ds-shadow-lg: 0 8px 24px rgba(0,0,0,.10);
}
```
---
## 🚀 بخش ۳: فازبندی اجرا
### 🔷 فاز ۱ — زیرساخت دیزاین سیستم (UI ~15%)
> **اولویت**: بالا | **ریسک**: پایین | **حجم**: ۶ فایل
| # | تسک | فایل | نوع تغییر |
|---|---|---|---|
| 1.1 | ارتقاء `CustomMudTheme.cs` — اضافه کردن PaletteDark، LayoutProperties، رنگ‌های Secondary/Tertiary/Info، تغییر Primary به `#6366f1` | `CustomMudTheme.cs` | UI |
| 1.2 | توسعه CSS Variables — اضافه کردن brand colors، semantic colors، soft backgrounds، gradients | `site.css` | UI |
| 1.3 | ساخت `<LoadingState>` component واحد | `Shared/LoadingState.razor` (جدید) | UX |
| 1.4 | ساخت `<EmptyState>` component واحد | `Shared/EmptyState.razor` (جدید) | UX |
| 1.5 | بهبود `<PageHeader>` — اضافه کردن آیکون، subtitle اختیاری | `Shared/PageHeader.razor` | UI |
| 1.6 | اضافه کردن `.page-container` CSS pattern | `site.css` | UI |
---
### 🔷 فاز ۲ — یکپارچه‌سازی صفحات Profile (UI ~10%, UX ~5%)
> **اولویت**: بالا | **ریسک**: پایین | **حجم**: ۹ فایل
| # | تسک | تغییرات |
|---|---|---|
| 2.1 | `Profile/Personal` → جایگزینی header دستی با `<PageHeader>` | UX |
| 2.2 | `Profile/Addresses``<PageHeader>` + `<LoadingState>` + `<EmptyState>` | UX |
| 2.3 | `Profile/Wallet``<PageHeader>` | UX |
| 2.4 | `Profile/Settings``<PageHeader>` | UX |
| 2.5 | `Profile/Tree``<PageHeader>` | UX |
| 2.6 | `Profile/WithdrawalRequests``<PageHeader>` + `<EmptyState>` | UX |
| 2.7 | `Profile/ChangePassword``<PageHeader>` | UX |
| 2.8 | `Profile/Index` (Dashboard) → حذف inline styles، استفاده از CSS Variables | UI |
| 2.9 | `Club/MembershipPage` + `Club/FeaturesPage``<PageHeader>` + `<LoadingState>` | UX |
---
### 🔷 فاز ۳ — یکپارچه‌سازی صفحات تخصصی (UI ~5%, UX ~5%)
> **اولویت**: متوسط | **ریسک**: پایین | **حجم**: ۵ فایل
| # | تسک | تغییرات |
|---|---|---|
| 3.1 | `Commission/Dashboard``<PageHeader>` + `<LoadingState>` | UX |
| 3.2 | `Commission/WeeklyBalance``<PageHeader>` + حذف inline gradient styles | UI + UX |
| 3.3 | `Network/NetworkStatistics``<PageHeader>` + `<LoadingState>` | UX |
| 3.4 | `PackageDetail` → wrap loading/error در `MudContainer` | UX bug fix |
| 3.5 | `Checkout` → Elevation=4→2، حذف inline radial-gradient | UI |
---
### 🔷 فاز ۴ — بهبود بصری فروشگاه‌ها (UI ~10%)
> **اولویت**: متوسط | **ریسک**: پایین | **حجم**: ۶ فایل
| # | تسک | تغییرات |
|---|---|---|
| 4.1 | `Store/Products` → حذف inline hero styles، استفاده از CSS class | UI |
| 4.2 | `Store/ProductDetail` → حذف inline image styles، ساخت `.product-image-main` CSS | UI |
| 4.3 | `DiscountStore/ProductDetail` → حذف hardcoded rgba، استفاده از `--ds-success-soft` | UI |
| 4.4 | `Gateway/*` (۳ صفحه) → حذف hardcoded `#10b981`/`#ef4444`، استفاده از `--ds-color-store`/`--ds-color-discount` | UI |
| 4.5 | یکسان‌سازی Elevation → `0` با border یا `2` | UI |
| 4.6 | یکسان‌سازی Container spacing → `py-6` | UI |
---
### 🔷 فاز ۵ — بهبود صفحات عمومی و بلاگ (UI ~10%)
> **اولویت**: پایین | **ریسک**: پایین | **حجم**: ۷ فایل
| # | تسک | تغییرات |
|---|---|---|
| 5.1 | `Index.razor` → حذف inline styles از hero، استفاده از CSS class | UI |
| 5.2 | `About.razor` → حذف inline radial-gradient | UI |
| 5.3 | `Contact.razor` → cleanup minor inline styles | UI |
| 5.4 | `FAQ.razor` → cleanup minor inline styles | UI |
| 5.5 | `Blog/Index` → حذف inline hero styles، استفاده از CSS class | UI |
| 5.6 | `Blog/Post` → حذف inline styles از hero image و typography | UI |
| 5.7 | `RegisterWizard` → بهینه‌سازی wizard-section dark mode | UI |
---
### 🔷 فاز ۶ — Polish نهایی و فرآیندی (UX ~10%)
> **اولویت**: پایین | **ریسک**: بسیار پایین | **حجم**: ۴ فایل + تست
| # | تسک | تغییرات |
|---|---|---|
| 6.1 | Fix routing inconsistency — Categories + Blog/Post | UX |
| 6.2 | MudSnackbar notifications styling | UI |
| 6.3 | Dialog styling consistency (AuthDialog, AddressDialogs) | UI |
| 6.4 | Micro-interactions — button press, card hover, page transition | UI |
| 6.5 | تست کامل Dark Mode در تمام صفحات | UI + QA |
| 6.6 | تست Mobile Responsive در تمام صفحات | UX + QA |
---
## 📈 بخش ۴: جدول تأثیرگذاری
### تأثیر UI (هدف ~۵۰٪ تغییر)
| حوزه | تعداد فایل | درصد تأثیر |
|---|---|---|
| Theme + CSS Variables | ۲ | ۱۵% (تأثیر سراسری) |
| حذف Inline Styles | ۱۱ | ۱۵% |
| یکسان‌سازی Elevation/Rounding | ۲۰+ | ۱۰% |
| بهبود رنگ‌بندی (CSS Variables) | ۸ | ۵% |
| Micro-interactions | سراسری | ۵% |
| **جمع** | | **~۵۰%** |
### تأثیر UX (هدف ~۲۰٪ تغییر)
| حوزه | تعداد فایل | درصد تأثیر |
|---|---|---|
| PageHeader یکپارچه | ۱۲ صفحه | ۸% |
| LoadingState واحد | ۱۵+ صفحه | ۴% |
| EmptyState واحد | ۸+ صفحه | ۳% |
| Container fixes (PackageDetail) | ۲ | ۲% |
| Routing consistency | ۲ | ۱% |
| Flow improvements | ۲ | ۲% |
| **جمع** | | **~۲۰%** |
---
## 🔧 بخش ۵: مشخصات فنی کامپوننت‌های جدید
### ۵.۱ LoadingState Component
```razor
@* Shared/LoadingState.razor *@
<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 {
[Parameter] public string Message { get; set; } = "در حال بارگذاری...";
}
```
### ۵.۲ EmptyState Component
```razor
@* Shared/EmptyState.razor *@
<MudStack AlignItems="AlignItems.Center" Class="py-12" Spacing="3">
<MudIcon Icon="@Icon" Size="Size.Large" Color="Color.Default" Class="mud-text-disabled" />
<MudText Typo="Typo.h6" Class="mud-text-secondary">@Title</MudText>
@if (!string.IsNullOrWhiteSpace(Description))
{
<MudText Typo="Typo.body2" Class="mud-text-secondary" Style="max-width:400px; text-align:center;">
@Description
</MudText>
}
@if (!string.IsNullOrWhiteSpace(ActionText))
{
<MudButton Variant="Variant.Filled" Color="Color.Primary"
Href="@ActionHref" OnClick="@OnAction" Class="mt-2">
@ActionText
</MudButton>
}
</MudStack>
@code {
[Parameter] public string Icon { get; set; } = Icons.Material.Filled.Inbox;
[Parameter] public string Title { get; set; } = "موردی یافت نشد";
[Parameter] public string? Description { get; set; }
[Parameter] public string? ActionText { get; set; }
[Parameter] public string? ActionHref { get; set; }
[Parameter] public EventCallback OnAction { get; set; }
}
```
### ۵.۳ PageHeader ارتقاء‌یافته
```razor
@* Shared/PageHeader.razor — ارتقاء‌یافته *@
<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 (!string.IsNullOrWhiteSpace(BackHref))
{
<MudButton Variant="Variant.Text" StartIcon="@Icons.Material.Filled.ArrowBack"
Href="@BackHref">بازگشت</MudButton>
}
else
{
<MudButton Variant="Variant.Text" StartIcon="@Icons.Material.Filled.ArrowBack"
OnClick="GoBack">بازگشت</MudButton>
}
</div>
@code {
[Parameter] public string Title { get; set; } = "";
[Parameter] public string? Subtitle { get; set; }
[Parameter] public string? BackHref { get; set; }
[Inject] private IJSRuntime JS { get; set; } = default!;
private async Task GoBack() => await JS.InvokeVoidAsync("history.back");
}
```
---
## ✅ بخش ۶: چک‌لیست تکمیل هر فاز
### فاز ۱ چک‌لیست:
- [ ] `CustomMudTheme.cs` — Primary→`#6366f1`, PaletteDark اضافه شد
- [ ] `site.css` — CSS Variables جدید (brand, semantic, soft, gradient)
- [ ] `Shared/LoadingState.razor` — ساخته و تست شد
- [ ] `Shared/EmptyState.razor` — ساخته و تست شد
- [ ] `Shared/PageHeader.razor` — Subtitle parameter اضافه شد
- [ ] `.page-container` CSS pattern اضافه شد
- [ ] Build: 0 errors ✅
- [ ] Dark Mode: صحیح ✅
- [ ] Mobile: صحیح ✅
### فاز ۲ چک‌لیست:
- [ ] `Profile/Personal``<PageHeader>`
- [ ] `Profile/Addresses``<PageHeader>` + `<LoadingState>` + `<EmptyState>`
- [ ] `Profile/Wallet``<PageHeader>`
- [ ] `Profile/Settings``<PageHeader>`
- [ ] `Profile/Tree``<PageHeader>`
- [ ] `Profile/WithdrawalRequests``<PageHeader>` + `<EmptyState>`
- [ ] `Profile/ChangePassword``<PageHeader>`
- [ ] `Profile/Index` → inline styles → CSS
- [ ] `Club/*``<PageHeader>` + `<LoadingState>`
- [ ] Build: 0 errors ✅
### فاز ۳ چک‌لیست:
- [ ] `Commission/*``<PageHeader>` + `<LoadingState>`
- [ ] `Network/*``<PageHeader>` + `<LoadingState>`
- [ ] `PackageDetail` → MudContainer wrapper برای loading/error
- [ ] `Checkout` → Elevation fix + inline cleanup
- [ ] `WeeklyBalance` → inline gradient → CSS class
- [ ] Build: 0 errors ✅
### فاز ۴ چک‌لیست:
- [ ] `Store/Products` → hero inline → CSS
- [ ] `Store/ProductDetail` → image inline → CSS class
- [ ] `DiscountStore/ProductDetail` → rgba → variable
- [ ] `Gateway/*` → hardcoded → variable
- [ ] Elevation یکسان‌سازی
- [ ] Container spacing یکسان‌سازی
- [ ] Build: 0 errors ✅
### فاز ۵ چک‌لیست:
- [ ] `Index.razor` → hero inline cleanup
- [ ] `About.razor` → radial-gradient cleanup
- [ ] `Blog/Index` + `Blog/Post` → inline cleanup
- [ ] `Contact.razor` + `FAQ.razor` → minor cleanup
- [ ] Build: 0 errors ✅
### فاز ۶ چک‌لیست:
- [ ] Routing fix (Categories, Blog/Post)
- [ ] MudSnackbar styling
- [ ] Dialog consistency
- [ ] Dark mode full test
- [ ] Mobile responsive full test
- [ ] Build: 0 errors ✅
---
## 📋 بخش ۷: خلاصه تغییرات در یک نگاه
```
فایل‌های تغییر‌یافته:
├── CustomMudTheme.cs [فاز ۱] — ارتقاء کامل تم
├── site.css [فاز ۱-۵] — CSS Variables + class‌های جدید
├── Shared/LoadingState.razor [فاز ۱] — جدید
├── Shared/EmptyState.razor [فاز ۱] — جدید
├── Shared/PageHeader.razor [فاز ۱] — بهبود
├── Profile/* (۷ فایل) [فاز ۲] — PageHeader + LoadingState
├── Profile/Index.razor [فاز ۲] — حذف inline styles
├── Club/* (۲ فایل) [فاز ۲] — PageHeader + LoadingState
├── Commission/* (۲ فایل) [فاز ۳] — PageHeader + LoadingState + cleanup
├── Network/* (۱ فایل) [فاز ۳] — PageHeader + LoadingState
├── PackageDetail.razor [فاز ۳] — Container fix
├── Checkout.razor [فاز ۳] — Elevation + cleanup
├── Store/* (۲ فایل) [فاز ۴] — inline → CSS
├── DiscountStore/ProductDetail [فاز ۴] — rgba → variable
├── Gateway/* (۳ فایل) [فاز ۴] — hardcoded → variable
├── Index.razor [فاز ۵] — hero cleanup
├── About.razor [فاز ۵] — gradient cleanup
├── Blog/* (۲ فایل) [فاز ۵] — inline cleanup
├── Contact.razor + FAQ.razor [فاز ۵] — minor cleanup
└── Categories + Blog/Post [فاز ۶] — routing fix
```
**مجموع فایل‌های تأثیرپذیر**: ~۳۵ فایل
**فایل‌های جدید**: ۲ (LoadingState, EmptyState)
**میزان تغییر UI**: ~۵۰٪
**میزان تغییر UX**: ~۲۰٪
**ریسک شکست**: پایین (تغییرات تدریجی، build verification در هر فاز)
@@ -1,56 +0,0 @@
---
# FrontOffice (Blazor Server) production.
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontoffice
namespace: default
labels:
app: frontoffice
environment: production
spec:
replicas: 1
selector:
matchLabels:
app: frontoffice
template:
metadata:
labels:
app: frontoffice
spec:
containers:
- name: frontoffice
image: 194.5.195.53:30080/admin/frontoffice:prod
imagePullPolicy: Always
ports:
- containerPort: 80
name: http
env:
- name: ASPNETCORE_ENVIRONMENT
value: "Production"
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
imagePullSecrets:
- name: gitea-registry-secret
---
apiVersion: v1
kind: Service
metadata:
name: frontoffice-svc
namespace: default
labels:
app: frontoffice
spec:
selector:
app: frontoffice
ports:
- port: 80
targetPort: 80
name: http
type: ClusterIP
-56
View File
@@ -1,56 +0,0 @@
---
# FrontOffice (Blazor Server) staging.
apiVersion: apps/v1
kind: Deployment
metadata:
name: frontoffice
namespace: default
labels:
app: frontoffice
environment: staging
spec:
replicas: 1
selector:
matchLabels:
app: frontoffice
template:
metadata:
labels:
app: frontoffice
spec:
containers:
- name: frontoffice
image: 194.5.195.53:30080/admin/frontoffice:latest
imagePullPolicy: Always
ports:
- containerPort: 80
name: http
env:
- name: ASPNETCORE_ENVIRONMENT
value: "Staging"
resources:
requests:
memory: "256Mi"
cpu: "250m"
limits:
memory: "512Mi"
cpu: "500m"
imagePullSecrets:
- name: gitea-registry-secret
---
apiVersion: v1
kind: Service
metadata:
name: frontoffice-svc
namespace: default
labels:
app: frontoffice
spec:
selector:
app: frontoffice
ports:
- port: 80
targetPort: 80
name: http
type: ClusterIP
+13 -52
View File
@@ -15,14 +15,13 @@ using CMSMicroservice.Protobuf.Protos.User;
using CMSMicroservice.Protobuf.Protos.UserCarts;
using CMSMicroservice.Protobuf.Protos.UserOrder;
using CMSMicroservice.Protobuf.Protos.UserWallet;
using CMSMicroservice.Protobuf.Protos.UserWalletHistory;
using CMSMicroservice.Protobuf.Protos.UserWalletChangeLog;
using CMSMicroservice.Protobuf.Protos.UserAddress;
using CMSMicroservice.Protobuf.Protos.Configuration;
using CMSMicroservice.Protobuf.Protos.NetworkMembership;
using CMSMicroservice.Protobuf.Protos.Commission;
using CMSMicroservice.Protobuf.Protos.AppVersion;
using CMSMicroservice.Protobuf.Protos.SitePage;
using CMSMicroservice.Protobuf.Protos.SitePageSettings;
using CMSMicroservice.Protobuf.Protos.BlogPost;
using CMSMicroservice.Protobuf.Protos.BlogCategory;
using CMSMicroservice.Protobuf.Protos.DiscountProduct;
@@ -30,7 +29,6 @@ using CMSMicroservice.Protobuf.Protos.DiscountCategory;
using CMSMicroservice.Protobuf.Protos.DiscountShoppingCart;
using CMSMicroservice.Protobuf.Protos.DiscountOrder;
using FrontOffice.Main.Utilities;
using FrontOffice.Main.Utilities.Seo;
namespace Microsoft.Extensions.DependencyInjection;
@@ -57,7 +55,6 @@ public static class ConfigureServices
services.AddSingleton<UserAuthInfo>();
services.AddScoped<AuthService>();
services.AddScoped<AuthDialogService>();
services.AddScoped<GuestActionGate>();
// Storefront services
services.AddScoped<CartService>();
services.AddScoped<ProductService>();
@@ -86,8 +83,6 @@ public static class ConfigureServices
services.AddScoped<DiscountProductService>();
services.AddScoped<DiscountCartService>();
services.AddScoped<DiscountOrderService>();
// Site Page Settings Service (simplified page management)
services.AddScoped<SitePageSettingsService>();
// Device detection: very light, dependency-free
services.AddTransient<IDeviceDetector, DeviceDetector>();
// PDF generation (Chromium only)
@@ -96,45 +91,26 @@ public static class ConfigureServices
// SignalR Token Notification Service
services.AddScoped<TokenNotificationService>();
// SEO
services.AddScoped<SeoMetadataProvider>();
services.AddScoped<SitemapGenerator>();
return services;
}
public static IServiceCollection AddGrpcServices(this IServiceCollection services, IConfiguration configuration)
{
var baseUrl = ResolveGatewayUrl(configuration)
?? throw new InvalidOperationException("Gateway URL is missing. Set GW_URL or GwUrl.");
var baseUrl = configuration["GwUrl"];
var isHttp = baseUrl.StartsWith("http://", StringComparison.OrdinalIgnoreCase);
// When the base URL is plain HTTP (e.g. in-cluster http://cms-svc:8080), we must force HTTP/1.1
// so that GrpcChannel does not upgrade to h2c and trigger HTTP_1_1_REQUIRED from Kestrel.
// For HTTPS the default negotiation is fine.
// Register optimized HttpClient for gRPC
services.AddScoped(sp =>
{
HttpMessageHandler inner = isHttp
? new SocketsHttpHandler
{
MaxConnectionsPerServer = 10,
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate
}
: new HttpClientHandler
{
MaxConnectionsPerServer = 10,
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate
};
var handler = new HttpClientHandler
{
MaxConnectionsPerServer = 10,
AutomaticDecompression = System.Net.DecompressionMethods.GZip | System.Net.DecompressionMethods.Deflate
};
return new HttpClient(new GrpcWebHandler(GrpcWebMode.GrpcWeb, inner))
return new HttpClient(new GrpcWebHandler(GrpcWebMode.GrpcWeb, handler))
{
Timeout = TimeSpan.FromMinutes(10),
BaseAddress = new Uri(baseUrl),
DefaultRequestVersion = isHttp ? System.Net.HttpVersion.Version11 : System.Net.HttpVersion.Version20,
DefaultVersionPolicy = isHttp
? System.Net.Http.HttpVersionPolicy.RequestVersionExact
: System.Net.Http.HttpVersionPolicy.RequestVersionOrHigher
BaseAddress = new Uri(baseUrl)
};
});
@@ -149,7 +125,7 @@ public static class ConfigureServices
services.AddScoped(CreateAuthenticatedClient<CMSMicroservice.Protobuf.Protos.UserCarts.UserCartsContract.UserCartsContractClient>);
services.AddScoped(CreateAuthenticatedClient<CMSMicroservice.Protobuf.Protos.City.CityContract.CityContractClient>);
services.AddScoped(CreateAuthenticatedClient<CMSMicroservice.Protobuf.Protos.UserAddress.UserAddressContract.UserAddressContractClient>);
services.AddScoped(CreateAuthenticatedClient<CMSMicroservice.Protobuf.Protos.UserWalletHistory.UserWalletHistoryContract.UserWalletHistoryContractClient>);
services.AddScoped(CreateAuthenticatedClient<CMSMicroservice.Protobuf.Protos.UserWalletChangeLog.UserWalletChangeLogContract.UserWalletChangeLogContractClient>);
services.AddScoped(CreateAuthenticatedClient<CMSMicroservice.Protobuf.Protos.ClubMembership.ClubMembershipContract.ClubMembershipContractClient>);
services.AddScoped(CreateAuthenticatedClient<CMSMicroservice.Protobuf.Protos.OtpToken.OtpTokenContract.OtpTokenContractClient>);
services.AddScoped(CreateAuthenticatedClient<CMSMicroservice.Protobuf.Protos.Configuration.ConfigurationContract.ConfigurationContractClient>);
@@ -157,7 +133,6 @@ public static class ConfigureServices
services.AddScoped(CreateAuthenticatedClient<CMSMicroservice.Protobuf.Protos.Commission.CommissionContract.CommissionContractClient>);
services.AddScoped(CreateAuthenticatedClient<CMSMicroservice.Protobuf.Protos.AppVersion.AppVersionContract.AppVersionContractClient>);
services.AddScoped(CreateAuthenticatedClient<CMSMicroservice.Protobuf.Protos.SitePage.SitePageContract.SitePageContractClient>);
services.AddScoped(CreateAuthenticatedClient<SitePageSettingsContract.SitePageSettingsContractClient>);
services.AddScoped(CreateAuthenticatedClient<CMSMicroservice.Protobuf.Protos.BlogPost.BlogPostContract.BlogPostContractClient>);
services.AddScoped(CreateAuthenticatedClient<CMSMicroservice.Protobuf.Protos.BlogCategory.BlogCategoryContract.BlogCategoryContractClient>);
// Image Resolver Service
@@ -172,22 +147,12 @@ public static class ConfigureServices
return services;
}
private static string? ResolveGatewayUrl(IConfiguration configuration)
{
var envUrl = configuration["GW_URL"];
if (!string.IsNullOrWhiteSpace(envUrl))
return envUrl.TrimEnd('/');
return configuration["GwUrl"]?.TrimEnd('/');
}
private static TClient CreateAuthenticatedClient<TClient>(IServiceProvider sp)
where TClient : class
{
var httpClient = sp.GetRequiredService<HttpClient>();
var localStorage = sp.GetRequiredService<ILocalStorageService>();
var baseUrl = httpClient.BaseAddress?.ToString() ?? throw new InvalidOperationException("Base URL not configured");
var isHttps = baseUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase);
var credentials = CallCredentials.FromInterceptor(async (context, metadata) =>
{
@@ -207,14 +172,10 @@ public static class ConfigureServices
}
});
var channelCredentials = isHttps
? ChannelCredentials.Create(new SslCredentials(), credentials)
: ChannelCredentials.Create(ChannelCredentials.Insecure, credentials);
var channel = GrpcChannel.ForAddress(baseUrl, new GrpcChannelOptions
{
UnsafeUseInsecureChannelCallCredentials = !isHttps,
Credentials = channelCredentials,
UnsafeUseInsecureChannelCallCredentials = true,
Credentials = ChannelCredentials.Create(new SslCredentials(), credentials),
HttpClient = httpClient,
MaxReceiveMessageSize = 1000 * 1024 * 1024, // 1 GB
MaxSendMessageSize = 1000 * 1024 * 1024 // 1 GB
-4
View File
@@ -20,10 +20,6 @@ FROM 194.5.195.53:32082/dotnet/aspnet:9.0 AS runtime
WORKDIR /app
COPY --from=build /app/publish .
# Trust the staging-ca so server-side gRPC calls to https://cms.se.kbs1.ir succeed without PartialChain
COPY ["FrontOffice.Main/staging-ca.crt", "/usr/local/share/ca-certificates/staging-ca.crt"]
RUN update-ca-certificates
ENV ASPNETCORE_URLS=http://+:80
EXPOSE 80
+9 -3
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
@@ -11,8 +11,8 @@
<ItemGroup>
<PackageReference Include="DateTimeConverterCL" Version="1.0.0" />
<!-- Replace all FrontOffice.BFF protobuf packages with CMS protobuf -->
<PackageReference Include="Foursat.CMSMicroservice.Protobuf" Version="0.0.210" />
<!-- <ProjectReference Include="../../../CMS/src/CMSMicroservice.Protobuf/CMSMicroservice.Protobuf.csproj" />-->
<PackageReference Include="Foursat.CMSMicroservice.Protobuf" Version="0.0.179" />
<!--<ProjectReference Include="../../../CMS/src/CMSMicroservice.Protobuf/CMSMicroservice.Protobuf.csproj" />-->
<PackageReference Include="MudBlazor" Version="8.14.0" />
<PackageReference Include="Blazored.LocalStorage" Version="4.5.0" />
<PackageReference Include="Mapster" Version="7.4.0" />
@@ -56,6 +56,12 @@
<Content Include="..\.dockerignore">
<Link>.dockerignore</Link>
</Content>
<Content Remove="Pages\Package\Packages.razor" />
</ItemGroup>
<ItemGroup>
<Compile Remove="Pages\Package\Packages.razor.cs" />
</ItemGroup>
</Project>
+36 -18
View File
@@ -1,6 +1,6 @@
@attribute [Route(RouteConstants.About.Index)]
@inject NavigationManager Navigation
@inject SitePageSettingsService PageSettingsService
@inject SitePageService SitePageService
<PageTitle>درباره ما | کارا بازار سلامت</PageTitle>
@@ -66,22 +66,40 @@ else
<MudPaper Elevation="2" Class="pa-6 text-center h-100">
<MudIcon Icon="@Icons.Material.Filled.Visibility" Size="Size.Large" Color="Color.Primary" Class="mb-4" />
<MudText Typo="Typo.h5" Class="mb-3">
@(_settings?.VisionTitle ?? "چشم‌انداز")
</MudText>
<MudText Typo="Typo.body1" Class="mud-text-secondary">
@(_settings?.VisionText ?? "تبدیل شدن به پیشروترین پلتفرم مدیریت تیم‌های فروش در منطقه، با ارائه راهکارهای هوشمند و کاربرپسند برای کسب‌وکارهای کوچک و بزرگ.")
@(_visionSection?.Title ?? "چشم‌انداز")
</MudText>
@if (!string.IsNullOrWhiteSpace(_visionSection?.HtmlContent))
{
<MudText Typo="Typo.body1" Class="mud-text-secondary">
@((MarkupString)_visionSection.HtmlContent)
</MudText>
}
else
{
<MudText Typo="Typo.body1" Class="mud-text-secondary">
تبدیل شدن به پیشروترین پلتفرم مدیریت تیم‌های فروش در منطقه، با ارائه راهکارهای هوشمند و کاربرپسند برای کسب‌وکارهای کوچک و بزرگ.
</MudText>
}
</MudPaper>
</MudItem>
<MudItem xs="12" md="6">
<MudPaper Elevation="2" Class="pa-6 text-center h-100">
<MudIcon Icon="@Icons.Material.Filled.TrackChanges" Size="Size.Large" Color="Color.Success" Class="mb-4" />
<MudText Typo="Typo.h5" Class="mb-3">
@(_settings?.MissionTitle ?? "مأموریت")
</MudText>
<MudText Typo="Typo.body1" Class="mud-text-secondary">
@(_settings?.MissionText ?? "توانمندسازی کسب‌وکارها از طریق فناوری‌های نوین، ساده‌سازی فرآیندهای پیچیده و ایجاد فرصت‌های جدید برای رشد و توسعه پایدار.")
@(_missionSection?.Title ?? "مأموریت")
</MudText>
@if (!string.IsNullOrWhiteSpace(_missionSection?.HtmlContent))
{
<MudText Typo="Typo.body1" Class="mud-text-secondary">
@((MarkupString)_missionSection.HtmlContent)
</MudText>
}
else
{
<MudText Typo="Typo.body1" Class="mud-text-secondary">
توانمندسازی کسب‌وکارها از طریق فناوری‌های نوین، ساده‌سازی فرآیندهای پیچیده و ایجاد کارا بازار سلامت‌های جدید برای رشد و توسعه پایدار.
</MudText>
}
</MudPaper>
</MudItem>
</MudGrid>
@@ -93,21 +111,21 @@ else
<MudContainer MaxWidth="MaxWidth.Large">
<MudText Typo="Typo.h3" Align="Align.Center" Class="mb-8">ارزش‌های ما</MudText>
<MudGrid Spacing="3" Justify="Justify.Center">
@if (_valueImages.Any())
@if (_valueSections.Any())
{
@foreach (var value in _valueImages)
@foreach (var value in _valueSections)
{
<MudItem xs="12" sm="6" md="4">
<MudPaper Elevation="2" Class="pa-4 rounded-xl text-center">
@if (!string.IsNullOrWhiteSpace(value.IconName))
{
<MudIcon Icon="@($"Icons.Material.Filled.{value.IconName}")" Size="Size.Large" Color="Color.Primary" Class="mb-3" />
<MudIcon Icon="@value.IconName" Size="Size.Large" Color="Color.Primary" Class="mb-3" />
}
<MudText Typo="Typo.h6" Class="mb-2">@value.Title</MudText>
@if (!string.IsNullOrWhiteSpace(value.Description))
@if (!string.IsNullOrWhiteSpace(value.HtmlContent))
{
<MudText Typo="Typo.body2" Class="mud-text-secondary">
@value.Description
@((MarkupString)value.HtmlContent)
</MudText>
}
</MudPaper>
@@ -181,9 +199,9 @@ else
<MudContainer MaxWidth="MaxWidth.Large">
<MudText Typo="Typo.h3" Align="Align.Center" Class="mb-8">تیم ما</MudText>
<MudGrid Spacing="4" Justify="Justify.Center">
@if (_teamImages.Any())
@if (_teamSections.Any())
{
@foreach (var member in _teamImages)
@foreach (var member in _teamSections)
{
<MudItem xs="12" sm="6" md="4">
<MudPaper Elevation="2" Class="pa-4 text-center">
@@ -204,10 +222,10 @@ else
{
<MudText Typo="Typo.body2" Class="mud-text-secondary mb-2">@member.Subtitle</MudText>
}
@if (!string.IsNullOrWhiteSpace(member.Description))
@if (!string.IsNullOrWhiteSpace(member.HtmlContent))
{
<MudText Typo="Typo.caption" Class="mud-text-secondary">
@member.Description
@((MarkupString)member.HtmlContent)
</MudText>
}
</MudPaper>
+10 -20
View File
@@ -5,22 +5,24 @@ namespace FrontOffice.Main.Pages;
public partial class About
{
private bool _loading = true;
private PageSettingsDto? _page;
private AboutSettings? _settings;
private List<PageSettingsImageDto> _valueImages = new();
private List<PageSettingsImageDto> _teamImages = new();
private SitePageDto? _page;
private SitePageSectionDto? _visionSection;
private SitePageSectionDto? _missionSection;
private List<SitePageSectionDto> _valueSections = new();
private List<SitePageSectionDto> _teamSections = new();
protected override async Task OnInitializedAsync()
{
try
{
_page = await PageSettingsService.GetPageAsync("about");
_page = await SitePageService.GetByKeyAsync("about");
if (_page != null)
{
_settings = _page.GetSettings<AboutSettings>();
_valueImages = _page.GetImages("values");
_teamImages = _page.GetImages("team");
_visionSection = _page.GetSection("vision");
_missionSection = _page.GetSection("mission");
_valueSections = _page.GetSections("value-").ToList();
_teamSections = _page.GetSections("team-").ToList();
}
}
catch
@@ -32,16 +34,4 @@ public partial class About
_loading = false;
}
}
private class AboutSettings
{
public string? VisionTitle { get; set; }
public string? VisionText { get; set; }
public string? VisionIcon { get; set; }
public string? MissionTitle { get; set; }
public string? MissionText { get; set; }
public string? MissionIcon { get; set; }
public string? ValuesTitle { get; set; }
public string? TeamTitle { get; set; }
}
}
+76 -28
View File
@@ -27,11 +27,6 @@
<MudPaper Elevation="2" Class="pa-4 rounded-lg">
<MudText Typo="Typo.h5" Class="mb-4">جزئیات پکیج</MudText>
@* ── G2: توضیح خرید پکیج (Q28) ── *@
<MudAlert Severity="Severity.Info" Variant="Variant.Text" Dense="true" Class="mb-3">
با خرید این پکیج، سقف پاداش هفتگی، ضریب کیف‌پول جادویی و دسترسی به فیچرهای اختصاصی برای شما فعال می‌شود.
</MudAlert>
@if (_selectedPackage != null)
{
<MudCard>
@@ -71,6 +66,80 @@
</MudStack>
}
</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>
</MudItem>
@@ -138,36 +207,15 @@
FullWidth="true"
StartIcon="@Icons.Material.Filled.Payment"
OnClick="ProcessPayment"
Disabled="@(!CanProceedToPayment || _isProcessingPayment || !_selectedPackage.SupportsDirectPurchase)"
Disabled="@(!CanProceedToPayment || _isProcessingPayment)"
Class="mt-2">
@(_isProcessingPayment ? "در حال پردازش..." : "پرداخت آنلاین")
</MudButton>
@if (_selectedPackage.SupportsDayaPurchase)
{
<MudButton Variant="Variant.Filled"
Color="Color.Tertiary"
Size="Size.Large"
FullWidth="true"
StartIcon="@Icons.Material.Filled.Diamond"
OnClick="DayaLoanPayment"
Disabled="@(!CanProceedToPayment)"
Class="mt-2">
تأمین اعتبار الماسی دایا
</MudButton>
}
@if (!_selectedPackage.SupportsDirectPurchase && !_selectedPackage.SupportsDayaPurchase)
{
<MudAlert Severity="Severity.Warning" Dense="true" Class="mt-2">
این پکیج در حال حاضر قابل خرید نیست.
</MudAlert>
}
@if (!CanProceedToPayment)
{
<MudText Typo="Typo.caption" Color="Color.Error" Align="Align.Center">
لطفاً پکیج را انتخاب کنید.
لطفاً پکیج و آدرس را انتخاب کنید.
</MudText>
}
</MudStack>
+108 -47
View File
@@ -1,7 +1,11 @@
using CMSMicroservice.Protobuf.Protos.Package;
using CMSMicroservice.Protobuf.Protos.Transactions;
using CMSMicroservice.Protobuf.Protos.UserAddress;
using CMSMicroservice.Protobuf.Protos.UserOrder;
using FrontOffice.Main.Utilities;
using Google.Protobuf.WellKnownTypes;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using MudBlazor;
using Severity = MudBlazor.Severity;
@@ -9,12 +13,17 @@ namespace FrontOffice.Main.Pages;
public partial class Checkout
{
[Inject] private PackageService PackageService { get; set; } = default!;
[Inject] private PackageContract.PackageContractClient PackageClient { get; set; } = default!;
[Inject] private UserAddressContract.UserAddressContractClient UserAddressContract { get; set; } = default!;
[Inject] private UserOrderContract.UserOrderContractClient UserOrderContract { get; set; } = default!;
[Inject] private TransactionsContract.TransactionsContractClient TransactionContract { get; set; } = default!;
[Parameter] public long? PackageId { get; set; }
private Pack? _selectedPackage;
private List<CustomerAddressModel> _addresses = new();
private CustomerAddressModel? _selectedAddress;
private bool _isLoadingAddresses;
private bool _isProcessingPayment;
// Discount code
@@ -25,11 +34,16 @@ public partial class Checkout
private long _discountAmount;
private long _finalPrice;
private bool CanProceedToPayment => _selectedPackage != null;
// Address management
private bool _isSettingDefaultAddress;
private long? _settingDefaultAddressId;
private bool CanProceedToPayment => _selectedPackage != null && _selectedAddress != null;
protected override async Task OnInitializedAsync()
{
await LoadPackageDetails();
await LoadAddresses();
}
private async Task LoadPackageDetails()
@@ -38,18 +52,15 @@ public partial class Checkout
{
try
{
var packages = await PackageService.GetAllPackagesAsync();
var pkg = packages.FirstOrDefault(p => p.Id == PackageId.Value);
if (pkg != null)
var response = await PackageClient.GetPackageAsync(new() { Id = PackageId.Value });
if (response != null)
{
_selectedPackage = new Pack(
Id: pkg.Id,
Title: pkg.Title,
Body: pkg.Description,
Image: pkg.ImageUrl,
Price: pkg.Price,
SupportsDirectPurchase: pkg.SupportsDirectPurchase,
SupportsDayaPurchase: pkg.SupportsDayaPurchase
Id: response.Id,
Title: response.Title,
Body: response.Description,
Image: response.ImagePath ?? string.Empty,
Price: response.Price
);
_finalPrice = _selectedPackage.Price;
}
@@ -61,6 +72,60 @@ 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()
{
if (string.IsNullOrWhiteSpace(_discountCode))
@@ -92,7 +157,7 @@ public partial class Checkout
_finalPrice = _selectedPackage!.Price;
}
}
catch (Exception)
catch (Exception ex)
{
_discountMessage = "خطا در اعمال کد تخفیف.";
_discountApplied = false;
@@ -108,11 +173,12 @@ public partial class Checkout
private async Task ProcessPayment()
{
if (_isProcessingPayment) return;
Snackbar.Add("درگاه پرداخت متصل نیست! لطفا در زمان دیگری مجددا تلاش فرمایید!", Severity.Warning);
return;
if (!CanProceedToPayment || _selectedPackage == null)
if (!CanProceedToPayment || _selectedPackage == null || _selectedAddress == null)
{
Snackbar.Add("لطفاً پکیج را انتخاب کنید.", Severity.Warning);
Snackbar.Add("لطفاً پکیج و آدرس را انتخاب کنید.", Severity.Warning);
return;
}
@@ -120,26 +186,33 @@ public partial class Checkout
try
{
var response = await PackageClient.CustomerPurchasePackageAsync(new CustomerPurchasePackageRequest
// Step 1: Create payment request
var paymentRequest = new CustomerPaymentRequestRequest
{
Amount = _finalPrice,
CallbackUrl = $"{Navigation.BaseUri}checkout/callback",
Description = $"خرید پکیج {_selectedPackage.Title}",
Currency = CurrencyEnum.Irt,
Type = TransactionTypeEnum.Real
};
var paymentResponse = await TransactionContract.CustomerPaymentRequestAsync(paymentRequest);
if (string.IsNullOrEmpty(paymentResponse.PaymentGWUrl))
Snackbar.Add("آدرس درگاه پرداخت دریافت نشد.", Severity.Error);
// Step 2: Create user order
var orderRequest = new CreateNewUserOrderRequest
{
Amount = _finalPrice,
PackageId = _selectedPackage.Id,
PurchaseMethod = PurchaseMethodEnum.PurchaseMethodGateway
});
PaymentStatus = CMSMicroservice.Protobuf.Protos.PaymentStatus.Pending
};
if (!response.Success)
{
Snackbar.Add(response.Message ?? "خطا در آغاز فرآیند پرداخت", Severity.Error);
return;
}
var orderResponse = await UserOrderContract.CreateNewUserOrderAsync(orderRequest);
if (!string.IsNullOrEmpty(response.PaymentGatewayUrl))
{
Navigation.NavigateTo(response.PaymentGatewayUrl, forceLoad: true);
}
else
{
Snackbar.Add("خطا در دریافت آدرس درگاه پرداخت", Severity.Error);
}
// Step 3: Redirect to payment gateway
Navigation.NavigateTo(paymentResponse.PaymentGWUrl);
}
catch (Exception ex)
{
@@ -152,17 +225,5 @@ public partial class Checkout
}
}
private async Task DayaLoanPayment()
{
if (_selectedPackage == null)
{
Snackbar.Add("لطفاً پکیج را انتخاب کنید.", Severity.Warning);
return;
}
var url = "https://dayadiamond.ir/profile/creditpurchase/?merchantcode=56146364";
await JSRuntime.InvokeVoidAsync("open", url, "_blank");
}
private record Pack(long Id, string Title, string Body, string Image, long Price, bool SupportsDirectPurchase = true, bool SupportsDayaPurchase = false);
}
private record Pack(long Id, string Title, string Body, string Image, long Price);
}
@@ -3,12 +3,7 @@
<MudPaper Elevation="2" Class="pa-4 rounded-lg">
<MudText Typo="Typo.h6" Class="mb-3">فعال‌سازی یا تمدید عضویت</MudText>
@* ── G7: راهنمای هزینه فعال‌سازی (Q28) ── *@
<MudAlert Severity="Severity.Info" Variant="Variant.Text" Dense="true" Class="mb-3" Icon="@Icons.Material.Filled.Info">
هزینه تخمینی بر اساس پکیج انتخابی محاسبه می‌شود. هر پکیج قیمت و شرایط فعال‌سازی متفاوتی دارد.
</MudAlert>
<MudForm @ref="_form" @bind-IsValid="_formIsValid">
<MudGrid Spacing="2">
<MudItem xs="12" sm="6">
@@ -10,7 +10,6 @@ public partial class ActivationSection : ComponentBase
[Inject] private ClubMembershipService ClubService { get; set; } = default!;
[Inject] private AuthService AuthService { get; set; } = default!;
[Inject] private UserContract.UserContractClient UserContract { get; set; } = default!;
[Inject] private PackageService PackageService { get; set; } = default!;
[Parameter] public EventCallback OnActivationSuccess { get; set; }
@@ -22,18 +21,6 @@ public partial class ActivationSection : ComponentBase
private long _packageId = 1;
private int _durationMonths = 1;
private string? _activationCode;
private long _basePackagePrice;
protected override async Task OnInitializedAsync()
{
try
{
var packages = await PackageService.GetAllPackagesAsync();
var basePackage = packages.FirstOrDefault(p => p.IsBasePackage) ?? packages.FirstOrDefault();
_basePackagePrice = basePackage?.Price ?? 0;
}
catch { /* fallback to 0 */ }
}
private async Task HandleActivateAsync()
{
@@ -88,8 +75,9 @@ public partial class ActivationSection : ComponentBase
private string GetEstimatedCost()
{
if (_basePackagePrice <= 0) return "در حال بارگذاری...";
var total = _basePackagePrice * _durationMonths;
// فرمول تقریبی: 56M per month (base amount from BFF implementation)
var baseAmount = 56_000_000;
var total = baseAmount * _durationMonths;
return $"{total:N0} تومان";
}
}
@@ -9,11 +9,6 @@
<MudStack Spacing="3">
<PageHeader Title="عضویت باشگاه مشتریان" BackHref="@RouteConstants.Profile.Index" />
@* ── G6: راهنمای قرارداد باشگاه (Q28) ── *@
<MudAlert Severity="Severity.Info" Variant="Variant.Text" Dense="true" Icon="@Icons.Material.Filled.Gavel">
قرارداد باشگاه مشتریان فقط یک‌بار امضا می‌شود و با هر بار خرید پکیج جدید نیازی به امضای مجدد نیست.
</MudAlert>
@if (_isLoading)
{
<LoadingState Message="در حال دریافت اطلاعات عضویت..." />
@@ -70,7 +65,7 @@
<MudIcon Icon="@Icons.Material.Filled.AccountBalanceWallet" Color="Color.Success" Size="Size.Large" />
<MudStack Spacing="0">
<MudText Typo="Typo.subtitle1">شارژ کیف پول فروشگاه اعتباری</MudText>
<MudText Typo="Typo.caption" Color="Color.Default">شارژ برابر ارزش پکیج فعال در کیف پول فروشگاه اعتباری</MudText>
<MudText Typo="Typo.caption" Color="Color.Default">شارژ ۵۶ میلیون تومان کیف پول فروشگاه اعتباری</MudText>
</MudStack>
</MudStack>
<MudDivider />
@@ -10,15 +10,10 @@
<MudStack Spacing="3">
<PageHeader Title="پاداش‌های من" BackHref="@RouteConstants.Profile.Index" />
@* ── G5: توضیح محاسبه پاداش (Q28) ── *@
<MudAlert Severity="Severity.Info" Variant="Variant.Text" Dense="true" Icon="@Icons.Material.Filled.Info">
پاداش هفتگی بر اساس هر پکیج جداگانه محاسبه می‌شود. هر پکیج Pool پورسانت مستقل دارد و Carryover بر اساس پکیج زیرمجموعه‌هاست.
</MudAlert>
<!-- فیلترها -->
<MudPaper Elevation="2" Class="pa-3 rounded-lg">
<MudGrid Spacing="2">
<MudItem xs="12" sm="6" md="3">
<MudItem xs="12" sm="6" md="4">
<WeekSelector @ref="_weekSelector"
@bind-Value="_selectedWeekDefinition"
Label="انتخاب هفته"
@@ -28,7 +23,7 @@
Clearable="true"
OnlyActive="false" />
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudItem xs="12" sm="6" md="4">
<MudSelect T="string" @bind-Value="_filterStatus" Label="وضعیت" Variant="Variant.Outlined">
<MudSelectItem T="string" Value="@string.Empty">همه</MudSelectItem>
<MudSelectItem T="string" Value="@("Pending")">در انتظار</MudSelectItem>
@@ -39,16 +34,7 @@
<MudSelectItem T="string" Value="@("Cancelled")">لغو شده</MudSelectItem>
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudSelect T="long?" @bind-Value="_filterPackageId" Label="پکیج" Variant="Variant.Outlined" Clearable="true">
<MudSelectItem T="long?" Value="@((long?)null)">همه پکیج‌ها</MudSelectItem>
@foreach (var pkg in _packages)
{
<MudSelectItem T="long?" Value="@((long?)pkg.Id)">@pkg.Title</MudSelectItem>
}
</MudSelect>
</MudItem>
<MudItem xs="12" sm="6" md="3">
<MudItem xs="12" sm="12" md="4">
<MudStack Row="true" Spacing="2" Style="height: 100%;" AlignItems="AlignItems.End">
<MudButton Variant="Variant.Filled" Color="Color.Primary" OnClick="ApplyFiltersAsync" StartIcon="@Icons.Material.Filled.FilterList" FullWidth="true">
اعمال فیلتر
@@ -101,7 +87,6 @@
<MudTable Items="_payouts" Hover="true" Striped="true" Dense="true" FixedHeader="true" Height="400px">
<HeaderContent>
<MudTh>هفته</MudTh>
<MudTh>پکیج</MudTh>
<MudTh>امتیاز</MudTh>
<MudTh>مبلغ (تومان)</MudTh>
<MudTh>وضعیت</MudTh>
@@ -112,11 +97,6 @@
<MudTd DataLabel="هفته">
<MudChip T="string" Color="Color.Primary" Size="Size.Small" Variant="Variant.Outlined">@context.WeekDisplayName</MudChip>
</MudTd>
<MudTd DataLabel="پکیج">
<MudChip T="string" Color="Color.Tertiary" Size="Size.Small">
@(string.IsNullOrEmpty(context.PackageTitle) ? "-" : context.PackageTitle)
</MudChip>
</MudTd>
<MudTd DataLabel="امتیاز">@context.BalancesEarned</MudTd>
<MudTd DataLabel="مبلغ">
<MudText Color="Color.Success"><strong>@context.AmountFormatted</strong></MudText>
@@ -147,10 +127,6 @@
<MudChip T="string" Color="Color.Primary" Size="Size.Small" Variant="Variant.Outlined">@payout.WeekDisplayName</MudChip>
<MudChip T="string" Color="@GetStatusColor(payout.StatusColor)" Size="Size.Small">@payout.StatusText</MudChip>
</MudStack>
@if (!string.IsNullOrEmpty(payout.PackageTitle))
{
<MudChip T="string" Color="Color.Tertiary" Size="Size.Small">@payout.PackageTitle</MudChip>
}
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h6" Color="Color.Success">@payout.AmountFormatted</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary">امتیاز: @payout.BalancesEarned</MudText>
@@ -8,10 +8,8 @@ namespace FrontOffice.Main.Pages.Commission;
public partial class CommissionDashboardPage : ComponentBase
{
[Inject] private CommissionService CommissionService { get; set; } = default!;
[Inject] private PackageService PackageService { get; set; } = default!;
private List<CommissionPayoutDto> _payouts = new();
private List<PackageDto> _packages = new();
private int _totalCount;
private long _totalAmount;
private int _pageNumber = 1;
@@ -21,26 +19,12 @@ public partial class CommissionDashboardPage : ComponentBase
private WeekSelector? _weekSelector;
private WeekDefinitionDto? _selectedWeekDefinition;
private string _filterStatus = string.Empty;
private long? _filterPackageId;
protected override async Task OnInitializedAsync()
{
await LoadPackagesAsync();
await LoadPayoutsAsync();
}
private async Task LoadPackagesAsync()
{
try
{
_packages = await PackageService.GetAllPackagesAsync();
}
catch
{
_packages = new List<PackageDto>();
}
}
private async Task LoadPayoutsAsync()
{
try
@@ -51,8 +35,7 @@ public partial class CommissionDashboardPage : ComponentBase
weekDefinitionId,
_filterStatus,
_pageNumber,
_pageSize,
_filterPackageId
_pageSize
);
_payouts = result.Payouts;
_totalCount = result.TotalCount;
@@ -78,7 +61,6 @@ public partial class CommissionDashboardPage : ComponentBase
{
_selectedWeekDefinition = null;
_filterStatus = string.Empty;
_filterPackageId = null;
_pageNumber = 1;
await LoadPayoutsAsync();
}
@@ -12,7 +12,7 @@
<!-- انتخاب هفته -->
<MudPaper Elevation="2" Class="pa-3 rounded-lg">
<MudGrid Spacing="2">
<MudItem xs="8" sm="4" md="4">
<MudItem xs="8" sm="6" md="6">
<WeekSelector @ref="_weekSelector"
@bind-Value="_selectedWeekDefinition"
Label="انتخاب هفته"
@@ -21,15 +21,15 @@
Dense="false"
OnlyActive="true" />
</MudItem>
<MudItem xs="4" sm="4" md="4">
<MudSelect T="long?" @bind-Value="_filterPackageId" Label="پکیج" Variant="Variant.Outlined" Clearable="true">
<MudSelectItem T="long?" Value="@((long?)null)">همه پکیج‌ها</MudSelectItem>
@foreach (var pkg in _packages)
{
<MudSelectItem T="long?" Value="@((long?)pkg.Id)">@pkg.Title</MudSelectItem>
}
</MudSelect>
</MudItem>
@* <MudItem xs="4" sm="3" md="3"> *@
@* <MudButton Variant="Variant.Outlined" *@
@* OnClick="LoadCurrentWeekAsync" *@
@* StartIcon="@Icons.Material.Filled.Today" *@
@* FullWidth="true" *@
@* Style="height: 56px;"> *@
@* هفته جاری *@
@* </MudButton> *@
@* </MudItem> *@
<MudItem xs="4" sm="3" md="3" Class="pt-6">
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
@@ -66,12 +66,6 @@
<MudItem xs="6" sm="4">
<MudText Typo="Typo.caption" Class="mud-text-secondary">پایان: <strong>@_weeklyBalance.EndDatePersian</strong></MudText>
</MudItem>
@if (!string.IsNullOrEmpty(_weeklyBalance.PackageTitle))
{
<MudItem xs="12" sm="12">
<MudChip T="string" Color="Color.Info" Size="Size.Small" Icon="@Icons.Material.Filled.Inventory2">@_weeklyBalance.PackageTitle</MudChip>
</MudItem>
}
</MudGrid>
</MudPaper>
@@ -8,7 +8,6 @@ namespace FrontOffice.Main.Pages.Commission;
public partial class WeeklyBalancePage : ComponentBase
{
[Inject] private CommissionService CommissionService { get; set; } = default!;
[Inject] private PackageService PackageService { get; set; } = default!;
[Inject] private ISnackbar SnackbarService { get; set; } = default!;
[Parameter]
@@ -18,8 +17,6 @@ public partial class WeeklyBalancePage : ComponentBase
private WeekSelector? _weekSelector;
private WeekDefinitionDto? _selectedWeekDefinition;
private WeeklyBalanceDto? _weeklyBalance;
private List<PackageDto> _packages = new();
private long? _filterPackageId;
private bool _isLoading = true;
private bool _hasError = false;
@@ -37,8 +34,6 @@ public partial class WeeklyBalancePage : ComponentBase
{
if (firstRender)
{
await LoadPackagesAsync();
// Wait for WeekSelector to initialize and load its cached weeks
if (_weekSelector != null)
{
@@ -93,7 +88,7 @@ public partial class WeeklyBalancePage : ComponentBase
long? weekDefinitionId = _selectedWeekDefinition?.Id;
_weeklyBalance = await CommissionService.GetMyWeeklyBalanceAsync(weekDefinitionId, _filterPackageId);
_weeklyBalance = await CommissionService.GetMyWeeklyBalanceAsync(weekDefinitionId);
// Update week display name from selected definition if available
if (_selectedWeekDefinition != null && _weeklyBalance != null)
@@ -118,18 +113,6 @@ public partial class WeeklyBalancePage : ComponentBase
}
}
private async Task LoadPackagesAsync()
{
try
{
_packages = await PackageService.GetAllPackagesAsync();
}
catch
{
_packages = new();
}
}
private async Task LoadCurrentWeekAsync()
{
if (_weekSelector != null)
+10 -10
View File
@@ -1,5 +1,5 @@
@attribute [Route(RouteConstants.Contact.Index)]
@inject SitePageSettingsService PageSettingsService
@inject SitePageService SitePageService
<PageTitle>ارتباط با ما | کارا بازار سلامت</PageTitle>
@@ -133,7 +133,7 @@
<div>
<MudText Typo="Typo.body2" >آدرس</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary">
@(_settings?.Address ?? "کرج مهرویلا میدان مادر ساختمان بزرگمهر طبقه ۴ واحد ۱۶")
@(_contactAddress ?? "کرج مهرویلا میدان مادر ساختمان بزرگمهر طبقه ۴ واحد ۱۶")
</MudText>
</div>
</MudStack>
@@ -142,7 +142,7 @@
<MudIcon Icon="@Icons.Material.Filled.Phone" Color="Color.Success" Size="Size.Large" />
<div>
<MudText Typo="Typo.body2" >تلفن</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary">@(_settings?.Phone ?? "026-34233563")</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary">@(_contactPhone ?? "026-34233563")</MudText>
</div>
</MudStack>
@@ -150,7 +150,7 @@
<MudIcon Icon="@Icons.Material.Filled.Email" Color="Color.Info" Size="Size.Large" />
<div>
<MudText Typo="Typo.body2" >ایمیل</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary">@(_settings?.Email ?? "info@kbs1.co")</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary">@(_contactEmail ?? "info@kbs1.co")</MudText>
</div>
</MudStack>
@@ -159,7 +159,7 @@
<div>
<MudText Typo="Typo.body2" >ساعات کاری</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary">
@((MarkupString)(_settings?.WorkingHours ?? "شنبه تا پنج‌شنبه<br />۹ صبح تا ۶ عصر"))
@((MarkupString)(_contactHours ?? "شنبه تا پنج‌شنبه<br />۹ صبح تا ۶ عصر"))
</MudText>
</div>
</MudStack>
@@ -174,7 +174,7 @@
<MudButton Variant="Variant.Outlined"
Color="Color.Inherit"
StartIcon="@Icons.Custom.Brands.Telegram"
Href="@(_settings?.TelegramUrl ?? "https://t.me/kbs1")"
Href="@(_socialTelegram ?? "https://t.me/kbs1")"
Target="_blank"
FullWidth="true">
تلگرام
@@ -183,7 +183,7 @@
<MudButton Variant="Variant.Outlined"
Color="Color.Inherit"
StartIcon="@Icons.Custom.Brands.Instagram"
Href="@(_settings?.InstagramUrl ?? "https://instagram.com/kbs1")"
Href="@(_socialInstagram ?? "https://instagram.com/kbs1")"
Target="_blank"
FullWidth="true">
اینستاگرام
@@ -192,7 +192,7 @@
<MudButton Variant="Variant.Outlined"
Color="Color.Inherit"
StartIcon="@Icons.Custom.Brands.LinkedIn"
Href="@(_settings?.LinkedinUrl ?? "https://linkedin.com/company/kbs1")"
Href="@(_socialLinkedin ?? "https://linkedin.com/company/kbs1")"
Target="_blank"
FullWidth="true">
لینکدین
@@ -201,7 +201,7 @@
<MudButton Variant="Variant.Outlined"
Color="Color.Inherit"
StartIcon="@Icons.Custom.Brands.WhatsApp"
Href="@(_settings?.WhatsappUrl ?? "https://wa.me/989123456789")"
Href="@(_socialWhatsapp ?? "https://wa.me/989123456789")"
Target="_blank"
FullWidth="true">
واتس‌اپ
@@ -226,7 +226,7 @@
<MudIcon Icon="@Icons.Material.Filled.Map" Size="Size.Large" Style="color: white;" />
<MudText Typo="Typo.h6" Style="color: white;">نقشه موقعیت مکانی</MudText>
<MudText Typo="Typo.body2" Style="color: rgba(255,255,255,0.8);" Align="Align.Center">
@(_settings?.Address ?? "کرج مهرویلا میدان مادر ساختمان بزرگمهر طبقه ۴ واحد ۱۶")
@(_contactAddress ?? "کرج مهرویلا میدان مادر ساختمان بزرگمهر طبقه ۴ واحد ۱۶")
</MudText>
</MudStack>
</div>
+51 -14
View File
@@ -12,19 +12,52 @@ public partial class Contact
private bool _isSubmitting;
private readonly ContactFormValidator _contactFormValidator = new();
// Dynamic CMS data (new simplified system)
private PageSettingsDto? _page;
private ContactSettings? _settings;
// Dynamic CMS data
private SitePageDto? _page;
private string? _contactAddress;
private string? _contactPhone;
private string? _contactEmail;
private string? _contactHours;
private string? _socialTelegram;
private string? _socialInstagram;
private string? _socialLinkedin;
private string? _socialWhatsapp;
protected override async Task OnInitializedAsync()
{
try
{
_page = await PageSettingsService.GetPageAsync("contact");
_page = await SitePageService.GetByKeyAsync("contact");
if (_page != null)
{
_settings = _page.GetSettings<ContactSettings>();
// Load contact info from CMS section
var contactInfoSection = _page.GetSection("contact-info");
if (contactInfoSection != null)
{
var contactData = contactInfoSection.GetExtraData<ContactInfoData>();
if (contactData != null)
{
_contactAddress = contactData.Address;
_contactPhone = contactData.Phone;
_contactEmail = contactData.Email;
_contactHours = contactData.Hours;
}
}
// Load social media links from CMS section
var socialSection = _page.GetSection("social-media");
if (socialSection != null)
{
var socialData = socialSection.GetExtraData<SocialMediaData>();
if (socialData != null)
{
_socialTelegram = socialData.Telegram;
_socialInstagram = socialData.Instagram;
_socialLinkedin = socialData.Linkedin;
_socialWhatsapp = socialData.Whatsapp;
}
}
}
}
catch
@@ -78,12 +111,12 @@ public partial class Contact
private void CallSupport()
{
Snackbar.Add($"شماره تماس: {_settings?.Phone ?? "026-34233563"}", Severity.Info);
Snackbar.Add($"شماره تماس: {_contactPhone ?? "026-34233563"}", Severity.Info);
}
private void SendEmail()
{
Snackbar.Add($"ایمیل: {_settings?.Email ?? "info@kbs1.co"}", Severity.Info);
Snackbar.Add($"ایمیل: {_contactEmail ?? "info@kbs1.co"}", Severity.Info);
}
public class ContactForm
@@ -109,16 +142,20 @@ public partial class Contact
}
}
// DTO class for SettingsJson deserialization
private class ContactSettings
// DTO classes for ExtraData JSON deserialization
private class ContactInfoData
{
public string? Address { get; set; }
public string? Phone { get; set; }
public string? Email { get; set; }
public string? WorkingHours { get; set; }
public string? TelegramUrl { get; set; }
public string? InstagramUrl { get; set; }
public string? LinkedinUrl { get; set; }
public string? WhatsappUrl { get; set; }
public string? Hours { get; set; }
}
private class SocialMediaData
{
public string? Telegram { get; set; }
public string? Instagram { get; set; }
public string? Linkedin { get; set; }
public string? Whatsapp { get; set; }
}
}
@@ -23,9 +23,9 @@
<HeaderContent>
<MudTh>محصول</MudTh>
<MudTh>قیمت واحد</MudTh>
<MudTh>سقف اعتبار</MudTh>
<MudTh>سقف تخفیف</MudTh>
<MudTh>تعداد</MudTh>
<MudTh>اعتبار</MudTh>
<MudTh>تخفیف</MudTh>
<MudTh>قیمت نهایی</MudTh>
<MudTh></MudTh>
</HeaderContent>
@@ -33,14 +33,14 @@
<MudTd>
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<AppImage Path="@GetImageUrl(context.ImageUrl)" Alt="@context.Title"
ImgWidth="64" ImgHeight="64" Class="rounded-lg" ObjectFit="ObjectFit.Cover" />
ImgWidth="64" ImgHeight="64" Class="product-thumb" />
<MudText>@context.Title</MudText>
</MudStack>
</MudTd>
<MudTd>@FormatPrice(context.UnitPrice)</MudTd>
<MudTd>
<MudChip T="string" Color="Color.Error" Variant="Variant.Outlined" Size="Size.Small">
@context.MaxDiscountPercent% اعتبار
@context.MaxDiscountPercent% تخفیف
</MudChip>
</MudTd>
<MudTd>
@@ -82,11 +82,11 @@
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<AppImage Path="@GetImageUrl(item.ImageUrl)" Alt="@item.Title"
ImgWidth="50" ImgHeight="50" Class="rounded-lg" ObjectFit="ObjectFit.Cover" />
ImgWidth="50" ImgHeight="50" Class="rounded-circle" />
<MudText Typo="Typo.subtitle2">@item.Title</MudText>
</MudStack>
<MudChip T="string" Color="Color.Error" Variant="Variant.Outlined" Size="Size.Small">
@item.MaxDiscountPercent% اعتبار
@item.MaxDiscountPercent% تخفیف
</MudChip>
</MudStack>
@@ -107,7 +107,7 @@
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
@if (item.DiscountAmount > 0)
{
<MudText Typo="Typo.caption" Color="Color.Success">اعتبار: @FormatPrice(item.DiscountAmount)-</MudText>
<MudText Typo="Typo.caption" Color="Color.Success">تخفیف: @FormatPrice(item.DiscountAmount)-</MudText>
}
<MudText>جمع: @FormatPrice(item.FinalPrice)</MudText>
<MudIconButton Icon="@Icons.Material.Filled.Delete" Color="Color.Error"
@@ -131,20 +131,13 @@
@if (DiscountCart.TotalDiscount > 0)
{
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.caption" Color="Color.Success">سقف اعتبار قابل اعمال:</MudText>
<MudText Typo="Typo.caption" Color="Color.Success">سقف تخفیف قابل اعمال:</MudText>
<MudText Typo="Typo.caption" Color="Color.Success">@FormatPrice(DiscountCart.TotalDiscount)- تومان</MudText>
</MudStack>
}
@if (VAT.IsEnabled)
{
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.caption" Class="mud-text-secondary">مالیات ارزش افزوده (@VAT.VatPercentage%):</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary">@FormatPrice(VatAmount)+ تومان</MudText>
</MudStack>
}
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.h6" Class="fw-bold">حداقل پرداخت:</MudText>
<MudText Typo="Typo.h6" Color="Color.Primary" Class="fw-bold">@FormatPrice(TotalWithVat) تومان</MudText>
<MudText Typo="Typo.h6" Color="Color.Primary" Class="fw-bold">@FormatPrice(DiscountCart.FinalPrice) تومان</MudText>
</MudStack>
</MudStack>
@@ -168,21 +161,14 @@
@if (DiscountCart.TotalDiscount > 0)
{
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Success">سقف اعتبار قابل اعمال:</MudText>
<MudText Typo="Typo.body2" Color="Color.Success">سقف تخفیف قابل اعمال:</MudText>
<MudText Typo="Typo.body2" Color="Color.Success">@FormatPrice(DiscountCart.TotalDiscount)- تومان</MudText>
</MudStack>
}
@if (VAT.IsEnabled)
{
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Class="mud-text-secondary">مالیات بر ارزش افزوده (@VAT.VatPercentage%):</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary">@FormatPrice(VatAmount)+ تومان</MudText>
</MudStack>
}
<MudDivider Class="my-1" />
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.subtitle1" Class="fw-bold">حداقل مبلغ پرداخت درگاه:</MudText>
<MudText Typo="Typo.subtitle1" Color="Color.Primary" Class="fw-bold">@FormatPrice(TotalWithVat) تومان</MudText>
<MudText Typo="Typo.subtitle1" Color="Color.Primary" Class="fw-bold">@FormatPrice(DiscountCart.FinalPrice) تومان</MudText>
</MudStack>
<MudText Typo="Typo.caption" Class="mud-text-secondary">
مبلغ دقیق پرداخت در مرحله تسویه مشخص خواهد شد.
@@ -6,16 +6,9 @@ namespace FrontOffice.Main.Pages.DiscountStore;
public partial class Cart : IDisposable
{
[Inject] private DiscountCartService DiscountCart { get; set; } = default!;
[Inject] private VATService VAT { get; set; } = default!;
[Inject] private AuthDialogService AuthDialogService { get; set; } = default!;
[Inject] private AuthService AuthService { get; set; } = default!;
protected override async Task OnInitializedAsync()
{
if (!await AuthService.IsAuthenticatedAsync())
{
await AuthDialogService.ShowAuthDialogAsync();
}
await DiscountCart.EnsureInitializedAsync();
DiscountCart.OnChange += StateHasChanged;
}
@@ -51,18 +44,6 @@ public partial class Cart : IDisposable
Navigation.NavigateTo(RouteConstants.DiscountStore.Checkout);
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await VAT.LoadAsync();
StateHasChanged();
}
}
private long VatAmount => VAT.CalculateVAT(DiscountCart.FinalPrice);
private long TotalWithVat => DiscountCart.FinalPrice + VatAmount;
private static string FormatPrice(long price) => $"{price:N0} ";
private static string GetImageUrl(string? imageUrl)
@@ -22,11 +22,9 @@
else if (_addresses.Count == 0)
{
<MudAlert Severity="Severity.Warning">
هیچ آدرسی ثبت نشده است. می‌توانید همین‌جا آدرس جدید اضافه کنید.
هیچ آدرسی ثبت نشده است. لطفاً از بخش پروفایل آدرس خود را اضافه کنید.
</MudAlert>
<MudButton Class="mt-2" Variant="Variant.Outlined" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="OpenAddAddressDialog">افزودن آدرس</MudButton>
<MudButton Class="mt-2" Variant="Variant.Outlined" Href="/profile/addresses">افزودن آدرس</MudButton>
}
else
{
@@ -43,25 +41,13 @@
<MudText Typo="Typo.subtitle2">@address.Title</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary">@address.Address</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
@if (address.IsDefault)
{
<MudChip T="string" Color="Color.Success" Variant="Variant.Outlined" Size="Size.Small">پیش‌فرض</MudChip>
}
<span @onclick:stopPropagation="true">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Color="Color.Primary"
aria-label="ویرایش آدرس"
OnClick="@(() => OpenEditAddressDialog(address))" />
</span>
</MudStack>
@if (address.IsDefault)
{
<MudChip T="string" Color="Color.Success" Variant="Variant.Outlined">پیش‌فرض</MudChip>
}
</MudStack>
</MudPaper>
}
<MudButton Class="mt-2" Variant="Variant.Text" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="OpenAddAddressDialog">افزودن آدرس جدید</MudButton>
</MudStack>
}
</MudPaper>
@@ -94,7 +80,7 @@
<MudStack Spacing="1">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<AppImage Path="@GetImageUrl(item.ImageUrl)" Alt="@item.Title"
ImgWidth="40" ImgHeight="40" Class="rounded-lg" ObjectFit="ObjectFit.Cover" />
ImgWidth="40" ImgHeight="40" Class="rounded-circle" />
<MudText Typo="Typo.subtitle2" Style="flex:1; margin:0 8px;">@item.Title</MudText>
<MudText Typo="Typo.subtitle2">@FormatPrice(item.FinalPrice)</MudText>
</MudStack>
@@ -104,7 +90,7 @@
</MudText>
@if (item.MaxDiscountPercent > 0)
{
<MudText Typo="Typo.caption" Color="Color.Error">@item.MaxDiscountPercent% اعتبار</MudText>
<MudText Typo="Typo.caption" Color="Color.Error">@item.MaxDiscountPercent% تخفیف</MudText>
}
</MudStack>
</MudStack>
@@ -122,17 +108,17 @@
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Success">اعتبار از کیف اعتباری:</MudText>
<MudText Typo="Typo.body2" Color="Color.Success">تخفیف از کیف اعتباری:</MudText>
<MudText Typo="Typo.body2" Color="Color.Success">@FormatPrice(DiscountCart.TotalDiscount)- تومان</MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2">مبلغ پس از اعتبار:</MudText>
<MudText Typo="Typo.body2">مبلغ پس از تخفیف:</MudText>
<MudText Typo="Typo.body2">@FormatPrice(NetGatewayAmount) تومان</MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Class="mud-text-secondary">مالیات بر ارزش افزوده (@VAT.VatPercentage٪):</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary">مالیات بر ارزش افزوده (۹٪):</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary">@FormatPrice(VatAmount)+ تومان</MudText>
</MudStack>
@@ -1,6 +1,5 @@
using Microsoft.AspNetCore.Components;
using CMSMicroservice.Protobuf.Protos.UserAddress;
using FrontOffice.Main.Pages.Profile.Components;
using FrontOffice.Main.Utilities;
using MudBlazor;
@@ -11,9 +10,6 @@ public partial class Checkout
[Inject] private DiscountCartService DiscountCart { get; set; } = default!;
[Inject] private DiscountOrderService DiscountOrderService { get; set; } = default!;
[Inject] private UserAddressContract.UserAddressContractClient UserAddressContract { get; set; } = default!;
[Inject] private VATService VAT { get; set; } = default!;
[Inject] private AuthDialogService AuthDialogService { get; set; } = default!;
[Inject] private AuthService AuthService { get; set; } = default!;
private List<CustomerAddressModel> _addresses = new();
private CustomerAddressModel? _selectedAddress;
@@ -22,11 +18,13 @@ public partial class Checkout
private string? _notes;
private bool _placing;
/// <summary>مبلغ درگاه قبل از مالیات (جمع کل - اعتبار)</summary>
private const decimal VAT_RATE = 0.09m;
/// <summary>مبلغ درگاه قبل از مالیات (جمع کل - تخفیف)</summary>
private long NetGatewayAmount => DiscountCart.TotalPrice - DiscountCart.TotalDiscount;
/// <summary>مالیات بر ارزش افزوده</summary>
private long VatAmount => VAT.CalculateVAT(NetGatewayAmount);
/// <summary>مالیات بر ارزش افزوده ۹٪</summary>
private long VatAmount => (long)(NetGatewayAmount * VAT_RATE);
/// <summary>مبلغ نهایی قابل پرداخت (شامل VAT)</summary>
private long FinalGatewayAmount => NetGatewayAmount + VatAmount;
@@ -35,17 +33,8 @@ public partial class Checkout
protected override async Task OnInitializedAsync()
{
if (!await AuthService.IsAuthenticatedAsync())
{
await AuthDialogService.ShowAuthDialogAsync();
}
await VAT.LoadAsync();
await DiscountCart.EnsureInitializedAsync();
var userInfo = await AuthService.GetUserAuthInfo();
if (userInfo.HasAddress)
await LoadAddresses();
else
_addresses = new();
await LoadAddresses();
}
private async Task LoadAddresses()
@@ -62,14 +51,12 @@ public partial class Checkout
else
{
_addresses = new();
_selectedAddress = null;
}
}
catch (Exception ex)
{
Snackbar.Add($"خطا در بارگذاری آدرس‌ها: {ex.Message}", Severity.Error);
_addresses = new();
_selectedAddress = null;
}
finally
{
@@ -78,30 +65,6 @@ public partial class Checkout
}
}
private async Task OpenAddAddressDialog()
{
var dialog = await DialogService.ShowAsync<AddAddressDialog>("افزودن آدرس جدید");
var result = await dialog.Result;
if (result is { Canceled: false })
{
await AuthService.RefreshTokenAsync();
await LoadAddresses();
}
}
private async Task OpenEditAddressDialog(CustomerAddressModel address)
{
var dialog = await DialogService.ShowAsync<EditAddressDialog>("ویرایش آدرس", new DialogParameters<EditAddressDialog>
{
{ x => x.Model, address }
});
var result = await dialog.Result;
if (result is { Canceled: false })
{
await LoadAddresses();
}
}
private async Task PlaceOrder()
{
if (!CanPlaceOrder || _selectedAddress is null)
@@ -82,8 +82,8 @@ else
<MudTh>محصول</MudTh>
<MudTh>قیمت واحد</MudTh>
<MudTh>تعداد</MudTh>
<MudTh>سقف اعتبار</MudTh>
<MudTh>اعتبار</MudTh>
<MudTh>سقف تخفیف</MudTh>
<MudTh>تخفیف</MudTh>
<MudTh>قیمت نهایی</MudTh>
</HeaderContent>
<RowTemplate>
@@ -131,7 +131,7 @@ else
@if (item.DiscountAmount > 0)
{
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Success">اعتبار (@item.MaxDiscountPercent%):</MudText>
<MudText Typo="Typo.body2" Color="Color.Success">تخفیف (@item.MaxDiscountPercent%):</MudText>
<MudText Typo="Typo.body2" Color="Color.Success">@FormatPrice(item.DiscountAmount)-</MudText>
</MudStack>
}
@@ -163,14 +163,6 @@ else
</MudStack>
}
@if (VAT.IsEnabled)
{
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Class="mud-text-secondary">مالیات بر ارزش افزوده (@VAT.VatPercentage%):</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary">@FormatPrice(OrderVatAmount)+</MudText>
</MudStack>
}
<MudDivider Class="my-1" />
<MudStack Row="true" Justify="Justify.SpaceBetween">
@@ -12,28 +12,12 @@ public partial class OrderDetail
public string? PaymentResult { get; set; }
[Inject] private DiscountOrderService DiscountOrderService { get; set; } = default!;
[Inject] private VATService VAT { get; set; } = default!;
private DiscountOrderDetail? _order;
private bool _loading = true;
private string? _paymentMessage;
private MudBlazor.Severity _paymentSeverity;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await VAT.LoadAsync();
StateHasChanged();
}
}
/// <summary>مبلغ قبل از مالیات (جمع کل - اعتبار)</summary>
private long PreVatGateway => _order is null ? 0 : (_order.TotalPrice - _order.DiscountBalanceUsed);
/// <summary>مالیات بر ارزش افزوده</summary>
private long OrderVatAmount => VAT.CalculateVAT(PreVatGateway);
protected override async Task OnInitializedAsync()
{
// نمایش پیام نتیجه پرداخت (بعد از بازگشت از درگاه)
@@ -24,8 +24,8 @@
<MudItem xs="12" md="5">
<MudPaper Class="pa-2 rounded-xl" Elevation="1">
<AppImage Path="@_selectedImage" Alt="@_product.Title"
ObjectFit="ObjectFit.Cover"
Style="width:100%; aspect-ratio:1/1; border-radius:12px;" />
ObjectFit="ObjectFit.Contain"
Style="width:100%; max-height:400px; border-radius:12px;" />
@if (_product.Images.Count > 1)
{
@@ -63,58 +63,25 @@
}
<!-- Price & Discount -->
@if (_isAuthenticated)
{
<MudPaper Class="pa-4 rounded-lg discount-price-box" Elevation="0">
<MudStack Spacing="2">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.body2" Class="mud-text-secondary">قیمت محصول:</MudText>
<MudText Typo="Typo.body1" Class="fw-bold">
@($"{_product.Price:N0}") تومان
</MudText>
</MudStack>
@if (VAT.IsEnabled)
{
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.body2" Class="mud-text-secondary">مالیات ارزش افزوده (@VAT.VatPercentage%):</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary">
@($"{VAT.CalculateVAT(_product.Price):N0}") تومان
</MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.body2" Class="mud-text-secondary">قیمت با مالیات:</MudText>
<MudText Typo="Typo.h5" Color="Color.Primary" Class="fw-bold">
@($"{VAT.AddVAT(_product.Price):N0}") تومان
</MudText>
</MudStack>
}
else
{
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.body2" Class="mud-text-secondary">قیمت نهایی:</MudText>
<MudText Typo="Typo.h5" Color="Color.Primary" Class="fw-bold">
@($"{_product.Price:N0}") تومان
</MudText>
</MudStack>
}
@if (_product.MaxDiscountPercent > 0)
{
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.body2" Class="mud-text-secondary">اعتبار از کیف اعتباری:</MudText>
<MudChip T="string" Color="Color.Error" Variant="Variant.Filled" Size="Size.Small">
@_product.MaxDiscountPercent% (@($"{_product.Price * _product.MaxDiscountPercent / 100:N0}") تومان)
</MudChip>
</MudStack>
}
<MudPaper Class="pa-4 rounded-lg discount-price-box" Elevation="0">
<MudStack Spacing="2">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.body2" Class="mud-text-secondary">قیمت محصول:</MudText>
<MudText Typo="Typo.h5" Color="Color.Primary" Class="fw-bold">
@($"{_product.Price:N0}") تومان
</MudText>
</MudStack>
</MudPaper>
}
else
{
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Icon="@Icons.Material.Filled.Lock">
برای مشاهده قیمت ابتدا وارد شوید
</MudAlert>
}
@if (_product.MaxDiscountPercent > 0)
{
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.body2" Class="mud-text-secondary">تخفیف از کیف اعتباری:</MudText>
<MudChip T="string" Color="Color.Error" Variant="Variant.Filled" Size="Size.Small">
@_product.MaxDiscountPercent% (@($"{_product.Price * _product.MaxDiscountPercent / 100:N0}") تومان)
</MudChip>
</MudStack>
}
</MudStack>
</MudPaper>
<!-- Stock & Stats -->
<MudStack Row="true" Spacing="3" Class="flex-wrap">
@@ -9,11 +9,6 @@ public partial class ProductDetail
[Inject] private DiscountProductService DiscountProductService { get; set; } = default!;
[Inject] private DiscountCartService DiscountCartService { get; set; } = default!;
[Inject] private VATService VAT { get; set; } = default!;
[Inject] private GuestActionGate GuestGate { get; set; } = default!;
[Inject] private AuthService AuthService { get; set; } = default!;
private bool _isAuthenticated;
private DiscountProductDetail? _product;
private string _selectedImage = string.Empty;
@@ -23,19 +18,9 @@ public partial class ProductDetail
protected override async Task OnInitializedAsync()
{
_isAuthenticated = await AuthService.IsAuthenticatedAsync();
await LoadProductAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await VAT.LoadAsync();
StateHasChanged();
}
}
private async Task LoadProductAsync()
{
_loading = true;
@@ -64,14 +49,8 @@ public partial class ProductDetail
_addingToCart = true;
try
{
var productId = _product.Id;
var qty = _quantity;
var title = _product.Title;
await GuestGate.RunAsync(async () =>
{
await DiscountCartService.AddAsync(productId, qty);
Snackbar.Add($"{title} به سبد خرید اضافه شد", MudBlazor.Severity.Success);
});
await DiscountCartService.AddAsync(_product.Id, _quantity);
Snackbar.Add($"{_product.Title} به سبد خرید اضافه شد", MudBlazor.Severity.Success);
}
catch
{
@@ -64,7 +64,7 @@
<MudText Class="mt-2 mud-text-secondary">در حال بارگذاری...</MudText>
</MudStack>
}
else if (_products.Count == 0)
else if (_result.Products.Count == 0)
{
<MudAlert Severity="Severity.Info" Class="my-4">محصولی یافت نشد.</MudAlert>
}
@@ -72,15 +72,14 @@
{
<!-- Products Grid -->
<MudGrid Spacing="1">
@foreach (var p in _products)
@foreach (var p in _result.Products)
{
<MudItem xs="6" sm="6" md="3"
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"
Style="cursor:pointer;">
Style="cursor:pointer;height: 300px">
<MudCardContent Class="d-flex flex-column pa-1 h-100">
<div style="aspect-ratio:1/1;width:100%;background-image: url('@(string.IsNullOrWhiteSpace(p.ThumbnailUrl) ? "/images/product-placeholder.svg" : p.ThumbnailUrl)');background-size: cover; background-position: center;border-radius: 0.5rem; position: relative;">
<div style="height: 60%;background-image: url('@(string.IsNullOrWhiteSpace(p.ThumbnailUrl) ? "/images/product-placeholder.svg" : p.ThumbnailUrl)');background-size: cover; background-position: center;border-radius: 0.5rem; position: relative;">
@if (p.RemainingCount <= 0)
{
<div style="position:absolute;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;border-radius:0.5rem;">
@@ -98,25 +97,13 @@
{
<MudChip T="string" Color="Color.Error" Variant="Variant.Filled" Size="Size.Small"
Style="position:absolute;top:8px;left:8px;">
@p.MaxDiscountPercent% اعتبار
@p.MaxDiscountPercent% تخفیف
</MudChip>
}
</div>
<div class="pa-1 flex-grow-1 d-flex flex-column justify-space-between">
<MudText Typo="Typo.subtitle1">@p.Title</MudText>
@if (_isAuthenticated)
{
<div>
<MudText Typo="Typo.subtitle2" Color="Color.Primary">@FormatPrice(p.Price)</MudText>
<MudText Typo="Typo.overline" Class="mud-text-secondary" Style="font-size:0.6rem;line-height:1;">(+ ارزش افزوده)</MudText>
</div>
}
else
{
<MudText Typo="Typo.caption" Class="mud-text-secondary">
<MudIcon Icon="@Icons.Material.Filled.Lock" Size="Size.Small" Class="me-1"/>برای مشاهده قیمت وارد شوید
</MudText>
}
<MudText Typo="Typo.subtitle2" Color="Color.Primary">@FormatPrice(p.Price)</MudText>
</div>
</MudCardContent>
<MudCardActions Class="mt-auto d-flex justify-space-between pa-2">
@@ -128,34 +115,19 @@
</MudButton>
</MudCardActions>
</MudCard>
</div>
</MudItem>
}
</MudGrid>
@* Lazy Load — بارگذاری بیشتر *@
@if (_loadingMore)
<!-- Pagination -->
@if (_result.TotalPages > 1)
{
<MudStack AlignItems="AlignItems.Center" Class="py-4">
<MudProgressCircular Color="Color.Primary" Size="Size.Small" Indeterminate="true"/>
<MudText Typo="Typo.caption" Class="mud-text-secondary">بارگذاری بیشتر...</MudText>
</MudStack>
}
else if (_hasMore)
{
<div class="d-flex justify-center py-4">
<MudButton Variant="Variant.Outlined" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.ExpandMore"
OnClick="LoadMore">
نمایش محصولات بیشتر
</MudButton>
<div class="d-flex justify-center mt-4">
<MudPagination Count="@_result.TotalPages" Selected="@_currentPage"
SelectedChanged="OnPageChanged"
Color="Color.Primary" Variant="Variant.Filled"
BoundaryCount="1" MiddleCount="3" />
</div>
}
else if (_products.Count > 0)
{
<MudText Typo="Typo.caption" Align="Align.Center" Class="py-4 mud-text-secondary">
همه @(_products.Count) محصول نمایش داده شد
</MudText>
}
}
</MudContainer>
@@ -1,7 +1,5 @@
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
using FrontOffice.Main.Utilities;
namespace FrontOffice.Main.Pages.DiscountStore;
@@ -10,203 +8,80 @@ public partial class Products : ComponentBase, IDisposable
{
[Inject] private DiscountProductService ProductService { get; set; } = default!;
[Inject] private DiscountCartService DiscountCart { get; set; } = default!;
[Inject] private GuestActionGate GuestGate { get; set; } = default!;
[Inject] private AuthService AuthService { get; set; } = default!;
[Inject] private IJSRuntime Js { get; set; } = default!;
private bool _isAuthenticated;
private string _search = string.Empty;
private long? _selectedCategoryId;
private int _currentPage = 1;
private bool _loading;
private bool _loadingMore;
private bool _hasMore = true;
private int _totalCount;
private const int PageSize = 12;
private const string DefaultSortBy = "price desc";
private List<DiscountProductCard> _products = new();
private DiscountProductListResult _result = new(new(), 0, 0, 1);
private List<DiscountCategoryNode> _categories = new();
private bool _ignoreNextLocationChange;
private bool _pendingScrollRestore;
protected override async Task OnInitializedAsync()
{
_isAuthenticated = await AuthService.IsAuthenticatedAsync();
_loading = true;
await DiscountCart.EnsureInitializedAsync();
DiscountCart.OnChange += StateHasChanged;
Navigation.LocationChanged += HandleLocationChanged;
_categories = await ProductService.GetCategoriesAsync();
ApplyStateFromUri();
_loading = true;
await LoadPages(_currentPage);
var categoriesTask = ProductService.GetCategoriesAsync();
var productsTask = ProductService.GetProductsAsync(page: 1, pageSize: PageSize);
await Task.WhenAll(categoriesTask, productsTask);
_categories = categoriesTask.Result;
_result = productsTask.Result;
_loading = false;
_pendingScrollRestore = true;
}
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()
private async Task LoadProductsAsync()
{
_loading = true;
_currentPage = 1;
StateHasChanged();
await LoadPages(1);
SyncUrl();
_loading = false;
StateHasChanged();
}
private async Task LoadMore()
{
if (_loadingMore || !_hasMore) return;
_loadingMore = true;
StateHasChanged();
_currentPage++;
var result = await ProductService.GetProductsAsync(
_result = await ProductService.GetProductsAsync(
page: _currentPage,
pageSize: PageSize,
search: string.IsNullOrWhiteSpace(_search) ? null : _search,
categoryId: _selectedCategoryId,
sortBy: DefaultSortBy);
_products.AddRange(result.Products);
_hasMore = result.CurrentPage < result.TotalPages;
SyncUrl();
_loadingMore = false;
categoryId: _selectedCategoryId);
_loading = false;
StateHasChanged();
}
private async Task SearchProducts() => await ReloadFromFilters();
private async Task SearchProducts() => await LoadProductsAsync();
private async Task OnSearchKeyUp(KeyboardEventArgs e)
{
if (e.Key == "Enter")
await ReloadFromFilters();
{
_currentPage = 1;
await LoadProductsAsync();
}
}
private async Task OnCategoryChanged(long? value)
{
_selectedCategoryId = value;
await ReloadFromFilters();
_currentPage = 1;
await LoadProductsAsync();
}
private async Task OnPageChanged(int page)
{
_currentPage = page;
await LoadProductsAsync();
}
private async Task AddToCart(DiscountProductCard p)
{
await GuestGate.RunAsync(() => DiscountCart.AddAsync(p.Id));
await DiscountCart.AddAsync(p.Id);
}
private async Task NavigateToProduct(long id)
private void NavigateToProduct(long id)
{
await ShopListScrollRestore.SaveAsync(Js, ShopListScrollRestore.DiscountKey, 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} تومان";
public void Dispose()
{
DiscountCart.OnChange -= StateHasChanged;
Navigation.LocationChanged -= HandleLocationChanged;
}
}
+1 -1
View File
@@ -55,7 +55,7 @@ public partial class FAQ
Icon = Icons.Material.Filled.Build,
Questions = new List<FAQQuestion>
{
new() { Question = "سازمان فروش چگونه کار می‌کند؟", Answer = "سازمان فروش بصری نمایش سلسله مراتبی تیم شما را نشان می‌دهد و امکان ردیابی روابط ارجاعی را فراهم می‌کند." },
new() { Question = "شجره‌نامه چگونه کار می‌کند؟", Answer = "شجره‌نامه بصری نمایش سلسله مراتبی تیم شما را نشان می‌دهد و امکان ردیابی روابط ارجاعی را فراهم می‌کند." },
new() { Question = "گزارش‌گیری به چه صورت است؟", Answer = "سیستم گزارش‌های جامع مالی، عملکردی و آماری ارائه می‌دهد که قابل فیلتر و دانلود به فرمت Excel است." },
new() { Question = "آیا از موبایل قابل استفاده است؟", Answer = "بله، اپلیکیشن کاملاً responsive است و تجربه کاربری عالی در موبایل و تبلت ارائه می‌دهد." }
}
+12 -188
View File
@@ -11,15 +11,15 @@
<MudStack AlignItems="AlignItems.Center" Spacing="4" Class="text-center">
<MudChip T="string" Color="Color.Default" Variant="Variant.Filled"
Class="pulse-chip hero-chip-glass" Size="Size.Small">
باشگاه مشتریان KBS کارا بازار سلامت
🌿 پلتفرم سلامت‌محور فروش و تیم‌سازی
</MudChip>
<MudText Typo="Typo.h1" Class="hero-title" Style="max-width:680px;">
@(_pageData?.HeroTitle ?? "رشد تیم، فروش واقعی، پاداش شفاف")
رشد تیم، فروش واقعی، پاداش شفاف
</MudText>
<MudText Typo="Typo.body1" Class="hero-subtitle" Style="max-width:520px;">
@(_pageData?.HeroSubtitle ?? "با دعوت از دوستان، از خریدهای واقعی محصولات سلامت پاداش بگیرید. ثبت‌نام سریع، داشبورد لحظه‌ای.")
با دعوت از دوستان، از خریدهای واقعی محصولات سلامت پاداش بگیرید. ثبت‌نام سریع، داشبورد لحظه‌ای.
</MudText>
<MudStack Row="true" Spacing="2" Class="mt-2 flex-wrap" Justify="Justify.Center">
@@ -27,13 +27,13 @@
Class="rounded-pill hero-cta-primary"
Size="Size.Large"
OnClick="NavigateToRegistrationWizard">
@(_settings?.HeroButtonPrimaryText ?? "شروع رایگان")
شروع رایگان
</MudButton>
<MudButton Variant="Variant.Outlined"
Class="rounded-pill hero-cta-outline"
Size="Size.Large"
OnClick="@(() => Navigation.NavigateTo("/blog"))">
@(_settings?.HeroButtonSecondaryText ?? "آخرین اخبار")
آخرین اخبار
</MudButton>
</MudStack>
@@ -99,7 +99,7 @@
<section class="section-landing">
<MudContainer MaxWidth="MaxWidth.Large">
<div class="text-center mb-8 fade-in-up">
<MudText Typo="Typo.h3">@(_settings?.StepsTitle ?? "سه گام تا شروع")</MudText>
<MudText Typo="Typo.h3">سه گام تا شروع</MudText>
<MudText Typo="Typo.body1" Class="mud-text-secondary mt-2">
بدون پیچیدگی، سریع و آسان
</MudText>
@@ -128,7 +128,7 @@
<section class="section-landing" style="background:var(--mud-palette-background-gray);">
<MudContainer MaxWidth="MaxWidth.Large">
<div class="text-center mb-8 fade-in-up">
<MudText Typo="Typo.h3">@(_settings?.FeaturesTitle ?? "چرا کارا بازار سلامت؟")</MudText>
<MudText Typo="Typo.h3">چرا کارا بازار سلامت؟</MudText>
<MudText Typo="Typo.body1" Class="mud-text-secondary mt-2" Style="max-width:540px;margin:0 auto;">
ابزارهایی ساده و قدرتمند برای رشد کسب‌وکار شما
</MudText>
@@ -172,179 +172,6 @@
</MudContainer>
</section>
@* ═══════════════════════════════════════════════
1c. TOP-SELLING REGULAR PRODUCTS
═══════════════════════════════════════════════ *@
@if (_loadingTopProducts)
{
<section class="section-landing" style="background:var(--mud-palette-background-gray);">
<MudContainer MaxWidth="MaxWidth.Large">
<MudStack AlignItems="AlignItems.Center" Class="py-4">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" Size="Size.Small" />
</MudStack>
</MudContainer>
</section>
}
else
{
@if (_topRegularProducts.Any())
{
<section class="section-landing" style="background:var(--mud-palette-background-gray);">
<MudContainer MaxWidth="MaxWidth.Large">
<div class="text-center mb-6 fade-in-up">
<MudText Typo="Typo.h3">محصولات پرفروش فروشگاه</MudText>
<MudText Typo="Typo.body1" Class="mud-text-secondary mt-2">
محبوب‌ترین محصولات کارا بازار سلامت
</MudText>
</div>
<MudGrid Spacing="2" Justify="Justify.FlexStart">
@foreach (var p in _topRegularProducts)
{
<MudItem xs="6" sm="6" md="4">
<MudCard Class="rounded-lg h-100 d-flex flex-column overflow-hidden landing-product-card"
Style="cursor:pointer;"
@onclick="() => NavigateToRegularProduct(p.Id)">
<MudCardContent Class="d-flex flex-column pa-1 h-100">
<div style="aspect-ratio:1/1;width:100%;background-image:url('@p.ImageUrl');background-size:cover;background-position:center;border-radius:0.5rem;position:relative;">
@if (p.RemainingCount <= 0)
{
<div style="position:absolute;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;border-radius:0.5rem;">
<MudChip T="string" Color="Color.Error" Variant="Variant.Filled" Size="Size.Small">ناموجود</MudChip>
</div>
}
else if (p.RemainingCount <= 5)
{
<MudChip T="string" Color="Color.Warning" Variant="Variant.Filled" Size="Size.Small"
Style="position:absolute;top:8px;right:8px;">
فقط @p.RemainingCount عدد
</MudChip>
}
</div>
<div class="pa-1 flex-grow-1 d-flex flex-column justify-space-between">
<MudText Typo="Typo.subtitle1" Style="display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">@p.Title</MudText>
@if (_isAuthenticated)
{
<MudText Typo="Typo.subtitle2" Color="Color.Primary">@FormatPrice(p.Price)</MudText>
}
else
{
<MudText Typo="Typo.caption" Class="mud-text-secondary">
<MudIcon Icon="@Icons.Material.Filled.Lock" Size="Size.Small" Class="me-1"/>برای مشاهده قیمت وارد شوید
</MudText>
}
</div>
</MudCardContent>
<MudCardActions Class="mt-auto pa-2" @onclick:stopPropagation="true">
<MudButton Variant="Variant.Filled" Color="Color.Primary" FullWidth="true"
StartIcon="@Icons.Material.Filled.AddShoppingCart"
Disabled="@(p.RemainingCount <= 0)"
OnClick="@(() => AddRegularToCart(p))">
@(p.RemainingCount <= 0 ? "ناموجود" : "افزودن به سبد")
</MudButton>
</MudCardActions>
</MudCard>
</MudItem>
}
</MudGrid>
<div class="text-center mt-6">
<MudButton Variant="Variant.Outlined" Color="Color.Primary" Class="rounded-pill"
Href="@RouteConstants.Store.Products"
EndIcon="@Icons.Material.Filled.ArrowBack">
مشاهده همه محصولات
</MudButton>
</div>
</MudContainer>
</section>
}
@* ═══════════════════════════════════════════════
1d. TOP-SELLING DISCOUNT STORE PRODUCTS
═══════════════════════════════════════════════ *@
@if (_topDiscountProducts.Any())
{
<section class="section-landing">
<MudContainer MaxWidth="MaxWidth.Large">
<div class="text-center mb-6 fade-in-up">
<MudText Typo="Typo.h3">محصولات پرفروش فروشگاه اعتباری</MudText>
<MudText Typo="Typo.body1" Class="mud-text-secondary mt-2">
محصولات ویژه اعضای باشگاه با پرداخت اعتباری
</MudText>
</div>
<MudGrid Spacing="2" Justify="Justify.FlexStart">
@foreach (var p in _topDiscountProducts)
{
<MudItem xs="6" sm="6" md="4">
<MudCard Class="rounded-lg h-100 d-flex flex-column overflow-hidden landing-product-card"
Style="cursor:pointer;"
@onclick="() => NavigateToDiscountProduct(p.Id)">
<MudCardContent Class="d-flex flex-column pa-1 h-100">
<div style="aspect-ratio:1/1;width:100%;background-image:url('@(string.IsNullOrWhiteSpace(p.ThumbnailUrl) ? p.ImageUrl : p.ThumbnailUrl)');background-size:cover;background-position:center;border-radius:0.5rem;position:relative;">
@if (p.RemainingCount <= 0)
{
<div style="position:absolute;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;border-radius:0.5rem;">
<MudChip T="string" Color="Color.Error" Variant="Variant.Filled" Size="Size.Small">ناموجود</MudChip>
</div>
}
else if (p.RemainingCount <= 5)
{
<MudChip T="string" Color="Color.Warning" Variant="Variant.Filled" Size="Size.Small"
Style="position:absolute;top:8px;right:8px;">
فقط @p.RemainingCount عدد
</MudChip>
}
@if (p.MaxDiscountPercent > 0)
{
<MudChip T="string" Color="Color.Secondary" Variant="Variant.Filled" Size="Size.Small"
Style="position:absolute;top:8px;left:8px;">
@p.MaxDiscountPercent% اعتبار
</MudChip>
}
</div>
<div class="pa-1 flex-grow-1 d-flex flex-column justify-space-between">
<MudText Typo="Typo.subtitle1" Style="display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">@p.Title</MudText>
@if (_isAuthenticated)
{
<div>
<MudText Typo="Typo.subtitle2" Color="Color.Primary">@FormatDiscountPrice(p.Price)</MudText>
<MudText Typo="Typo.overline" Class="mud-text-secondary" Style="font-size:0.6rem;line-height:1;">(+ ارزش افزوده)</MudText>
</div>
}
else
{
<MudText Typo="Typo.caption" Class="mud-text-secondary">
<MudIcon Icon="@Icons.Material.Filled.Lock" Size="Size.Small" Class="me-1"/>برای مشاهده قیمت وارد شوید
</MudText>
}
</div>
</MudCardContent>
<MudCardActions Class="mt-auto pa-2" @onclick:stopPropagation="true">
<MudButton Variant="Variant.Filled" Color="Color.Secondary" FullWidth="true"
StartIcon="@Icons.Material.Filled.AddShoppingCart"
Disabled="@(p.RemainingCount <= 0)"
OnClick="@(() => AddDiscountToCart(p))">
@(p.RemainingCount <= 0 ? "ناموجود" : "افزودن به سبد")
</MudButton>
</MudCardActions>
</MudCard>
</MudItem>
}
</MudGrid>
<div class="text-center mt-6">
<MudButton Variant="Variant.Outlined" Color="Color.Secondary" Class="rounded-pill"
Href="@RouteConstants.DiscountStore.Products"
EndIcon="@Icons.Material.Filled.ArrowBack">
مشاهده همه محصولات اعتباری
</MudButton>
</div>
</MudContainer>
</section>
}
}
@* ═══════════════════════════════════════════════
5. LATEST BLOG POSTS
═══════════════════════════════════════════════ *@
@@ -429,12 +256,10 @@ else
@* ═══════════════════════════════════════════════
6. TESTIMONIALS
═══════════════════════════════════════════════ *@
@if (_testimonials.Count > 0)
{
<section class="section-landing">
<MudContainer MaxWidth="MaxWidth.Large">
<div class="text-center mb-8 fade-in-up">
<MudText Typo="Typo.h3">@(_settings?.TestimonialsTitle ?? "اعتماد مشتریان")</MudText>
<MudText Typo="Typo.h3">اعتماد مشتریان</MudText>
<MudText Typo="Typo.body1" Class="mud-text-secondary mt-2">
بخشی از تجربه استفاده از «کارا بازار سلامت»
</MudText>
@@ -467,7 +292,6 @@ else
</MudGrid>
</MudContainer>
</section>
}
@* ═══════════════════════════════════════════════
7. FAQ
@@ -475,7 +299,7 @@ else
<section class="section-landing" style="background:var(--mud-palette-background-gray);">
<MudContainer MaxWidth="MaxWidth.Medium">
<div class="text-center mb-8 fade-in-up">
<MudText Typo="Typo.h3">@(_settings?.FaqTitle ?? "سوالات متداول")</MudText>
<MudText Typo="Typo.h3">سوالات متداول</MudText>
<MudText Typo="Typo.body1" Class="mud-text-secondary mt-2">
پاسخ به سوالات رایج شما
</MudText>
@@ -502,16 +326,16 @@ else
<div class="cta-banner pa-8 pa-md-12 fade-in-up">
<MudStack AlignItems="AlignItems.Center" Spacing="3">
<MudText Typo="Typo.h3" Align="Align.Center" Class="dash-hero-name">
@(_settings?.CtaTitle ?? "آماده شروع هستید؟")
آماده شروع هستید؟
</MudText>
<MudText Typo="Typo.body1" Align="Align.Center" Class="hero-subtitle">
@(_settings?.CtaDescription ?? "همین الان ثبت‌نام کنید و از مزایای کارا بازار سلامت بهره‌مند شوید.")
همین الان ثبت‌نام کنید و از مزایای کارا بازار سلامت بهره‌مند شوید.
</MudText>
<MudButton Variant="Variant.Filled"
Size="Size.Large"
Class="rounded-pill mt-2 hero-cta-primary"
OnClick="NavigateToRegistrationWizard">
@(_settings?.CtaButtonText ?? "شروع رایگان")
شروع رایگان
</MudButton>
</MudStack>
</div>
+59 -269
View File
@@ -9,88 +9,21 @@ public partial class Index : IDisposable
{
[Inject] private BlogPostService BlogPostService { get; set; } = default!;
[Inject] private AuthService AuthService { get; set; } = default!;
[Inject] private SitePageSettingsService PageSettingsService { get; set; } = default!;
[Inject] private ProductService ProductService { get; set; } = default!;
[Inject] private DiscountProductService DiscountProductService { get; set; } = default!;
[Inject] private CartService Cart { get; set; } = default!;
[Inject] private DiscountCartService DiscountCart { get; set; } = default!;
[Inject] private GuestActionGate GuestGate { get; set; } = default!;
[Inject] private VATService VAT { get; set; } = default!;
private bool _isAuthenticated;
// ── CMS page data ──
private PageSettingsDto? _pageData;
private LandingSettings? _settings;
// ── Top-selling product sections ──
private const int LandingTopProductCount = 6;
private List<Product> _topRegularProducts = new();
private List<DiscountProductCard> _topDiscountProducts = new();
private bool _loadingTopProducts = true;
// ── Latest blog posts (loaded from CMS) ──
private List<BlogPostCardDto> _latestPosts = new();
// ── Data lists (populated from DB or fallback) ──
private List<(string Icon, string Text)> _trustBadges = new();
private List<(string Title, string Desc)> _steps = new();
private List<(string Icon, string Title, string Desc, int Delay)> _features = new();
private List<StatItem> _stats = new();
private List<TestimonialItem> _testimonials = new();
private List<QA> _faqs = new();
// Track whether animations need re-initialization after data load
private bool _dataLoaded;
private bool _animationsInitialized;
protected override async Task OnInitializedAsync()
{
MainService.OnChangeHandler += OnStateChanged;
_isAuthenticated = await AuthService.IsAuthenticatedAsync();
// Load landing page settings from CMS
try
{
_pageData = await PageSettingsService.GetPageAsync("landing");
if (_pageData != null)
{
_settings = _pageData.GetSettings<LandingSettings>();
}
}
catch
{
// Fallback: settings remain null → defaults below
}
PopulateFromSettings();
_dataLoaded = true;
// Load top-selling in-stock products and blog posts in parallel
var topRegTask = ProductService.GetTopSellingAsync(LandingTopProductCount, inStock: true);
var topDiscTask = DiscountProductService.GetTopSellingAsync(LandingTopProductCount, inStock: true);
var featuredPostsTask = BlogPostService.GetFeaturedPostsAsync(2);
try
{
await Task.WhenAll(topRegTask, topDiscTask, featuredPostsTask);
_topRegularProducts = topRegTask.Result.Products;
_topDiscountProducts = topDiscTask.Result.Products;
}
catch
{
// Fallback: sections remain empty
}
_loadingTopProducts = false;
// Load latest published blog posts
// اول پین‌شده‌ها، اگر کافی نبود آخرین پست‌ها اضافه شود
try
{
_latestPosts = featuredPostsTask.IsCompletedSuccessfully
? featuredPostsTask.Result
: await BlogPostService.GetFeaturedPostsAsync(2);
_latestPosts = await BlogPostService.GetFeaturedPostsAsync(2);
// اگر فقط ۱ پین‌شده داریم، ۱ پست آخر هم اضافه کن
if (_latestPosts.Count < 2)
{
var result = await BlogPostService.GetPublishedPostsAsync(page: 1, pageSize: 2);
@@ -111,163 +44,10 @@ public partial class Index : IDisposable
}
}
private void PopulateFromSettings()
{
// ── Trust badges ──
if (_settings?.TrustBadges?.Any() == true)
{
_trustBadges = _settings.TrustBadges
.Select(b => (ResolveIcon(b.IconName), b.Text ?? ""))
.ToList();
}
else
{
_trustBadges = new()
{
(Icons.Material.Outlined.Timer, "ثبت‌نام زیر ۲ دقیقه"),
(Icons.Material.Outlined.SupportAgent, "پشتیبانی ۷×۲۴"),
(Icons.Material.Outlined.Lock, "پرداخت ایمن"),
(Icons.Material.Outlined.Verified, "تضمین کیفیت"),
};
}
// ── Steps ──
if (_settings?.Steps?.Any() == true)
{
_steps = _settings.Steps
.Select(s => (s.Title ?? "", s.Description ?? ""))
.ToList();
}
else
{
_steps = new()
{
("ثبت‌نام و احراز هویت", "یک حساب بسازید، شماره موبایل را تأیید و اطلاعات هویتی را تکمیل کنید."),
("دعوت دوستان", "لینک دعوت اختصاصی خود را با دوستان و آشنایان به اشتراک بگذارید."),
("دریافت پاداش", "از خریدهای واقعی اعضای تیمتان پاداش شفاف و لحظه‌ای دریافت کنید."),
};
}
// ── Features ──
if (_settings?.Features?.Any() == true)
{
_features = _settings.Features
.Select((f, i) => (ResolveIcon(f.IconName), f.Title ?? "", f.Description ?? "", i * 100))
.ToList();
}
else
{
_features = new()
{
(Icons.Material.Outlined.VerifiedUser, "ثبت‌نام سریع و ساده", "در چند گام کوتاه حساب بسازید و شروع کنید.", 0),
(Icons.Material.Outlined.StarBorder, "پاداش‌های شفاف", "قوانین روشن، دسترسی آسان به سوابق و گزارش‌ها.", 100),
(Icons.Material.Outlined.Devices, "طراحی واکنش‌گرا", "تجربه‌ای روان در موبایل و دسکتاپ.", 200),
(Icons.Material.Outlined.Groups, "تیم‌سازی هوشمند", "ساختار درختی شبکه و مدیریت تیم‌های فروش.", 300),
(Icons.Material.Outlined.Insights, "گزارش‌های لحظه‌ای", "داشبورد پویا برای مشاهده عملکرد و کمیسیون.", 400),
(Icons.Material.Outlined.Lock, "امنیت بالا", "رمزنگاری اطلاعات و احراز هویت چندمرحله‌ای.", 500),
};
}
// ── Stats ──
if (_settings?.Stats?.Any() == true)
{
_stats = _settings.Stats
.Select((s, i) => new StatItem(
$"stat-{i}",
$"{s.Value}{s.Suffix}",
s.Value,
s.Suffix ?? "",
ResolveColor(s.Color),
s.Label ?? ""))
.ToList();
}
else
{
_stats = new()
{
new("stat-growth", "+۵۰٪", 50, "%+", Color.Success, "رشد میانگین تیم"),
new("stat-uptime", "۹۹.۹٪", 99.9, "%", Color.Primary, "آپ‌تایم سرویس"),
new("stat-deploy", "۳ روز", 3, " روز", Color.Warning, "میانگین زمان استقرار"),
new("stat-coverage", "+۲۰ کشور", 20, "+", Color.Secondary, "پوشش ارسال کد"),
};
}
// ── Testimonials ──
// اگر تنظیمات از CMS لود شده، لیست خالی یعنی ادمین عمداً حذف کرده — بدون fallback hardcode
if (_settings is not null)
{
_testimonials = (_settings.Testimonials ?? [])
.Select((t, i) => new TestimonialItem(t.Quote ?? "", t.Name ?? "", t.Role ?? "", i * 150))
.ToList();
}
else
{
_testimonials = new()
{
new("با کارا بازار سلامت، محاسبه کارمزدها و پایش تیم‌ها بدون اکسل و دردسر انجام می‌شود.", "شرکت سینا نت", "مدیر عملیات", 0),
new("سازمان فروش بصری و گزارش‌های دقیق باعث شد رشد تیم را لحظه‌ای ببینیم.", "هولدینگ آریانا", "مدیر فروش", 150),
new("سادگی ثبت‌نام و شفافیت پاداش‌ها مهم‌ترین مزیت این پلتفرم است.", "گروه بهداشتی نوین", "مدیر توسعه", 300),
};
}
// ── FAQs ──
if (_settings?.Faqs?.Any() == true)
{
_faqs = _settings.Faqs
.Select(f => new QA(f.Question ?? "", f.Answer ?? ""))
.ToList();
}
else
{
_faqs = new()
{
new("دامنه اختصاصی دارم؛ قابل اتصال است؟", "بله، پشت دامنه و گواهی SSL خودتان مستقر می‌شود."),
new("با دیتابیس خودم کار می‌کند؟", "کاملاً. SQL Server، PostgreSQL و MySQL پشتیبانی می‌شود."),
new("چه درگاه‌هایی پشتیبانی می‌شود؟", "Stripe و PayPal یا درگاه اختصاصی از طریق وب‌هوک‌ها."),
new("می‌توانم داده‌ها را خروجی بگیرم؟", "هر زمان از داشبورد ادمین خروجی CSV/Excel بگیرید."),
};
}
}
/// <summary>
/// Resolve icon name from SettingsJson to MudBlazor icon string.
/// Falls back to a generic icon.
/// </summary>
private static string ResolveIcon(string? iconName)
{
if (string.IsNullOrWhiteSpace(iconName)) return Icons.Material.Outlined.Info;
// Try to get from MudBlazor Icons via reflection
var field = typeof(Icons.Material.Outlined).GetField(iconName,
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
if (field != null) return (string)(field.GetValue(null) ?? Icons.Material.Outlined.Info);
// Also try Filled
field = typeof(Icons.Material.Filled).GetField(iconName,
System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Static);
if (field != null) return (string)(field.GetValue(null) ?? Icons.Material.Outlined.Info);
return Icons.Material.Outlined.Info;
}
private static Color ResolveColor(string? colorName) => colorName?.ToLowerInvariant() switch
{
"primary" => Color.Primary,
"secondary" => Color.Secondary,
"success" => Color.Success,
"warning" => Color.Warning,
"error" => Color.Error,
"info" => Color.Info,
_ => Color.Default
};
protected override async Task OnAfterRenderAsync(bool firstRender)
{
// Init/re-init scroll animations after data is loaded and rendered
if ((firstRender || (_dataLoaded && !_animationsInitialized)) && _steps.Any())
if (firstRender)
{
_animationsInitialized = true;
// Init scroll-triggered fade-in animations
await JS.InvokeVoidAsync("initScrollAnimations");
@@ -303,60 +83,70 @@ public partial class Index : IDisposable
Navigation.NavigateTo($"/blog/{slug}");
}
private void NavigateToRegularProduct(long id)
=> Navigation.NavigateTo(RouteConstants.Store.ProductDetail + id);
private void NavigateToDiscountProduct(long id)
=> Navigation.NavigateTo(RouteConstants.DiscountStore.ProductDetail + id);
private async Task AddRegularToCart(Product p)
=> await GuestGate.RunAsync(() => Cart.Add(p, 1));
private async Task AddDiscountToCart(DiscountProductCard p)
=> await GuestGate.RunAsync(() => DiscountCart.AddAsync(p.Id));
private string FormatPrice(long price) => $"{VAT.AddVAT(price):N0} تومان";
private string FormatDiscountPrice(long price) => $"{price:N0} تومان";
private async void OnStateChanged()
{
await InvokeAsync(StateHasChanged);
}
// ── Trust badges ──
private readonly List<(string Icon, string Text)> _trustBadges = new()
{
(Icons.Material.Outlined.Timer, "ثبت‌نام زیر ۲ دقیقه"),
(Icons.Material.Outlined.SupportAgent, "پشتیبانی ۷×۲۴"),
(Icons.Material.Outlined.Lock, "پرداخت ایمن"),
(Icons.Material.Outlined.Verified, "تضمین کیفیت"),
};
// ── How it works steps ──
private readonly List<(string Title, string Desc)> _steps = new()
{
("ثبت‌نام و احراز هویت", "یک حساب بسازید، شماره موبایل را تأیید و اطلاعات هویتی را تکمیل کنید."),
("دعوت دوستان", "لینک دعوت اختصاصی خود را با دوستان و آشنایان به اشتراک بگذارید."),
("دریافت پاداش", "از خریدهای واقعی اعضای تیمتان پاداش شفاف و لحظه‌ای دریافت کنید."),
};
// ── Feature cards data (6 cards) ──
private readonly List<(string Icon, string Title, string Desc, int Delay)> _features = new()
{
(Icons.Material.Outlined.VerifiedUser, "ثبت‌نام سریع و ساده", "در چند گام کوتاه حساب بسازید و شروع کنید.", 0),
(Icons.Material.Outlined.StarBorder, "پاداش‌های شفاف", "قوانین روشن، دسترسی آسان به سوابق و گزارش‌ها.", 100),
(Icons.Material.Outlined.Devices, "طراحی واکنش‌گرا", "تجربه‌ای روان در موبایل و دسکتاپ.", 200),
(Icons.Material.Outlined.Groups, "تیم‌سازی هوشمند", "ساختار درختی شبکه و مدیریت تیم‌های فروش.", 300),
(Icons.Material.Outlined.Insights, "گزارش‌های لحظه‌ای", "داشبورد پویا برای مشاهده عملکرد و کمیسیون.", 400),
(Icons.Material.Outlined.Lock, "امنیت بالا", "رمزنگاری اطلاعات و احراز هویت چندمرحله‌ای.", 500),
};
// ── Stats data (animated counters) ──
private readonly List<StatItem> _stats = new()
{
new("stat-growth", "+۵۰٪", 50, "%+", Color.Success, "رشد میانگین تیم"),
new("stat-uptime", "۹۹.۹٪", 99.9, "%", Color.Primary, "آپ‌تایم سرویس"),
new("stat-deploy", "۳ روز", 3, " روز", Color.Warning, "میانگین زمان استقرار"),
new("stat-coverage", "+۲۰ کشور", 20, "+", Color.Secondary, "پوشش ارسال کد"),
};
// ── Testimonials ──
private readonly List<TestimonialItem> _testimonials = new()
{
new("با کارا بازار سلامت، محاسبه کارمزدها و پایش تیم‌ها بدون اکسل و دردسر انجام می‌شود.", "شرکت سینا نت", "مدیر عملیات", 0),
new("شجره‌نامه بصری و گزارش‌های دقیق باعث شد رشد تیم را لحظه‌ای ببینیم.", "هولدینگ آریانا", "مدیر فروش", 150),
new("سادگی ثبت‌نام و شفافیت پاداش‌ها مهم‌ترین مزیت این پلتفرم است.", "گروه بهداشتی نوین", "مدیر توسعه", 300),
};
// ── FAQ data ──
private readonly List<QA> _faqs = new()
{
new("دامنه اختصاصی دارم؛ قابل اتصال است؟", "بله، پشت دامنه و گواهی SSL خودتان مستقر می‌شود."),
new("با دیتابیس خودم کار می‌کند؟", "کاملاً. SQL Server، PostgreSQL و MySQL پشتیبانی می‌شود."),
new("چه درگاه‌هایی پشتیبانی می‌شود؟", "Stripe و PayPal یا درگاه اختصاصی از طریق وب‌هوک‌ها."),
new("می‌توانم داده‌ها را خروجی بگیرم؟", "هر زمان از داشبورد ادمین خروجی CSV/Excel بگیرید."),
};
// ── Records ──
private record QA(string Q, string A);
private record StatItem(string ElementId, string Display, double Target, string Suffix, Color Color, string Label);
private record TestimonialItem(string Quote, string Name, string Role, int Delay);
// ── SettingsJson DTO ──
private class LandingSettings
{
public string? HeroButtonPrimaryText { get; set; }
public string? HeroButtonSecondaryText { get; set; }
public List<TrustBadgeItem>? TrustBadges { get; set; }
public string? StepsTitle { get; set; }
public List<StepItem>? Steps { get; set; }
public string? FeaturesTitle { get; set; }
public List<FeatureItem>? Features { get; set; }
public string? StatsTitle { get; set; }
public List<StatSettingItem>? Stats { get; set; }
public string? TestimonialsTitle { get; set; }
public List<TestimonialSettingItem>? Testimonials { get; set; }
public string? FaqTitle { get; set; }
public List<FaqItem>? Faqs { get; set; }
public string? CtaTitle { get; set; }
public string? CtaDescription { get; set; }
public string? CtaButtonText { get; set; }
public bool? FeaturedBlogEnabled { get; set; }
}
private class TrustBadgeItem { public string? IconName { get; set; } public string? Text { get; set; } }
private class StepItem { public string? Title { get; set; } public string? Description { get; set; } }
private class FeatureItem { public string? IconName { get; set; } public string? Title { get; set; } public string? Description { get; set; } }
private class StatSettingItem { public string? Label { get; set; } public double Value { get; set; } public string? Suffix { get; set; } public string? Color { get; set; } }
private class TestimonialSettingItem { public string? Quote { get; set; } public string? Name { get; set; } public string? Role { get; set; } }
private class FaqItem { public string? Question { get; set; } public string? Answer { get; set; } }
public void Dispose()
{
MainService.OnChangeHandler -= OnStateChanged;
-99
View File
@@ -1,99 +0,0 @@
@attribute [Route(RouteConstants.Licenses.Index)]
@inject SitePageSettingsService PageSettingsService
<PageTitle>مجوزها و گواهینامه‌ها | کارا بازار سلامت</PageTitle>
@if (_loading)
{
<MudContainer MaxWidth="MaxWidth.Large" Class="py-16">
<LoadingState />
</MudContainer>
}
else
{
<!-- Hero Section -->
<section class="licenses-hero-section py-16">
<MudContainer MaxWidth="MaxWidth.Large">
<MudStack Spacing="3" AlignItems="AlignItems.Center" Class="text-center">
<MudChip T="string" Color="Color.Secondary" Variant="Variant.Filled">مجوزها و گواهینامه‌ها</MudChip>
<MudText Typo="Typo.h2" Class="mb-3">
@(_pageData?.HeroTitle ?? "مجوزها و گواهینامه‌های رسمی")
</MudText>
<MudText Typo="Typo.body1" Class="mud-text-secondary" Style="max-width: 700px;">
@(_pageData?.HeroSubtitle ?? "ما با دریافت مجوزهای رسمی و تأییدیه‌های معتبر، اطمینان شما را در استفاده از خدمات خود جلب کرده‌ایم.")
</MudText>
</MudStack>
</MudContainer>
</section>
<MudStack Spacing="8" Class="pb-12">
<!-- Licenses Gallery -->
<section class="py-12">
<MudContainer MaxWidth="MaxWidth.Large">
<MudText Typo="Typo.h3" Align="Align.Center" Class="mb-8">مجوزهای ما</MudText>
@if (_licenseImages.Any())
{
<MudGrid Spacing="4" Justify="Justify.Center">
@foreach (var license in _licenseImages)
{
<MudItem xs="6" sm="6" md="4">
<MudCard Elevation="2" Class="h-100">
<MudCardMedia Image="@license.ImagePath" Height="250" />
<MudCardContent>
@if (!string.IsNullOrWhiteSpace(license.Title))
{
<MudText Typo="Typo.h6" Align="Align.Center" Class="mb-1">@license.Title</MudText>
}
@if (!string.IsNullOrWhiteSpace(license.Description))
{
<MudText Typo="Typo.body2" Align="Align.Center" Class="mud-text-secondary">@license.Description</MudText>
}
</MudCardContent>
@if (!string.IsNullOrWhiteSpace(license.LinkUrl))
{
<MudCardActions Class="justify-center">
<MudButton Variant="Variant.Text"
Color="Color.Primary"
Href="@license.LinkUrl"
Target="_blank"
StartIcon="@Icons.Material.Filled.OpenInNew">
مشاهده جزئیات
</MudButton>
</MudCardActions>
}
</MudCard>
</MudItem>
}
</MudGrid>
}
else
{
<MudAlert Severity="Severity.Info" Variant="Variant.Text" Class="my-8" Dense="true">
اطلاعات مجوزها به‌زودی بارگذاری خواهد شد.
</MudAlert>
}
</MudContainer>
</section>
<!-- CTA Section -->
<section class="py-12">
<MudContainer MaxWidth="MaxWidth.Medium">
<MudPaper Elevation="3" Class="pa-8 rounded-xl text-center">
<MudIcon Icon="@Icons.Material.Filled.VerifiedUser" Size="Size.Large" Color="Color.Primary" Class="mb-4" />
<MudText Typo="Typo.h4" Class="mb-3">نیاز به اطلاعات بیشتر دارید؟</MudText>
<MudText Typo="Typo.body1" Class="mud-text-secondary mb-6">
برای دریافت اطلاعات بیشتر درباره مجوزها و گواهینامه‌های ما، با ما تماس بگیرید.
</MudText>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Size="Size.Large"
OnClick="() => Navigation.NavigateTo(RouteConstants.Contact.Index)">
تماس با ما
</MudButton>
</MudPaper>
</MudContainer>
</section>
</MudStack>
}
@@ -1,31 +0,0 @@
using FrontOffice.Main.Utilities;
namespace FrontOffice.Main.Pages;
public partial class Licenses
{
private bool _loading = true;
private PageSettingsDto? _pageData;
private List<PageSettingsImageDto> _licenseImages = new();
protected override async Task OnInitializedAsync()
{
try
{
_pageData = await PageSettingsService.GetPageAsync("licenses");
if (_pageData != null)
{
_licenseImages = _pageData.GetImages("licenses");
}
}
catch
{
// Fallback: page remains null → hardcoded content will render
}
finally
{
_loading = false;
}
}
}
@@ -20,7 +20,7 @@
<MudIcon Icon="@Icons.Material.Filled.CardGiftcard" Size="Size.Large" Color="Color.Primary" Style="font-size: 80px;" />
<MudText Typo="Typo.h5">شما هنوز پکیجی خریداری نکرده‌اید</MudText>
<MudText Typo="Typo.body1" Class="mud-text-secondary" Style="max-width: 500px;">
با خرید پکیج، به باشگاه مشتریان بپیوندید و از مزایای ویژه مانند پاداش هفتگی و تیم‌سازی بهره‌مند شوید.
با خرید پکیج طلایی، به باشگاه مشتریان بپیوندید و از مزایای ویژه مانند پاداش هفتگی و تیم‌سازی بهره‌مند شوید.
</MudText>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
@@ -34,76 +34,7 @@
}
else
{
@* ── G3: راهنمای پکیج‌های من (Q28) ── *@
<MudAlert Severity="Severity.Info" Variant="Variant.Text" Dense="true" Class="mb-2" Icon="@Icons.Material.Filled.Info">
بعد از تکمیل چرخه کیف‌پول جادویی، می‌توانید مجدداً پکیج خریداری کرده و دور جدیدی را آغاز کنید.
</MudAlert>
<MudGrid Spacing="4">
@* Re-Purchase / Cycle Progress Section *@
@if (_canRepurchase)
{
<MudItem xs="12">
<MudAlert Severity="Severity.Success" Icon="@Icons.Material.Filled.Celebration" Class="mb-0">
<MudStack Row="true" AlignItems="AlignItems.Center" Justify="Justify.SpaceBetween" Style="width:100%">
<MudText Typo="Typo.body1">
🎉 چرخه جادویی تکمیل شد! می‌توانید پکیج جدید بخرید.
</MudText>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.ShoppingCart"
OnClick="@(() => Navigation.NavigateTo(RouteConstants.Package.List))">
خرید پکیج جدید
</MudButton>
</MudStack>
</MudAlert>
</MudItem>
}
else if (_magicStatus != null && _magicStatus.WalletMode == 1)
{
<MudItem xs="12">
<MudPaper Elevation="2" Class="pa-6">
<MudStack Spacing="3">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.AutoAwesome" Color="Color.Warning" />
<MudText Typo="Typo.h6">پیشرفت چرخه جادویی</MudText>
@if (_magicStatus.PurchaseCycleCount > 0)
{
<MudChip T="string" Size="Size.Small" Color="Color.Info">دور @(_magicStatus.PurchaseCycleCount + 1)</MudChip>
}
</MudStack>
<MudDivider />
<MudStack Spacing="2">
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Class="mud-text-secondary">واریز انجام‌شده:</MudText>
<MudText Typo="Typo.body2">@FormatPrice(_magicStatus.MagicTotalDeposited)</MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Class="mud-text-secondary">سقف واریز:</MudText>
<MudText Typo="Typo.body2">@FormatPrice(_magicStatus.MagicMaxDeposit)</MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Class="mud-text-secondary">باقیمانده:</MudText>
<MudText Typo="Typo.body2" Color="Color.Warning">@FormatPrice(_magicStatus.MagicRemainingDeposit)</MudText>
</MudStack>
<MudProgressLinear Color="Color.Primary" Value="@GetDepositProgress()" Class="my-2" Rounded="true" Size="Size.Large">
<MudText Typo="Typo.caption"><b>@GetDepositProgress()%</b></MudText>
</MudProgressLinear>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Class="mud-text-secondary">اعتبار دریافتی:</MudText>
<MudText Typo="Typo.body2" Color="Color.Success">@FormatPrice(_magicStatus.MagicTotalCredited)</MudText>
</MudStack>
</MudStack>
</MudStack>
</MudPaper>
</MudItem>
}
<!-- Package Status Card -->
<MudItem xs="12" md="6">
<MudPaper Elevation="2" Class="pa-6 h-100">
@@ -118,7 +49,7 @@
<MudStack Spacing="2">
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Class="mud-text-secondary">نوع پکیج:</MudText>
<MudText Typo="Typo.body2">@_userStatus.PackageTitle</MudText>
<MudText Typo="Typo.body2">پکیج طلایی</MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
@@ -215,7 +146,7 @@
<MudStack Spacing="3">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.Stars" Color="Color.Warning" />
<MudText Typo="Typo.h6">مزایای پکیج</MudText>
<MudText Typo="Typo.h6">مزایای پکیج طلایی</MudText>
</MudStack>
<MudDivider />
@@ -9,9 +9,7 @@ public partial class MyPackages : ComponentBase
[Inject] private PackageService PackageService { get; set; } = default!;
private UserPackageStatusDto? _userStatus;
private MagicWalletStatus? _magicStatus;
private bool _isLoading = true;
private bool _canRepurchase = false;
private List<BreadcrumbItem> _breadcrumbItems = new()
{
@@ -32,18 +30,6 @@ public partial class MyPackages : ComponentBase
try
{
_userStatus = await PackageService.GetUserPackageStatusAsync();
if (_userStatus?.HasPurchasedPackage == true)
{
_magicStatus = await WalletService.GetMagicWalletStatusAsync();
// Cycle complete: wallet back to Normal mode, at least one cycle done,
// and deposit ceiling fully used (remaining = 0)
_canRepurchase = _magicStatus != null
&& _magicStatus.WalletMode == 0 // Normal mode
&& _magicStatus.PurchaseCycleCount >= 1 // At least one cycle completed
&& _magicStatus.MagicRemainingDeposit == 0; // Deposit ceiling used up
}
}
catch (Exception ex)
{
@@ -81,11 +67,4 @@ public partial class MyPackages : ComponentBase
{
return string.Format("{0:N0} تومان", price);
}
private int GetDepositProgress()
{
if (_magicStatus == null || _magicStatus.MagicMaxDeposit <= 0) return 0;
var pct = (int)((_magicStatus.MagicTotalDeposited * 100) / _magicStatus.MagicMaxDeposit);
return Math.Min(pct, 100);
}
}
@@ -5,11 +5,6 @@
<MudContainer MaxWidth="MaxWidth.Large" Class="py-8">
<PageHeader Title="پکیج‌های سرمایه‌گذاری" BackHref="@RouteConstants.Profile.Index" />
@* ── G1: راهنمای سیستم پکیج‌بیس (Q28) ── *@
<MudAlert Severity="Severity.Info" Variant="Variant.Text" Dense="true" Class="mb-4" Icon="@Icons.Material.Filled.Info">
هر پکیج ویژگی‌ها و مزایای منحصربه‌فردی دارد. با انتخاب پکیج مناسب، از سقف پاداش و ضریب کیف‌پول جادویی متفاوتی بهره‌مند می‌شوید.
</MudAlert>
@if (_isLoading)
{
<MudStack AlignItems="AlignItems.Center" Class="py-16">
@@ -32,7 +27,7 @@
{
<MudAlert Severity="Severity.Success" Class="mb-6" Icon="@Icons.Material.Filled.CheckCircle">
<MudText>
شما قبلاً پکیج را خریداری کرده‌اید.
شما قبلاً پکیج طلایی را خریداری کرده‌اید.
@if (_userStatus.IsClubMemberActive)
{
<span>عضویت باشگاه شما فعال است.</span>
@@ -69,43 +64,20 @@
<MudText Typo="Typo.h6" Color="Color.Success">@package.FormattedPrice</MudText>
</MudStack>
<!-- Package Highlights -->
<!-- Features Preview -->
<MudStack Spacing="1" Class="mt-2">
@if (package.SupportsDirectPurchase)
{
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
<MudIcon Icon="@Icons.Material.Filled.Check" Size="Size.Small" Color="Color.Primary" />
<MudText Typo="Typo.caption">پرداخت مستقیم</MudText>
</MudStack>
}
@if (package.SupportsDayaPurchase)
{
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
<MudIcon Icon="@Icons.Material.Filled.Check" Size="Size.Small" Color="Color.Primary" />
<MudText Typo="Typo.caption">پرداخت با اعتبار دایا</MudText>
</MudStack>
}
@if (package.DiscountMultiplier > 0)
{
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
<MudIcon Icon="@Icons.Material.Filled.Check" Size="Size.Small" Color="Color.Primary" />
<MudText Typo="Typo.caption">ضریب اعتبار: @package.DiscountMultiplier.ToString("F1")x</MudText>
</MudStack>
}
@if (package.MagicWalletMultiplier > 0)
{
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
<MudIcon Icon="@Icons.Material.Filled.Check" Size="Size.Small" Color="Color.Primary" />
<MudText Typo="Typo.caption">کیف پول جادویی: @package.MagicWalletMultiplier.ToString("F1")x</MudText>
</MudStack>
}
@if (package.IsBasePackage)
{
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
<MudIcon Icon="@Icons.Material.Filled.Star" Size="Size.Small" Color="Color.Warning" />
<MudText Typo="Typo.caption">پکیج پایه</MudText>
</MudStack>
}
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
<MudIcon Icon="@Icons.Material.Filled.Check" Size="Size.Small" Color="Color.Primary" />
<MudText Typo="Typo.caption">عضویت در باشگاه مشتریان</MudText>
</MudStack>
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
<MudIcon Icon="@Icons.Material.Filled.Check" Size="Size.Small" Color="Color.Primary" />
<MudText Typo="Typo.caption">دریافت پاداش هفتگی</MudText>
</MudStack>
<MudStack Row="true" Spacing="1" AlignItems="AlignItems.Center">
<MudIcon Icon="@Icons.Material.Filled.Check" Size="Size.Small" Color="Color.Primary" />
<MudText Typo="Typo.caption">تیم‌سازی نامحدود</MudText>
</MudStack>
</MudStack>
</MudStack>
</MudCardContent>
@@ -128,9 +100,9 @@
<MudGrid Spacing="4">
<MudItem xs="12" md="8">
<MudStack Spacing="2">
<MudText Typo="Typo.h5" Style="color: white;">چرا پکیج بخریم؟</MudText>
<MudText Typo="Typo.h5" Style="color: white;">چرا پکیج طلایی؟</MudText>
<MudText Typo="Typo.body1" Style="color: rgba(255,255,255,0.9);">
با خرید پکیج، علاوه بر دسترسی به محصولات ویژه، می‌توانید تیم فروش خود را بسازید
با خرید پکیج طلایی، علاوه بر دسترسی به محصولات ویژه، می‌توانید تیم فروش خود را بسازید
و از پاداش‌های هفتگی بهره‌مند شوید. هر چه تیم شما گسترده‌تر، درآمد شما بیشتر!
</MudText>
</MudStack>
@@ -64,38 +64,36 @@ public partial class PackageDetail : IDisposable
try
{
// Load package details via Customer RPC
var response = await PackageClient.GetCustomerPackageDetailsAsync(
new GetCustomerPackageDetailsRequest { PackageId = Id },
cancellationToken: _loadCts.Token);
// Load package details
var packageResponse = await PackageClient.GetPackageAsync(request: new() { Id = Id}, cancellationToken: _loadCts.Token);
if (response != null)
if (packageResponse != null)
{
// Build features from API response
var features = response.Features?.Select(f => f.Title).ToList() ?? new List<string>();
// Build specifications from API features (highlighted ones)
var specs = response.Features?
.Where(f => f.IsHighlighted)
.Select(f => new Specification
{
Name = f.Title,
Value = f.Description,
Icon = string.IsNullOrEmpty(f.Icon) ? Icons.Material.Filled.Star : f.Icon
}).ToList() ?? new List<Specification>();
_package = new PackageDetailDto
{
Id = response.Id,
Title = response.Title,
Body = response.Description,
Image = response.ImagePath ?? string.Empty,
Specifications = specs,
Features = features,
Id = packageResponse.Id,
Title = packageResponse.Title,
Body = packageResponse.Description,
Image = packageResponse.ImagePath ?? string.Empty,
Specifications = new List<Specification>
{
new() { Name = "ظرفیت", Value = "تا ۲۰۰ عضو", Icon = Icons.Material.Filled.Group },
new() { Name = "شجره‌نامه", Value = "پیشرفته", Icon = Icons.Material.Filled.AccountTree },
new() { Name = "گزارش‌گیری", Value = "جامع", Icon = Icons.Material.Filled.Analytics },
new() { Name = "پشتیبانی", Value = "۲۴ ساعته", Icon = Icons.Material.Filled.Support }
},
Features = new List<string>
{
"مدیریت تیم نامحدود",
"شجره‌نامه بصری",
"محاسبه کارمزد خودکار",
"گزارش‌های مالی",
"پشتیبانی اولویت‌دار"
},
Pricing = new PricingInfo
{
OriginalPrice = response.Price,
FinalPrice = response.Price,
OriginalPrice = packageResponse.Price,
FinalPrice = packageResponse.Price,
HasDiscount = false,
DiscountPercent = 0
}
@@ -1,4 +1,4 @@
using FrontOffice.Main.Utilities;
using CMSMicroservice.Protobuf.Protos.UserAddress;
using CMSMicroservice.Protobuf.Protos.UserAddress;
using FrontOffice.Main.Pages.Profile.Components;
using Microsoft.AspNetCore.Components;
@@ -9,7 +9,6 @@ namespace FrontOffice.Main.Pages.Profile;
public partial class Addresses : ComponentBase
{
[Inject] private UserAddressContract.UserAddressContractClient UserAddressContract { get; set; } = default!;
[Inject] private AuthService AuthService { get; set; } = default!;
private List<CustomerAddressModel> _addresses = new();
private bool _isLoadingAddresses;
@@ -43,10 +42,7 @@ public partial class Addresses : ComponentBase
var dialog = await DialogService.ShowAsync<AddAddressDialog>("افزودن آدرس جدید");
var result = await dialog.Result;
if (result is not null && !result.Canceled)
{
await LoadAddresses();
await AuthService.RefreshTokenAsync();
}
}
private async Task OpenEditAddressDialog(CustomerAddressModel address)
@@ -84,7 +80,6 @@ public partial class Addresses : ComponentBase
await UserAddressContract.DeleteCustomerAddressAsync(new() { Id = id });
Snackbar.Add("آدرس حذف شد.", Severity.Success);
await LoadAddresses();
await AuthService.RefreshTokenAsync();
}
catch (Exception ex)
{
@@ -1,212 +0,0 @@
@attribute [Route(RouteConstants.Profile.ChargeCreditWallet)]
@attribute [Authorize]
<PageTitle>شارژ کیف‌پول اصلی</PageTitle>
<MudContainer MaxWidth="MaxWidth.Medium" Class="py-6">
<MudStack Spacing="3">
<PageHeader Title="💳 شارژ کیف‌پول اصلی" BackHref="@RouteConstants.Profile.Wallet" />
@if (_paymentResult != null)
{
<MudAlert Severity="@(_paymentResult == "success" ? MudBlazor.Severity.Success : MudBlazor.Severity.Error)"
Class="rounded-lg" Variant="Variant.Filled">
@if (_paymentResult == "success")
{
<MudStack Spacing="2">
<span>شارژ کیف پول اصلی با موفقیت انجام شد! ✅</span>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Size="Size.Small"
StartIcon="@Icons.Material.Filled.ShoppingCart"
OnClick="ContinueShoppingAsync">
ادامه خرید
</MudButton>
</MudStack>
}
else if (_paymentResult == "cancelled")
{
<span>پرداخت توسط شما لغو شد.</span>
}
else
{
<span>پرداخت ناموفق بود. لطفاً دوباره تلاش کنید.</span>
}
</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">
<MudText Typo="Typo.h6" Class="mb-3">
<MudIcon Icon="@Icons.Material.Filled.AccountBalanceWallet" Class="ml-1" />
شارژ موجودی اصلی
</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>
<MudStack Spacing="2">
<MudNumericField @bind-Value="_chargeAmount"
Label="مبلغ (تومان)"
Variant="Variant.Outlined"
Min="10_000"
Max="1_000_000_000"
Format="N0"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Payments"
HelperText="حداقل ۱۰,۰۰۰ و حداکثر ۱,۰۰۰,۰۰۰,۰۰۰ تومان" />
<MudStack Row="true" Spacing="1" Class="flex-wrap">
@foreach (var preset in _presetAmounts)
{
<MudButton Variant="Variant.Outlined" Size="Size.Small"
Color="@(_chargeAmount == preset ? Color.Primary : Color.Default)"
OnClick="() => _chargeAmount = preset"
Class="rounded-pill">
@FormatToman(preset)
</MudButton>
}
</MudStack>
@if (_chargeAmount > 0)
{
<MudPaper Outlined="true" Class="pa-3 rounded-lg" Style="background: #eff6ff;">
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2">مبلغ پرداخت:</MudText>
<MudText Typo="Typo.body2"><strong>@FormatToman(_chargeAmount)</strong></MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2">موجودی دریافتی:</MudText>
<MudText Typo="Typo.body2" Color="Color.Primary"><strong>@FormatToman(_chargeAmount)</strong></MudText>
</MudStack>
</MudPaper>
}
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Size="Size.Large"
FullWidth="true"
Disabled="@(_isProcessing || _chargeAmount < 10_000)"
OnClick="StartCreditCharge"
StartIcon="@Icons.Material.Filled.Payment"
Class="rounded-lg mt-2">
@if (_isProcessing)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="me-2" />
<span>در حال انتقال به درگاه...</span>
}
else
{
<span>پرداخت با کارت بانکی</span>
}
</MudButton>
</MudStack>
</MudPaper>
<MudExpansionPanels Elevation="1" Class="rounded-lg">
<MudExpansionPanel Text="قوانین شارژ کیف پول اصلی" MaxHeight="500" IsInitiallyExpanded="false">
<MudList T="string" Dense="true">
<MudListItem T="string" Icon="@Icons.Material.Filled.CheckCircle" IconColor="Color.Success">
موجودی اصلی برای خرید از فروشگاه عادی و پرداخت سفارش قابل استفاده است
</MudListItem>
<MudListItem T="string" Icon="@Icons.Material.Filled.CheckCircle" IconColor="Color.Success">
حداقل مبلغ شارژ: ۱۰,۰۰۰ تومان
</MudListItem>
<MudListItem T="string" Icon="@Icons.Material.Filled.CheckCircle" IconColor="Color.Success">
مبلغ واریزی بدون ضریب به موجودی اضافه می‌شود (۱:۱)
</MudListItem>
<MudListItem T="string" Icon="@Icons.Material.Filled.Info" IconColor="Color.Info">
پرداخت از طریق درگاه بانکی انجام می‌شود
</MudListItem>
</MudList>
</MudExpansionPanel>
</MudExpansionPanels>
}
</MudStack>
</MudContainer>
@@ -1,199 +0,0 @@
using FrontOffice.Main.Utilities;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
using MudBlazor;
namespace FrontOffice.Main.Pages.Profile;
public partial class ChargeCreditWallet : ComponentBase
{
[Inject] private AuthService AuthService { get; set; } = default!;
private bool _isProcessing;
private long _chargeAmount;
private string? _paymentResult;
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 };
[SupplyParameterFromQuery(Name = "payment")]
public string? PaymentQueryParam { get; set; }
[SupplyParameterFromQuery(Name = "amount")]
public long? AmountQueryParam { get; set; }
[SupplyParameterFromQuery(Name = "returnUrl")]
public string? ReturnUrlQueryParam { get; set; }
private bool ShowChargeForm =>
_authLoaded
&& _isClubMemberActive
&& !_redirectingToMagic
&& (!_needsMagicCeilingConsent || _allowNormalDespiteMagic);
protected override async Task OnInitializedAsync()
{
_paymentResult = PaymentQueryParam;
if (AmountQueryParam is > 0)
_chargeAmount = CreditChargeNavigation.NormalizeChargeAmount(AmountQueryParam.Value);
if (CreditChargeNavigation.IsValidReturnUrl(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)
{
if (firstRender && !string.IsNullOrEmpty(_pendingReturnUrl) && _isClubMemberActive && !_redirectingToMagic)
await CreditChargeNavigation.SaveReturnUrlAsync(JSRuntime, _pendingReturnUrl);
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()
{
if (!_isClubMemberActive)
{
Snackbar.Add(
"برای شارژ کیف پول اصلی ابتدا باید پکیج را خریداری کرده و قرارداد باشگاه مشتریان را امضا کنید.",
Severity.Warning);
return;
}
if (await EvaluateMagicWalletGateAsync())
return;
if (_needsMagicCeilingConsent && !_allowNormalDespiteMagic)
return;
if (_chargeAmount <= 0 || _isProcessing) return;
_isProcessing = true;
StateHasChanged();
try
{
if (!string.IsNullOrEmpty(_pendingReturnUrl))
await CreditChargeNavigation.SaveReturnUrlAsync(JSRuntime, _pendingReturnUrl);
else
{
var storedReturnUrl = await CreditChargeNavigation.GetReturnUrlAsync(JSRuntime);
if (CreditChargeNavigation.IsValidReturnUrl(storedReturnUrl))
_pendingReturnUrl = storedReturnUrl;
}
var (success, gatewayUrl, error) = await WalletService.InitiateCreditChargeAsync(_chargeAmount);
if (success && !string.IsNullOrEmpty(gatewayUrl))
{
Navigation.NavigateTo(gatewayUrl, forceLoad: true);
}
else
{
Snackbar.Add(error ?? "خطا در ایجاد درخواست پرداخت", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
}
finally
{
_isProcessing = false;
StateHasChanged();
}
}
/// <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()
{
var returnUrl = await CreditChargeNavigation.GetReturnUrlAsync(JSRuntime);
await CreditChargeNavigation.ClearReturnUrlAsync(JSRuntime);
if (CreditChargeNavigation.IsValidReturnUrl(returnUrl))
Navigation.NavigateTo(returnUrl!);
}
private static string FormatToman(long toman)
=> string.Format("{0:N0} تومان", toman);
}
@@ -1,121 +0,0 @@
@attribute [Route(RouteConstants.Profile.ChargeDiscountWallet)]
@attribute [Authorize]
<PageTitle>شارژ کیف‌پول اعتباری</PageTitle>
<MudContainer MaxWidth="MaxWidth.Medium" Class="py-6">
<MudStack Spacing="3">
<PageHeader Title="💳 شارژ کیف‌پول اعتباری" BackHref="@RouteConstants.Profile.Wallet" />
@* ─── نتیجه پرداخت (اگه از callback برگشته) ─── *@
@if (_paymentResult != null)
{
<MudAlert Severity="@(_paymentResult == "success" ? MudBlazor.Severity.Success : MudBlazor.Severity.Error)"
Class="rounded-lg" Variant="Variant.Filled">
@if (_paymentResult == "success")
{
<span>شارژ اعتباری با موفقیت انجام شد! ✅</span>
}
else if (_paymentResult == "cancelled")
{
<span>پرداخت توسط شما لغو شد.</span>
}
else
{
<span>پرداخت ناموفق بود. لطفاً دوباره تلاش کنید.</span>
}
</MudAlert>
}
@* ─── فرم شارژ ─── *@
<MudPaper Elevation="2" Class="pa-5 rounded-lg">
<MudText Typo="Typo.h6" Class="mb-3">
<MudIcon Icon="@Icons.Material.Filled.CreditCard" Class="ml-1" />
شارژ موجودی اعتباری
</MudText>
<MudAlert Severity="MudBlazor.Severity.Info" Dense="true" Class="mb-3" Icon="@Icons.Material.Filled.Info">
مبلغ وارد شده (به تومان) از طریق درگاه پرداخت به موجودی اعتباری شما اضافه می‌شود.
از موجودی اعتباری فقط برای خرید از فروشگاه اعتباری می‌توانید استفاده کنید.
</MudAlert>
<MudStack Spacing="2">
<MudNumericField @bind-Value="_chargeAmount"
Label="مبلغ (تومان)"
Variant="Variant.Outlined"
Min="10_000"
Max="1_000_000_000"
Format="N0"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Payments"
HelperText="حداقل ۱۰,۰۰۰ و حداکثر ۱,۰۰۰,۰۰۰,۰۰۰ تومان" />
@* دکمه‌های مبلغ سریع *@
<MudStack Row="true" Spacing="1" Class="flex-wrap">
@foreach (var preset in _presetAmounts)
{
<MudButton Variant="Variant.Outlined" Size="Size.Small"
Color="@(_chargeAmount == preset ? Color.Primary : Color.Default)"
OnClick="() => _chargeAmount = preset"
Class="rounded-pill">
@FormatToman(preset)
</MudButton>
}
</MudStack>
@if (_chargeAmount > 0)
{
<MudPaper Outlined="true" Class="pa-3 rounded-lg" Style="background: #f0fdf4;">
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2">مبلغ پرداخت:</MudText>
<MudText Typo="Typo.body2"><strong>@FormatToman(_chargeAmount)</strong></MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2">اعتبار دریافتی:</MudText>
<MudText Typo="Typo.body2" Color="Color.Success"><strong>@FormatToman(_chargeAmount)</strong></MudText>
</MudStack>
</MudPaper>
}
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Size="Size.Large"
FullWidth="true"
Disabled="@(_isProcessing || _chargeAmount < 10_000)"
OnClick="StartDiscountCharge"
StartIcon="@Icons.Material.Filled.Payment"
Class="rounded-lg mt-2">
@if (_isProcessing)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="me-2" />
<span>در حال انتقال به درگاه...</span>
}
else
{
<span>پرداخت با کارت بانکی</span>
}
</MudButton>
</MudStack>
</MudPaper>
@* ─── قوانین ─── *@
<MudExpansionPanels Elevation="1" Class="rounded-lg">
<MudExpansionPanel Text="قوانین شارژ اعتباری" MaxHeight="500" IsInitiallyExpanded="false">
<MudList T="string" Dense="true">
<MudListItem T="string" Icon="@Icons.Material.Filled.CheckCircle" IconColor="Color.Success">
موجودی اعتباری فقط قابل استفاده در فروشگاه اعتباری است
</MudListItem>
<MudListItem T="string" Icon="@Icons.Material.Filled.CheckCircle" IconColor="Color.Success">
حداقل مبلغ شارژ: ۱۰,۰۰۰ تومان
</MudListItem>
<MudListItem T="string" Icon="@Icons.Material.Filled.CheckCircle" IconColor="Color.Success">
مبلغ واریزی بدون ضریب به موجودی اضافه می‌شود (۱:۱)
</MudListItem>
<MudListItem T="string" Icon="@Icons.Material.Filled.Info" IconColor="Color.Info">
پرداخت از طریق درگاه بانکی انجام می‌شود
</MudListItem>
</MudList>
</MudExpansionPanel>
</MudExpansionPanels>
</MudStack>
</MudContainer>
@@ -1,57 +0,0 @@
using FrontOffice.Main.Utilities;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace FrontOffice.Main.Pages.Profile;
public partial class ChargeDiscountWallet : ComponentBase
{
private bool _isProcessing;
private long _chargeAmount;
private string? _paymentResult;
private readonly long[] _presetAmounts = { 500_000, 1_000_000, 5_000_000, 10_000_000, 20_000_000, 50_000_000 };
[SupplyParameterFromQuery(Name = "payment")]
public string? PaymentQueryParam { get; set; }
protected override void OnInitialized()
{
_paymentResult = PaymentQueryParam;
}
private async Task StartDiscountCharge()
{
if (_chargeAmount <= 0 || _isProcessing) return;
_isProcessing = true;
StateHasChanged();
try
{
// مبلغ به تومان — CMS خودش موقع ارسال به درگاه ×۱۰ می‌کنه
var (success, gatewayUrl, error) = await WalletService.InitiateDiscountChargeAsync(_chargeAmount);
if (success && !string.IsNullOrEmpty(gatewayUrl))
{
Navigation.NavigateTo(gatewayUrl, forceLoad: true);
}
else
{
Snackbar.Add(error ?? "خطا در ایجاد درخواست پرداخت", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
}
finally
{
_isProcessing = false;
StateHasChanged();
}
}
private static string FormatToman(long toman)
=> string.Format("{0:N0} تومان", toman);
}
@@ -61,11 +61,11 @@
<span class="stat-value">@_statistics.TotalMembers</span>
</div>
<div class="stat-item">
<span class="stat-label">سازمان چپ:</span>
<span class="stat-label">پای چپ:</span>
<span class="stat-value left">@_statistics.LeftLegCount</span>
</div>
<div class="stat-item">
<span class="stat-label">سازمان راست:</span>
<span class="stat-label">پای راست:</span>
<span class="stat-value right">@_statistics.RightLegCount</span>
</div>
<div class="stat-item">
@@ -1,4 +1,4 @@
using FrontOffice.Main.Utilities;
using FrontOffice.Main.Utilities;
using Microsoft.AspNetCore.Components;
using Microsoft.JSInterop;
@@ -95,27 +95,7 @@ public partial class OrganizationChart : IAsyncDisposable
try
{
_dotNetHelper = DotNetObjectReference.Create(this);
// Explicit camelCase mapping (same pattern as BackOffice admin chart)
var flatData = _networkTree.ToFlatArray().Select(n => new
{
id = n.Id,
parentId = n.ParentId,
fullName = n.FullName,
mobile = n.Mobile,
avatar = n.Avatar,
position = n.Position,
level = n.Level,
isActive = n.IsActive,
isClubActive = n.IsClubActive,
activationWeekNumber = n.ActivationWeekNumber,
joinedAt = n.JoinedAt,
referralCode = n.ReferralCode,
packageName = n.PackageName ?? string.Empty,
goldLeftLegTotal = n.GoldLeftLegTotal,
goldRightLegTotal = n.GoldRightLegTotal,
silverLeftLegTotal = n.SilverLeftLegTotal,
silverRightLegTotal = n.SilverRightLegTotal
}).ToArray();
var flatData = _networkTree.ToFlatArray();
await JSRuntime.InvokeVoidAsync("OrgChart.init", "org-chart-container", flatData, _dotNetHelper);
}
+50 -5
View File
@@ -45,7 +45,7 @@
<div class="wallet-strip-item">
<div class="wallet-strip-label">
<MudIcon Icon="@Icons.Material.Outlined.CreditCard" Size="Size.Small" Color="Color.Primary" />
<MudText Typo="Typo.caption" Class="mud-text-secondary">اصلی</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary">اعتباری</MudText>
</div>
<MudText Typo="Typo.subtitle2" Color="Color.Primary">@_walletCredit</MudText>
</div>
@@ -53,7 +53,7 @@
<div class="wallet-strip-item">
<div class="wallet-strip-label">
<MudIcon Icon="@Icons.Material.Outlined.Discount" Size="Size.Small" Color="Color.Success" />
<MudText Typo="Typo.caption" Class="mud-text-secondary">کیف اعتباری</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary">تخفیف باشگاه</MudText>
</div>
<MudText Typo="Typo.subtitle2" Color="Color.Success">@_walletDiscount</MudText>
</div>
@@ -61,7 +61,7 @@
<div class="wallet-strip-item">
<div class="wallet-strip-label">
<MudIcon Icon="@Icons.Material.Outlined.Groups" Size="Size.Small" Color="Color.Warning" />
<MudText Typo="Typo.caption" Class="mud-text-secondary">پاداش های دریافتی</MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary">پاداش تیمی</MudText>
</div>
<MudText Typo="Typo.subtitle2" Color="Color.Warning">@_walletNetwork</MudText>
</div>
@@ -119,7 +119,7 @@
لینک دعوت شما هنوز فعال نشده است
</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary mb-1" Style="max-width:560px; margin:0 auto;">
برای فعال‌سازی لینک دعوت، ابتدا یکی از <strong>پکیج‌ها</strong> را تهیه کنید و سپس در <strong>باشگاه مشتریان</strong> عضو شوید.
برای فعال‌سازی لینک دعوت، ابتدا <strong>پکیج پایه</strong> را تهیه کنید و سپس در <strong>باشگاه مشتریان</strong> عضو شوید.
</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary mb-4" Style="max-width:560px; margin:0 auto;">
از مزایای ویژه عضویت بهره‌مند شده و با فعالیت در زمینه توسعه فروشگاه‌ها پاداش دریافت کنید.
@@ -130,7 +130,7 @@
Class="rounded-pill"
StartIcon="@Icons.Material.Filled.ShoppingCart"
OnClick="OpenPurchaseOptions">
تهیه پکیج
شروع فرآیند تامین اعتبار
</MudButton>
</MudPaper>
}
@@ -195,4 +195,49 @@
</MudGrid>
</MudContainer>
@* ── Purchase Options Dialog ── *@
<MudDialog @bind-Visible="_showPurchaseBottomSheet">
<DialogContent>
<MudStack Spacing="3" Class="pa-2">
<MudText Typo="Typo.h6" Align="Align.Center">
خرید پکیج پایه ۵۶ میلیون تومان
</MudText>
<MudDivider />
<MudText Typo="Typo.body2" Class="mud-text-secondary">
برای خرید پکیج پایه، یکی از روش‌های زیر را انتخاب کنید:
</MudText>
<MudPaper Elevation="0" Class="pa-4 rounded-lg" Style="border: 2px solid var(--mud-palette-primary);">
<MudStack Spacing="2">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.CreditCard" Color="Color.Primary" Size="Size.Medium" />
<MudText Typo="Typo.subtitle1" Color="Color.Primary"><b>پرداخت مستقیم</b></MudText>
</MudStack>
<MudText Typo="Typo.body2" Class="mud-text-secondary">پرداخت آنی از طریق درگاه بانکی</MudText>
<MudButton Variant="Variant.Filled" Disabled="true" Color="Color.Primary" FullWidth="true"
StartIcon="@Icons.Material.Filled.Payment" OnClick="DirectPayment">
پرداخت با کارت بانکی(بزودی)
</MudButton>
</MudStack>
</MudPaper>
<MudPaper Elevation="0" Class="pa-4 rounded-lg" Style="border: 2px solid var(--mud-palette-tertiary);">
<MudStack Spacing="2">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.Diamond" Color="Color.Tertiary" Size="Size.Medium" />
<MudText Typo="Typo.subtitle1" Color="Color.Tertiary"><b>اعتبار الماسی دایا</b></MudText>
</MudStack>
<MudText Typo="Typo.body2" Class="mud-text-secondary">تأمین اعتبار از طریق خرید توکن الماس و دریافت وام</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Tertiary" FullWidth="true"
StartIcon="@Icons.Material.Filled.AccountBalance" OnClick="DayaLoanPayment">
تأمین اعتبار الماسی
</MudButton>
</MudStack>
</MudPaper>
<MudButton Variant="Variant.Text" Color="Color.Default" FullWidth="true" OnClick="ClosePurchaseOptions">
بستن
</MudButton>
</MudStack>
</DialogContent>
</MudDialog>
@@ -42,19 +42,18 @@ public partial class Index
private bool _isClubMemberActive;
private bool CanShowReferralLink => _hasPurchasedPackage && _isClubMemberActive;
// Purchase flow
// Purchase Options Bottom Sheet
private bool _showPurchaseBottomSheet;
private bool _isProcessingPayment;
private int _purchaseCycleCount; // تعداد دورهای خرید — اگر > 0 فقط IPG مجاز
// Dashboard Tiles
private readonly List<DashTile> _dashTiles = new()
{
new(RouteConstants.Profile.Personal, Icons.Material.Filled.Person, "اطلاعات شخصی", "نمایش و ویرایش", "background:rgba(99,102,241,.12); color:#6366f1;"),
new(RouteConstants.Profile.Addresses, Icons.Material.Filled.LocationOn, "آدرس‌ها", "مدیریت آدرس‌ها", "background:rgba(99,102,241,.12); color:#6366f1;"),
new(RouteConstants.Profile.Tree, Icons.Material.Filled.AccountTree, "سازمان فروش", "ساختار تیم", "background:rgba(99,102,241,.12); color:#6366f1;"),
new(RouteConstants.Profile.Tree, Icons.Material.Filled.AccountTree, "شجره‌نامه", "ساختار تیم", "background:rgba(99,102,241,.12); color:#6366f1;"),
new(RouteConstants.Gateway.StoreChooser, Icons.Material.Filled.Store, "فروشگاه‌ها", "ورود به فروشگاه", "background:rgba(16,185,129,.12); color:#10b981;"),
new(RouteConstants.Profile.Wallet, Icons.Material.Filled.AccountBalanceWallet, "کیف پول", "مدیریت و تاریخچه", "background:rgba(16,185,129,.12); color:#10b981;"),
new(RouteConstants.Profile.MagicWallet, Icons.Material.Filled.AutoAwesome, "کیف‌پول جادویی", "شارژ چند‌برابری", "background:rgba(168,85,247,.12); color:#a855f7;"),
new(RouteConstants.Profile.WithdrawalRequests, Icons.Material.Filled.RequestPage, "درخواست برداشت", "ثبت و پیگیری", "background:rgba(245,158,11,.12); color:#f59e0b;"),
new(RouteConstants.Club.Membership, Icons.Material.Filled.CardMembership, "باشگاه مشتریان", "عضویت و مزایا", "background:rgba(168,85,247,.12); color:#a855f7;"),
new(RouteConstants.Network.Statistics, Icons.Material.Filled.Groups, "آمار باشگاه", "جزئیات باشگاه", "background:rgba(59,130,246,.12); color:#3b82f6;"),
@@ -148,11 +147,6 @@ public partial class Index
var discount = FormatPrice(b.DiscountBalance);
_walletNetwork = FormatPrice(b.NetworkBalance);
_walletDiscount = discount;
// بارگذاری تعداد دورهای خرید برای کنترل روش‌های پرداخت
var magicStatus = await WalletService.GetMagicWalletStatusAsync();
_purchaseCycleCount = magicStatus.PurchaseCycleCount;
StateHasChanged();
}
@@ -333,7 +327,6 @@ public partial class Index
if (!result.Canceled)
{
await LoadAddresses();
await AuthService.RefreshTokenAsync();
}
}
@@ -386,7 +379,6 @@ public partial class Index
});
Snackbar.Add("آدرس با موفقیت حذف شد.", Severity.Success);
await LoadAddresses();
await AuthService.RefreshTokenAsync();
}
catch (Exception ex)
{
@@ -395,62 +387,41 @@ public partial class Index
}
}
private async Task OpenPurchaseOptions()
private void OpenPurchaseOptions()
{
var options = new DialogOptions
{
MaxWidth = MaxWidth.Small,
FullWidth = true,
CloseOnEscapeKey = true,
BackdropClick = true
};
var parameters = new DialogParameters<Shared.PackagePurchaseDialog>
{
{ x => x.PurchaseCycleCount, _purchaseCycleCount }
};
var dialog = await DialogService.ShowAsync<Shared.PackagePurchaseDialog>(
string.Empty, parameters, options);
var result = await dialog.Result;
if (result.Canceled) return;
if (result.Data is Shared.PackagePurchaseDialog.PackagePurchaseResult purchase)
{
switch (purchase.Method)
{
case Shared.PackagePurchaseDialog.PaymentMethodType.DirectPayment:
await ProcessDirectPayment(purchase.Package.Id);
break;
case Shared.PackagePurchaseDialog.PaymentMethodType.DayaLoan:
await ProcessDayaLoanPayment();
break;
}
}
_showPurchaseBottomSheet = true;
}
private async Task ProcessDirectPayment(long packageId)
private void ClosePurchaseOptions()
{
_showPurchaseBottomSheet = false;
}
private async Task DirectPayment()
{
if (_isProcessingPayment) return;
_isProcessingPayment = true;
StateHasChanged();
_showPurchaseBottomSheet = false;
try
{
var response = await PackageContract.CustomerPurchasePackageAsync(new CustomerPurchasePackageRequest
// Create callback URL for payment verification
var callbackUrl = $"{Navigation.BaseUri}profile/payment-callback";
// Call BFF to initiate payment (UserId is taken from JWT token in BFF)
var response = await PackageContract.InitiateBasePackagePaymentAsync(new InitiateBasePackagePaymentRequest
{
PackageId = packageId,
PurchaseMethod = PurchaseMethodEnum.PurchaseMethodGateway
CallbackUrl = callbackUrl
});
if (!response.Success)
{
Snackbar.Add(response.Message ?? "خطا در آغاز فرآیند پرداخت", Severity.Error);
return;
}
// Redirect to payment gateway
if (!string.IsNullOrEmpty(response.PaymentGatewayUrl))
{
Navigation.NavigateTo(response.PaymentGatewayUrl, forceLoad: true);
@@ -471,29 +442,41 @@ public partial class Index
}
}
private async Task ProcessDayaLoanPayment()
private async Task DayaLoanPayment()
{
// Validate user info before redirecting
if (string.IsNullOrWhiteSpace(_updateUserRequest.FirstName))
{
Snackbar.Add("لطفا اطلاعات شخصی خود را تکمیل کنید. (نام وارد نشده)", Severity.Error);
_showPurchaseBottomSheet = false;
Snackbar.Add($"لطفا اطلاعات شخصی خود را تکمیل کنید. (نام وارد نشده)", Severity.Error);
return;
}
if (string.IsNullOrWhiteSpace(_updateUserRequest.LastName))
{
Snackbar.Add("لطفا اطلاعات شخصی خود را تکمیل کنید. (نام خانوادگی وارد نشده)", Severity.Error);
_showPurchaseBottomSheet = false;
Snackbar.Add($"لطفا اطلاعات شخصی خود را تکمیل کنید. (نام خانوادگی وارد نشده)", Severity.Error);
return;
}
if (string.IsNullOrWhiteSpace(_updateUserRequest.NationalCode))
{
Snackbar.Add("لطفا اطلاعات شخصی خود را تکمیل کنید. (کدملی وارد نشده)", Severity.Error);
_showPurchaseBottomSheet = false;
Snackbar.Add($"لطفا اطلاعات شخصی خود را تکمیل کنید. (کدملی وارد نشده)", Severity.Error);
return;
}
if (_updateUserRequest.BirthDate == null)
{
Snackbar.Add("لطفا اطلاعات شخصی خود را تکمیل کنید. (تاریخ تولد وارد نشده)", Severity.Error);
_showPurchaseBottomSheet = false;
Snackbar.Add($"لطفا اطلاعات شخصی خود را تکمیل کنید. (تاریخ تولد وارد نشده)", Severity.Error);
return;
}
if (!_addresses.Any())
{
_showPurchaseBottomSheet = false;
Snackbar.Add($"آدرس محل سکونت شما الزامی است!", Severity.Error);
return;
}
_showPurchaseBottomSheet = false;
var url = "https://dayadiamond.ir/profile/creditpurchase/?merchantcode=56146364";
await JSRuntime.InvokeVoidAsync("open", url, "_blank");
}
@@ -1,229 +0,0 @@
@attribute [Route(RouteConstants.Profile.MagicWallet)]
@attribute [Authorize]
<PageTitle>کیف‌پول جادویی</PageTitle>
<MudContainer MaxWidth="MaxWidth.Medium" Class="py-6">
<MudStack Spacing="3">
<PageHeader Title="🪄 کیف‌پول جادویی" BackHref="@RouteConstants.Profile.Wallet" />
@if (_isLoading)
{
<MudPaper Elevation="2" Class="pa-6 rounded-lg text-center">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" />
<MudText Class="mt-3">در حال بارگذاری...</MudText>
</MudPaper>
}
else if (_status.WalletMode != 1)
{
@* ═══ حالت عادی — Magic فعال نیست ═══ *@
<MudPaper Elevation="2" Class="pa-6 rounded-lg text-center">
<MudIcon Icon="@Icons.Material.Filled.Lock" Color="Color.Default" Size="Size.Large" Style="font-size: 64px;" />
<MudText Typo="Typo.h5" Class="mt-4">کیف‌پول جادویی فعال نیست</MudText>
<MudText Typo="Typo.body1" Class="mt-2 mud-text-secondary">
برای فعال شدن کیف‌پول جادویی، ابتدا پکیج پایه را خریداری کنید و سپس
تمام موجودی اصلی خود را از فروشگاه خرج کنید. وقتی موجودی اصلی به صفر برسد،
کیف‌پول جادویی فعال می‌شود.
</MudText>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Class="mt-4 rounded-pill"
Href="@RouteConstants.Profile.Wallet"
StartIcon="@Icons.Material.Filled.AccountBalanceWallet">
بازگشت به کیف پول
</MudButton>
</MudPaper>
}
else
{
@* ═══ حالت جادویی فعال ═══ *@
@* ── G4: هشدار تنظیمات پکیج‌بیس (Q28) ── *@
<MudAlert Severity="Severity.Warning" Variant="Variant.Text" Dense="true" Icon="@Icons.Material.Filled.Warning">
سقف واریز و ضریب اعتبار کیف‌پول جادویی بر اساس پکیج شما تنظیم شده است. پکیج‌های بالاتر سقف و ضریب بیشتری دارند.
</MudAlert>
@* ─── وضعیت و پیشرفت ─── *@
<MudPaper Elevation="2" Class="pa-5 rounded-xl" Style="background: linear-gradient(135deg, #7c3aed 0%, #a855f7 100%); color: white;">
<MudStack Spacing="2">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.AutoAwesome" Size="Size.Large" />
<MudText Typo="Typo.h5">حالت جادویی فعال</MudText>
</MudStack>
<MudGrid Spacing="2">
<MudItem xs="6" sm="3">
<MudText Typo="Typo.caption" Style="opacity:0.8">موجودی فعلی</MudText>
<MudText Typo="Typo.h6">@FormatPrice(_status.Balance)</MudText>
</MudItem>
<MudItem xs="6" sm="3">
<MudText Typo="Typo.caption" Style="opacity:0.8">مجموع واریزی</MudText>
<MudText Typo="Typo.h6">@FormatPrice(_status.MagicTotalDeposited)</MudText>
</MudItem>
<MudItem xs="6" sm="3">
<MudText Typo="Typo.caption" Style="opacity:0.8">مجموع اعتبار</MudText>
<MudText Typo="Typo.h6">@FormatPrice(_status.MagicTotalCredited)</MudText>
</MudItem>
<MudItem xs="6" sm="3">
<MudText Typo="Typo.caption" Style="opacity:0.8">سقف باقیمانده</MudText>
<MudText Typo="Typo.h6">@FormatPrice(_status.MagicRemainingDeposit)</MudText>
</MudItem>
</MudGrid>
@* نوار پیشرفت *@
<MudStack Spacing="0" Class="mt-1">
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.caption" Style="opacity:0.8">پیشرفت سقف واریز</MudText>
<MudText Typo="Typo.caption" Style="opacity:0.8">@(_progressPercent.ToString("0"))%</MudText>
</MudStack>
<MudProgressLinear Value="@_progressPercent" Color="Color.Warning" Size="Size.Medium"
Rounded="true" Class="mt-1" Style="background: rgba(255,255,255,0.3);" />
</MudStack>
@if (_status.MagicActivatedAt.HasValue)
{
<MudText Typo="Typo.caption" Style="opacity:0.7">
فعال از: @_status.MagicActivatedAt.Value.MiladiToJalaliWithTime()
</MudText>
}
</MudStack>
</MudPaper>
@* ─── فرم شارژ ─── *@
@if (_status.MagicRemainingDeposit > 0)
{
<MudPaper Elevation="2" Class="pa-5 rounded-lg">
<MudText Typo="Typo.h6" Class="mb-3">
<MudIcon Icon="@Icons.Material.Filled.CreditCard" Class="ml-1" />
شارژ کیف‌پول جادویی
</MudText>
<MudAlert Severity="MudBlazor.Severity.Info" Dense="true" Class="mb-3" Icon="@Icons.Material.Filled.Info">
مبلغ واریزی شما <strong>×@_status.MagicMultiplier.ToString("0.#")</strong> به موجودی اضافه می‌شود.
مثلاً ۱۰ میلیون واریز = @FormatToman((long)(10_000_000 * (decimal)_status.MagicMultiplier)) اعتبار.
</MudAlert>
<MudStack Spacing="2">
<MudNumericField @bind-Value="_chargeAmount"
Label="مبلغ واریز (تومان)"
Variant="Variant.Outlined"
Min="10_000"
Max="@_status.MagicRemainingDeposit"
Format="N0"
Adornment="Adornment.Start"
AdornmentIcon="@Icons.Material.Filled.Payments"
HelperText="@($"حداکثر: {FormatPrice(_status.MagicRemainingDeposit)} (تومان)")" />
@if (_chargeAmount > 0)
{
<MudPaper Outlined="true" Class="pa-3 rounded-lg" Style="background: #f0fdf4;">
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2">مبلغ واریز:</MudText>
<MudText Typo="Typo.body2">@FormatToman(_chargeAmount)</MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Success"><strong>اعتبار دریافتی (×@_status.MagicMultiplier.ToString("0.#")):</strong></MudText>
<MudText Typo="Typo.body2" Color="Color.Success"><strong>@FormatToman((long)(_chargeAmount * (decimal)_status.MagicMultiplier))</strong></MudText>
</MudStack>
</MudPaper>
}
@* دکمه‌های مبلغ سریع *@
<MudStack Row="true" Spacing="1" Class="flex-wrap">
@foreach (var preset in _presetAmounts)
{
if (preset <= _status.MagicRemainingDeposit)
{
<MudButton Variant="Variant.Outlined" Size="Size.Small"
Color="@(_chargeAmount == preset ? Color.Primary : Color.Default)"
OnClick="() => _chargeAmount = preset"
Class="rounded-pill">
@FormatToman(preset)
</MudButton>
}
}
</MudStack>
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
Size="Size.Large"
FullWidth="true"
Disabled="@(_isProcessing || _chargeAmount < 10_000)"
OnClick="StartMagicCharge"
StartIcon="@Icons.Material.Filled.Payment"
Class="rounded-lg mt-2">
@if (_isProcessing)
{
<MudProgressCircular Size="Size.Small" Indeterminate="true" Class="me-2" />
<span>در حال انتقال به درگاه...</span>
}
else
{
<span>پرداخت با کارت بانکی</span>
}
</MudButton>
</MudStack>
</MudPaper>
}
else
{
<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>
}
@* ─── قوانین ─── *@
<MudExpansionPanels Elevation="1" Class="rounded-lg">
<MudExpansionPanel Text="قوانین کیف‌پول جادویی" MaxHeight="500" IsInitiallyExpanded="false">
<MudList T="string" Dense="true">
<MudListItem T="string" Icon="@Icons.Material.Filled.CheckCircle" IconColor="Color.Success">
هر مبلغ واریزی ×@_status.MagicMultiplier.ToString("0.#") به موجودی اضافه می‌شود
</MudListItem>
<MudListItem T="string" Icon="@Icons.Material.Filled.CheckCircle" IconColor="Color.Success">
سقف واریز در هر دور: @FormatPrice(_status.MagicMaxDeposit)
</MudListItem>
<MudListItem T="string" Icon="@Icons.Material.Filled.CheckCircle" IconColor="Color.Success">
حداکثر اعتبار: @FormatPrice(_status.MagicMaxCredit)
</MudListItem>
<MudListItem T="string" Icon="@Icons.Material.Filled.Warning" IconColor="Color.Warning">
در حالت جادویی، کمیسیون و پاداش های دریافتی غیرفعال است
</MudListItem>
<MudListItem T="string" Icon="@Icons.Material.Filled.Info" IconColor="Color.Info">
بعد از اتمام سقف و خرج موجودی، با خرید مجدد پکیج دور جدید شروع می‌شود
</MudListItem>
</MudList>
</MudExpansionPanel>
</MudExpansionPanels>
}
@* ─── نتیجه پرداخت (اگه از callback برگشته) ─── *@
@if (_paymentResult != null)
{
<MudAlert Severity="@(_paymentResult == "success" ? MudBlazor.Severity.Success : MudBlazor.Severity.Error)"
Class="rounded-lg" Variant="Variant.Filled">
@if (_paymentResult == "success")
{
<span>شارژ جادویی با موفقیت انجام شد! ✅</span>
}
else
{
<span>پرداخت ناموفق بود. لطفاً دوباره تلاش کنید.</span>
}
</MudAlert>
}
</MudStack>
</MudContainer>
@@ -1,77 +0,0 @@
using DateTimeConverterCL;
using FrontOffice.Main.Utilities;
using Microsoft.AspNetCore.Components;
using MudBlazor;
namespace FrontOffice.Main.Pages.Profile;
public partial class MagicWallet : ComponentBase
{
private MagicWalletStatus _status = new(0, 0, 0, 0, 0, 0, null, 0);
private bool _isLoading = true;
private bool _isProcessing;
private long _chargeAmount;
private double _progressPercent;
private string? _paymentResult;
private readonly long[] _presetAmounts = { 1_000_000, 5_000_000, 10_000_000, 20_000_000, 50_000_000, 100_000_000 };
[SupplyParameterFromQuery(Name = "payment")]
public string? PaymentQueryParam { get; set; }
protected override async Task OnInitializedAsync()
{
_paymentResult = PaymentQueryParam;
await LoadStatus();
}
private async Task LoadStatus()
{
_isLoading = true;
_status = await WalletService.GetMagicWalletStatusAsync();
_progressPercent = _status.MagicMaxDeposit > 0
? (double)_status.MagicTotalDeposited / _status.MagicMaxDeposit * 100
: 0;
_isLoading = false;
}
private async Task StartMagicCharge()
{
if (_chargeAmount <= 0 || _isProcessing) return;
_isProcessing = true;
StateHasChanged();
try
{
// مبلغ به تومان — CMS خودش موقع ارسال به درگاه ×۱۰ می‌کنه
var (success, gatewayUrl, error) = await WalletService.InitiateMagicChargeAsync(_chargeAmount);
if (success && !string.IsNullOrEmpty(gatewayUrl))
{
Navigation.NavigateTo(gatewayUrl, forceLoad: true);
}
else
{
Snackbar.Add(error ?? "خطا در ایجاد درخواست پرداخت", Severity.Error);
}
}
catch (Exception ex)
{
Snackbar.Add($"خطا: {ex.Message}", Severity.Error);
}
finally
{
_isProcessing = false;
StateHasChanged();
}
}
private static string FormatPrice(long price)
=> string.Format("{0:N0} تومان", price);
private static string FormatToman(long toman)
=> string.Format("{0:N0} تومان", toman);
}
@@ -3,7 +3,7 @@
@using Blazored.LocalStorage
@using CMSMicroservice.Protobuf.Protos.Package
@using CMSMicroservice.Protobuf.Protos.User
@using FrontOffice.Main.Utilities
@inject IJSRuntime JS
<PageTitle>نتیجه پرداخت | کارا بازار سلامت</PageTitle>
@@ -21,10 +21,10 @@
<MudText Typo="Typo.h5" Color="Color.Success" Class="mt-4">پرداخت موفق</MudText>
<MudText Typo="Typo.body1" Class="mt-2">@_message</MudText>
@if (_transactionId > 0)
@if (!string.IsNullOrEmpty(_refId))
{
<MudText Typo="Typo.body2" Class="mt-3">
<strong>کد ارجاعی:</strong> @_transactionId
<strong>کد پیگیری:</strong> @_refId
</MudText>
}
@@ -33,10 +33,13 @@
<MudText Typo="Typo.body2" Color="Color.Secondary">
موجودی کیف پول: @FormatPrice(_walletBalance)
</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary">
موجودی اعتبار تخفیف: @FormatPrice(_discountBalance)
</MudText>
<MudButton Color="Color.Primary" Variant="Variant.Filled" Class="mt-6"
Href="@_successReturnUrl">
@_successReturnText
OnClick="GoBack">
بازگشت به پروفایل
</MudButton>
}
else
@@ -46,8 +49,8 @@
<MudText Typo="Typo.body1" Class="mt-2">@_message</MudText>
<MudButton Color="Color.Primary" Variant="Variant.Filled" Class="mt-6"
Href="@_failReturnUrl">
@_failReturnText
OnClick="GoBack">
بازگشت به پروفایل
</MudButton>
<MudButton Color="Color.Secondary" Variant="Variant.Outlined" Class="mt-3 mr-2"
@@ -61,19 +64,12 @@
@code {
[Inject] private PackageContract.PackageContractClient PackageContractClient { get; set; } = default!;
[Inject] private UserContract.UserContractClient UserContractClient { get; set; } = default!;
[Inject] private DiscountOrderService DiscountOrderService { get; set; } = default!;
[Inject] private ILocalStorageService LocalStorage { get; set; } = default!;
[Inject] private AuthService AuthService { get; set; } = default!;
[Inject] private NavigationManager NavManager { get; set; } = default!;
private const string TokenStorageKey = "auth:token";
/// <summary>
/// نوع پرداخت: package (پیش‌فرض) | magic-wallet | discount-wallet | discount-order
/// </summary>
[SupplyParameterFromQuery(Name = "type")]
private string? PaymentType { get; set; }
[SupplyParameterFromQuery(Name = "orderId")]
private long OrderId { get; set; }
@@ -89,42 +85,15 @@
private bool _isLoading = true;
private bool _isSuccess;
private string _message = string.Empty;
private long _transactionId;
private string? _refId;
private long _walletBalance;
private bool _verifyCompleted;
private readonly SemaphoreSlim _verifyGate = new(1, 1);
// دکمه‌های بازگشت بسته به نوع پرداخت
private string _successReturnUrl = "/profile";
private string _successReturnText = "بازگشت به پروفایل";
private string _failReturnUrl = "/profile";
private string _failReturnText = "بازگشت به پروفایل";
private long _discountBalance;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && !_verifyCompleted)
if (firstRender)
{
await VerifyPaymentOnceAsync();
}
}
/// <summary>
/// Blazor Server may invoke render/verify twice in quick succession — gate with semaphore.
/// </summary>
private async Task VerifyPaymentOnceAsync()
{
if (_verifyCompleted) return;
await _verifyGate.WaitAsync();
try
{
if (_verifyCompleted) return;
await VerifyPayment();
_verifyCompleted = true;
}
finally
{
_verifyGate.Release();
}
}
@@ -132,26 +101,35 @@
{
try
{
// تعیین نوع پرداخت — اگر type نباشد، پیش‌فرض خرید پکیج
var type = PaymentType?.ToLowerInvariant() ?? "package";
switch (type)
if (OrderId <= 0 || TransactionId <= 0 || string.IsNullOrEmpty(Authority))
{
case "magic-wallet":
await VerifyMagicWalletCharge();
break;
case "discount-wallet":
await VerifyDiscountWalletCharge();
break;
case "credit-wallet":
await VerifyCreditWalletCharge();
break;
case "discount-order":
await VerifyDiscountOrderPayment();
break;
default: // "package" or missing
await VerifyPackagePurchase();
break;
_isSuccess = false;
_message = "پارامترهای پرداخت نامعتبر است";
_isLoading = false;
StateHasChanged();
return;
}
var response = await PackageContractClient.VerifyBasePackagePaymentAsync(new VerifyBasePackagePaymentRequest
{
OrderId = OrderId,
TransactionId = TransactionId,
PaymentSuccess = Status == "OK",
RefId = !string.IsNullOrEmpty(Authority) ? Authority : null,
Message = !string.IsNullOrEmpty(Status) ? Status : null
});
_isSuccess = response.Success;
_message = response.Message;
_refId = response.ReferenceCode ?? "";
_walletBalance = response.WalletBalance;
_discountBalance = response.DiscountBalance;
// اگر پرداخت موفق بود، توکن را refresh می‌کنیم
// چون PackagePurchaseMethod تغییر کرده و باید در claims جدید باشد
if (_isSuccess)
{
await RefreshTokenAsync();
}
}
catch (Exception ex)
@@ -165,186 +143,10 @@
StateHasChanged();
}
}
/// <summary>
/// تأیید خرید پکیج — رفتار فعلی
/// </summary>
private async Task VerifyPackagePurchase()
{
_successReturnUrl = "/profile";
_successReturnText = "بازگشت به پروفایل";
_failReturnUrl = "/profile";
_failReturnText = "بازگشت به پروفایل";
if (OrderId <= 0 || string.IsNullOrEmpty(Authority))
{
_isSuccess = false;
_message = "پارامترهای پرداخت نامعتبر است";
return;
}
var response = await PackageContractClient.CustomerVerifyPackagePurchaseAsync(new CustomerVerifyPackagePurchaseRequest
{
OrderId = OrderId,
Authority = Authority ?? string.Empty,
Status = Status ?? string.Empty
});
_isSuccess = response.Success;
_message = response.Message;
_transactionId = response.TransactionId;
if (_isSuccess)
{
await UpdateWalletBalanceAndRefreshToken();
}
}
/// <summary>
/// تأیید شارژ کیف‌پول جادویی
/// </summary>
private async Task VerifyMagicWalletCharge()
{
_successReturnUrl = "/profile/magic-wallet";
_successReturnText = "بازگشت به کیف پول جادویی";
_failReturnUrl = "/profile/magic-wallet";
_failReturnText = "بازگشت به کیف پول جادویی";
if (string.IsNullOrEmpty(Authority))
{
_isSuccess = false;
_message = "کد Authority نامعتبر است";
return;
}
var (success, message) = await WalletService.VerifyMagicChargeAsync(
Authority, Status ?? "NOK");
_isSuccess = success;
_message = message;
if (_isSuccess)
{
await UpdateWalletBalance();
}
}
/// <summary>
/// تأیید شارژ کیف پول تخفیفی
/// </summary>
private async Task VerifyDiscountWalletCharge()
{
_successReturnUrl = "/profile/charge-discount-wallet";
_successReturnText = "بازگشت به کیف پول تخفیفی";
_failReturnUrl = "/profile/charge-discount-wallet";
_failReturnText = "بازگشت به کیف پول تخفیفی";
if (string.IsNullOrEmpty(Authority))
{
_isSuccess = false;
_message = "کد Authority نامعتبر است";
return;
}
var (success, message) = await WalletService.VerifyDiscountChargeAsync(
Authority, Status ?? "NOK");
_isSuccess = success;
_message = message;
if (_isSuccess)
{
await UpdateWalletBalance();
}
}
/// <summary>
/// تأیید شارژ کیف پول اصلی
/// </summary>
private async Task VerifyCreditWalletCharge()
{
_successReturnUrl = "/profile/charge-credit-wallet?payment=success";
_successReturnText = "بازگشت به شارژ کیف پول اصلی";
_failReturnUrl = "/profile/charge-credit-wallet?payment=failed";
_failReturnText = "بازگشت به شارژ کیف پول اصلی";
if (string.IsNullOrEmpty(Authority))
{
_isSuccess = false;
_message = "کد Authority نامعتبر است";
return;
}
var (success, message) = await WalletService.VerifyCreditChargeAsync(
Authority, Status ?? "NOK");
_isSuccess = success;
_message = message;
if (_isSuccess)
{
await UpdateWalletBalance();
}
}
/// <summary>
/// تأیید پرداخت سفارش فروشگاه تخفیفی
/// </summary>
private async Task VerifyDiscountOrderPayment()
{
_successReturnUrl = $"/discount-store/order/{OrderId}";
_successReturnText = "مشاهده سفارش";
_failReturnUrl = $"/discount-store/order/{OrderId}";
_failReturnText = "مشاهده سفارش";
if (OrderId <= 0 || string.IsNullOrEmpty(Authority))
{
_isSuccess = false;
_message = "پارامترهای پرداخت نامعتبر است";
return;
}
var (success, message, _) = await DiscountOrderService.VerifyDiscountOrderPaymentAsync(
OrderId, Authority, Status ?? "NOK");
_isSuccess = success;
_message = message;
}
/// <summary>
/// بروزرسانی موجودی کیف پول
/// </summary>
private async Task UpdateWalletBalance()
{
try
{
var balances = await WalletService.GetBalancesAsync();
_walletBalance = balances.CreditBalance;
}
catch { /* اگر خطا شد، صفر نمایش بده */ }
}
/// <summary>
/// بروزرسانی موجودی + refresh توکن (فقط برای خرید پکیج)
/// </summary>
private async Task UpdateWalletBalanceAndRefreshToken()
{
await UpdateWalletBalance();
await RefreshTokenAsync();
}
private void RetryPayment()
{
var type = PaymentType?.ToLowerInvariant() ?? "package";
var url = type switch
{
"magic-wallet" => "/profile/magic-wallet",
"discount-wallet" => "/profile/charge-discount-wallet",
"credit-wallet" => "/profile/charge-credit-wallet",
"discount-order" => "/discount-store",
_ => "/profile"
};
NavManager.NavigateTo(url, forceLoad: true);
NavManager.NavigateTo("/profile");
}
/// <summary>
@@ -372,4 +174,6 @@
}
private static string FormatPrice(long price) => string.Format("{0:N0} تومان", price);
private async Task GoBack() => await JS.InvokeVoidAsync("history.back");
}
@@ -1,11 +1,11 @@
@attribute [Route(RouteConstants.Profile.Tree)]
@using FrontOffice.Main.Pages.Profile.Components
<PageTitle>سازمان فروش</PageTitle>
<PageTitle>شجره‌نامه</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="py-6">
<MudStack Spacing="3">
<PageHeader Title="سازمان فروش" BackHref="@RouteConstants.Profile.Index" />
<PageHeader Title="شجره‌نامه" BackHref="@RouteConstants.Profile.Index" />
<MudPaper Elevation="2" Class="pa-4 rounded-lg">
<OrganizationChart />
+10 -108
View File
@@ -9,13 +9,13 @@
<MudGrid Spacing="2">
<MudItem xs="12" sm="6" md="4">
<MudPaper Elevation="2" Class="pa-4 rounded-lg">
<MudText Typo="Typo.subtitle2" Class="mud-text-secondary">موجودی اصلی</MudText>
<MudText Typo="Typo.subtitle2" Class="mud-text-secondary">موجودی عادی</MudText>
<MudText Typo="Typo.h4" Color="Color.Primary">@_balances.Credit</MudText>
</MudPaper>
</MudItem>
<MudItem xs="12" sm="6" md="4">
<MudPaper Elevation="2" Class="pa-4 rounded-lg">
<MudText Typo="Typo.subtitle2" Class="mud-text-secondary">پاداش های دریافتی</MudText>
<MudText Typo="Typo.subtitle2" Class="mud-text-secondary">موجودی شبکه</MudText>
<MudText Typo="Typo.h4" Color="Color.Success">@_balances.Network</MudText>
</MudPaper>
</MudItem>
@@ -38,104 +38,6 @@
درخواست‌های برداشت
</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"
Color="Color.Primary"
Size="Size.Large"
FullWidth="true"
StartIcon="@Icons.Material.Filled.AccountBalanceWallet"
Href="@RouteConstants.Profile.ChargeCreditWallet"
Class="rounded-lg">
شارژ کیف‌پول اصلی
</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"
Color="Color.Info"
Size="Size.Large"
FullWidth="true"
StartIcon="@Icons.Material.Filled.CreditCard"
Href="@RouteConstants.Profile.ChargeDiscountWallet"
Class="rounded-lg">
شارژ کیف‌پول اعتباری
</MudButton>
<MudPaper Elevation="2" Class="pa-4 rounded-lg">
<MudText Typo="Typo.h6" Class="mb-2">تراکنش‌ها و مسیرهای شارژ</MudText>
@@ -169,8 +71,8 @@
<MudTable Items="_txs" Dense="true" Hover="true" Striped="true">
<HeaderContent>
<MudTh>تاریخ</MudTh>
<MudTh>اصلی (تغییرات / مانده)</MudTh>
<MudTh>پاداش های دریافتی (تغییرات / مانده)</MudTh>
<MudTh>عادی (تغییرات / مانده)</MudTh>
<MudTh>شبکه (تغییرات / مانده)</MudTh>
<MudTh>اعتباری (تغییرات / مانده)</MudTh>
<MudTh>شناسه ارجاع</MudTh>
<MudTh>توضیحات</MudTh>
@@ -179,7 +81,7 @@
<MudTd DataLabel="تاریخ">
<MudText Typo="Typo.caption">@context.Date</MudText>
</MudTd>
<MudTd DataLabel="اصلی">
<MudTd DataLabel="عادی">
<MudStack Spacing="0">
<MudText Color="@(context.CreditChange > 0 ? Color.Success : context.CreditChange < 0 ? Color.Error : Color.Default)" Typo="Typo.body2">
@(context.CreditChange != 0 ? (context.CreditChange > 0 ? "+" : "") + FormatPrice(context.CreditChange) : "-")
@@ -187,7 +89,7 @@
<MudText Typo="Typo.caption" Class="mud-text-secondary">@FormatPrice(context.CreditBalance)</MudText>
</MudStack>
</MudTd>
<MudTd DataLabel="پاداش های دریافتی">
<MudTd DataLabel="شبکه">
<MudStack Spacing="0">
<MudText Color="@(context.NetworkChange > 0 ? Color.Success : context.NetworkChange < 0 ? Color.Error : Color.Default)" Typo="Typo.body2">
@(context.NetworkChange != 0 ? (context.NetworkChange > 0 ? "+" : "") + FormatPrice(context.NetworkChange) : "-")
@@ -223,10 +125,10 @@
<MudDivider />
<MudStack Row="true" Spacing="2" Justify="Justify.SpaceBetween">
<!-- کیف پول اصلی -->
<!-- کیف پول عادی -->
<MudPaper Class="pa-2 flex-grow-1" Outlined="true">
<MudStack Spacing="1" AlignItems="AlignItems.Center">
<MudText Typo="Typo.caption" Color="Color.Primary">اصلی</MudText>
<MudText Typo="Typo.caption" Color="Color.Primary">عادی</MudText>
<MudText Color="@(tx.CreditChange > 0 ? Color.Success : tx.CreditChange < 0 ? Color.Error : Color.Default)" Style="font-weight: 600; font-size: 0.75rem;">
@(tx.CreditChange != 0 ? (tx.CreditChange > 0 ? "+" : "") + FormatPrice(tx.CreditChange) : "-")
</MudText>
@@ -234,10 +136,10 @@
</MudStack>
</MudPaper>
<!-- پاداش های دریافتی -->
<!-- کیف پول شبکه -->
<MudPaper Class="pa-2 flex-grow-1" Outlined="true">
<MudStack Spacing="1" AlignItems="AlignItems.Center">
<MudText Typo="Typo.caption" Color="Color.Success">پاداش های دریافتی</MudText>
<MudText Typo="Typo.caption" Color="Color.Success">شبکه</MudText>
<MudText Color="@(tx.NetworkChange > 0 ? Color.Success : tx.NetworkChange < 0 ? Color.Error : Color.Default)" Style="font-weight: 600; font-size: 0.75rem;">
@(tx.NetworkChange != 0 ? (tx.NetworkChange > 0 ? "+" : "") + FormatPrice(tx.NetworkChange) : "-")
</MudText>
@@ -6,43 +6,13 @@ namespace FrontOffice.Main.Pages.Profile;
public partial class Wallet : ComponentBase
{
[Inject] private AuthService AuthService { get; set; } = default!;
private (string Credit, string Discount, string Network) _balances = ("-", "-", "-");
private List<WalletTransaction> _txs = new();
private string? _filterReferenceId;
private string _filterType = "all";
private bool _isClubMemberActive;
private bool _hasPurchasedPackage;
private bool _isMagicWallet;
private bool _magicCeilingFull;
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();
_balances = (FormatPrice(b.CreditBalance), FormatPrice(b.DiscountBalance), FormatPrice(b.NetworkBalance));
_txs = await WalletService.GetTransactionsAsync();
@@ -58,19 +58,8 @@ public partial class WithdrawalRequests : ComponentBase
try
{
_isSubmittingWithdrawal = true;
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);
_withdrawIban=_withdrawIban!.Trim().ToUpper().Replace("IR", "").Replace(" ", "");
await WalletService.RequestWithdrawalAsync(_selectedPayout.Id, _withdrawMethod, "IR"+_withdrawIban);
Snackbar.Add("درخواست برداشت ثبت شد.", Severity.Success);
// بروزرسانی لیست
@@ -136,26 +125,4 @@ public partial class WithdrawalRequests : ComponentBase
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;
}
}
+2 -2
View File
@@ -31,7 +31,7 @@
<MudStack Spacing="1">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<AppImage Path="@GetProductImageUrl(context.ImageUrl)" Alt="@context.Title"
ImgWidth="64" ImgHeight="64" Class="rounded-lg" ObjectFit="ObjectFit.Cover" />
ImgWidth="64" ImgHeight="64" Class="product-thumb" />
<MudText>@context.Title</MudText>
</MudStack>
@if (context.Discount > 0 || !string.IsNullOrWhiteSpace(context.Created) || !string.IsNullOrWhiteSpace(context.Description))
@@ -87,7 +87,7 @@
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<AppImage Path="@GetProductImageUrl(item.ImageUrl)" Alt="@item.Title"
ImgWidth="50" ImgHeight="50" Class="rounded-lg" ObjectFit="ObjectFit.Cover" />
ImgWidth="50" ImgHeight="50" Class="rounded-circle" />
<MudText Typo="Typo.subtitle2">@item.Title</MudText>
</MudStack>
@if (item.Discount > 0)
@@ -8,17 +8,11 @@ public partial class Cart : ComponentBase, IDisposable
{
[Inject] private CartService CartService { get; set; } = default!;
[Inject] private VATService VAT { get; set; } = default!;
[Inject] private AuthDialogService AuthDialogService { get; set; } = default!;
[Inject] private AuthService AuthService { get; set; } = default!;
// Navigation and Snackbar are available via _Imports.razor
private CartService CartData => CartService;
protected override async Task OnInitializedAsync()
{
if (!await AuthService.IsAuthenticatedAsync())
{
await AuthDialogService.ShowAuthDialogAsync();
}
// لود سبد خرید (فقط اگر کاربر لاگین کرده باشد)
await CartService.EnsureInitializedAsync();
CartService.OnChange += StateHasChanged;
@@ -21,11 +21,9 @@
else if (_addresses.Count == 0)
{
<MudAlert Severity="Severity.Warning">
هیچ آدرسی ثبت نشده است. می‌توانید همین‌جا آدرس جدید اضافه کنید.
هیچ آدرسی ثبت نشده است. لطفاً از بخش پروفایل آدرس خود را اضافه کنید.
</MudAlert>
<MudButton Class="mt-2" Variant="Variant.Outlined" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="OpenAddAddressDialog">افزودن آدرس</MudButton>
<MudButton Class="mt-2" Variant="Variant.Outlined" Href="/profile/addresses">افزودن آدرس</MudButton>
}
else
{
@@ -42,25 +40,13 @@
<MudText Typo="Typo.subtitle2">@address.Title</MudText>
<MudText Typo="Typo.body2" Class="mud-text-secondary">@address.Address</MudText>
</MudStack>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
@if (address.IsDefault)
{
<MudChip T="string" Color="Color.Success" Variant="Variant.Outlined" Size="Size.Small">پیش‌فرض</MudChip>
}
<span @onclick:stopPropagation="true">
<MudIconButton Icon="@Icons.Material.Filled.Edit"
Size="Size.Small"
Color="Color.Primary"
aria-label="ویرایش آدرس"
OnClick="@(() => OpenEditAddressDialog(address))" />
</span>
</MudStack>
@if (address.IsDefault)
{
<MudChip T="string" Color="Color.Success" Variant="Variant.Outlined">پیش‌فرض</MudChip>
}
</MudStack>
</MudPaper>
}
<MudButton Class="mt-2" Variant="Variant.Text" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Add"
OnClick="OpenAddAddressDialog">افزودن آدرس جدید</MudButton>
</MudStack>
}
</MudPaper>
@@ -100,7 +86,7 @@
<MudStack Spacing="1">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<AppImage Path="@GetProductImageUrl(item.ImageUrl)" Alt="@item.Title"
ImgWidth="48" ImgHeight="48" Class="rounded-lg" ObjectFit="ObjectFit.Cover" />
ImgWidth="48" ImgHeight="48" Class="rounded-circle" />
<MudText Typo="Typo.subtitle2">@item.Title</MudText>
<MudText Typo="Typo.subtitle2">@FormatPrice(item.LineTotal)</MudText>
</MudStack>
@@ -1,7 +1,5 @@
using CMSMicroservice.Protobuf.Protos.UserAddress;
using CMSMicroservice.Protobuf.Protos.UserOrder;
using FrontOffice.Main.Pages.Profile.Components;
using FrontOffice.Main.Shared;
using FrontOffice.Main.Utilities;
using Microsoft.AspNetCore.Components;
using MudBlazor;
@@ -16,8 +14,6 @@ public partial class CheckoutSummary : ComponentBase
[Inject] private VATService VAT { get; set; } = default!;
[Inject] private UserAddressContract.UserAddressContractClient UserAddressContract { get; set; } = default!;
[Inject] private UserOrderContract.UserOrderContractClient UserOrderContract { get; set; } = default!;
[Inject] private AuthDialogService AuthDialogService { get; set; } = default!;
[Inject] private AuthService AuthService { get; set; } = default!;
// Snackbar and Navigation are injected via _Imports.razor
private List<CustomerAddressModel> _addresses = new();
@@ -31,16 +27,9 @@ public partial class CheckoutSummary : ComponentBase
protected override async Task OnInitializedAsync()
{
if (!await AuthService.IsAuthenticatedAsync())
{
await AuthDialogService.ShowAuthDialogAsync();
}
// لود سبد خرید (فقط اگر کاربر لاگین کرده باشد)
await Cart.EnsureInitializedAsync();
var userInfo = await AuthService.GetUserAuthInfo();
if (userInfo.HasAddress)
await LoadAddresses();
else
_addresses = new();
await LoadAddresses();
await LoadWalletBalance();
}
@@ -48,6 +37,7 @@ public partial class CheckoutSummary : ComponentBase
{
if (firstRender)
{
// بارگذاری نرخ VAT
await VAT.LoadAsync();
}
await base.OnAfterRenderAsync(firstRender);
@@ -56,7 +46,9 @@ public partial class CheckoutSummary : ComponentBase
private async Task LoadWalletBalance()
{
var walletResult = await WalletService.GetBalancesAsync();
walletBalance = walletResult.CreditBalance;
walletBalance = walletResult.CreditBalance
// + walletResult.NetworkBalance
;
}
private async Task LoadAddresses()
@@ -73,14 +65,12 @@ public partial class CheckoutSummary : ComponentBase
else
{
_addresses = new();
_selectedAddress = null;
}
}
catch (Exception ex)
{
Snackbar.Add($"خطا در بارگذاری آدرس‌ها: {ex.Message}", Severity.Error);
_addresses = new();
_selectedAddress = null;
}
finally
{
@@ -89,30 +79,6 @@ public partial class CheckoutSummary : ComponentBase
}
}
private async Task OpenAddAddressDialog()
{
var dialog = await DialogService.ShowAsync<AddAddressDialog>("افزودن آدرس جدید");
var result = await dialog.Result;
if (result is { Canceled: false })
{
await AuthService.RefreshTokenAsync();
await LoadAddresses();
}
}
private async Task OpenEditAddressDialog(CustomerAddressModel address)
{
var dialog = await DialogService.ShowAsync<EditAddressDialog>("ویرایش آدرس", new DialogParameters<EditAddressDialog>
{
{ x => x.Model, address }
});
var result = await dialog.Result;
if (result is { Canceled: false })
{
await LoadAddresses();
}
}
private async Task PlaceOrder()
{
if (!CanPlaceOrder || _selectedAddress is null)
@@ -121,15 +87,11 @@ public partial class CheckoutSummary : ComponentBase
return;
}
var totalRequired = VAT.AddVAT(Cart.Total);
if (await TryHandleInsufficientBalanceAsync(totalRequired))
return;
try
{
var request = new SubmitShopBuyOrderRequest
{
TotalAmount = totalRequired
TotalAmount = VAT.AddVAT(Cart.Total)
};
var response = await UserOrderContract.SubmitShopBuyOrderAsync(request);
@@ -139,87 +101,17 @@ public partial class CheckoutSummary : ComponentBase
}
catch (Exception ex)
{
if (CreditChargeNavigation.IsInsufficientWalletBalance(ex))
{
await LoadWalletBalance();
await TryHandleInsufficientBalanceAsync(totalRequired);
return;
}
Snackbar.Add($"خطا در ثبت سفارش: {ex.Message}", Severity.Error);
}
}
private async Task<bool> TryHandleInsufficientBalanceAsync(long totalRequired)
{
if (walletBalance >= totalRequired)
return false;
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>
{
{ x => x.CurrentBalance, walletBalance },
{ x => x.RequiredAmount, totalRequired },
{ 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>(
"موجودی کافی نیست",
parameters,
new DialogOptions
{
CloseOnEscapeKey = true,
MaxWidth = MaxWidth.Small,
FullWidth = true
});
var result = await dialog.Result;
if (allowCharge && result is { Canceled: false } && result.Data is long chargeShortfall)
{
var chargeAmount = CreditChargeNavigation.NormalizeChargeAmount(chargeShortfall);
Navigation.NavigateTo(CreditChargeNavigation.BuildChargeUrl(
chargeAmount,
RouteConstants.Store.CheckoutSummary));
}
return true;
}
private static string FormatPrice(long price) => string.Format("{0:N0} تومان", price);
/// <summary>
/// محاسبه مالیات بر ارزش افزوده
/// </summary>
private long CalculateVAT() => VAT.CalculateVAT(Cart.Total);
private static string GetProductImageUrl(string? imageUrl)
=> string.IsNullOrWhiteSpace(imageUrl) ? "/images/product-placeholder.svg" : imageUrl.TrimStart('/');
}
}
@@ -47,7 +47,7 @@ else
<MudTd>
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<AppImage Path="@GetProductImageUrl(context.ProductThumbnailPath)" Alt="@context.ProductTitle"
ImgWidth="60" ImgHeight="60" Class="rounded-lg" ObjectFit="ObjectFit.Cover" />
ImgWidth="60" ImgHeight="60" Class="rounded-circle" />
<MudText>@context.ProductTitle</MudText>
</MudStack>
</MudTd>
@@ -66,7 +66,7 @@ else
<MudStack Spacing="1">
<MudStack Row="true" Spacing="2" AlignItems="AlignItems.Center">
<AppImage Path="@GetProductImageUrl(it.ProductThumbnailPath)" Alt="@it.ProductTitle"
ImgWidth="60" ImgHeight="60" Class="rounded-lg" ObjectFit="ObjectFit.Cover" />
ImgWidth="60" ImgHeight="60" Class="rounded-circle" />
<MudText>@it.ProductTitle</MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
@@ -47,3 +47,4 @@ public partial class OrderDetail : ComponentBase
private static string GetProductImageUrl(string? imageUrl)
=> string.IsNullOrWhiteSpace(imageUrl) ? "/images/product-placeholder.svg" : imageUrl.TrimStart('/');
}
@@ -46,19 +46,12 @@ public partial class OrderTracking : ComponentBase
private void BuildTrackingSteps()
{
if (_order is null) return;
// Build tracking steps based on PaymentStatus
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 =
[
_trackingSteps = new List<TrackingStep>
{
new()
{
Title = "ثبت سفارش",
@@ -68,77 +61,45 @@ public partial class OrderTracking : ComponentBase
},
new()
{
Title = "پرداخت",
Description = isPaid ? "پرداخت شما تایید شد" : "منتظر تایید پرداخت هستیم",
Title = "در انتظار پرداخت",
Description = "منتظر تایید پرداخت هستیم",
IsCompleted = isPaid,
IsCurrent = !isPaid && !isCancelled,
IsCurrent = !isPaid,
Date = isPaid ? FormatDate(_order.PaymentDate) : ""
},
new()
{
Title = "تایید پرداخت",
Description = "پرداخت شما تایید شد",
IsCompleted = isPaid,
IsCurrent = false,
Date = isPaid ? FormatDate(_order.PaymentDate) : ""
},
new()
{
Title = "در حال پردازش",
Description = "سفارش شما در حال آماده‌سازی است",
IsCompleted = false,
IsCurrent = isPaid,
Date = ""
},
new()
{
Title = "ارسال شده",
Description = "سفارش شما به پست تحویل داده شد",
IsCompleted = false,
IsCurrent = false,
Date = ""
},
new()
{
Title = "تحویل داده شده",
Description = "سفارش به دست شما رسید",
IsCompleted = false,
IsCurrent = false,
Date = ""
}
];
if (isCancelled)
{
_trackingSteps.Add(new()
{
Title = "لغو شده",
Description = "ارسال این سفارش لغو شده است",
IsCompleted = true,
IsCurrent = true,
Date = ""
});
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 = "ارسال شده",
Description = "سفارش شما به پست تحویل داده شد",
IsCompleted = isDelivered,
IsCurrent = isInTransit,
Date = ""
});
_trackingSteps.Add(new()
{
Title = "تحویل داده شده",
Description = "سفارش به دست شما رسید",
IsCompleted = isDelivered,
IsCurrent = false,
Date = ""
});
};
}
private static string FormatDate(Google.Protobuf.WellKnownTypes.Timestamp? timestamp)
+72 -94
View File
@@ -1,102 +1,80 @@
@attribute [Route(RouteConstants.Store.Orders)]
@* Injection is handled in code-behind *@
<PageTitle>سفارشات من</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="py-6">
<MudStack Spacing="3">
<PageHeader Title="سفارشات من" BackHref="@RouteConstants.Store.Products" />
@if (_loading)
{
<LoadingState />
}
else if (_orders.Count == 0)
{
<MudAlert Severity="Severity.Info">هنوز سفارشی ثبت نشده است.</MudAlert>
<MudButton Variant="Variant.Outlined" StartIcon="@Icons.Material.Filled.Store"
Href="@RouteConstants.Store.Products">
مشاهده فروشگاه
</MudButton>
}
else
{
<!-- Desktop Table -->
<MudHidden Breakpoint="Breakpoint.MdAndUp" Invert="true">
<MudPaper Elevation="1" Class="pa-4 rounded-lg">
<MudTable Items="_orders">
<HeaderContent>
<MudTh>شماره سفارش</MudTh>
<MudTh>تعداد اقلام</MudTh>
<MudTh>مبلغ کل</MudTh>
<MudTh>وضعیت پرداخت</MudTh>
<MudTh>وضعیت ارسال</MudTh>
<MudTh>تاریخ</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Id</MudTd>
<MudTd>@GetItemsCount(context)</MudTd>
<MudTd>@FormatPrice(GetTotalAmount(context))</MudTd>
<MudTd>
<MudChip T="string" Size="Size.Small"
Color="@GetPaymentStatusColor(context.PaymentStatus)"
Variant="Variant.Filled">
@GetPaymentStatusText(context.PaymentStatus)
</MudChip>
</MudTd>
<MudTd>
<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>
</RowTemplate>
</MudTable>
</MudPaper>
</MudHidden>
<!-- Mobile Cards -->
<MudHidden Breakpoint="Breakpoint.MdAndUp">
<MudStack Spacing="2">
@foreach (var order in _orders)
<PageHeader Title="سفارشات من" BackHref="@RouteConstants.Store.Products" />
@if (_loading)
{
<LoadingState />
}
else if (_orders.Count == 0)
{
<MudAlert Severity="Severity.Info">هنوز سفارشی ثبت نکرده‌اید.</MudAlert>
}
else
{
<MudHidden Breakpoint="Breakpoint.MdAndUp" Invert="true">
<MudTable Items="_orders" Dense="true">
<HeaderContent>
<MudTh>شناسه</MudTh>
<MudTh>تاریخ</MudTh>
<MudTh>وضعیت</MudTh>
<MudTh>مبلغ</MudTh>
<MudTh></MudTh>
</HeaderContent>
<RowTemplate>
<MudTd>@context.Id</MudTd>
<MudTd>
@if (context.PaymentDate != null)
{
<MudPaper Class="pa-3 rounded-lg cursor-pointer" Outlined="true"
@onclick="() => ViewOrder(order.Id)">
<MudStack Spacing="1">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center">
<MudText Typo="Typo.subtitle2">سفارش #@order.Id</MudText>
<MudChip T="string" Size="Size.Small"
Color="@GetPaymentStatusColor(order.PaymentStatus)"
Variant="Variant.Filled">
@GetPaymentStatusText(order.PaymentStatus)
</MudChip>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.caption" Class="mud-text-secondary">@FormatDate(order)</MudText>
<MudChip T="string" Size="Size.Small"
Color="@GetDeliveryStatusColor((int)order.DeliveryStatus)"
Variant="Variant.Outlined">
@GetDeliveryStatusText((int)order.DeliveryStatus)
</MudChip>
</MudStack>
<MudDivider />
<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>
</MudPaper>
@context.PaymentDate.ToDateTime().MiladiToJalaliWithTime()
}
</MudStack>
</MudHidden>
}
</MudStack>
else
{
<text>در انتظار پرداخت</text>
}
</MudTd>
<MudTd>@GetStatusText(context.PaymentStatus)</MudTd>
<MudTd>@FormatPrice(context.FactorDetails.Sum(s=>(s.UnitPrice ?? 0) * (s.Count ?? 0)))</MudTd>
<MudTd>
<MudButton Variant="Variant.Text" Href="@($"{RouteConstants.Store.OrderDetail}{context.Id}")" StartIcon="@Icons.Material.Filled.Receipt">جزئیات</MudButton>
</MudTd>
</RowTemplate>
</MudTable>
</MudHidden>
<MudHidden Breakpoint="Breakpoint.MdAndUp">
<MudStack Spacing="2">
@foreach (var o in _orders)
{
<MudPaper Class="pa-3 rounded-lg" Outlined="true">
<MudStack Spacing="1">
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText>سفارش #@o.Id</MudText>
<MudText Class="mud-text-secondary">
@if (o.PaymentDate != null)
{
@o.PaymentDate.ToDateTime().MiladiToJalaliWithTime()
}
else
{
<text>در انتظار پرداخت</text>
}
</MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText>وضعیت: @GetStatusText(o.PaymentStatus)</MudText>
<MudText Color="Color.Primary">@FormatPrice(o.FactorDetails.Sum(s=>(s.UnitPrice ?? 0) * (s.Count ?? 0)))</MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.FlexEnd">
<MudButton Size="Size.Small" Variant="Variant.Outlined" Href="@($"{RouteConstants.Store.OrderDetail}{o.Id}")" StartIcon="@Icons.Material.Filled.Receipt">جزئیات</MudButton>
</MudStack>
</MudStack>
</MudPaper>
}
</MudStack>
</MudHidden>
}
</MudContainer>
@@ -1,5 +1,5 @@
using System;
using CMSMicroservice.Protobuf.Protos.UserOrder;
using DateTimeConverterCL;
using FrontOffice.Main.Utilities;
using Microsoft.AspNetCore.Components;
using Messages = CMSMicroservice.Protobuf.Protos;
@@ -10,6 +10,7 @@ namespace FrontOffice.Main.Pages.Store;
public partial class Orders : ComponentBase
{
[Inject] private OrderService OrderService { get; set; } = default!;
[Inject] private VATService VAT { get; set; } = default!;
private List<GetUserOrderResponse> _orders = new();
private bool _loading;
@@ -32,81 +33,28 @@ public partial class Orders : ComponentBase
}
}
private void ViewOrder(long orderId)
protected override async Task OnAfterRenderAsync(bool firstRender)
{
Navigation.NavigateTo($"{RouteConstants.Store.OrderDetail}{orderId}");
}
private static string FormatPrice(long price) => $"{price:N0}";
private static string FormatDate(GetUserOrderResponse order)
{
try
if (firstRender)
{
var timestamp = order.PaymentDate;
if (timestamp is null)
return "در انتظار پرداخت";
return timestamp.ToDateTime().ToLocalTime().MiladiToJalaliWithTime();
// بارگذاری نرخ VAT
await VAT.LoadAsync();
}
catch
await base.OnAfterRenderAsync(firstRender);
}
private static string FormatPrice(long price) => string.Format("{0:N0} تومان", price);
private string GetStatusText(Messages.PaymentStatus contextPaymentStatus)
{
return contextPaymentStatus switch
{
return "—";
}
Messages.PaymentStatus.Pending => "در انتظار پرداخت",
Messages.PaymentStatus.Success => "پرداخت شده",
Messages.PaymentStatus.Reject => "پرداخت ناموفق",
_ => "نامشخص",
};
}
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.Success => "پرداخت شده",
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
};
}
@@ -33,7 +33,7 @@ else
{
<AppImage Path="@MainImageUrl" Alt="@MainImageAlt"
ObjectFit="ObjectFit.Cover"
Style="width:100%; aspect-ratio:1/1; border-radius:12px;" />
Style="width:100%; max-height:400px; border-radius:12px;" />
}
else
{
@@ -78,24 +78,15 @@ else
@* </MudStack> *@
@* } *@
<MudDivider Class="my-2"/>
@if (_isAuthenticated)
{
<MudStack Spacing="1">
<MudText Typo="Typo.h5" Color="Color.Primary">@FormatPrice(_product.Price)</MudText>
@if (VAT.IsEnabled)
{
<MudText Typo="Typo.caption" Class="mud-text-secondary">
(شامل @VAT.VatPercentage% مالیات بر ارزش افزوده)
</MudText>
}
</MudStack>
}
else
{
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Outlined" Icon="@Icons.Material.Filled.Lock">
برای مشاهده قیمت ابتدا وارد شوید
</MudAlert>
}
<MudStack Spacing="1">
<MudText Typo="Typo.h5" Color="Color.Primary">@FormatPrice(_product.Price)</MudText>
@if (VAT.IsEnabled)
{
<MudText Typo="Typo.caption" Class="mud-text-secondary">
(شامل @VAT.VatPercentage% مالیات بر ارزش افزوده)
</MudText>
}
</MudStack>
<!-- نمایش وضعیت موجودی -->
@if (IsInStock)
@@ -150,27 +141,18 @@ else
{
<MudGrid Class="align-center" Justify="Justify.SpaceBetween">
<MudItem xs="6">
@if (_isAuthenticated)
{
<MudStack Spacing="1">
@if (HasDiscount && OriginalPrice is not null)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudText Typo="Typo.caption"
Class="mud-text-secondary mud-line-through">@FormatPrice(OriginalPrice.Value)</MudText>
<MudChip T="string" Color="Color.Error" Variant="Variant.Filled" Size="Size.Small"
Label="true">@($"٪{_product!.Discount}")</MudChip>
</MudStack>
}
<MudText Typo="Typo.h6" Color="Color.Primary">@FormatPrice(TotalPrice)</MudText>
</MudStack>
}
else
{
<MudText Typo="Typo.caption" Class="mud-text-secondary">
<MudIcon Icon="@Icons.Material.Filled.Lock" Size="Size.Small" Class="me-1"/>برای مشاهده قیمت وارد شوید
</MudText>
}
<MudStack Spacing="1">
@if (HasDiscount && OriginalPrice is not null)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="1">
<MudText Typo="Typo.caption"
Class="mud-text-secondary mud-line-through">@FormatPrice(OriginalPrice.Value)</MudText>
<MudChip T="string" Color="Color.Error" Variant="Variant.Filled" Size="Size.Small"
Label="true">@($"٪{_product!.Discount}")</MudChip>
</MudStack>
}
<MudText Typo="Typo.h6" Color="Color.Primary">@FormatPrice(TotalPrice)</MudText>
</MudStack>
</MudItem>
@if (IsInCart)
@@ -12,16 +12,11 @@ public partial class ProductDetail : ComponentBase, IDisposable
[Inject] private ProductService ProductService { get; set; } = default!;
[Inject] private CartService Cart { get; set; } = default!;
[Inject] private VATService VAT { get; set; } = default!;
[Inject] private GuestActionGate GuestGate { get; set; } = default!;
[Inject] private AuthService AuthService { get; set; } = default!;
private bool _isAuthenticated;
[Parameter] public long id { get; set; }
private Product? _product;
private bool _loading;
private bool _initialized;
private int _qty = 1;
private const int MinQty = 1;
@@ -49,35 +44,29 @@ public partial class ProductDetail : ComponentBase, IDisposable
private bool IsInCart => CurrentCartItem is not null;
private int CurrentCartQuantity => CurrentCartItem?.Quantity ?? 0;
protected override async Task OnInitializedAsync()
{
_isAuthenticated = await AuthService.IsAuthenticatedAsync();
await Cart.EnsureInitializedAsync();
Cart.OnChange += HandleCartChanged;
_initialized = true;
}
protected override async Task OnParametersSetAsync()
{
if (!_initialized || id <= 0)
return;
await LoadProductAsync();
}
private async Task LoadProductAsync()
protected override async Task OnInitializedAsync()
{
// لود سبد خرید (فقط اگر کاربر لاگین کرده باشد)
await Cart.EnsureInitializedAsync();
Cart.OnChange += HandleCartChanged;
_loading = true;
_product = await ProductService.GetByIdAsync(id);
_loading = false;
if (_product is not null)
{
_galleryItems = BuildGalleryItems(_product);
_selectedGalleryImage = _galleryItems.FirstOrDefault();
_categoryPaths = _product.Categories;
UpdateBreadcrumb();
_qty = Math.Clamp(CurrentCartItem?.Quantity ?? MinQty, MinQty, Math.Max(MinQty, MaxQty));
_qty = Math.Clamp(CurrentCartItem?.Quantity ?? _qty, MinQty, MaxQty);
}
else
{
@@ -85,33 +74,32 @@ public partial class ProductDetail : ComponentBase, IDisposable
_categoryPaths = Array.Empty<ProductCategoryPathInfo>();
_breadcrumbItems.Clear();
}
StateHasChanged();
await base.OnInitializedAsync();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
await base.OnAfterRenderAsync(firstRender);
if (firstRender)
{
// بارگذاری نرخ VAT
await VAT.LoadAsync();
}
await base.OnAfterRenderAsync(firstRender);
}
private async Task AddToCart()
{
if (_product is null) return;
var product = _product;
await GuestGate.RunAsync(() => Cart.Add(product, 1));
await Cart.Add(_product, 1);
}
private async Task RemoveFromCart()
{
if (_product is null) return;
await GuestGate.RunAsync(async () =>
{
_qty--;
await Cart.UpdateQuantity(CurrentCartItem!.ProductId, _qty);
});
_qty--;
await Cart.UpdateQuantity(CurrentCartItem.ProductId, _qty);
}
private void IncreaseLocalQty()
@@ -94,7 +94,7 @@
{
<MudText Typo="Typo.body2" Class="mud-text-secondary">
<MudIcon Icon="@Icons.Material.Filled.Inventory2" Size="Size.Small" Class="me-1"/>
@(_totalCount) محصول
@(_products.Count) محصول
</MudText>
}
</MudItem>
@@ -118,14 +118,13 @@
@foreach (var p in _products)
{
<MudItem xs="6" sm="6" md="3"
onclick="@(() => OpenProduct(p.Id))">
<div id="@($"shop-product-{p.Id}")" class="h-100">
onclick="@(() => Navigation.NavigateTo($"{RouteConstants.Store.ProductDetail}{p.Id}"))">
<MudCard Class="rounded-lg h-100 d-flex flex-column overflow-hidden"
Style="cursor:pointer;">
Style="cursor:pointer;height: 300px">
<MudCardContent Class="d-flex flex-column pa-1 h-100">
<div style="aspect-ratio:1/1;width:100%;background-image: url('@p.ImageUrl');background-size: cover; background-position: center;border-radius: 0.5rem; position: relative;">
<div style="height: 60%;background-image: url('@p.ImageUrl');background-size: cover; background-position: center;border-radius: 0.5rem; position: relative;">
@if (p.RemainingCount <= 0)
{
<div style="position:absolute;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;border-radius:0.5rem;">
@@ -142,16 +141,8 @@
</div>
<div class="pa-1 flex-grow-1 d-flex flex-column justify-space-between">
<MudText Typo="Typo.subtitle1">@p.Title</MudText>
@if (_isAuthenticated)
{
<MudText Typo="Typo.subtitle2" Color="Color.Primary">@FormatPrice(p.Price)</MudText>
}
else
{
<MudText Typo="Typo.caption" Class="mud-text-secondary">
<MudIcon Icon="@Icons.Material.Filled.Lock" Size="Size.Small" Class="me-1"/>برای مشاهده قیمت وارد شوید
</MudText>
}
<MudText Typo="Typo.subtitle2" Color="Color.Primary">@FormatPrice(p.Price)</MudText>
</div>
</MudCardContent>
<MudCardActions Class="mt-auto d-flex justify-space-between pa-2">
@@ -165,34 +156,8 @@
</MudButton>
</MudCardActions>
</MudCard>
</div>
</MudItem>
}
</MudGrid>
@* Lazy Load — بارگذاری بیشتر *@
@if (_loadingMore)
{
<MudStack AlignItems="AlignItems.Center" Class="py-4">
<MudProgressCircular Color="Color.Primary" Size="Size.Small" Indeterminate="true"/>
<MudText Typo="Typo.caption" Class="mud-text-secondary">بارگذاری بیشتر...</MudText>
</MudStack>
}
else if (_hasMore)
{
<div class="d-flex justify-center py-4">
<MudButton Variant="Variant.Outlined" Color="Color.Primary"
StartIcon="@Icons.Material.Filled.ExpandMore"
OnClick="LoadMore">
نمایش محصولات بیشتر
</MudButton>
</div>
}
else if (_products.Count > 0)
{
<MudText Typo="Typo.caption" Align="Align.Center" Class="py-4 mud-text-secondary">
همه @(_products.Count) محصول نمایش داده شد
</MudText>
}
}
</MudContainer>
@@ -2,166 +2,90 @@ using System.Linq;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Routing;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.JSInterop;
using Microsoft.AspNetCore.WebUtilities;
using FrontOffice.Main.Utilities;
namespace FrontOffice.Main.Pages.Store;
public enum ProductSortOption
{
PriceDesc, // گران‌ترین (پیش‌فرض)
PriceAsc, // ارزان‌ترین
Newest, // جدیدترین
Title // الفبایی
}
public partial class Products : ComponentBase, IDisposable
{
[Inject] private ProductService ProductService { get; set; } = default!;
[Inject] private CategoryService CategoryService { get; set; } = default!;
[Inject] private CartService Cart { get; set; } = default!;
[Inject] private VATService VAT { get; set; } = default!;
[Inject] private GuestActionGate GuestGate { get; set; } = default!;
[Inject] private AuthService AuthService { get; set; } = default!;
[Inject] private IJSRuntime Js { get; set; } = default!;
private bool _isAuthenticated;
private string _query = string.Empty;
private bool _loading;
private bool _loadingMore;
private bool _hasMore = true;
private int _currentPage = 1;
private int _totalCount;
private const int PageSize = 12;
private List<Product> _products = new();
private long? _activeCategoryId;
private string? _activeCategoryTitle;
private ProductSortOption _sortOption = ProductSortOption.PriceDesc;
private bool _ignoreNextLocationChange;
private bool _pendingScrollRestore;
private ProductSortOption _sortOption = ProductSortOption.PriceDesc; // پیش‌فرض: گران‌ترین
protected override async Task OnInitializedAsync()
{
_isAuthenticated = await AuthService.IsAuthenticatedAsync();
// لود سبد خرید (فقط اگر کاربر لاگین کرده باشد)
await Cart.EnsureInitializedAsync();
Cart.OnChange += StateHasChanged;
Navigation.LocationChanged += HandleLocationChanged;
ApplyStateFromUri();
await LoadPages(_currentPage);
_pendingScrollRestore = true;
await Load();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
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);
// بارگذاری نرخ VAT
await VAT.LoadAsync();
}
await base.OnAfterRenderAsync(firstRender);
}
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)
private async Task Load()
{
_loading = true;
_products.Clear();
_hasMore = true;
pagesToLoad = Math.Max(1, pagesToLoad);
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;
_totalCount = result.TotalCount;
}
else
{
_products.AddRange(result.Products);
}
_currentPage = page;
_hasMore = result.HasNext;
if (!_hasMore)
break;
}
UpdateCategoryFilterFromUri();
var sortBy = GetSortByValue();
_products = await ProductService.GetProductsAsync(_query, _activeCategoryId, sortBy);
_activeCategoryTitle = _activeCategoryId is { } categoryId
? (await CategoryService.GetByIdAsync(categoryId))?.Title
: null;
_loading = false;
}
private async Task ReloadFromFilters()
private string GetSortByValue()
{
_currentPage = 1;
await LoadPages(1);
SyncUrl();
StateHasChanged();
return _sortOption switch
{
ProductSortOption.PriceDesc => "price desc",
ProductSortOption.PriceAsc => "price asc",
ProductSortOption.Newest => "id desc",
ProductSortOption.Title => "title asc",
_ => "price desc"
};
}
private async Task LoadMore()
private async Task OnSortChanged()
{
if (_loadingMore || !_hasMore) return;
_loadingMore = true;
StateHasChanged();
_currentPage++;
var sortBy = ShopListQueryState.ToApiSortBy(_sortOption);
var result = await ProductService.GetProductsPagedAsync(
_query, _activeCategoryId, sortBy, _currentPage, PageSize);
_products.AddRange(result.Products);
_hasMore = result.HasNext;
SyncUrl();
_loadingMore = false;
StateHasChanged();
await Load();
}
private async Task OnSortChanged() => await ReloadFromFilters();
private async Task OnQueryChanged(KeyboardEventArgs _) => await ReloadFromFilters();
private async Task OnQueryChanged(KeyboardEventArgs _)
{
await Load();
}
private async Task AddToCart(Product p)
{
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}");
await Cart.Add(p, 1);
}
private string FormatPrice(long price) => $"{VAT.AddVAT(price):N0} تومان";
@@ -174,33 +98,24 @@ public partial class Products : ComponentBase, IDisposable
private void HandleLocationChanged(object? sender, LocationChangedEventArgs args)
{
if (_ignoreNextLocationChange)
_ = InvokeAsync(Load);
}
private void UpdateCategoryFilterFromUri()
{
var uri = Navigation.ToAbsoluteUri(Navigation.Uri);
if (QueryHelpers.ParseQuery(uri.Query).TryGetValue("category", out var values) &&
long.TryParse(values.FirstOrDefault(), out var categoryId))
{
_ignoreNextLocationChange = false;
_activeCategoryId = categoryId;
return;
}
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();
});
_activeCategoryId = null;
}
private async Task ClearCategoryFilter()
private void ClearCategoryFilter()
{
_activeCategoryId = null;
_activeCategoryTitle = null;
await ReloadFromFilters();
Navigation.NavigateTo(RouteConstants.Store.Products);
}
}
+1 -40
View File
@@ -1,5 +1,4 @@
@page "/"
@model FrontOffice.Main.Pages.HostModel
@using Microsoft.AspNetCore.Components.Web
@namespace FrontOffice.Main.Pages
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@@ -9,26 +8,6 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>@Model.Seo.Title</title>
<meta name="description" content="@Model.Seo.Description" />
<link rel="canonical" href="@Model.Seo.CanonicalUrl" />
@if (Model.Seo.NoIndex)
{
<meta name="robots" content="noindex, nofollow" />
}
else
{
<meta name="robots" content="index, follow" />
}
<meta property="og:type" content="website" />
<meta property="og:locale" content="fa_IR" />
<meta property="og:title" content="@Model.Seo.Title" />
<meta property="og:description" content="@Model.Seo.Description" />
<meta property="og:url" content="@Model.Seo.CanonicalUrl" />
<meta property="og:site_name" content="کارابازار سلامت" />
<meta name="twitter:card" content="summary" />
<meta name="twitter:title" content="@Model.Seo.Title" />
<meta name="twitter:description" content="@Model.Seo.Description" />
<meta name="theme-color" content="#7c4dff" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
@@ -36,7 +15,7 @@
<link rel="icon" type="image/png" href="favicon.png"/>
<component type="typeof(HeadOutlet)" render-mode="Server" />
@* <link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" /> *@
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
<!-- MudBlazor CSS (باید قبل از site.css باشه تا override بشه) -->
<link href="_content/MudBlazor/MudBlazor.min.css" rel="stylesheet" asp-append-version="true" />
<!-- Custom styles (بعد از MudBlazor برای override) -->
@@ -44,26 +23,8 @@
<link href="FrontOffice.Main.styles.css" rel="stylesheet" />
<!-- d3-org-chart custom styles -->
<link href="css/org-chart.css" rel="stylesheet" asp-append-version="true" />
<style>
.seo-crawlable {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
</head>
<body>
@if (!string.IsNullOrWhiteSpace(Model.Seo.CrawlableContent))
{
<div class="seo-crawlable">@Model.Seo.CrawlableContent</div>
}
<component type="typeof(App)" render-mode="Server" />
<div id="blazor-error-ui">
@@ -1,21 +0,0 @@
using FrontOffice.Main.Utilities.Seo;
using Microsoft.AspNetCore.Mvc.RazorPages;
namespace FrontOffice.Main.Pages;
public class HostModel : PageModel
{
private readonly SeoMetadataProvider _seoMetadataProvider;
public SeoPageMetadata Seo { get; private set; } = default!;
public HostModel(SeoMetadataProvider seoMetadataProvider)
{
_seoMetadataProvider = seoMetadataProvider;
}
public async Task OnGetAsync()
{
Seo = await _seoMetadataProvider.GetForPathAsync(Request.Path);
}
}
-13
View File
@@ -1,6 +1,5 @@
using FluentValidation;
using FrontOffice.Main.Utilities;
using FrontOffice.Main.Utilities.Seo;
using System.Net;
using FrontOffice.Main.Utilities.Pdf;
@@ -13,7 +12,6 @@ builder.Services.AddServerSideBlazor();
#region AddCommonServices
builder.Services.AddCommonServices();
builder.Services.Configure<SeoSettings>(builder.Configuration.GetSection(SeoSettings.SectionName));
builder.Services.AddSingleton<MainService>();
#endregion
@@ -57,17 +55,6 @@ webApp.UseStaticFiles();
webApp.UseRouting();
webApp.MapBlazorHub();
webApp.MapGet("/sitemap.xml", async (SitemapGenerator sitemapGenerator, CancellationToken cancellationToken) =>
{
var bytes = await sitemapGenerator.GenerateBytesAsync(cancellationToken);
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");
+2 -6
View File
@@ -22,9 +22,7 @@
PhoneNumber="@_phoneNumber"
ResendRemaining="_resendRemaining"
OnChangePhone="ChangePhoneAsync"
OnResendOtp="ResendOtpAsync"
ReferralCode="@_referralCode"
ReferralCodeChanged="OnReferralCodeChanged" />
OnResendOtp="ResendOtpAsync" />
</div>
}
else
@@ -53,9 +51,7 @@ else
PhoneNumber="@_phoneNumber"
ResendRemaining="_resendRemaining"
OnChangePhone="ChangePhoneAsync"
OnResendOtp="ResendOtpAsync"
ReferralCode="@_referralCode"
ReferralCodeChanged="OnReferralCodeChanged" />
OnResendOtp="ResendOtpAsync" />
<MudStack Class="mt-4" Spacing="2">
@if (_currentStep == AuthStep.Phone)
@@ -42,9 +42,6 @@ public partial class AuthDialog : IDisposable
private string? _captchaCode;
private string? _captchaInput;
// Referral code field
private string? _referralCode;
[Inject] private ILocalStorageService LocalStorage { get; set; } = default!;
[Inject] private UserContract.UserContractClient UserClient { get; set; } = default!;
@@ -69,20 +66,9 @@ public partial class AuthDialog : IDisposable
{
_phoneRequest.Mobile = storedPhone;
}
_referralCode = await LocalStorage.GetItemAsync<string>("referral:code");
// await LocalStorage.RemoveItemAsync(TokenStorageKey);
}
private async Task OnReferralCodeChanged(string? value)
{
_referralCode = value;
if (!string.IsNullOrWhiteSpace(value))
await LocalStorage.SetItemAsync("referral:code", value);
else
await LocalStorage.RemoveItemAsync("referral:code");
}
private void GenerateCaptcha()
{
_captchaCode = Guid.NewGuid().ToString("N")[..6].ToUpperInvariant();
@@ -196,12 +182,9 @@ public partial class AuthDialog : IDisposable
_verifyRequest.Mobile = _phoneNumber;
var storedReferralCode = await LocalStorage.GetItemAsync<string>("referral:code");
var effectiveReferralCode = !string.IsNullOrWhiteSpace(_referralCode)
? _referralCode
: storedReferralCode;
if (!string.IsNullOrWhiteSpace(effectiveReferralCode))
if (!string.IsNullOrWhiteSpace(storedReferralCode))
{
_verifyRequest.ParentReferralCode = effectiveReferralCode;
_verifyRequest.ParentReferralCode = storedReferralCode;
}
var validationResult = true; // _verifyRequestValidator.Validate(_verifyRequest);
@@ -9,7 +9,6 @@
@inject AuthService AuthService
@inject ISnackbar Snackbar
@inject NavigationManager Navigation
@inject PackageService PackageService
<MudDialog DisableSidePadding="true">
<DialogContent>
@@ -124,23 +123,6 @@
private readonly Guid _signGuid = Guid.NewGuid();
private System.Timers.Timer? _timer;
private const string TokenStorageKey = "auth:token";
private string _packageDisplayName = "پکیج پایه";
private string _packageDisplayPrice = "۵۶ میلیون تومان";
protected override async Task OnInitializedAsync()
{
try
{
var packages = await PackageService.GetAllPackagesAsync();
var basePackage = packages.FirstOrDefault(p => p.IsBasePackage) ?? packages.FirstOrDefault();
if (basePackage != null)
{
_packageDisplayName = basePackage.Title;
_packageDisplayPrice = $"{basePackage.Price:N0} تومان";
}
}
catch { /* fallback to defaults */ }
}
// این Modal غیرقابل بسته شدن است - Options در ShowAsync تنظیم می‌شوند
@@ -260,7 +242,7 @@
<p>۱-۳. پاداش معرفی: مبلغی که به ازای هر معرفی موفق به کاربر تعلق می‌گیرد.</p>
<p><strong>ماده ۲ - شرایط عضویت</strong></p>
<p>۲-۱. کاربر با پرداخت مبلغ {_packageDisplayPrice} ({_packageDisplayName})، امکان عضویت در باشگاه مشتریان را خواهد داشت.</p>
<p>۲-۱. کاربر با پرداخت مبلغ ۵۶ میلیون تومان (پکیج پایه)، امکان عضویت در باشگاه مشتریان را خواهد داشت.</p>
<p>۲-۲. پس از امضای این قرارداد، لینک دعوت کاربر فعال خواهد شد.</p>
<p>۲-۳. کاربر می‌تواند حداکثر ۲ نفر را مستقیماً دعوت کند (یک نفر در تیم اول و یک نفر در تیم دوم).</p>
-1
View File
@@ -55,7 +55,6 @@
<MudLink Href="@(RouteConstants.FAQ.Index)" Class="footer-link">سوالات متداول</MudLink>
<MudLink Href="@(RouteConstants.Contact.Index)" Class="footer-link">ارتباط با ما</MudLink>
<MudLink Href="@(RouteConstants.About.Index)" Class="footer-link">درباره ما</MudLink>
<MudLink Href="@(RouteConstants.Licenses.Index)" Class="footer-link">مجوزها</MudLink>
</MudStack>
</MudItem>
@@ -1,152 +0,0 @@
@using FrontOffice.Main.Utilities
<MudDialog>
<TitleContent>
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.AccountBalanceWallet" Color="Color.Warning" />
<MudText Typo="Typo.h6">موجودی کیف پول اصلی کافی نیست</MudText>
</MudStack>
</TitleContent>
<DialogContent>
<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>
}
}
else
{
<MudAlert Severity="Severity.Warning" Dense="true" Variant="Variant.Outlined">
برای شارژ کیف پول اصلی ابتدا باید پکیج را خریداری کرده و قرارداد باشگاه مشتریان را امضا کنید.
</MudAlert>
}
<MudPaper Outlined="true" Class="pa-3 rounded-lg">
<MudStack Spacing="1">
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2">موجودی فعلی:</MudText>
<MudText Typo="Typo.body2"><strong>@FormatPrice(CurrentBalance)</strong></MudText>
</MudStack>
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2">مبلغ سفارش:</MudText>
<MudText Typo="Typo.body2"><strong>@FormatPrice(RequiredAmount)</strong></MudText>
</MudStack>
<MudDivider Class="my-1" />
<MudStack Row="true" Justify="Justify.SpaceBetween">
<MudText Typo="Typo.body2" Color="Color.Error">کمبود:</MudText>
<MudText Typo="Typo.body2" Color="Color.Error"><strong>@FormatPrice(ShortfallAmount)</strong></MudText>
</MudStack>
</MudStack>
</MudPaper>
</MudStack>
</DialogContent>
<DialogActions>
<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"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.CardGiftcard"
OnClick="GoToPackages">
مشاهده پکیج‌ها
</MudButton>
}
else
{
<MudButton Variant="Variant.Filled"
Color="Color.Primary"
StartIcon="@Icons.Material.Filled.Handshake"
OnClick="GoToClubMembership">
امضای قرارداد باشگاه
</MudButton>
}
</DialogActions>
</MudDialog>
@code {
[CascadingParameter]
private IMudDialogInstance MudDialog { get; set; } = default!;
[Parameter]
public long CurrentBalance { get; set; }
[Parameter]
public long RequiredAmount { get; set; }
[Parameter]
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 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);
}
+3 -7
View File
@@ -19,7 +19,7 @@
</MudHidden>
<div class="d-flex align-center gap-2">
<MudLink Href="@(_isAuthenticated ? RouteConstants.Profile.Index : RouteConstants.Main.MainPage)" Underline="Underline.None">
<MudLink Href="@(RouteConstants.Main.MainPage)" Underline="Underline.None">
<MudStack Row="true" Spacing="3" AlignItems="AlignItems.Center">
<MudHidden Breakpoint="Breakpoint.MdAndUp" Invert="true">
<MudImage ObjectFit="ObjectFit.Cover"
@@ -73,7 +73,6 @@
<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>
<MudLink Href="@(RouteConstants.Licenses.Index)" Typo="Typo.subtitle1" Class="mud-link">مجوزها</MudLink>
</div>
<div class="d-flex align-center gap-2">
@@ -192,9 +191,6 @@
<MudLink Href="@(RouteConstants.Contact.Index)" Typo="Typo.subtitle1" OnClick="() => _drawerOpen=false">
ارتباط با ما
</MudLink>
<MudLink Href="@(RouteConstants.Licenses.Index)" Typo="Typo.subtitle1" OnClick="() => _drawerOpen=false">
مجوزها
</MudLink>
<MudDivider Class="my-2"/>
@if (_isAuthenticated)
@@ -285,9 +281,9 @@
else
{
@* ── Default bottom nav (outside stores) ── *@
<MudLink Href="@(_isAuthenticated ? RouteConstants.Profile.Index : RouteConstants.Main.MainPage)" Class="bottom-nav-item">
<MudLink Href="@(RouteConstants.Profile.Index)" Class="bottom-nav-item">
<MudIcon Icon="@Icons.Material.Outlined.Home" Size="Size.Medium" />
<MudText Typo="Typo.caption">@(_isAuthenticated ? "داشبورد" : "خانه")</MudText>
<MudText Typo="Typo.caption">خانه</MudText>
</MudLink>
@if (_isAuthenticated)
{
@@ -1,234 +0,0 @@
@using FrontOffice.Main.Utilities
@inject PackageService PackageService
@inject ISnackbar Snackbar
<MudDialog>
<TitleContent>
@if (_selectedPackage != null)
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIconButton Icon="@Icons.Material.Filled.ArrowForward"
Size="Size.Small"
OnClick="BackToList" />
<MudText Typo="Typo.h6">انتخاب روش پرداخت</MudText>
</MudStack>
}
else
{
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.ShoppingCart" Color="Color.Primary" />
<MudText Typo="Typo.h6">انتخاب پکیج</MudText>
</MudStack>
}
</TitleContent>
<DialogContent>
@if (_isLoading)
{
<MudStack AlignItems="AlignItems.Center" Class="py-8">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" />
<MudText Typo="Typo.body2" Class="mud-text-secondary mt-2">در حال بارگذاری پکیج‌ها...</MudText>
</MudStack>
}
else if (_selectedPackage != null)
{
@* ── Step 2: Payment method selection ── *@
<MudStack Spacing="3">
@* Selected Package Summary *@
<MudPaper Elevation="0" Class="pa-3 rounded-lg" Style="background:rgba(99,102,241,.06);">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudAvatar Size="Size.Medium" Style="background:rgba(99,102,241,.15); color:#6366f1;">
<MudIcon Icon="@Icons.Material.Filled.Inventory2" />
</MudAvatar>
<MudStack Spacing="0" Class="flex-grow-1">
<MudText Typo="Typo.subtitle1"><b>@_selectedPackage.Title</b></MudText>
<MudText Typo="Typo.body2" Color="Color.Success">@_selectedPackage.FormattedPrice</MudText>
</MudStack>
</MudStack>
</MudPaper>
<MudDivider />
<MudText Typo="Typo.body2" Class="mud-text-secondary">
روش پرداخت خود را انتخاب کنید:
</MudText>
@* Direct Payment — always available *@
<MudPaper Elevation="0" Class="pa-4 rounded-lg pkg-payment-option"
Style="border:2px solid var(--mud-palette-primary); cursor:pointer;"
@onclick="() => SelectPaymentMethod(PaymentMethodType.DirectPayment)">
<MudStack Spacing="2">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.CreditCard" Color="Color.Primary" Size="Size.Medium" />
<MudStack Spacing="0" Class="flex-grow-1">
<MudText Typo="Typo.subtitle1" Color="Color.Primary"><b>پرداخت مستقیم</b></MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary">پرداخت آنی از طریق درگاه بانکی</MudText>
</MudStack>
<MudIcon Icon="@Icons.Material.Filled.ArrowBack" Color="Color.Primary" Size="Size.Small" />
</MudStack>
</MudStack>
</MudPaper>
@* Daya Loan — only for base package AND first purchase cycle *@
@if (_selectedPackage.SupportsDayaPurchase && PurchaseCycleCount == 0)
{
<MudPaper Elevation="0" Class="pa-4 rounded-lg pkg-payment-option"
Style="border:2px solid var(--mud-palette-tertiary); cursor:pointer;"
@onclick="() => SelectPaymentMethod(PaymentMethodType.DayaLoan)">
<MudStack Spacing="2">
<MudStack Row="true" AlignItems="AlignItems.Center" Spacing="2">
<MudIcon Icon="@Icons.Material.Filled.Diamond" Color="Color.Tertiary" Size="Size.Medium" />
<MudStack Spacing="0" Class="flex-grow-1">
<MudText Typo="Typo.subtitle1" Color="Color.Tertiary"><b>اعتبار الماسی دایا</b></MudText>
<MudText Typo="Typo.caption" Class="mud-text-secondary">تأمین اعتبار از طریق خرید توکن الماس</MudText>
</MudStack>
<MudIcon Icon="@Icons.Material.Filled.ArrowBack" Color="Color.Tertiary" Size="Size.Small" />
</MudStack>
</MudStack>
</MudPaper>
}
else if (!_selectedPackage.SupportsDayaPurchase)
{
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="mt-1">
اعتبار الماسی دایا فقط برای پکیج پایه قابل استفاده است.
</MudAlert>
}
else if (PurchaseCycleCount > 0)
{
<MudAlert Severity="Severity.Info" Dense="true" Variant="Variant.Text" Class="mt-1">
از دور دوم به بعد، خرید پکیج فقط از طریق درگاه بانکی امکان‌پذیر است.
</MudAlert>
}
</MudStack>
}
else
{
@* ── Step 1: Package tile grid ── *@
<MudStack Spacing="3">
<MudText Typo="Typo.body2" Class="mud-text-secondary">
پکیج مورد نظر خود را انتخاب کنید:
</MudText>
<MudGrid Spacing="3">
@foreach (var pkg in _packages)
{
<MudItem xs="12" sm="6" md="@(_packages.Count <= 2 ? 6 : 4)">
<MudPaper Elevation="0" Class="pa-4 rounded-xl pkg-tile text-center"
Style="@GetTileStyle(pkg)"
@onclick="() => SelectPackage(pkg)">
@if (pkg.IsBasePackage)
{
<MudChip T="string" Size="Size.Small" Color="Color.Primary" Variant="Variant.Filled"
Class="pkg-tile-badge">پایه</MudChip>
}
<MudStack Spacing="1" AlignItems="AlignItems.Center">
<MudAvatar Size="Size.Large" Style="@GetAvatarStyle(pkg)" Class="mb-1">
<MudIcon Icon="@Icons.Material.Filled.Inventory2" Size="Size.Large" />
</MudAvatar>
<MudText Typo="Typo.subtitle1"><b>@pkg.Title</b></MudText>
<MudText Typo="Typo.h6" Color="Color.Success">@pkg.FormattedPrice</MudText>
@if (!string.IsNullOrWhiteSpace(pkg.Description))
{
<MudText Typo="Typo.caption" Class="mud-text-secondary"
Style="display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden;">
@((MarkupString)pkg.Description)
</MudText>
}
@* Key features *@
<MudStack Spacing="0" Class="mt-2" Style="width:100%;">
@if (pkg.MagicWalletMultiplier > 0)
{
<MudText Typo="Typo.caption" Class="mud-text-secondary">
<MudIcon Icon="@Icons.Material.Filled.AutoAwesome" Size="Size.Small" Class="me-1" Style="font-size:.85rem;vertical-align:middle;" />
ضریب جادویی: @pkg.MagicWalletMultiplier.ToString("F1")x
</MudText>
}
@if (pkg.DiscountMultiplier > 0)
{
<MudText Typo="Typo.caption" Class="mud-text-secondary">
<MudIcon Icon="@Icons.Material.Filled.Discount" Size="Size.Small" Class="me-1" Style="font-size:.85rem;vertical-align:middle;" />
ضریب اعتبار: @pkg.DiscountMultiplier.ToString("F1")x
</MudText>
}
@if (pkg.SupportsDayaPurchase)
{
<MudText Typo="Typo.caption" Color="Color.Tertiary">
<MudIcon Icon="@Icons.Material.Filled.Diamond" Size="Size.Small" Class="me-1" Style="font-size:.85rem;vertical-align:middle;" />
قابل خرید با دایا
</MudText>
}
</MudStack>
</MudStack>
</MudPaper>
</MudItem>
}
</MudGrid>
</MudStack>
}
</DialogContent>
<DialogActions>
<MudButton OnClick="Cancel" Variant="Variant.Text" Color="Color.Default">بستن</MudButton>
</DialogActions>
</MudDialog>
@code {
[CascadingParameter] private IMudDialogInstance MudDialog { get; set; } = default!;
/// <summary>Number of previous purchase cycles — controls Daya eligibility</summary>
[Parameter] public int PurchaseCycleCount { get; set; }
private List<PackageDto> _packages = new();
private PackageDto? _selectedPackage;
private bool _isLoading = true;
protected override async Task OnInitializedAsync()
{
_isLoading = true;
try
{
_packages = await PackageService.GetAllPackagesAsync();
}
catch (Exception ex)
{
Snackbar.Add("خطا در بارگذاری پکیج‌ها", MudBlazor.Severity.Error);
}
finally
{
_isLoading = false;
}
}
private void SelectPackage(PackageDto package)
{
_selectedPackage = package;
}
private void BackToList()
{
_selectedPackage = null;
}
private void SelectPaymentMethod(PaymentMethodType method)
{
var result = new PackagePurchaseResult(_selectedPackage!, method);
MudDialog.Close(DialogResult.Ok(result));
}
private void Cancel() => MudDialog.Cancel();
private static string GetTileStyle(PackageDto pkg)
{
return pkg.IsBasePackage
? "border:2px solid var(--mud-palette-primary); background:rgba(99,102,241,.04); cursor:pointer;"
: "border:2px solid var(--mud-palette-lines-default); background:var(--mud-palette-surface); cursor:pointer;";
}
private static string GetAvatarStyle(PackageDto pkg)
{
return pkg.IsBasePackage
? "background:rgba(99,102,241,.15); color:#6366f1;"
: "background:rgba(16,185,129,.12); color:#10b981;";
}
public enum PaymentMethodType { DirectPayment, DayaLoan }
public record PackagePurchaseResult(PackageDto Package, PaymentMethodType Method);
}
@@ -36,14 +36,6 @@
Color="Color.Primary"
Class="mb-2" />
<MudTextField Value="ReferralCode"
ValueChanged="@((string? v) => ReferralCodeChanged.InvokeAsync(v))"
Label="کد معرف (اختیاری)"
Variant="Variant.Outlined"
Immediate="true"
Placeholder="کد معرف را وارد کنید"
Class="mb-3" />
@if (!string.IsNullOrWhiteSpace(ErrorMessage))
{
<MudAlert Severity="Severity.Error" Dense="true" Elevation="0"
@@ -29,10 +29,6 @@ public partial class PhoneVerifyForm
[Parameter] public string? PhoneNumber { get; set; }
[Parameter] public int ResendRemaining { get; set; }
// ── Referral ──
[Parameter] public string? ReferralCode { get; set; }
[Parameter] public EventCallback<string?> ReferralCodeChanged { get; set; }
// ── Verify actions ──
[Parameter] public EventCallback OnChangePhone { get; set; }
[Parameter] public EventCallback OnResendOtp { get; set; }
@@ -79,7 +79,6 @@ public class AuthService
var isSignMainContractStr = claims.FirstOrDefault(c => c.key == "IsSignMainContract").value;
var hasPurchasedPackageStr = claims.FirstOrDefault(c => c.key == "HasPurchasedPackage").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.FirstName = firstName ?? string.Empty;
@@ -89,7 +88,6 @@ public class AuthService
_userAuthInfo.IsSignMainContract = bool.TryParse(isSignMainContractStr, out var isSignMainContract) && isSignMainContract;
_userAuthInfo.HasPurchasedPackage = bool.TryParse(hasPurchasedPackageStr, out var hasPurchased) && hasPurchased;
_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()
@@ -15,8 +15,6 @@ public class CommissionPayoutDto
public string AmountFormatted { get; set; } = string.Empty;
public int Status { get; set; }
public string DatePersian { get; set; } = string.Empty;
public long PackageId { get; set; }
public string PackageTitle { get; set; } = string.Empty;
// Computed properties for UI
public string StatusText => Status switch
@@ -71,8 +69,6 @@ public class WeeklyBalanceDto
public int RightNewMembers { get; set; }
public DateTime StartDate { get; set; }
public DateTime EndDate { get; set; }
public long PackageId { get; set; }
public string PackageTitle { get; set; } = string.Empty;
// Formatted properties
public string LeftBalanceFormatted => $"{LeftBalance:N0} ";
@@ -78,8 +78,7 @@ public class CommissionService
long? weekDefinitionId,
string? status,
int pageNumber,
int pageSize,
long? packageId = null)
int pageSize)
{
try
{
@@ -108,11 +107,6 @@ public class CommissionService
_ => null
};
}
if (packageId.HasValue && packageId.Value > 0)
{
request.PackageId = packageId.Value;
}
var response = await _client.GetMyCommissionPayoutsAsync(request);
@@ -127,9 +121,7 @@ public class CommissionService
TotalAmount = p.TotalAmount,
AmountFormatted = p.AmountFormatted,
Status = p.Status,
DatePersian = p.DatePersian,
PackageId = p.PackageId,
PackageTitle = p.PackageTitle
DatePersian = p.DatePersian
}).ToList(),
TotalCount = (int)(response.MetaData?.TotalCount ?? 0),
PageNumber = pageNumber,
@@ -153,7 +145,7 @@ public class CommissionService
/// Get weekly balance details for a specific week
/// Maps to: CommissionCQ.GetMyWeeklyBalances
/// </summary>
public async Task<WeeklyBalanceDto> GetMyWeeklyBalanceAsync(long? weekDefinitionId = null, long? packageId = null)
public async Task<WeeklyBalanceDto> GetMyWeeklyBalanceAsync(long? weekDefinitionId = null)
{
try
{
@@ -171,11 +163,6 @@ public class CommissionService
{
request.WeekDefinitionId=7;
}
if (packageId.HasValue && packageId.Value > 0)
{
request.PackageId = packageId.Value;
}
var response = await _client.GetMyWeeklyBalancesAsync(request);
@@ -196,9 +183,7 @@ public class CommissionService
LeftNewMembers = balance.LeftLegNewMembers,
RightNewMembers = balance.RightLegNewMembers,
StartDate = balance.CalculatedAt?.ToDateTime().AddDays(-7) ?? DateTime.Now.AddDays(-7),
EndDate = balance.CalculatedAt?.ToDateTime() ?? DateTime.Now,
PackageId = balance.PackageId,
PackageTitle = balance.PackageTitle
EndDate = balance.CalculatedAt?.ToDateTime() ?? DateTime.Now
};
}
@@ -242,9 +227,7 @@ public class CommissionService
TotalAmount = p.TotalAmount,
AmountFormatted = p.AmountFormatted,
Status = p.Status,
DatePersian = p.DatePersian,
PackageId = p.PackageId,
PackageTitle = p.PackageTitle
DatePersian = p.DatePersian
}).ToList();
}
catch
@@ -1,50 +0,0 @@
using Grpc.Core;
using Microsoft.JSInterop;
namespace FrontOffice.Main.Utilities;
public static class CreditChargeNavigation
{
public const long MinChargeAmount = 10_000;
public const string ReturnUrlStorageKey = "credit_charge_return_url";
private const string InsufficientBalanceMarker = "موجودی کیف پول کافی نیست";
public static long NormalizeChargeAmount(long shortfall) =>
Math.Max(shortfall, MinChargeAmount);
public static string BuildChargeUrl(long amount, string? returnUrl)
{
var url = $"{RouteConstants.Profile.ChargeCreditWallet}?amount={amount}";
if (IsValidReturnUrl(returnUrl))
url += $"&returnUrl={Uri.EscapeDataString(returnUrl!)}";
return url;
}
public static bool IsValidReturnUrl(string? url) =>
!string.IsNullOrWhiteSpace(url)
&& url.StartsWith('/')
&& !url.StartsWith("//", StringComparison.Ordinal);
public static bool IsInsufficientWalletBalanceMessage(string? message) =>
message?.Contains(InsufficientBalanceMarker, StringComparison.Ordinal) == true;
public static bool IsInsufficientWalletBalance(Exception ex) =>
ex switch
{
RpcException rpc => IsInsufficientWalletBalanceMessage(rpc.Status.Detail)
|| IsInsufficientWalletBalanceMessage(rpc.Message),
_ => IsInsufficientWalletBalanceMessage(ex.Message)
};
public static async Task SaveReturnUrlAsync(IJSRuntime js, string returnUrl)
{
if (!IsValidReturnUrl(returnUrl)) return;
await js.InvokeVoidAsync("sessionStorage.setItem", ReturnUrlStorageKey, returnUrl);
}
public static async Task<string?> GetReturnUrlAsync(IJSRuntime js) =>
await js.InvokeAsync<string?>("sessionStorage.getItem", ReturnUrlStorageKey);
public static async Task ClearReturnUrlAsync(IJSRuntime js) =>
await js.InvokeVoidAsync("sessionStorage.removeItem", ReturnUrlStorageKey);
}
@@ -112,27 +112,6 @@ public class DiscountOrderService
catch { return false; }
}
public async Task<(bool Success, string Message, long OrderId)> VerifyDiscountOrderPaymentAsync(
long orderId, string authority, string status)
{
try
{
var response = await _client.CustomerVerifyDiscountOrderPaymentAsync(
new CustomerVerifyDiscountOrderPaymentRequest
{
OrderId = orderId,
Authority = authority,
Status = status
});
return (response.Success, response.Message, response.OrderId);
}
catch (Grpc.Core.RpcException ex)
{
return (false, ex.Status.Detail ?? "خطا در بررسی وضعیت پرداخت", orderId);
}
}
public async Task<DiscountOrderListResult> GetUserOrdersAsync(int page = 1, int pageSize = 10)
{
try
@@ -219,8 +198,6 @@ public class DiscountOrderService
2 => "ارسال شده",
3 => "تحویل داده شده",
4 => "لغو شده",
5 => "آماده تحویل در دفتر",
6 => "مرجوع شده",
_ => "نامشخص"
};
@@ -230,9 +207,7 @@ public class DiscountOrderService
1 => Color.Info,
2 => Color.Primary,
3 => Color.Success,
4 => Color.Dark,
5 => Color.Primary,
6 => Color.Error,
4 => Color.Error,
_ => Color.Default
};
@@ -80,7 +80,7 @@ public class DiscountProductService
public async Task<DiscountProductListResult> GetProductsAsync(
int page = 1, int pageSize = 12,
string? search = null, long? categoryId = null,
bool? inStock = null, string? sortBy = null)
bool? inStock = null)
{
try
{
@@ -97,8 +97,6 @@ public class DiscountProductService
request.CategoryId = categoryId.Value;
if (inStock.HasValue)
request.InStock = inStock.Value;
if (!string.IsNullOrWhiteSpace(sortBy))
request.SortBy = sortBy;
var response = await _productClient.GetDiscountProductsAsync(request);
@@ -127,9 +125,6 @@ public class DiscountProductService
}
}
public Task<DiscountProductListResult> GetTopSellingAsync(int count = 6, bool? inStock = null)
=> GetProductsAsync(page: 1, pageSize: count, sortBy: "SaleCount desc", inStock: inStock);
public async Task<DiscountProductDetail?> GetByIdAsync(long productId)
{
try
@@ -1,42 +0,0 @@
namespace FrontOffice.Main.Utilities;
/// <summary>
/// هر action ای که نیاز به لاگین دارد را از طریق این سرویس اجرا کنید.
/// اگر کاربر لاگین نباشد، مودال ورود نشان داده می‌شود.
/// پس از ورود موفق، action به صورت خودکار اجرا می‌شود.
/// </summary>
public class GuestActionGate
{
private readonly AuthService _authService;
private readonly AuthDialogService _authDialogService;
public GuestActionGate(AuthService authService, AuthDialogService authDialogService)
{
_authService = authService;
_authDialogService = authDialogService;
}
/// <summary>
/// اگر کاربر لاگین باشد action را اجرا می‌کند.
/// در غیر این صورت مودال لاگین را باز کرده و پس از ورود موفق، action را اجرا می‌کند.
/// </summary>
/// <returns>true اگر action اجرا شد، false اگر کاربر لاگین نکرد.</returns>
public async Task<bool> RunAsync(Func<Task> action)
{
if (await _authService.IsAuthenticatedAsync())
{
await action();
return true;
}
await _authDialogService.ShowAuthDialogAsync();
if (await _authService.IsAuthenticatedAsync())
{
await action();
return true;
}
return false;
}
}
@@ -21,23 +21,6 @@ public class NetworkNodeDto
/// کد معرف کاربر
/// </summary>
public string? ReferralCode { get; set; }
/// <summary>
/// نام پکیج موثر (مثلاً پکیج طلایی / نقره‌ای)
/// </summary>
public string? PackageName { get; set; }
/// <summary>آخرین امتیاز پای چپ پکیج طلایی</summary>
public int GoldLeftLegTotal { get; set; }
/// <summary>آخرین امتیاز پای راست پکیج طلایی</summary>
public int GoldRightLegTotal { get; set; }
/// <summary>آخرین امتیاز پای چپ پکیج نقره‌ای</summary>
public int SilverLeftLegTotal { get; set; }
/// <summary>آخرین امتیاز پای راست پکیج نقره‌ای</summary>
public int SilverRightLegTotal { get; set; }
}
/// <summary>
@@ -60,16 +43,6 @@ public class FlatNetworkNodeDto
/// کد معرف کاربر - فقط برای کاربران فعال در باشگاه نمایش داده شود
/// </summary>
public string? ReferralCode { get; set; }
/// <summary>
/// نام پکیج موثر (مثلاً پکیج طلایی / نقره‌ای)
/// </summary>
public string? PackageName { get; set; }
public int GoldLeftLegTotal { get; set; }
public int GoldRightLegTotal { get; set; }
public int SilverLeftLegTotal { get; set; }
public int SilverRightLegTotal { get; set; }
}
/// <summary>
@@ -108,12 +81,7 @@ public class NetworkTreeDto
IsClubActive = node.IsClubActive,
ActivationWeekNumber = node.ActivationWeekNumber,
JoinedAt = node.JoinedAt,
ReferralCode = node.ReferralCode,
PackageName = node.PackageName,
GoldLeftLegTotal = node.GoldLeftLegTotal,
GoldRightLegTotal = node.GoldRightLegTotal,
SilverLeftLegTotal = node.SilverLeftLegTotal,
SilverRightLegTotal = node.SilverRightLegTotal
ReferralCode = node.ReferralCode
};
result.Add(flatNode);
@@ -137,11 +137,6 @@ public class NetworkMembershipService
IsActive = node.IsActive,
IsClubActive = node.IsClubActive,
ReferralCode = node.ReferralCode,
PackageName = string.IsNullOrWhiteSpace(node.PackageName) ? null : node.PackageName,
GoldLeftLegTotal = node.GoldLeftLegTotal,
GoldRightLegTotal = node.GoldRightLegTotal,
SilverLeftLegTotal = node.SilverLeftLegTotal,
SilverRightLegTotal = node.SilverRightLegTotal,
ActivationWeekNumber = node.ActivationWeekDefinitionId?.ToString(),
JoinedAt = node.JoinedAt?.ToDateTime(),
LeftChild = MapNodeFromProto(node.LeftChild),
@@ -10,17 +10,8 @@ public record PackageDto(
string Title,
string Description,
string ImageUrl,
long Price,
long ActivationFee = 0,
double DiscountMultiplier = 0,
double MagicWalletMultiplier = 0,
long MagicWalletMaxDeposit = 0,
long MagicWalletMaxCredit = 0,
bool IsBasePackage = false,
bool SupportsDayaPurchase = false,
bool SupportsDirectPurchase = false)
long Price)
{
/// <summary>قیمت فرمت‌شده به تومان</summary>
public string FormattedPrice => string.Format("{0:N0} تومان", Price);
}
@@ -29,7 +20,6 @@ public record PackageDto(
/// </summary>
public record UserPackageStatusDto(
bool HasPurchasedPackage,
string? PackageTitle,
string? PurchaseMethod, // DayaLoan, DirectPurchase, or null
bool IsClubMemberActive,
long WalletBalance,
@@ -65,15 +55,7 @@ public class PackageService
m.Title,
m.Description,
m.ImagePath,
m.Price,
m.ActivationFee,
m.DiscountMultiplier,
m.MagicWalletMultiplier,
m.MagicWalletMaxDeposit,
m.MagicWalletMaxCredit,
m.IsBasePackage,
m.SupportsDayaPurchase,
m.SupportsDirectPurchase))
m.Price))
.ToList();
}
catch (Exception ex)
@@ -117,32 +99,15 @@ public class PackageService
/// <summary>
/// Get user's package purchase status
/// </summary>
public async Task<UserPackageStatusDto> GetUserPackageStatusAsync(CancellationToken ct = default)
public Task<UserPackageStatusDto> GetUserPackageStatusAsync(CancellationToken ct = default)
{
try
{
var response = await _client.GetUserPackageStatusAsync(
new GetUserPackageStatusRequest(), cancellationToken: ct);
return new UserPackageStatusDto(
HasPurchasedPackage: response.HasPurchasedPackage,
PackageTitle: null,
PurchaseMethod: response.PackagePurchaseMethod,
IsClubMemberActive: response.IsClubMemberActive,
WalletBalance: response.WalletBalance,
CanActivateClubMembership: response.CanActivateClubMembership,
PurchaseDate: response.LastPurchaseDate?.ToDateTime());
}
catch
{
return new UserPackageStatusDto(
HasPurchasedPackage: false,
PackageTitle: null,
PurchaseMethod: null,
IsClubMemberActive: false,
WalletBalance: 0,
CanActivateClubMembership: false,
PurchaseDate: null);
}
// TODO: Connect to GetUserPackageStatus RPC when available in FrontOffice.BFF
return Task.FromResult(new UserPackageStatusDto(
HasPurchasedPackage: false,
PurchaseMethod: null,
IsClubMemberActive: false,
WalletBalance: 0,
CanActivateClubMembership: false,
PurchaseDate: null));
}
}
@@ -26,13 +26,6 @@ public record Product(
= [];
}
public record ProductListResult(
List<Product> Products,
int TotalCount,
int TotalPages,
int CurrentPage,
bool HasNext);
public record ProductGalleryImage(
long ProductGalleryId,
long ProductImageId,
@@ -66,58 +59,32 @@ public class ProductService
}
public async Task<List<Product>> GetProductsAsync(string? query = null, long? categoryId = null, string? sortBy = null)
{
var result = await GetProductsPagedAsync(query: query, categoryId: categoryId, sortBy: sortBy, page: 1, pageSize: 500);
return result.Products;
}
public Task<ProductListResult> GetTopSellingAsync(int count = 6, bool? inStock = null)
=> GetProductsPagedAsync(sortBy: "SaleCount desc", page: 1, pageSize: count, inStock: inStock);
public async Task<ProductListResult> GetProductsPagedAsync(
string? query = null, long? categoryId = null, string? sortBy = null,
int page = 1, int pageSize = 12, bool? inStock = null)
{
try
{
var request = new GetAllProductsByFilterRequest
{
PaginationState = new CMSMicroservice.Protobuf.Protos.PaginationState
Filter = new GetAllProductsByFilterFilter
{
PageNumber = page,
PageSize = pageSize
},
Filter = new GetAllProductsByFilterFilter()
Title = query ?? string.Empty,
Description = query ?? string.Empty,
ShortInfomation = query ?? string.Empty,
FullInformation = query ?? string.Empty
}
};
// فقط Title — CMS فیلترها را AND می‌زند؛ ارسال query در همه فیلدها جستجو را می‌شکند
if (!string.IsNullOrWhiteSpace(query))
{
request.Filter.Title = query.Trim();
}
if (categoryId is { } value)
{
request.Filter.CategoryId = value;
}
if (inStock.HasValue)
{
request.Filter.InStock = inStock.Value;
}
if (!string.IsNullOrEmpty(sortBy))
{
request.SortBy = sortBy;
}
var resp = await _client.GetAllProductsByFilterAsync(request);
var products = MapAndCache(resp.Models);
var totalCount = (int)(resp.MetaData?.TotalCount ?? 0);
var totalPages = (int)(resp.MetaData?.TotalPage ?? 0);
var hasNext = resp.MetaData?.HasNext ?? false;
return new ProductListResult(products, totalCount, totalPages, page, hasNext);
return MapAndCache(resp.Models);
}
catch
{
@@ -130,73 +97,36 @@ public class ProductService
p.Description.Contains(q, StringComparison.OrdinalIgnoreCase));
}
var all = list.OrderBy(p => p.Id).ToList();
return new ProductListResult(all, all.Count, 1, 1, false);
return list.OrderBy(p => p.Id).ToList();
}
}
public async Task<Product?> GetByIdAsync(long id)
{
if (id <= 0)
return null;
TryGetCachedProduct(id, out var cached);
if (cached is not null && HasDetailedData(cached))
if (TryGetCachedProduct(id, out var cached) && HasDetailedData(cached))
{
return cached;
}
// جزئیات کامل (گالری + دسته‌بندی)
var detailed = await TryFetchDetailAsync(id);
if (detailed is not null)
return detailed;
// fallback: همان API لیست محصولات — ناموجودها را هم برمی‌گرداند
var fromFilter = await TryFetchByFilterIdAsync(id);
if (fromFilter is not null)
return fromFilter;
return cached;
}
private async Task<Product?> TryFetchDetailAsync(long id)
{
try
{
var resp = await _client.GetProductsAsync(new GetProductsRequest { Id = id });
if (resp is null || resp.Id <= 0)
if (resp == null)
{
return null;
}
return MapAndCache(resp);
}
catch
{
return null;
}
}
private async Task<Product?> TryFetchByFilterIdAsync(long id)
{
try
{
var resp = await _client.GetAllProductsByFilterAsync(new GetAllProductsByFilterRequest
if (cached is not null)
{
PaginationState = new CMSMicroservice.Protobuf.Protos.PaginationState
{
PageNumber = 1,
PageSize = 1
},
Filter = new GetAllProductsByFilterFilter { Id = id }
});
return cached;
}
var model = resp.Models.FirstOrDefault(m => m.Id == id);
if (model is null)
return null;
return MapAndCache(resp.Models).FirstOrDefault(p => p.Id == id);
}
catch
{
return null;
TryGetCachedProduct(id, out var result);
return result;
}
}
@@ -22,9 +22,6 @@ public static class RouteConstants
public const string ChangePassword = "/profile/change-password";
public const string Tree = "/profile/tree";
public const string Wallet = "/profile/wallet";
public const string MagicWallet = "/profile/magic-wallet";
public const string ChargeDiscountWallet = "/profile/charge-discount-wallet";
public const string ChargeCreditWallet = "/profile/charge-credit-wallet";
public const string WithdrawalRequests = "/profile/withdrawal-requests";
}
@@ -70,11 +67,6 @@ public static class RouteConstants
public const string Index = "/contact";
}
public static class Licenses
{
public const string Index = "/licenses";
}
public static class Blog
{
public const string Index = "/blog";
@@ -1,213 +0,0 @@
using System.Text.RegularExpressions;
using Microsoft.Extensions.Options;
namespace FrontOffice.Main.Utilities.Seo;
public class SeoMetadataProvider
{
private readonly BlogPostService _blogPostService;
private readonly SeoSettings _settings;
private static readonly Dictionary<string, (string Title, string Description)> StaticPages = new(StringComparer.OrdinalIgnoreCase)
{
["/"] = (
"کارابازار سلامت | باشگاه مشتریان KBS",
"باشگاه مشتریان کارابازار سلامت — رشد تیم، فروش واقعی و پاداش شفاف. فروشگاه محصولات سلامت، کیف پول و ثبت‌نام رایگان."),
[RouteConstants.About.Index] = (
"درباره ما | کارابازار سلامت",
"آشنایی با کارابازار سلامت، باشگاه مشتریان KBS و خدمات فروشگاه محصولات سلامت."),
[RouteConstants.FAQ.Index] = (
"سوالات متداول | کارابازار سلامت",
"پاسخ سوالات متداول درباره باشگاه مشتریان، کیف پول، پاداش‌ها و خرید از کارابازار سلامت."),
[RouteConstants.Contact.Index] = (
"ارتباط با ما | کارابازار سلامت",
"تماس با تیم پشتیبانی کارابازار سلامت — سوالات، پیشنهادات و پشتیبانی کاربران."),
[RouteConstants.Licenses.Index] = (
"مجوزها و گواهینامه‌ها | کارابازار سلامت",
"مجوزها، گواهینامه‌ها و مدارک رسمی کارابازار سلامت."),
[RouteConstants.Blog.Index] = (
"بلاگ | کارابازار سلامت",
"آخرین مقالات و اخبار باشگاه مشتریان کارابازار سلامت."),
[RouteConstants.Store.Products] = (
"محصولات | کارابازار سلامت",
"فروشگاه محصولات سلامت کارابازار — خرید آنلاین با پاداش باشگاه مشتریان."),
[RouteConstants.Store.Categories] = (
"دسته‌بندی‌ها | کارابازار سلامت",
"دسته‌بندی محصولات فروشگاه کارابازار سلامت."),
[RouteConstants.Package.List] = (
"پکیج‌ها | کارابازار سلامت",
"پکیج‌های عضویت باشگاه مشتریان کارابازار سلامت."),
[RouteConstants.Club.Membership] = (
"عضویت باشگاه مشتریان | کارابازار سلامت",
"عضویت در باشگاه مشتریان KBS و بهره‌مندی از مزایا و پاداش‌ها."),
[RouteConstants.Club.Features] = (
"ویژگی‌های باشگاه مشتریان | کارابازار سلامت",
"امکانات و ویژگی‌های باشگاه مشتریان کارابازار سلامت."),
[RouteConstants.DiscountStore.Products] = (
"فروشگاه اعتباری | کارابازار سلامت",
"خرید از فروشگاه اعتباری کارابازار سلامت با کیف پول تخفیفی."),
[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)
{
RouteConstants.Profile.Index,
"/profile/",
RouteConstants.Checkout.Index,
RouteConstants.Store.Cart,
RouteConstants.Store.CheckoutSummary,
RouteConstants.Store.Orders,
"/order/",
RouteConstants.DiscountStore.Cart,
RouteConstants.DiscountStore.Checkout,
RouteConstants.DiscountStore.Orders,
"/discount-store/order/",
RouteConstants.Gateway.OrdersChooser,
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)
{
_blogPostService = blogPostService;
_settings = settings.Value;
}
public async Task<SeoPageMetadata> GetForPathAsync(string path, CancellationToken cancellationToken = default)
{
var normalizedPath = NormalizePath(path);
var baseUrl = _settings.SiteUrl.TrimEnd('/');
if (IsNoIndexPath(normalizedPath))
{
return new SeoPageMetadata
{
Title = _settings.SiteName,
Description = _settings.DefaultDescription,
CanonicalUrl = $"{baseUrl}{normalizedPath}",
NoIndex = true
};
}
if (normalizedPath.StartsWith(RouteConstants.Blog.Post, StringComparison.OrdinalIgnoreCase)
&& normalizedPath.Length > RouteConstants.Blog.Post.Length)
{
var slug = normalizedPath[RouteConstants.Blog.Post.Length..].Trim('/');
if (!string.IsNullOrWhiteSpace(slug))
{
var post = await _blogPostService.GetBySlugAsync(slug);
if (post != null)
{
var description = !string.IsNullOrWhiteSpace(post.Summary)
? post.Summary
: $"مقاله {post.Title} — بلاگ کارابازار سلامت";
return new SeoPageMetadata
{
Title = $"{post.Title} | بلاگ کارابازار سلامت",
Description = description,
CanonicalUrl = $"{baseUrl}{RouteConstants.Blog.Post}{post.Slug}",
CrawlableContent = $"{post.Title}. {description}"
};
}
}
}
if (StaticPages.TryGetValue(normalizedPath, out var page))
{
return new SeoPageMetadata
{
Title = page.Title,
Description = page.Description,
CanonicalUrl = $"{baseUrl}{normalizedPath}",
CrawlableContent = normalizedPath == "/" ? GetHomeCrawlableContent() : null
};
}
if (IsIndexableDynamicRoute(normalizedPath))
{
return new SeoPageMetadata
{
Title = _settings.SiteName,
Description = _settings.DefaultDescription,
CanonicalUrl = $"{baseUrl}{normalizedPath}"
};
}
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)
{
if (string.IsNullOrWhiteSpace(path))
return "/";
var normalized = path.Split('?', '#')[0].Trim();
if (!normalized.StartsWith('/'))
normalized = "/" + normalized;
if (normalized.Length > 1 && normalized.EndsWith('/'))
normalized = normalized.TrimEnd('/');
return normalized;
}
private static bool IsNoIndexPath(string path)
{
if (NoIndexPaths.Contains(path))
return true;
foreach (var prefix in NoIndexPrefixes)
{
if (path.Equals(prefix, StringComparison.OrdinalIgnoreCase)
|| path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
private static string GetHomeCrawlableContent() =>
"کارابازار سلامت (KBS) — باشگاه مشتریان و فروشگاه محصولات سلامت. " +
"با عضویت در باشگاه مشتریان KBS از پاداش شفاف، کیف پول جادویی، فروشگاه اعتباری و شبکه فروش بهره‌مند شوید. " +
"ثبت‌نام رایگان در kbs2.ir.";
}
@@ -1,10 +0,0 @@
namespace FrontOffice.Main.Utilities.Seo;
public sealed class SeoPageMetadata
{
public required string Title { get; init; }
public required string Description { get; init; }
public required string CanonicalUrl { get; init; }
public string? CrawlableContent { get; init; }
public bool NoIndex { get; init; }
}

Some files were not shown because too many files have changed in this diff Show More