Browse Source

feat: add core panel UI features including billing, tours, services, layout components, and i18n support

master
Muhammad A. Ghorbani 3 weeks ago
parent
commit
76b84d8363
  1. 548
      guide/aqila_panel_ui_design_system_standard.md
  2. 6
      src/app/router.tsx
  3. 244
      src/components/layout/AppSidebar.tsx
  4. 67
      src/components/layout/LanguageSwitcher.tsx
  5. 60
      src/components/layout/Topbar.tsx
  6. 124
      src/config/navigation.ts
  7. 428
      src/features/billing/BillingPage.tsx
  8. 5
      src/features/billing/components/BillingDetailModal.tsx
  9. 21
      src/features/billing/components/BillingServiceBadge.tsx
  10. 14
      src/features/billing/components/BillingStatusBadge.tsx
  11. 28
      src/features/billing/components/RejectFactorDialog.tsx
  12. 47
      src/features/global-search/GlobalSearchDialog.tsx
  13. 402
      src/features/services/ServicesPage.tsx
  14. 4
      src/features/services/components/CategoriesServiceView.tsx
  15. 4
      src/features/services/components/CreateLocationDialog.tsx
  16. 8
      src/features/services/components/DuasServiceView.tsx
  17. 1368
      src/features/services/components/FaqServiceView.tsx
  18. 2
      src/features/services/components/ShrinesServiceView.tsx
  19. 26
      src/features/tours/OngoingToursPage.tsx
  20. 34
      src/features/tours/ToursPage.tsx
  21. 141
      src/i18n/index.ts
  22. 4
      src/index.css
  23. 133
      src/locales/ar.json
  24. 133
      src/locales/en.json
  25. 133
      src/locales/fa.json
  26. 2
      src/main.tsx
  27. 39
      src/stores/billing-badge.ts

548
guide/aqila_panel_ui_design_system_standard.md

@ -1,174 +1,205 @@
# سند جامع سیستم طراحی بصری و استانداردهای رابط کاربری (UI Design System & Typography Standard)
# سند جامع سیستم طراحی بصری، تایپوگرافی و معماری رسپانسیو پنل مدیریت عقیله (UI Design System & Responsive Standard)
**سامانه مدیریت و پلتفرم عقیله (Aqila)**
**مرجع سیستم:** کلاینت وب و پنل مدیریت (`aqila_panel`) و راهنمای سراسری هوش مصنوعی (AI Agent Directive)
**نسخه:** 1.0.0
**سامانه پلتفرم و پنل مدیریت عقیله (Aqila Web Panel)**
**مرجع سیستم:** کلاینت وب (`aqila_panel`) و راهنمای سراسری هوش مصنوعی و توسعه‌دهندگان (Single Source of Truth)
**نسخه:** 2.0.0 (ویرایش جامع دسکتاپ، تبلت و موبایل)
**وضعیت:** مصوب و الزامی (Mandatory Reference) **وضعیت:** مصوب و الزامی (Mandatory Reference)
--- ---
## ۱. هدف و دامنه کاربرد (Objective & Scope) ## ۱. هدف و دامنه کاربرد (Objective & Scope)
این سند، **مرجع واحد و غیرقابل‌تغییر (Single Source of Truth)** برای تمامی استانداردهای بصری، تایپوگرافی، رنگ‌بندی، چیدمان، فاصله‌گذاری، کامپوننت‌های تعاملی و رفتارهای چندزبانه در رابط کاربری سامانه عقیله است.
این سند، **مرجع قطعی، جامع و غیرقابل‌تغییر** برای تمامی استانداردهای بصری، تایپوگرافی، پالت رنگی خنثی، نقاط شکست (Breakpoints)، چیدمان‌های چندستونه دسکتاپ و واکنش‌گرایی موبایل (Mobile Responsiveness) در پنل مدیریت عقیله است.
> [!IMPORTANT] > [!IMPORTANT]
> **قانون الزام‌آور هوش مصنوعی (AI Agent Rule):**
> هوش مصنوعی (AI Agent) در زمان ایجاد هر صفحه جدید، ساخت هر کامپوننت، ویرایش صفحات موجود، بازطراحی فرم‌ها، فیلترها، جداول، تگ‌ها و وضعیت‌های بارگذاری (Loading)، **باید ابتدا و بدون استثنا این سند را به عنوان مرجع قطعی رعایت نماید.** هرگونه کدنویسی بصری سلیقه‌ای، استفاده از رنگ‌های رنگین‌کمانی مدادرنگی (Crayon-like)، فونت‌های متفرقه یا ساختارهای ناهمگون اکیداً ممنوع است.
> **قانون الزام‌آور هوش مصنوعی و توسعه‌دهندگان (AI Agent & Dev Directive):**
> در هنگام ایجاد هر صفحه جدید، کامپوننت، فرم، فیلتر، جدول، دیالوگ، شیت یا وضعیت بارگذاری (Loading)، **باید ابتدا و بدون استثنا کلیه بندهای این سند به عنوان استاندارد قطعی اعمال شوند.**
> هرگونه کدنویسی بصری سلیقه‌ای، استفاده از رنگ‌های رنگین‌کمانی مدادرنگی (Crayon-like UI)، فونت‌های متفرقه، پدینگ‌های هاردکدشده بدون مدیاکوئری، یا جدول‌های غیرریسپانسیو که در موبایل سرریز (Overflow) نامناسب ایجاد کنند، **اکیداً ممنوع و مردود است.**
--- ---
## ۲. زبان، تایپوگرافی و نگارش چندزبانه (Language & Typography)
## ۲. نقاط شکست و معماری چیدمان رسپانسیو (Breakpoints & Layout Architecture)
سامانه عقیله یک سیستم بین‌المللی با تمرکز بر سه زبان اصلی **فارسی (FA)**، **عربی (AR)** و **انگلیسی (EN)** است.
پنل مدیریت عقیله باید تجربه‌ای بی‌نقص و روان از مانیتورهای عریض دسکتاپ تا گوشی‌های هوشمند ارائه دهد. نقاط شکست استاندارد پروژه مطابق با سیستم رسمی Tailwind CSS و متناسب با ابعاد داشبورد به شرح زیر تعریف شده است:
``` ```
┌────────────────────────────────────────────────────────────────────────┐
│ مقیاس تایپوگرافی عقیله │
├───────────────────┬────────────┬─────────┬──────────────┬──────────────┤
│ سطح متنی (Role) │ سایز (Size)│ وزن (Wt)│ Line-Height │ کلاس Tailwind│
├───────────────────┼────────────┼─────────┼──────────────┼──────────────┤
│ Display / H1 │ 24px (1.5r)│ Black/900│ 1.35 (32px) │ text-2xl │
│ Page Title / H2 │ 20px (1.25)│ Bold/800│ 1.4 (28px) │ text-xl │
│ Section Title / H3│ 16px (1.0r)│ Bold/700│ 1.45 (24px) │ text-base │
│ Card Title / H4 │ 14px (.875)│ Bold/700│ 1.5 (21px) │ text-sm │
│ Body Text (اصلی) │ 13px-14px │ Medium/500│ 1.6 (22px) │ text-xs/sm │
│ Secondary / Muted │ 12px (.75r)│ Regular │ 1.5 (18px) │ text-xs │
│ Caption / Footnote│ 11px (.687)│ Medium │ 1.4 (16px) │ text-[11px] │
│ Badge / Tag Label │ 10px-11px │ Bold/700│ 1.0 (12px) │ text-[10px] │
│ Monospace / Stats │ 12px-20px │ Bold/900│ 1.2 (Tabular)│ font-mono │
└───────────────────┴────────────┴─────────┴──────────────┴──────────────┘
┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ جدول نقاط شکست رسپانسیو پنل عقیله │
├───────────┬──────────────┬──────────────────┬────────────────────────────────────────────────────┤
│ نقطه شکست │ بازه عرضی │ دستگاه هدف │ رفتار چیدمان و اسکلت اصلی │
├───────────┼──────────────┼──────────────────┼────────────────────────────────────────────────────┤
│ xs / Base │ < 640px │ گوشی‌های هوشمند │ تک‌ستونه، سایدبار منوی همبرگری، فیلترهای استک‌شده، │
│ │ │ (Mobile Portrait)│ تبدیل جداول به کارت/اسکرول افقی، دیالوگ باتم‌شیت │
├───────────┼──────────────┼──────────────────┼────────────────────────────────────────────────────┤
│ sm │ 640px-767px │ فبلت و موبایل افقی│ فرم‌های ۲ ستونه، فیلترهای منعطف، پدینگ‌های میانی │
├───────────┼──────────────┼──────────────────┼────────────────────────────────────────────────────┤
│ md │ 768px-1023px │ تبلت (Tablet) │ سایدبار کشویی (Overlay)، جداول فشرده با اسکرول، │
│ │ │ │ کارت‌های آماری ۲ الی ۳ ستونه │
├───────────┼──────────────┼──────────────────┼────────────────────────────────────────────────────┤
│ lg │ 1024px-1279px│ لپ‌تاپ / دسکتاپ │ سایدبار ثابت راست (240px)، هدر کامل، جداول جامع │
├───────────┼──────────────┼──────────────────┼────────────────────────────────────────────────────┤
│ xl │ 1280px-1535px│ مانیتور استاندارد│ نمایش سه‌ستونه: سایدبار راست + محتوا + پنل اسلاید چپ│
├───────────┼──────────────┼──────────────────┼────────────────────────────────────────────────────┤
│ 2xl │ ≥ 1536px │ مانیتورهای عریض │ حداکثر خوانایی، گرید ۴ ستونه کارت‌های KPI و آمار │
└───────────┴──────────────┴──────────────────┴────────────────────────────────────────────────────┘
``` ```
### ۲.۱. خانواده فونت‌ها (Font Families)
1. **فارسی و عربی (RTL):**
- فونت استاندارد: `Vazirmatn` (وزن‌های 400, 500, 700, 800, 900)
- فونت فال‌بک: `system-ui, -apple-system, 'Segoe UI', Tahoma, sans-serif`
2. **انگلیسی و ارقام/داده‌های فنی (LTR):**
- متون انگلیسی: `Inter, system-ui, sans-serif`
- ارقام مالی، شناسه‌ها، شماره سفارش، تاریخ‌های میلادی و ساعت: `font-mono` (فونت مونو اسپیس با Tabular Figures جهت تراز دقیق در جداول و کارت‌ها).
### ۲.۲. ترکیب متون دوزبانه و اعداد (Mixed-Language Content)
- **ارقام فارسی در برابر انگلیسی:**
- در متون توضیحی و عناوین فارسی از ارقام بومی (`toFa(number)`) استفاده شود.
- در کد رهگیری، شماره کارت، شناسه دیتابیس (`#1240`)، مبالغ ارزی دلاری/دیناری (`$1,200` یا `15,000 IQD`) و تاریخ‌های لاگ، منحصراً از ارقام انگلیسی درون بلوک `font-mono` و با جهت `dir="ltr"` استفاده شود.
- **اصطلاحات فنی و لاتین در متن فارسی:**
- کلیه اسلاگ‌ها، نام فایل‌ها و عبارت‌های انگلیسی درون متون فارسی باید درون محفظه مجزا یا با `dir="ltr"` قرار گیرند تا نظم کلمات به هم نخورد.
- **پرهیز از Letter-Spacing غیرمجاز:**
- در خطوط فارسی و عربی به هیچ وجه از `letter-spacing` (کلاس‌های `tracking-widest` و...) استفاده نشود؛ زیرا باعث تکه‌تکه شدن حروف متصل می‌شود.
### ۲.۱. ساختار شل اصلی در دسکتاپ در برابر موبایل (Responsive Shell Structure)
---
شل اصلی اپلیکیشن (`AuthenticatedShell`) به صورت داینامیک بین دو حالت سوئیچ می‌کند:
## ۳. جهت‌چینی، ترازبندی و قواعد RTL / LTR
1. **نمای دسکتاپ و مانیتورهای بزرگ (`lg` و بالاتر / `≥ 1024px`):**
- **سایدبار ناوبری راست (`AppSidebar`):** عرض ثابت `240px`، همیشه قابل مشاهده و قابل جمع‌شدن به صورت آیکونی.
- **بخش میانی محتوا (`SidebarInset`):** شامل `Topbar` با ارتفاع `h-16` و محتوای اصلی با پدینگ `px-8 py-6`.
- **پنل جزئیات سمت چپ (`PanelSlot`):** در ابعاد `≥ 1280px` به صورت ستون کناری ثابت (Off-canvas با عرض `384px`) باز شده و محتوا را به نرمی جمع می‌کند.
### ۳.۱. اصول جهت‌چینی (Directionality Rules)
- رابط کاربری به صورت پیش‌فرض **راست‌به‌چپ (`dir="rtl"`)** است.
- **ویژگی‌های منطقی (CSS Logical Properties):**
- به جای `left` و `right`، همواره از ویژگی‌های منطقی استفاده شود:
- پدینگ و مارجین: `ps-` (padding-start)، `pe-` (padding-end)، `ms-` (margin-start)، `me-` (margin-end).
- گوشه‌ها: `rounded-s-` (start) و `rounded-e-` (end).
- ترازبندی متن: `text-start` و `text-end` به جای `text-left` و `text-right`.
2. **نمای تبلت و موبایل (`< 1024px`):**
- **سایدبار ناوبری:** به صورت کشوی مدال (`Sheet` / Drawer) با کلیک روی دکمه همبرگری هدر باز می‌شود.
- **پنل جزئیات (`PanelSlot`):** به صورت `Sheet` تمام‌صفحه یا با عرض `w-[min(88vw,384px)]` روی صفحه شناور می‌شود.
- **پدینگ محتوا:** پدینگ به `px-4 py-4 pb-12` کاهش می‌یابد تا بیشترین فضای مفید به داده‌ها اختصاص یابد.
### ۳.۲. آیکون‌ها در RTL
- آیکون‌های هدایتی (مانند پیکان بازگشت، بعدی/قبلی، Breadcrumbs) باید در حالت RTL متناسب با جهت جریان اطلاعات بچرخند یا به صورت طبیعی بر اساس `start/end` قرار گیرند.
- آیکون‌های متقارن (مانند جستجو، تقویم، تنظیمات، سبد خرید) نیاز به چرخش ندارند.
```tsx
// ساختار استاندارد کانتینر اصلی محتوای هر صفحه
<div className="space-y-4 sm:space-y-6">
{/* هدر صفحه: در موبایل عمودی و در دسکتاپ افقی */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-lg font-black text-foreground sm:text-xl md:text-2xl">
{pageTitle}
</h1>
<p className="text-xs text-muted-foreground sm:text-sm">
{pageDescription}
</p>
</div>
<div className="flex items-center gap-2">
{headerActions}
</div>
</div>
{/* بدنه محتوا */}
{children}
</div>
```
--- ---
## ۴. پالت رنگی خنثی، مینیمال و معنادار (Neutral & Semantic Color System)
## ۳. سیستم تایپوگرافی و مقیاس متنی رسپانسیو (Responsive Typography Matrix)
> [!CAUTION]
> **پرهیز جدی از رابط کاربری مدادرنگی (No "Crayon-like" Overly Colorful UI):**
> بیش از **۹۰٪ رابط کاربری** باید از رنگ‌های خنثی (سفید، خاکستری، اسلیت، تیره) تشکیل شود. استفاده از رنگ‌های تند (آبی روشن، نارنجی فسفری، بنفش تیره، سبز فسفری) به عنوان پس‌زمینه کارت‌ها، بج‌ها و کادرها کاملاً ممنوع است مگر اینکه معنای وضعیتی مشخصی داشته باشد.
تایپوگرافی در پنل عقیله باید در مانیتورهای بزرگ کاملاً شارپ و خوانا، و در موبایل بدون ایجاد شکستگی‌های نامطلوب یا سرریز کلمات باشد:
``` ```
┌────────────────────────────────────────────────────────────────────────┐
│ معماری پالت رنگی عقیله │
├───────────────────┬──────────────────────┬─────────────────────────────┤
│ توکن معنایی │ تم دارک (Dark Mode) │ تم لایت (Light Mode) │
├───────────────────┼──────────────────────┼─────────────────────────────┤
│ background │ #18191D (تیره مات) │ #F8FAFC (سفید-خاکستری محو) │
│ surface / card │ #22252C (کارت پایه) │ #FFFFFF (سفید خالص) │
│ surface-muted │ #1E2128 (پس‌زمینه فرعی)│ #F1F5F9 (خاکستری بسیار روشن)│
│ border │ rgba(255,255,255,0.1)│ rgba(15,23,42,0.10) │
│ border-soft │ rgba(255,255,255,0.06)│ rgba(15,23,42,0.06) │
│ primary (هویت برنز)│ #D09460 │ #D09460 │
│ primary-muted │ rgba(208,148,96,0.12)│ rgba(208,148,96,0.10) │
│ text-foreground │ #FFFFFF (خوانایی کامل)│ #0F172A (سرمه‌ای-مشکی پرکنتراست)│
│ text-muted │ #8B8B8B (خاکستری متوسط)│ #64748B (خاکستری خوانا) │
│ text-subtle │ #666666 │ #94A3B8 │
└───────────────────┴──────────────────────┴─────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ مقیاس تایپوگرافی تطبیقی (Responsive Typography) │
├───────────────────┬──────────────┬──────────────┬─────────┬──────────────┬───────────────────────┤
│ سطح متنی (Role) │ سایز موبایل │ سایز دسکتاپ │ وزن (Wt)│ Line-Height │ کلاس Tailwind پیشنهادی│
├───────────────────┼──────────────┼──────────────┼─────────┼──────────────┼───────────────────────┤
│ Display / H1 │ 20px (1.25r) │ 24px (1.5r) │ 900/Bold│ 1.35 (32px) │ text-xl md:text-2xl │
│ Page Title / H2 │ 18px (1.12r) │ 20px (1.25r) │ 800/Bold│ 1.4 (28px) │ text-lg md:text-xl │
│ Section Title / H3│ 15px (.937r) │ 16px (1.0r) │ 700/Bold│ 1.45 (24px) │ text-[15px] md:text-base│
│ Card Title / H4 │ 13px (.812r) │ 14px (.875r) │ 700/Bold│ 1.5 (21px) │ text-xs sm:text-sm │
│ Body Text (اصلی) │ 13px (.812r) │ 14px (.875r) │ 500/Med │ 1.6 (22px) │ text-xs sm:text-sm │
│ Secondary / Muted │ 11px (.687r) │ 12px (.75r) │ 400/Reg │ 1.5 (18px) │ text-[11px] sm:text-xs│
│ Caption / Footnote│ 10px (.625r) │ 11px (.687r) │ 500/Med │ 1.4 (16px) │ text-[10px] sm:text-[11px]│
│ Badge / Tag Label │ 10px (.625r) │ 11px (.687r) │ 700/Bold│ 1.0 (12px) │ text-[10px] sm:text-[11px]│
│ Monospace / Stats │ 14px-20px │ 16px-24px │ 900/Mono│ 1.2 (Tabular)│ font-mono tabular-nums│
└───────────────────┴──────────────┴──────────────┴─────────┴──────────────┴───────────────────────┘
``` ```
### ۴.۱. رنگ‌های معنایی و وضعیت‌ها (Semantic Status Colors)
رنگ‌های اشباع منحصراً برای اعلام وضعیت و فیدبک‌های مهم سیستم رزرو شده‌اند:
1. **موفقیت / تایید (Success):**
- کارکرد: پرداخت موفق، تور تکمیل‌شده، رزرو فعال.
- رنگ: `Emerald` (لایت: `text-emerald-700 bg-emerald-50 border-emerald-200` | دارک: `text-emerald-400 bg-emerald-500/10 border-emerald-500/20`).
2. **هشدار / در انتظار (Warning / Pending):**
- کارکرد: در انتظار پرداخت، بررسی فیش، نزدیک به ظرفیت.
- رنگ: `Amber` (لایت: `text-amber-700 bg-amber-50 border-amber-200` | دارک: `text-amber-400 bg-amber-500/10 border-amber-500/20`).
3. **خطا / رد شده (Error / Destructive):**
- کارکرد: فیش نامعتبر، سفارش لغوشده، حذف آیتم.
- رنگ: `Rose / Red` (لایت: `text-rose-700 bg-rose-50 border-rose-200` | دارک: `text-rose-400 bg-rose-500/10 border-rose-500/20`).
4. **اطلاعات عمومی (Informational / Neutral):**
- کارکرد: دسته‌بندی‌ها، تعداد بازدید، مشخصات عادی، تگ‌های عمومی.
- رنگ: **کاملاً خنثی (Neutral / Slate)** (`bg-muted/40 text-muted-foreground border-border-soft`). هرگز برای تگ‌های معمولی از رنگ‌های رنگین‌کمانی استفاده نشود.
### ۳.۱. فونت‌ها و ترکیب دوزبانه (Fonts & Multi-Language)
1. **فارسی و عربی (RTL):**
- فونت رسمی: `Vazirmatn` (وزن‌های 400، 500، 600، 700، 800، 900).
- برای خطوط فارسی و عربی به هیچ عنوان از `letter-spacing` (کلاس‌های `tracking-wide` یا `tracking-widest`) استفاده نشود زیرا اتصالات حروف شکسته می‌شود.
2. **انگلیسی و متون فنی (LTR):**
- فونت استاندارد: `Inter, system-ui, sans-serif`.
3. **ارقام مالی، شناسه‌ها، مبالغ و کدها:**
- منحصراً از کلاس `font-mono tabular-nums` همراه با جهت `dir="ltr"` برای نمایش ارقام انگلیسی، قیمت‌ها (`$1,250` یا `25,000 IQD`)، شناسه‌های سیستمی (`#TRIP-1082`) و تاریخ‌های لاگ استفاده شود.
- در عناوین و متون فارسی توضیحی از متد کمکی `toFa(number)` برای نمایش بومی اعداد بهره گرفته شود.
--- ---
## ۵. مقیاس فواصل، ابعاد و شعاع انحناها (Spacing, Sizing & Radii)
## ۴. مقیاس فواصل، پدینگ‌ها، ابعاد و ارگونومی لمسی (Spacing & Touch Targets)
سامانه از سیستم فاصله‌گذاری مضرب ۴ و ۸ پیکسلی تبعیت می‌کند:
رعایت سیستم فاصله‌گذاری مضرب ۴ و ۸ پیکسلی برای حفظ ریتم عمودی و افقی الزامی است:
``` ```
┌────────────────────────────────────────────────────────────────────────┐
│ مقیاس فواصل استاندارد (Spacing) │
├─────────────┬─────────────┬────────────────────────────────────────────┤
│ توکن │ مقدار (px) │ کاربرد و موارد مصرف │
├─────────────┼─────────────┼────────────────────────────────────────────┤
│ space-xs │ 4px │ فاصله بین آیکون و متن، پدینگ میکرو │
│ space-sm │ 8px │ فاصله عناصر درون یک کامپوننت، گپ دکمه‌ها │
│ space-md │ 12px │ فاصله آیتم‌های لیست، پدینگ فیلدها │
│ space-lg │ 16px │ پدینگ افقی صفحات، پدینگ پیش‌فرض کارت‌ها │
│ space-xl │ 20px │ فاصله بین بخش‌ها و ستون‌های فرم‌ها │
│ space-2xl │ 24px │ گپ بین کارت‌های اصلی و سکشن‌های داشبورد │
│ space-3xl │ 32px │ فاصله عمودی سکشن‌های بزرگ │
└─────────────┴─────────────┴────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ ماتریس فواصل و پدینگ‌های رسپانسیو │
├─────────────┬─────────────┬─────────────┬────────────────────────────────────────────────────────┤
│ نام توکن │ موبایل (px) │ دسکتاپ (px) │ کاربرد و موارد استفاده │
├─────────────┼─────────────┼──────────────────────────────────────────────────────────────────────┤
│ Page Padding│ 16px (px-4) │ 32px (px-8) │ پدینگ افقی دیواره‌های اصلی صفحه به محتوا │
│ Card Padding│ 14px (p-3.5)│ 24px (p-6) │ پدینگ داخلی کارت‌های آماری، فرم‌ها و جداول │
│ Form Gap │ 12px (gap-3)│ 16px (gap-4)│ فاصله عمودی و افقی بین فیلدهای ورودی فرم │
│ Section Gap │ 16px (gap-4)│ 24px (gap-6)│ فاصله بین بلوک‌های اصلی و سکشن‌های صفحه │
│ Table Cell │ 12px (p-3) │ 16px (p-4) │ پدینگ سلول‌های جدول داده‌ها │
└─────────────┴─────────────┴─────────────┴────────────────────────────────────────────────────────┘
``` ```
### ۵.۱. شعاع انحناها (Border Radii)
- **تگ‌ها، بج‌ها و المان‌های کوچک:** `rounded-lg` (8px الی 10px)
- **فیلدهای ورودی و دکمه‌ها:** `rounded-xl` (12px)
- **کارت‌ها، جداول و بخش‌ها:** `rounded-2xl` (16px)
- **دیالوگ‌ها، پاپ‌آپ‌ها و مودال‌ها:** `rounded-2xl` الی `rounded-3xl` (20px - 24px)
- **بج‌های کپسولی / قرصی:** `rounded-full` (9999px)
### ۴.۱. حداقل ابعاد تعاملی برای لمس در موبایل (Mobile Touch Targets)
در موبایل، کاربر با انگشت با صفحه تعامل دارد نه با نشانگر دقیق ماوس. لذا:
- **ارتفاع دکمه‌ها و فیلدها:** حداقل `38px` الی `44px` در موبایل (`h-10 sm:h-9`).
- **آیکون‌باتن‌های عملیاتی:** کادر کلیک‌پذیر حداقل `36x36px` با پدینگ امن (`size-9 p-1.5`).
- **فاصله بین دکمه‌های مجاور:** حداقل `8px` (`gap-2`) تا از کلیک اشتباه جلوگیری شود.
### ۴.۲. شعاع انحنای المان‌ها (Border Radii)
- **تگ‌ها، بج‌ها و المان‌های ریز:** `rounded-lg` (8px الی 10px).
- **فیلدهای ورودی و دکمه‌ها:** `rounded-xl` (12px الی 14px).
- **کارت‌ها، سکشن‌ها و جداول:** `rounded-2xl` (16px).
- **دیالوگ‌ها در دسکتاپ:** `rounded-2xl` الی `rounded-3xl` (20px الی 24px).
- **دیالوگ‌ها در موبایل (Bottom Sheet):** لبه‌های بالا گرد `rounded-t-2xl` و لبه‌های پایین صاف `rounded-b-none`.
- **بج‌های کپسولی / قرصی:** `rounded-full` (9999px).
--- ---
## ۶. استاندارد جامع تگ‌ها و بج‌های وضعیتی (Tags & Status Badges)
## ۵. پالت رنگی خنثی، بدون مدادرنگی (Neutral Slate Color Palette)
> [!CAUTION]
> **قانون قطعی ۹۰٪ خنثی (Strict 90% Neutral Rule):**
> بیش از **۹۰ درصد** محیط بصری پنل مدیریت باید از رنگ‌های خنثی (مشکی تیره مات، زغالی، اسلیت، خاکستری، سفید) تشکیل شود. استفاده از رنگ‌های تند و رنگین‌کمانی برای پس‌زمینه کارت‌ها، هدرها، ستون‌های جدول یا تگ‌های معمولی اکیداً ممنوع است.
```
┌──────────────────────────────────────────────────────────────────────────────────────────────────┐
│ پالت رنگی رسمی پنل مدیریت عقیله │
├───────────────────┬───────────────────────────────┬──────────────────────────────────────────────┤
│ توکن معنایی │ تم دارک (Dark Mode - پیش‌فرض) │ تم لایت (Light Mode) │
├───────────────────┼───────────────────────────────┼──────────────────────────────────────────────┤
│ background │ #18191D (تیره مات و عمیق) │ #F8FAFC (سفید-خاکستری لطیف) │
│ surface / card │ #22252C (کارت پایه زغالی) │ #FFFFFF (سفید خالص) │
│ popover / panel │ #1E2128 (پنل‌های اسلاید) │ #FFFFFF │
│ border │ rgba(255, 255, 255, 0.12) │ rgba(15, 23, 42, 0.12) │
│ border-soft │ rgba(255, 255, 255, 0.07) │ rgba(15, 23, 42, 0.07) │
│ primary (برنز برند)│ #D09460 │ #D09460 │
│ primary-muted │ rgba(208, 148, 96, 0.15) │ rgba(208, 148, 96, 0.10) │
│ text-foreground │ #FFFFFF │ #0F172A │
│ text-muted │ #8B8B8B │ #64748B │
│ text-subtle │ #4D4D4D │ #94A3B8 │
└───────────────────┴───────────────────────────────┴──────────────────────────────────────────────┘
```
کلیه تگ‌ها باید ساختاری منسجم و یکدست داشته باشند:
### ۵.۱. بج‌های وضعیتی استاندارد (Semantic Badges)
رنگ‌های اشباع منحصراً و صرفاً برای ۴ حالت سیستم مجاز هستند:
```tsx ```tsx
// ۱. تگ خنثی استاندارد (اطلاعات عمومی، دسته‌بندی، کشور، نسخه)
<Badge variant="outline" className="border-border-soft bg-surface-base text-muted-foreground text-[11px] font-medium px-2.5 py-0.5 rounded-lg">
عراق
// ۱. تگ خنثی معمولی (دسته‌بندی، کشور، نسخه، برچسب‌ها) - بیش از ۸۰٪ تگ‌ها
<Badge variant="outline" className="border-border-soft bg-muted/40 text-muted-foreground text-[10px] sm:text-[11px] font-medium px-2 py-0.5 rounded-lg">
بلیت و پرواز
</Badge> </Badge>
// ۲. بج وضعیتی تایید شده / موفق (Approved / Success)
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 px-2.5 py-0.5 text-[11px] font-bold text-emerald-600 dark:text-emerald-400">
// ۲. وضعیت موفق / فعال / تایید شده (Success)
<span className="inline-flex items-center gap-1.5 rounded-full bg-emerald-500/10 border border-emerald-500/20 px-2.5 py-0.5 text-[10px] sm:text-[11px] font-bold text-emerald-600 dark:text-emerald-400">
<span className="size-1.5 rounded-full bg-emerald-500" /> <span className="size-1.5 rounded-full bg-emerald-500" />
تأیید شده تأیید شده
</span> </span>
// ۳. بج وضعیتی در انتظار / بررسی (Pending / Warning)
<span className="inline-flex items-center gap-1.5 rounded-full bg-amber-500/10 border border-amber-500/20 px-2.5 py-0.5 text-[11px] font-bold text-amber-600 dark:text-amber-400">
// ۳. وضعیت هشدار / در حال پردازش (Warning / Pending)
<span className="inline-flex items-center gap-1.5 rounded-full bg-amber-500/10 border border-amber-500/20 px-2.5 py-0.5 text-[10px] sm:text-[11px] font-bold text-amber-600 dark:text-amber-400">
<span className="size-1.5 rounded-full bg-amber-500 animate-pulse" /> <span className="size-1.5 rounded-full bg-amber-500 animate-pulse" />
در انتظار پرداخت
در انتظار بررسی
</span> </span>
// ۴. بج وضعیتی رد شده / خطا (Rejected / Error)
<span className="inline-flex items-center gap-1.5 rounded-full bg-rose-500/10 border border-rose-500/20 px-2.5 py-0.5 text-[11px] font-bold text-rose-600 dark:text-rose-400">
// ۴. وضعیت خطا / لغوشده / حذف (Error / Destructive)
<span className="inline-flex items-center gap-1.5 rounded-full bg-rose-500/10 border border-rose-500/20 px-2.5 py-0.5 text-[10px] sm:text-[11px] font-bold text-rose-600 dark:text-rose-400">
<span className="size-1.5 rounded-full bg-rose-500" /> <span className="size-1.5 rounded-full bg-rose-500" />
رد شده رد شده
</span> </span>
@ -176,112 +207,225 @@
--- ---
## ۷. استانداردهای کامپوننت‌های پایه (Core Components Standard)
## ۶. استاندارد جامع جداول داده‌ای و تطبیق با موبایل (Responsive Data Tables)
### ۷.۱. دکمه‌ها (Buttons)
- **دکمه اصلی (Primary):** زمینه برنز عقیله (`bg-primary text-primary-foreground`)، ارتفاع 36px (`h-9`)، متن ضخیم (`font-bold text-xs`)، گوشه‌های `rounded-xl`.
- **دکمه ثانویه (Secondary / Outline):** زمینه خنثی یا بوردر ملایم (`border border-border-soft hover:bg-card/70 text-foreground`).
- **دکمه شبح (Ghost / Icon):** بدون کادر برای آیکون‌های عملیاتی داخل جدول با هاور لطیف.
- **دکمه خطرناک (Destructive):** رنگ رز ملایم (`text-rose-500 hover:bg-rose-500/10`).
جداول عریض داده‌ای مهم‌ترین چالش در صفحات وب هستند. در پنل عقیله باید از یکی از دو الگوی استاندارد زیر استفاده شود:
### ۷.۲. فیلدهای ورودی و جستجو (Inputs & Filters)
- ارتفاع استاندارد: `h-9` (36px).
- رنگ زمینه: `bg-surface-base` یا `bg-card/60`.
- بوردر: `border border-border-soft focus:border-primary/50 focus:ring-1 focus:ring-primary/30`.
- سایز متن ورودی و Placeholder: `text-xs`.
### الگوی اول: نمایش دوگانه (جدول در دسکتاپ + لیست کارت در موبایل - الگو طلایی)
در این الگو، برای صفحات موبایل (`< 768px`) به جای جدول پهن ناخوانا، داده‌ها به صورت کارت‌های مدرن و شکیل نمایش داده می‌شوند:
```tsx
{/* نمای دسکتاپ و تبلت (md به بالا) */}
<div className="hidden md:block rounded-2xl border border-border-soft bg-card overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-start text-xs">
<thead className="border-b border-border-soft bg-muted/40 text-muted-foreground font-bold">
<tr>
<th className="p-4 text-start">عنوان سرویس</th>
<th className="p-4 text-start">دسته‌بندی</th>
<th className="p-4 text-start">مبلغ</th>
<th className="p-4 text-start">وضعیت</th>
<th className="p-4 text-end">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-border-soft">
{items.map((item) => (
<tr key={item.id} className="hover:bg-muted/20 transition-colors">
<td className="p-4 font-medium text-foreground">{item.title}</td>
<td className="p-4 text-muted-foreground">{item.category}</td>
<td className="p-4 font-mono font-bold">{toFa(item.price)} د.ع</td>
<td className="p-4">{/* بج وضعیت */}</td>
<td className="p-4 text-end">{/* دکمه‌های ویرایش/حذف */}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* نمای موبایل (زیر md) - کارت‌های مجزا */}
<div className="flex flex-col gap-3 md:hidden">
{items.map((item) => (
<Card key={item.id} className="border-border-soft bg-card p-4 rounded-2xl">
<div className="flex items-start justify-between gap-2 border-b border-border-soft pb-3">
<div>
<p className="font-bold text-sm text-foreground">{item.title}</p>
<span className="text-[11px] text-muted-foreground">{item.category}</span>
</div>
<div>{/* بج وضعیت */}</div>
</div>
<div className="flex items-center justify-between pt-3 text-xs">
<span className="text-muted-foreground">مبلغ کل:</span>
<span className="font-mono font-black text-foreground">{toFa(item.price)} د.ع</span>
</div>
<div className="mt-3 flex items-center justify-end gap-2 border-t border-border-soft pt-3">
<Button variant="ghost" size="sm" className="h-8 text-xs">مشاهده جزئیات</Button>
<Button variant="outline" size="sm" className="h-8 text-xs">ویرایش</Button>
</div>
</Card>
))}
</div>
```
### ۷.۳. جداول داده‌ای (Data Tables)
- هدر جدول: زمینه ملایم `bg-card/60`، متن کم‌رنگ `text-muted-foreground text-xs font-bold`.
- ردیف‌ها: ارتفاع مناسب، بوردر جداکننده `divide-y divide-border-soft`، هاور روان `hover:bg-card/50`.
- سلول‌ها: پدینگ یکدست `p-3.5` یا `p-4`، اعداد به صورت `font-mono font-bold`.
### الگوی دوم: جدول با اسکرول افقی نرم و ستون عملیات چسبان (Sticky Action Column)
چنانچه اطلاعات جدول بسیار متراکم است، کل جدول درون کانتینر `overflow-x-auto` قرار گرفته و ستون عملیات در سمت چپ (یا انتهای RTL) چسبان (`sticky end-0`) می‌ماند:
### ۷.۴. کارت‌های شاخص آماری (KPI Metric Cards)
- ساختار: کانتینر خنثی با کادر ملایم، عنوان کوچک در بالا (`text-xs text-muted-foreground`)، عدد بزرگ مونو در مرکز (`text-2xl font-black font-mono`) و توضیح کوتاه در زیر.
- رنگ‌آمیزی: زمینه ملایم بدون اشباع شدید رنگی.
```tsx
<div className="rounded-2xl border border-border-soft bg-card overflow-hidden">
<div className="overflow-x-auto no-scrollbar sm:overflow-x-auto">
<table className="w-full min-w-[680px] text-start text-xs">
{/* ردیف‌ها */}
</table>
</div>
</div>
```
--- ---
## ۸. استاندارد لودینگ و اسکلتون (Shimmer / Loading Skeleton Standard)
## ۷. نوار فیلترها، جستجو و فرم‌ها در وب و موبایل (Filters & Forms)
### ۷.۱. نوار فیلترهای بالا (Filter Bar)
- **دسکتاپ (`sm` به بالا):** چینش افقی `flex flex-row items-center justify-between gap-3`. فیلد سرچ گسترده و فیلترهای دراپ‌داون در امتداد یک خط.
- **موبایل:** چینش عمودی استک‌شده `flex flex-col gap-2.5 items-stretch`. سرچ تمام‌عرض در بالا و دکمه‌های فیلتر/اکشن در زیر.
```tsx
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
{/* بخش جستجو و انتخابگرها */}
<div className="flex flex-1 flex-col gap-2 sm:flex-row sm:items-center">
<div className="relative flex-1 sm:max-w-xs">
<Input
placeholder="جستجو در اطلاعات..."
className="h-9 w-full bg-card/60 text-xs ps-8 rounded-xl border-border-soft"
/>
<Ic.Search className="absolute start-2.5 top-2.5 size-4 text-muted-foreground" />
</div>
<div className="flex items-center gap-2">
{/* دراپ‌داون وضعیت یا سرویس */}
</div>
</div>
{/* دکمه عملیات اصلی */}
<Button className="h-9 gap-1.5 rounded-xl bg-primary text-xs font-bold text-primary-foreground sm:w-auto w-full">
<Ic.Plus className="size-4" />
افزودن آیتم جدید
</Button>
</div>
```
### ۷.۲. گرید فیلدهای فرم (Responsive Form Grids)
فرم‌ها در دیالوگ‌ها یا صفحات باید به صورت واکنش‌گرا ستون‌بندی شوند:
- موبایل: ۱ ستونه (`grid-cols-1`).
- تبلت و فبلت: ۲ ستونه (`sm:grid-cols-2`).
- دسکتاپ عریض: ۳ ستونه (`lg:grid-cols-3`).
```tsx
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 sm:gap-4">
<div className="space-y-1.5">
<label className="text-xs font-bold text-muted-foreground">عنوان فارسی</label>
<Input className="h-9 text-xs rounded-xl bg-card border-border-soft" />
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-muted-foreground">عنوان عربی</label>
<Input className="h-9 text-xs rounded-xl bg-card border-border-soft" />
</div>
<div className="space-y-1.5 sm:col-span-2 lg:col-span-1">
<label className="text-xs font-bold text-muted-foreground">عنوان انگلیسی</label>
<Input className="h-9 text-xs rounded-xl bg-card border-border-soft" dir="ltr" />
</div>
</div>
```
---
## ۸. دیالوگ‌ها، مودال‌ها و باتم‌شیت‌ها (Modals & Bottom Sheets)
دیالوگ‌های پنل عقیله باید در دسکتاپ در مرکز تصویر باز شده و در موبایل به صورت سازگار با انگشت دست عمل کنند:
```tsx
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="w-full max-w-full sm:max-w-xl md:max-w-2xl max-h-[92vh] flex flex-col p-0 rounded-t-2xl sm:rounded-2xl border-border-soft bg-[#22252c] overflow-hidden">
{/* هدر ثابت دیالوگ */}
<DialogHeader className="p-4 sm:p-5 border-b border-border-soft bg-card/60">
<DialogTitle className="text-base font-extrabold text-foreground">
ویرایش مشخصات سرویس
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
اطلاعات را به دقت در هر سه زبان تکمیل نمایید.
</DialogDescription>
</DialogHeader>
{/* محتوای دارای اسکرول عمودی */}
<div className="flex-1 overflow-y-auto p-4 sm:p-6 space-y-4">
{/* فیلدهای ورودی */}
</div>
{/* فوتر چسبان دیالوگ با دکمه‌های منظم */}
<DialogFooter className="p-3 sm:p-4 border-t border-border-soft bg-card/60 flex flex-col-reverse sm:flex-row gap-2 sm:justify-end">
<Button variant="outline" onClick={() => setIsOpen(false)} className="h-9 text-xs w-full sm:w-auto rounded-xl">
انصراف
</Button>
<Button className="h-9 text-xs font-bold w-full sm:w-auto rounded-xl bg-primary text-primary-foreground">
ذخیره تغییرات
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
```
---
## ۹. استاندارد سراسری اسکلتون لودینگ (Shimmer Loading Standard)
> [!IMPORTANT] > [!IMPORTANT]
> **الگوی واحد و سراسری لودینگ (Unified Shimmer Standard):**
> لودینگ تمامی صفحات، جداول و کارت‌ها در سامانه عقیله (هم در پنل وب و هم در اپلیکیشن فلاتر) باید منحصراً از اسکلتون یکپارچه شیمر با پالت خنثی (Neutral Slate) و ریتم پالس متناسب تبعیت کنند. هرگونه اسپینر ناهماهنگ، کانتینرهای بدون انیمیشن یا استفاده از رنگ‌های متفرقه در لودینگ اکیداً ممنوع است.
### ۸.۱. پالت رنگی و زمان‌بندی استاندارد شیمر (Shimmer Color Tokens & Timing)
برای جلوگیری از چندپارگی بصری و پرهیز از لودینگ‌های کدر یا مدادرنگی، رنگ‌های شیمر در تمام کلاینت‌ها قفل شده است:
- **تم روشن (Light Mode):**
- رنگ پایه (Base): `#E2E8F0` (توکن `outline` / `muted-200`)
- رنگ درخشش (Highlight): `#F8FAFC` (توکن `muted-50`)
- **تم تاریک (Dark Mode):**
- رنگ پایه (Base): `#22252C` (توکن `card-base`)
- رنگ درخشش (Highlight): `#333742` (توکن `card-highlight`)
- **دوره تناوب انیمیشن (Cycle Period):** دقیقاً `1500ms` (۱.۵ ثانیه) برای ایجاد پالس موجی لطیف، طبیعی و یکنواخت.
### ۸.۲. مشخصات پیاده‌سازی در وب و پنل مدیریت (`aqila_panel`)
- **کامپوننت مرجع:** `@/components/shared/Shimmer`
- **کانتینر:** `p-8 space-y-3` (یا درون `Card` با بوردر استاندارد).
- **ردیف‌ها:** `h-12 bg-muted/30 animate-pulse rounded-lg`.
- **رفتار در تم لایت و دارک:** پالس ملایم با کنتراست طبیعی متناسب با توکن `--muted`.
> **الگوی واحد لودینگ:** تمام صفحات، جداول و کارت‌ها در هنگام دریافت اطلاعات باید از کامپوننت سراسری `@/components/shared/Shimmer` با انیمیشن پالس و پالت خنثی استفاده کنند.
### ۹.۱. توکن‌های رنگی و دوره تناوب شیمر
- **تم لایت:** رنگ پایه `#E2E8F0`، هایلایت `#F8FAFC`.
- **تم دارک:** رنگ پایه `#22252C`، هایلایت `#333742`.
- **دوره انیمیشن:** دقیقاً `1500ms` با انیمیشن ملایم پالس.
```tsx ```tsx
// نحوه فراخوانی استاندارد در صفحات پنل
import { Shimmer } from '@/components/shared/Shimmer' import { Shimmer } from '@/components/shared/Shimmer'
{isLoading ? ( {isLoading ? (
<Shimmer withCard cardHeaderTitle="صورت‌حساب‌ها" rows={5} />
<div className="space-y-4">
{/* اسکلتون کارت‌های آماری */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 sm:gap-4">
<Shimmer className="h-24 rounded-2xl" />
<Shimmer className="h-24 rounded-2xl" />
<Shimmer className="h-24 rounded-2xl" />
<Shimmer className="h-24 rounded-2xl" />
</div>
{/* اسکلتون جدول داده‌ها */}
<Shimmer withCard cardHeaderTitle="در حال بارگذاری اطلاعات..." rows={6} />
</div>
) : ( ) : (
<TableContent />
<MainPageContent />
)} )}
``` ```
### ۸.۳. مشخصات پیاده‌سازی در اپلیکیشن فلاتر (`aqila_flutter`)
- **مرجع واحد هماهنگ‌کننده موج (Single Source of Truth):** ویجت **`AppShimmer`** در مسیر:
`lib/core/ui_kit/atoms/app_shimmer.dart`
- **ویجت‌های اتمیک درون اسکلتون:**
1. **`ShimmerBox`:** جهت ترسیم کارت‌ها، بنرها، تصاویر و دکمه‌ها (`AppRadii.radiusSm` تا `AppRadii.radiusLg`).
2. **`ShimmerLine`:** جهت ترسیم سطرهای متنی و عناوین با ارتفاع پیش‌فرض `14px` و انحنای `4px`.
3. **`ShimmerCircle`:** جهت ترسیم آواتارها، آیکون‌ها و بج‌های دایره‌ای.
- **وضعیت ویجت `ShimmerWidget`:**
ویجت `ShimmerWidget` در مسیر `lib/core/widgets/shimmer_widget.dart` صرفاً یک **آداپتور سازگاری گذشته‌نگر (Legacy Adapter)** است تا کدهای قدیمی متوقف نشوند. در کلیه فیچرها، صفحات و پول‌ریکوئست‌های جدید باید **منحصراً از `AppShimmer`** استفاده شود.
```dart
// نحوه فراخوانی استاندارد در اپلیکیشن فلاتر
import 'package:tourism/core/ui_kit/atoms/app_shimmer.dart';
return const AppShimmer(
child: Column(
children: [
ShimmerBox(width: double.infinity, height: 160, borderRadius: 16),
SizedBox(height: 12),
ShimmerLine(width: 140, height: 16),
SizedBox(height: 8),
ShimmerLine(width: 220, height: 12),
],
),
);
```
--- ---
## ۹. دسترسی‌پذیری و تم‌های تاریک و روشن (Accessibility, Light & Dark Mode)
## ۱۰. دسترسی‌پذیری، ارگونومی و مناطق امن (Accessibility & Safe Areas)
1. **حداقل کنتراست متنی (WCAG AA Compliance):**
- تمامی متون اصلی باید دارای نسبت کنتراست حداقل `4.5:1` با پس‌زمینه باشند.
- از قرار دادن متن خاکستری روشن روی پس‌زمینه سفید یا خاکستری تیره روی پس‌زمینه مشکی اکیداً خودداری شود.
2. **حالت غیرفعال (Disabled States):**
- شفافیت `opacity-50` همراه با `cursor-not-allowed` بدون ناخوانا شدن متن.
3. **وضوح فوکوس (Focus Rings):**
- تمامی فیلدها و دکمه‌ها باید دارای `focus-visible:ring-2 focus-visible:ring-primary/40` باشند تا ناوبری با کیبورد به درستی کار کند.
1. **کنتراست استاندارد متون (WCAG AA):**
- متن اصلی در برابر پس‌زمینه حداقل نسبت `4.5:1` داشته باشد.
- از متن‌های خاکستری کم‌کنتراست در تم دارک پرهیز شود.
2. **منطقه امن دکمه‌های پایین موبایل (Safe Area Inset):**
- نوارهای عملیاتی چسبان پایین صفحه (Sticky Bottom Bar) باید دارای پدینگ `pb-[calc(0.75rem+env(safe-area-inset-bottom))]` باشند تا با نوار هوم‌بار آیفون تداخل پیدا نکنند.
3. **حالت فوکوس کیبورد (Focus Visibility):**
- تمامی دکمه‌ها و فیلدها باید دارای کادر فوکوس واضح با `focus-visible:ring-2 focus-visible:ring-primary/50` باشند.
--- ---
## ۱۰. چک‌لیست طلایی هوش مصنوعی قبل از تحویل هر تسک UI
## ۱۱. چک‌لیست طلایی هوش مصنوعی قبل از تحویل هر تسک وب و پنل (AI Golden Checklist)
قبل از تحویل هرگونه خروجی یا ثبت تغییرات در رابط کاربری، Agent باید این چک‌لیست را بررسی کند:
قبل از نهایی کردن هرگونه تغییر در پنل مدیریت، بررسی موارد زیر الزامی است:
- [ ] **بررسی عدم استفاده از رنگ‌های مدادرنگی:** آیا از رنگ‌های تند غیرضروری برای کارت‌ها و تگ‌های عادی پرهیز شده است؟
- [ ] **بررسی تایپوگرافی چندزبانه:** آیا متون فارسی از `Vazirmatn` و اعداد/کدها از `font-mono` تبعیت می‌کنند؟
- [ ] **بررسی ابعاد و فواصل:** آیا پدینگ‌ها و مارجین‌ها مضرب ۴ و ۸ بوده و پدینگ افقی صفحات `16px` است؟
- [ ] **بررسی وضعیت لودینگ:** آیا لودینگ از کامپوننت استاندارد `Shimmer` با انیمیشن پالس استفاده می‌کند؟
- [ ] **بررسی دوگانگی تم:** آیا صفحه در هر دو حالت Light Mode و Dark Mode کنتراست و خوانایی کامل دارد؟
- [ ] **بررسی اصطلاحات و کلیدها:** آیا منطق برنامه صرفاً با کلیدهای انگلیسی کار کرده و هیچ متنی هاردکد نشده است؟
- [ ] **۱. پالت خنثی و بدون رنگین‌کمان:** آیا بیش از ۹۰٪ المان‌ها دارای رنگ‌های خنثی بوده و از رنگ‌های فسفری/تند پرهیز شده است؟
- [ ] **۲. سازگاری کامل با موبایل (Responsive):** آیا صفحه در ابعاد موبایل (`375px` و `414px`) تست شده و اسکرول افقی نامطلوب (Horizontal Overflow) ندارد؟
- [ ] **۳. تایپوگرافی چندزبانه:** آیا فونت متون فارسی/عربی `Vazirmatn` و ارقام/مبالغ/کدها `font-mono tabular-nums` با `dir="ltr"` است؟
- [ ] **۴. ابعاد کلیک‌پذیر (Touch Targets):** آیا دکمه‌ها و فیلدها در موبایل ارتفاع کافی (حداقل `38px` تا `44px`) دارند؟
- [ ] **۵. لودینگ یکدست (Shimmer):** آیا در حالت لودینگ از کامپوننت استاندارد `Shimmer` با پالت خنثی استفاده شده است؟
- [ ] **۶. دوگانگی تم:** آیا کنتراست صفحه در هر دو حالت Light Mode و Dark Mode خوانا و استاندارد است؟
- [ ] **۷. منطق بیزینس و کلیدهای انگلیسی:** آیا تمامی مقایسه‌ها و شرط‌های کد بر اساس کلیدهای استاندارد انگلیسی API پیاده شده‌اند؟

6
src/app/router.tsx

@ -8,7 +8,6 @@ const LoginPage = lazy(() => import('@/features/auth/LoginPage'))
const DashboardHome = lazy(() => import('@/features/dashboard/DashboardHome')) const DashboardHome = lazy(() => import('@/features/dashboard/DashboardHome'))
const ToursPage = lazy(() => import('@/features/tours/ToursPage')) const ToursPage = lazy(() => import('@/features/tours/ToursPage'))
const OngoingToursPage = lazy(() => import('@/features/tours/OngoingToursPage')) const OngoingToursPage = lazy(() => import('@/features/tours/OngoingToursPage'))
const TourOrdersPage = lazy(() => import('@/features/tours/TourOrdersPage'))
const ServicesPage = lazy(() => import('@/features/services/ServicesPage')) const ServicesPage = lazy(() => import('@/features/services/ServicesPage'))
const BillingPage = lazy(() => import('@/features/billing/BillingPage')) const BillingPage = lazy(() => import('@/features/billing/BillingPage'))
@ -26,9 +25,10 @@ export default function AppRouter() {
<Route path="/" element={<PageReady><DashboardHome /></PageReady>} /> <Route path="/" element={<PageReady><DashboardHome /></PageReady>} />
<Route path="/tours" element={<PageReady><ToursPage /></PageReady>} /> <Route path="/tours" element={<PageReady><ToursPage /></PageReady>} />
<Route path="/tours/ongoing" element={<PageReady><OngoingToursPage /></PageReady>} /> <Route path="/tours/ongoing" element={<PageReady><OngoingToursPage /></PageReady>} />
<Route path="/tours/orders" element={<PageReady><TourOrdersPage /></PageReady>} />
<Route path="/services" element={<PageReady><ServicesPage /></PageReady>} />
<Route path="/tours/orders" element={<Navigate to="/billing?service=tour" replace />} />
<Route path="/services" element={<Navigate to="/services/simcard" replace />} />
<Route path="/services/:serviceKey" element={<PageReady><ServicesPage /></PageReady>} /> <Route path="/services/:serviceKey" element={<PageReady><ServicesPage /></PageReady>} />
<Route path="/services/:serviceKey/:subKey" element={<PageReady><ServicesPage /></PageReady>} />
<Route path="/billing" element={<PageReady><BillingPage /></PageReady>} /> <Route path="/billing" element={<PageReady><BillingPage /></PageReady>} />
{/* هدایت مسیرهای ناشناخته به صفحه اصلی */} {/* هدایت مسیرهای ناشناخته به صفحه اصلی */}

244
src/components/layout/AppSidebar.tsx

@ -14,19 +14,59 @@ import {
SidebarMenuBadge, SidebarMenuBadge,
} from '@/components/ui/sidebar' } from '@/components/ui/sidebar'
import { useAuth, useLogout } from '@/features/auth/use-auth' import { useAuth, useLogout } from '@/features/auth/use-auth'
import { navigationConfig } from '@/config/navigation'
import { getNavigationConfig, type NavItemConfig, type NavSubItemConfig } from '@/config/navigation'
import { siteConfig } from '@/config/site' import { siteConfig } from '@/config/site'
import { AqilaLogo } from '@/components/AqilaLogo' import { AqilaLogo } from '@/components/AqilaLogo'
import { useTranslation } from '@/i18n'
import { useBillingBadgeStore } from '@/stores/billing-badge'
import * as React from 'react' import * as React from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import type { NavItemConfig } from '@/config/navigation'
function BillingSidebarBadges() {
const { pendingReviewCount, awaitingPaymentCount, fetchCounts } = useBillingBadgeStore()
const { t } = useTranslation()
React.useEffect(() => {
fetchCounts()
// Poll every 30 seconds for live updates
const interval = setInterval(fetchCounts, 30000)
return () => clearInterval(interval)
}, [fetchCounts])
if (pendingReviewCount === 0 && awaitingPaymentCount === 0) return null
return (
<div className="flex items-center gap-1 ms-auto shrink-0">
{/* ۱. دایره نارنجی: در انتظار بررسی ادمین (فیش پرداخت توسط کاربر آپلود شده) */}
{pendingReviewCount > 0 && (
<span
className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-amber-500 text-white font-mono font-bold text-[10px] shadow-xs animate-pulse"
title={`${t('billing.pendingReview')}: ${pendingReviewCount}`}
>
{pendingReviewCount}
</span>
)}
{/* ۲. دایره آبی: در انتظار پرداخت کاربر */}
{awaitingPaymentCount > 0 && (
<span
className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 rounded-full bg-blue-500 text-white font-mono font-bold text-[10px] shadow-xs"
title={`${t('billing.awaitingPayment')}: ${awaitingPaymentCount}`}
>
{awaitingPaymentCount}
</span>
)}
</div>
)
}
function Tail({ badge, soon }: { badge?: string; soon?: boolean }) { function Tail({ badge, soon }: { badge?: string; soon?: boolean }) {
const { t } = useTranslation()
if (soon) { if (soon) {
return ( return (
<span className="ms-auto rounded-full bg-white/8 px-2 py-0.5 text-[10px] text-grey-3"> <span className="ms-auto rounded-full bg-white/8 px-2 py-0.5 text-[10px] text-grey-3">
به‌زودی
{t('navigation.soon')}
</span> </span>
) )
} }
@ -34,22 +74,141 @@ function Tail({ badge, soon }: { badge?: string; soon?: boolean }) {
return null return null
} }
function checkSubItemActive(sub: NavSubItemConfig, pathname: string): boolean {
if (sub.to) {
if (sub.to === '/tours' || sub.to === '/services') {
if (pathname === sub.to) return true
} else if (pathname === sub.to || pathname.startsWith(sub.to + '/')) {
return true
}
}
if (sub.children && sub.children.length > 0) {
return sub.children.some((c) => checkSubItemActive(c, pathname))
}
return false
}
function SubItem({ sub, pathname, dir }: { sub: NavSubItemConfig; pathname: string; dir: 'rtl' | 'ltr' }) {
const hasGrandChildren = Boolean(sub.children && sub.children.length > 0)
const isGrandChildActive = Boolean(hasGrandChildren && sub.children?.some((c) => checkSubItemActive(c, pathname)))
const [isOpen, setIsOpen] = React.useState<boolean>(isGrandChildActive)
React.useEffect(() => {
if (isGrandChildActive) {
setIsOpen(true)
}
}, [isGrandChildActive])
if (hasGrandChildren && sub.children) {
return (
<div className="flex flex-col">
<button
type="button"
onClick={() => setIsOpen((prev) => !prev)}
className={cn(
'flex w-full items-center justify-between gap-2 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors',
isGrandChildActive
? 'text-primary font-bold bg-primary/5'
: 'text-grey-3 hover:bg-white/5 hover:text-foreground'
)}
>
<div className="flex items-center gap-2 min-w-0">
{sub.icon && <Ic name={sub.icon} className="size-3.5 shrink-0" />}
<span className="whitespace-nowrap">{sub.title}</span>
</div>
<div className="flex items-center gap-1 ms-auto shrink-0">
{sub.badge && (
<span className="rounded-full bg-primary/20 px-1.5 py-0.2 text-[10px] text-primary">
{sub.badge}
</span>
)}
<Ic
name={isOpen ? 'chevUp' : 'chevDown'}
className="size-3 text-grey-3 transition-transform shrink-0 opacity-70"
/>
</div>
</button>
{isOpen && (
<div
className={cn(
'mt-1 flex flex-col gap-1 border-border/40',
dir === 'ltr' ? 'ml-3 border-l pl-2' : 'mr-3 border-r pr-2'
)}
>
{sub.children.map((grand) => {
const isGrandActive = grand.to
? grand.to === '/services' || grand.to === '/tours'
? pathname === grand.to
: pathname === grand.to || pathname.startsWith(grand.to + '/')
: false
return (
<NavLink
key={grand.to || grand.title}
to={grand.to || '#'}
className={cn(
'flex items-center gap-2 rounded-lg px-2 py-1.5 text-[11px] font-medium transition-colors',
isGrandActive
? 'bg-primary/15 text-primary font-bold shadow-xs'
: 'text-grey-3 hover:bg-white/5 hover:text-foreground'
)}
>
{grand.icon && <Ic name={grand.icon} className="size-3 shrink-0" />}
<span className="whitespace-nowrap">{grand.title}</span>
{grand.badge && (
<span className="ms-auto rounded-full bg-primary/20 px-1.5 py-0.2 text-[9px] text-primary">
{grand.badge}
</span>
)}
</NavLink>
)
})}
</div>
)}
</div>
)
}
const isSubActive =
sub.to === '/tours' || sub.to === '/services'
? pathname === sub.to
: Boolean(sub.to && (pathname === sub.to || pathname.startsWith(sub.to + '/')))
return (
<NavLink
to={sub.to || '#'}
className={cn(
'flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors',
isSubActive
? 'bg-primary/15 text-primary font-bold shadow-xs'
: 'text-grey-3 hover:bg-white/5 hover:text-foreground'
)}
>
{sub.icon && <Ic name={sub.icon} className="size-3.5 shrink-0" />}
<span className="whitespace-nowrap">{sub.title}</span>
{sub.badge && (
<span className="ms-auto rounded-full bg-primary/20 px-1.5 py-0.2 text-[10px] text-primary">
{sub.badge}
</span>
)}
</NavLink>
)
}
function NavItem({ item }: { item: NavItemConfig }) { function NavItem({ item }: { item: NavItemConfig }) {
const { pathname } = useLocation() const { pathname } = useLocation()
const { dir } = useTranslation()
const hasChildren = Boolean(item.children && item.children.length > 0) const hasChildren = Boolean(item.children && item.children.length > 0)
const isChildActive = const isChildActive =
hasChildren &&
item.children?.some((c) =>
c.to === '/tours' || c.to === '/services' ? pathname === c.to : pathname.startsWith(c.to)
)
hasChildren && item.children?.some((c) => checkSubItemActive(c, pathname))
const isAlwaysOpen = Boolean(item.alwaysOpen) const isAlwaysOpen = Boolean(item.alwaysOpen)
const [isOpen, setIsOpen] = React.useState<boolean>(isAlwaysOpen || Boolean(isChildActive)) const [isOpen, setIsOpen] = React.useState<boolean>(isAlwaysOpen || Boolean(isChildActive))
React.useEffect(() => { React.useEffect(() => {
if (isAlwaysOpen || isChildActive) {
if (isChildActive) {
setIsOpen(true) setIsOpen(true)
} }
}, [isAlwaysOpen, isChildActive])
}, [isChildActive])
if (hasChildren && item.children) { if (hasChildren && item.children) {
return ( return (
@ -79,45 +238,34 @@ function NavItem({ item }: { item: NavItemConfig }) {
</SidebarMenuButton> </SidebarMenuButton>
{(isAlwaysOpen || isOpen) && ( {(isAlwaysOpen || isOpen) && (
<div className="mr-5 mt-1 flex flex-col gap-1 border-r border-border/40 pr-2">
{item.children.map((sub) => {
const isSubActive =
sub.to === '/tours' || sub.to === '/services'
? pathname === sub.to
: pathname.startsWith(sub.to)
return (
<NavLink
key={sub.to}
to={sub.to}
className={cn(
'flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-xs font-medium transition-colors',
isSubActive
? 'bg-primary/15 text-primary font-bold shadow-xs'
: 'text-grey-3 hover:bg-white/5 hover:text-foreground'
)}
>
<Ic name={sub.icon} className="size-3.5 shrink-0" />
<span className="whitespace-nowrap">{sub.title}</span>
{sub.badge && (
<span className="ms-auto rounded-full bg-primary/20 px-1.5 py-0.2 text-[10px] text-primary">
{sub.badge}
</span>
)}
</NavLink>
)
})}
<div className={cn(
'mt-1 flex flex-col gap-1 border-border/40',
dir === 'ltr' ? 'ml-5 border-l pl-2' : 'mr-5 border-r pr-2'
)}>
{item.children.map((sub, idx) => (
<SubItem key={sub.to || sub.title || idx} sub={sub} pathname={pathname} dir={dir} />
))}
</div> </div>
)} )}
</SidebarMenuItem> </SidebarMenuItem>
) )
} }
const isActive = item.to ? (item.to === '/' ? pathname === '/' : pathname.startsWith(item.to)) : false
const isActive = item.to
? item.to === '/' || item.to === '/services' || item.to === '/tours'
? pathname === item.to
: pathname === item.to || pathname.startsWith(item.to + '/')
: false
const isBilling = item.to === '/billing'
const inner = ( const inner = (
<> <>
<Ic name={item.icon} className="size-4 shrink-0" /> <Ic name={item.icon} className="size-4 shrink-0" />
<span className="whitespace-nowrap">{item.title}</span> <span className="whitespace-nowrap">{item.title}</span>
<Tail badge={item.badge} soon={item.soon} />
{isBilling ? (
<BillingSidebarBadges />
) : (
<Tail badge={item.badge} soon={item.soon} />
)}
</> </>
) )
@ -140,8 +288,9 @@ function NavItem({ item }: { item: NavItemConfig }) {
function SidebarUserFooter() { function SidebarUserFooter() {
const { user } = useAuth() const { user } = useAuth()
const logout = useLogout() const logout = useLogout()
const { t } = useTranslation()
const firstLetter = user?.name?.[0] ?? user?.email?.[0]?.toUpperCase() ?? 'م'
const firstLetter = user?.name?.[0] ?? user?.email?.[0]?.toUpperCase() ?? 'A'
return ( return (
<SidebarFooter className="mt-auto pb-4"> <SidebarFooter className="mt-auto pb-4">
@ -152,7 +301,7 @@ function SidebarUserFooter() {
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="truncate text-xs font-bold text-grey-1"> <div className="truncate text-xs font-bold text-grey-1">
{user?.name?.trim() || 'مدیر کل سیستم'}
{user?.name?.trim() || t('navigation.adminRole')}
</div> </div>
<div dir="ltr" className="truncate text-start text-[11px] text-grey-3"> <div dir="ltr" className="truncate text-start text-[11px] text-grey-3">
{user?.email ?? '[email protected]'} {user?.email ?? '[email protected]'}
@ -162,12 +311,12 @@ function SidebarUserFooter() {
<SidebarMenu> <SidebarMenu>
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton <SidebarMenuButton
tooltip="خروج"
tooltip={t('navigation.logout')}
onClick={logout} onClick={logout}
className="text-grey-2 hover:bg-rose-500/10 hover:text-rose-400" className="text-grey-2 hover:bg-rose-500/10 hover:text-rose-400"
> >
<Ic name="logout" className="size-4 shrink-0" /> <Ic name="logout" className="size-4 shrink-0" />
<span className="truncate">خروج از سیستم</span>
<span className="truncate">{t('navigation.logout')}</span>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
@ -176,8 +325,11 @@ function SidebarUserFooter() {
} }
export default function AppSidebar() { export default function AppSidebar() {
const { t, dir } = useTranslation()
const navConfig = React.useMemo(() => getNavigationConfig(t), [t])
return ( return (
<Sidebar side="right" collapsible="offcanvas" className="h-svh">
<Sidebar side={dir === 'ltr' ? 'left' : 'right'} collapsible="offcanvas" className="h-svh">
<SidebarHeader className="py-3"> <SidebarHeader className="py-3">
<div className="flex items-center gap-3 px-2"> <div className="flex items-center gap-3 px-2">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary text-white shadow-md"> <div className="flex size-10 items-center justify-center rounded-xl bg-primary text-white shadow-md">
@ -185,7 +337,7 @@ export default function AppSidebar() {
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<div className="truncate text-base font-extrabold leading-tight text-foreground"> <div className="truncate text-base font-extrabold leading-tight text-foreground">
{siteConfig.name}
{t('navigation.brand')}
</div> </div>
</div> </div>
</div> </div>
@ -193,12 +345,12 @@ export default function AppSidebar() {
<SidebarSeparator /> <SidebarSeparator />
<SidebarContent className="no-scrollbar px-1 flex-1"> <SidebarContent className="no-scrollbar px-1 flex-1">
{navigationConfig.map((group, idx) => (
{navConfig.map((group, idx) => (
<SidebarGroup key={group.title || idx}> <SidebarGroup key={group.title || idx}>
{group.title ? <SidebarGroupLabel>{group.title}</SidebarGroupLabel> : null} {group.title ? <SidebarGroupLabel>{group.title}</SidebarGroupLabel> : null}
<SidebarMenu> <SidebarMenu>
{group.items.map((item) => ( {group.items.map((item) => (
<NavItem key={item.title} item={item} />
<NavItem key={item.titleKey || item.title} item={item} />
))} ))}
</SidebarMenu> </SidebarMenu>
</SidebarGroup> </SidebarGroup>

67
src/components/layout/LanguageSwitcher.tsx

@ -0,0 +1,67 @@
import React from 'react'
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { Ic } from '@/icons'
import { useTranslation, SupportedLanguage } from '@/i18n'
export default function LanguageSwitcher() {
const { language, setLanguage, languages, t, currentLanguageInfo } = useTranslation()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-9 px-2.5 sm:px-3 rounded-xl border-border/70 bg-card/40 hover:bg-card text-xs font-semibold gap-2 shadow-xs transition-all"
title={t('topbar.language')}
>
<span className="text-sm leading-none">{currentLanguageInfo.flag}</span>
<span className="hidden sm:inline-block text-[12px] text-foreground">
{currentLanguageInfo.nativeName}
</span>
<Ic name="chevDown" className="size-3 text-muted-foreground transition-transform opacity-70" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-44 rounded-2xl p-1.5 shadow-xl border-border/70">
<DropdownMenuLabel className="text-[11px] font-bold text-muted-foreground px-2 py-1">
{t('topbar.selectLanguage')}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{languages.map((item) => {
const isSelected = item.code === language
return (
<DropdownMenuItem
key={item.code}
onSelect={() => setLanguage(item.code as SupportedLanguage)}
onClick={() => setLanguage(item.code as SupportedLanguage)}
className={`flex items-center justify-between px-2.5 py-2 rounded-xl text-xs font-medium cursor-pointer transition-colors ${
isSelected
? 'bg-primary/15 text-primary font-bold shadow-2xs'
: 'text-foreground hover:bg-muted/50'
}`}
>
<div className="flex items-center gap-2">
<span className="text-base leading-none">{item.flag}</span>
<span>{item.nativeName}</span>
<span className="text-[10px] text-muted-foreground uppercase font-mono">
({item.code})
</span>
</div>
{isSelected && <Ic name="check" className="size-3.5 text-primary" />}
</DropdownMenuItem>
)
})}
</DropdownMenuContent>
</DropdownMenu>
)
}

60
src/components/layout/Topbar.tsx

@ -1,41 +1,54 @@
import React, { useMemo } from 'react'
import { useLocation } from 'react-router-dom' import { useLocation } from 'react-router-dom'
import { SidebarTrigger } from '@/components/ui/sidebar' import { SidebarTrigger } from '@/components/ui/sidebar'
import ThemeToggle from '@/components/layout/ThemeToggle' import ThemeToggle from '@/components/layout/ThemeToggle'
import ServerHealthTrigger from '@/components/layout/ServerHealthTrigger' import ServerHealthTrigger from '@/components/layout/ServerHealthTrigger'
import { navigationConfig } from '@/config/navigation'
import { siteConfig } from '@/config/site'
import LanguageSwitcher from '@/components/layout/LanguageSwitcher'
import { getNavigationConfig } from '@/config/navigation'
import { useTranslation } from '@/i18n'
export default function Topbar() { export default function Topbar() {
const { pathname } = useLocation() const { pathname } = useLocation()
const { t } = useTranslation()
// پیدا کردن عنوان صفحه بر اساس مسیر از کانفیگ
let currentTitle = siteConfig.name
for (const group of navigationConfig) {
for (const it of group.items) {
if (it.to === pathname) {
currentTitle = it.title
break
}
if (it.children) {
const sub = it.children.find((c) => c.to === pathname)
if (sub) {
currentTitle = sub.title
break
const navConfig = useMemo(() => getNavigationConfig(t), [t])
// پیدا کردن پویای عنوان صفحه بر اساس مسیر فعلی و زبان فعال
const currentTitle = useMemo(() => {
if (pathname === '/') return t('navigation.dashboardTitle')
for (const group of navConfig) {
for (const it of group.items) {
if (it.to === pathname) {
return it.title
}
if (it.children) {
for (const sub of it.children) {
if (sub.to === pathname) {
return sub.title
}
if (sub.children) {
const grand = sub.children.find((g) => g.to === pathname)
if (grand) {
return grand.title
}
}
}
} }
} }
} }
}
if (pathname === '/') currentTitle = 'داشبورد مدیریت'
return t('navigation.brand')
}, [pathname, navConfig, t])
return ( return (
<header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b border-border-soft bg-background px-4 md:px-8"> <header className="flex h-16 shrink-0 items-center justify-between gap-3 border-b border-border-soft bg-background px-4 md:px-8">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<SidebarTrigger className="-ms-1 shrink-0" /> <SidebarTrigger className="-ms-1 shrink-0" />
{/* عنوان و مسیر */}
{/* عنوان و مسیر با ترجمه بلادرنگ */}
<div className="min-w-0"> <div className="min-w-0">
<p className="truncate text-[11px] text-grey-3"> <p className="truncate text-[11px] text-grey-3">
{siteConfig.shortName} / {currentTitle}
{t('navigation.brandShort')} / {currentTitle}
</p> </p>
<h1 className="truncate text-base font-extrabold text-foreground md:text-lg"> <h1 className="truncate text-base font-extrabold text-foreground md:text-lg">
{currentTitle} {currentTitle}
@ -43,9 +56,12 @@ export default function Topbar() {
</div> </div>
</div> </div>
{/* ابزارهای هدر: وضعیت سرور، سوئیچ تم */}
<div className="flex items-center gap-2.5 shrink-0">
{/* نشانگر کوچک وضعیت اتصال سرور */}
{/* ابزارهای هدر: انتخابگر زبان، وضعیت اتصال سرور، سوئیچ تم */}
<div className="flex items-center gap-2 sm:gap-2.5 shrink-0">
{/* انتخابگر زبان سه گانه (فارسی، عربی، انگلیسی) */}
<LanguageSwitcher />
{/* نشانگر وضعیت اتصال سرور */}
<ServerHealthTrigger /> <ServerHealthTrigger />
{/* سوئیچ دارک و لایت */} {/* سوئیچ دارک و لایت */}

124
src/config/navigation.ts

@ -1,14 +1,19 @@
import type { IconName } from '@/icons' import type { IconName } from '@/icons'
import { translate, useLanguageStore } from '@/i18n'
export interface NavSubItemConfig { export interface NavSubItemConfig {
title: string title: string
to: string
icon: IconName
titleKey?: string
to?: string
icon?: IconName
badge?: string badge?: string
soon?: boolean
children?: NavSubItemConfig[]
} }
export interface NavItemConfig { export interface NavItemConfig {
title: string title: string
titleKey?: string
to?: string to?: string
icon: IconName icon: IconName
badge?: string badge?: string
@ -22,39 +27,82 @@ export interface NavGroupConfig {
items: NavItemConfig[] items: NavItemConfig[]
} }
export const navigationConfig: NavGroupConfig[] = [
{
title: '',
items: [
{ title: 'داشبورد', to: '/', icon: 'home' },
{
title: 'تورها',
icon: 'map',
children: [
{ title: 'فهرست همه تورها', to: '/tours', icon: 'map' },
{ title: 'تورهای در حال برگزاری', to: '/tours/ongoing', icon: 'calendar' },
{ title: 'سفارشات و رزروها', to: '/tours/orders', icon: 'clipboardCheck' },
],
},
{
title: 'خدمات سامانه',
icon: 'sparkles',
alwaysOpen: true,
children: [
{ title: 'مرکز خدمات (همه)', to: '/services', icon: 'grid' },
{ title: 'سیم‌کارت (Simcard)', to: '/services/simcard', icon: 'simcard' },
{ title: 'راهنمای زائر (Zayer Guide)', to: '/services/zayer-guide', icon: 'guide' },
{ title: 'صرافی آنلاین (Exchange)', to: '/services/exchange', icon: 'exchange' },
{ title: 'ادعیه و زیارات (Duas)', to: '/services/duas', icon: 'book' },
{ title: 'اماکن زیارتی (Shrines)', to: '/services/shrines', icon: 'shrine' },
{ title: 'فروشگاه معصومه (Store)', to: '/services/store', icon: 'store' },
{ title: 'تخمین هزینه (Cost Estimate)', to: '/services/cost-estimate', icon: 'calculator' },
{ title: 'سوالات متداول (FAQ)', to: '/services/faq', icon: 'helpCircle' },
{ title: 'پشتیبانی (Support)', to: '/services/consultant', icon: 'headset' },
],
},
{ title: 'صورت‌حساب‌ها و مالی', to: '/billing', icon: 'receipt' },
{ title: 'تنظیمات', to: '/settings', icon: 'settings' },
],
},
]
export function getNavigationConfig(t: (key: string) => string): NavGroupConfig[] {
return [
{
title: '',
items: [
{ title: t('navigation.dashboard'), titleKey: 'navigation.dashboard', to: '/', icon: 'home' },
{ title: t('navigation.billing'), titleKey: 'navigation.billing', to: '/billing', icon: 'receipt' },
{
title: t('navigation.tours'),
titleKey: 'navigation.tours',
icon: 'map',
children: [
{
title: t('navigation.allTours'),
titleKey: 'navigation.allTours',
to: '/tours',
icon: 'map',
},
{
title: t('navigation.ongoingTours'),
titleKey: 'navigation.ongoingTours',
to: '/tours/ongoing',
icon: 'calendar',
},
],
},
{ title: t('navigation.simcard'), titleKey: 'navigation.simcard', to: '/services/simcard', icon: 'simcard' },
{ title: t('navigation.zayerGuide'), titleKey: 'navigation.zayerGuide', to: '/services/zayer-guide', icon: 'guide' },
{ title: t('navigation.exchange'), titleKey: 'navigation.exchange', to: '/services/exchange', icon: 'exchange' },
{
title: t('navigation.duas'),
titleKey: 'navigation.duas',
icon: 'book',
children: [
{
title: t('navigation.duasList'),
titleKey: 'navigation.duasList',
to: '/services/duas',
icon: 'book',
},
{
title: t('navigation.categories'),
titleKey: 'navigation.categories',
to: '/services/categories',
icon: 'folder',
},
],
},
{ title: t('navigation.shrines'), titleKey: 'navigation.shrines', to: '/services/shrines', icon: 'shrine' },
{ title: t('navigation.store'), titleKey: 'navigation.store', to: '/services/store', icon: 'store' },
{ title: t('navigation.costEstimate'), titleKey: 'navigation.costEstimate', to: '/services/cost-estimate', icon: 'calculator' },
{
title: t('navigation.support'),
titleKey: 'navigation.support',
icon: 'headset',
children: [
{
title: t('navigation.supporters'),
titleKey: 'navigation.supporters',
to: '/services/consultant',
icon: 'headset',
},
{
title: t('navigation.faq'),
titleKey: 'navigation.faq',
to: '/services/faq',
icon: 'helpCircle',
},
],
},
{ title: t('navigation.settings'), titleKey: 'navigation.settings', to: '/settings', icon: 'settings' },
],
},
]
}
export const navigationConfig: NavGroupConfig[] = getNavigationConfig((key) =>
translate(useLanguageStore.getState().language, key)
)

428
src/features/billing/BillingPage.tsx

@ -1,16 +1,10 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react' import React, { useState, useEffect, useMemo, useCallback } from 'react'
import { useSearchParams } from 'react-router-dom'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Avatar } from '@/components/ui/avatar' import { Avatar } from '@/components/ui/avatar'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import FilterSelect from '@/components/shared/FilterSelect' import FilterSelect from '@/components/shared/FilterSelect'
import { import {
Table, Table,
@ -20,7 +14,7 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from '@/components/ui/table' } from '@/components/ui/table'
import { Ic } from '@/icons'
import { Ic, IconName } from '@/icons'
import { Shimmer } from '@/components/shared/Shimmer' import { Shimmer } from '@/components/shared/Shimmer'
import { fetchFactors, reviewFactor, clearFactorReceipt } from './services/billing-api' import { fetchFactors, reviewFactor, clearFactorReceipt } from './services/billing-api'
import type { FactorItem, FactorStatus, FactorService, BillingStats } from './types' import type { FactorItem, FactorStatus, FactorService, BillingStats } from './types'
@ -29,21 +23,47 @@ import { BillingServiceBadge } from './components/BillingServiceBadge'
import { BillingDetailModal } from './components/BillingDetailModal' import { BillingDetailModal } from './components/BillingDetailModal'
import { RejectFactorDialog } from './components/RejectFactorDialog' import { RejectFactorDialog } from './components/RejectFactorDialog'
import { ReceiptLightboxDialog } from './components/ReceiptLightboxDialog' import { ReceiptLightboxDialog } from './components/ReceiptLightboxDialog'
import { useTranslation } from '@/i18n'
import { useBillingBadgeStore } from '@/stores/billing-badge'
const PAGE_SIZE = 15 const PAGE_SIZE = 15
interface ServiceTabItem {
id: string
titleKey: string
icon: IconName
isCore?: boolean
}
const SERVICE_TABS: ServiceTabItem[] = [
{ id: 'all', titleKey: 'billing.tabAll', icon: 'receipt' },
{ id: 'tour', titleKey: 'billing.tabTour', icon: 'map', isCore: true },
{ id: 'simcard', titleKey: 'billing.tabSimcard', icon: 'simcard' },
{ id: 'tasrif', titleKey: 'billing.tabExchange', icon: 'exchange' },
{ id: 'shop', titleKey: 'billing.tabShop', icon: 'store' },
{ id: 'custom_trip', titleKey: 'billing.tabCustomTrip', icon: 'calculator' },
]
export default function BillingPage() { export default function BillingPage() {
const { t, language } = useTranslation()
const [searchParams, setSearchParams] = useSearchParams()
const [factors, setFactors] = useState<FactorItem[]>([]) const [factors, setFactors] = useState<FactorItem[]>([])
const [totalCount, setTotalCount] = useState<number>(0) const [totalCount, setTotalCount] = useState<number>(0)
const [isLoading, setIsLoading] = useState<boolean>(true) const [isLoading, setIsLoading] = useState<boolean>(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
const [actionSuccessMessage, setActionSuccessMessage] = useState<string | null>(null) const [actionSuccessMessage, setActionSuccessMessage] = useState<string | null>(null)
// Filters & Pagination
const [searchQuery, setSearchQuery] = useState<string>('')
const [selectedStatus, setSelectedStatus] = useState<string>('all')
const [selectedService, setSelectedService] = useState<string>('all')
const [currentPage, setCurrentPage] = useState<number>(1)
// Filters initialized from URL search params
const initialService = searchParams.get('service') || 'all'
const initialStatus = searchParams.get('status') || 'all'
const initialSearch = searchParams.get('search') || ''
const initialPage = parseInt(searchParams.get('page') || '1', 10) || 1
const [selectedService, setSelectedService] = useState<string>(initialService)
const [selectedStatus, setSelectedStatus] = useState<string>(initialStatus)
const [searchQuery, setSearchQuery] = useState<string>(initialSearch)
const [currentPage, setCurrentPage] = useState<number>(initialPage)
// Modals state // Modals state
const [selectedFactorForDetail, setSelectedFactorForDetail] = useState<FactorItem | null>(null) const [selectedFactorForDetail, setSelectedFactorForDetail] = useState<FactorItem | null>(null)
@ -54,6 +74,32 @@ export default function BillingPage() {
const [updatingId, setUpdatingId] = useState<number | null>(null) const [updatingId, setUpdatingId] = useState<number | null>(null)
const [isSubmittingReject, setIsSubmittingReject] = useState<boolean>(false) const [isSubmittingReject, setIsSubmittingReject] = useState<boolean>(false)
// Sync state to URL search params
const updateUrlParams = useCallback(
(service: string, status: string, search: string, page: number) => {
const params = new URLSearchParams()
if (service && service !== 'all') params.set('service', service)
if (status && status !== 'all') params.set('status', status)
if (search) params.set('search', search)
if (page > 1) params.set('page', page.toString())
setSearchParams(params, { replace: true })
},
[setSearchParams]
)
// Sync URL changes back to state (e.g. browser back/forward or deep-link)
useEffect(() => {
const urlService = searchParams.get('service') || 'all'
const urlStatus = searchParams.get('status') || 'all'
const urlSearch = searchParams.get('search') || ''
const urlPage = parseInt(searchParams.get('page') || '1', 10) || 1
setSelectedService(urlService)
setSelectedStatus(urlStatus)
setSearchQuery(urlSearch)
setCurrentPage(urlPage)
}, [searchParams])
// Load factors from API // Load factors from API
const loadFactors = useCallback(async () => { const loadFactors = useCallback(async () => {
setIsLoading(true) setIsLoading(true)
@ -70,27 +116,44 @@ export default function BillingPage() {
setFactors(res.results || []) setFactors(res.results || [])
setTotalCount(res.count || 0) setTotalCount(res.count || 0)
} catch (err: any) { } catch (err: any) {
setError(err?.message || 'خطا در برقراری ارتباط با سرور و بارگذاری صورت‌حساب‌ها.')
setError(err?.message || t('common.error'))
} finally { } finally {
setIsLoading(false) setIsLoading(false)
} }
}, [selectedStatus, selectedService, searchQuery, currentPage])
}, [selectedStatus, selectedService, searchQuery, currentPage, t])
useEffect(() => { useEffect(() => {
loadFactors() loadFactors()
}, [loadFactors]) }, [loadFactors])
// Reset page when filters change
const handleFilterChange = (setter: (val: string) => void) => (val: string) => {
setter(val)
// Change Service Tab
const handleSelectServiceTab = (serviceId: string) => {
setSelectedService(serviceId)
setCurrentPage(1)
updateUrlParams(serviceId, selectedStatus, searchQuery, 1)
}
// Change Status Filter
const handleSelectStatus = (statusId: string) => {
setSelectedStatus(statusId)
setCurrentPage(1)
updateUrlParams(selectedService, statusId, searchQuery, 1)
}
// Change Search Query
const handleSearchChange = (query: string) => {
setSearchQuery(query)
setCurrentPage(1) setCurrentPage(1)
updateUrlParams(selectedService, selectedStatus, query, 1)
} }
// Reset Filters
const handleResetFilters = () => { const handleResetFilters = () => {
setSearchQuery('')
setSelectedStatus('all')
setSelectedService('all') setSelectedService('all')
setSelectedStatus('all')
setSearchQuery('')
setCurrentPage(1) setCurrentPage(1)
updateUrlParams('all', 'all', '', 1)
} }
// KPI Statistics calculations // KPI Statistics calculations
@ -139,6 +202,7 @@ export default function BillingPage() {
setSelectedFactorForLightbox(updated) setSelectedFactorForLightbox(updated)
} }
showToastMessage(`صورت‌حساب شماره #${factorId} با موفقیت تایید شد.`) showToastMessage(`صورت‌حساب شماره #${factorId} با موفقیت تایید شد.`)
useBillingBadgeStore.getState().fetchCounts()
} catch (err: any) { } catch (err: any) {
alert(`خطا در تایید صورت‌حساب: ${err?.message || 'مشکل غیرمنتظره'}`) alert(`خطا در تایید صورت‌حساب: ${err?.message || 'مشکل غیرمنتظره'}`)
} finally { } finally {
@ -162,6 +226,7 @@ export default function BillingPage() {
setSelectedFactorForLightbox(updated) setSelectedFactorForLightbox(updated)
} }
showToastMessage(`صورت‌حساب شماره #${factorId} با ثبت دلیل رد شد.`) showToastMessage(`صورت‌حساب شماره #${factorId} با ثبت دلیل رد شد.`)
useBillingBadgeStore.getState().fetchCounts()
} catch (err: any) { } catch (err: any) {
alert(`خطا در رد صورت‌حساب: ${err?.message || 'مشکل غیرمنتظره'}`) alert(`خطا در رد صورت‌حساب: ${err?.message || 'مشکل غیرمنتظره'}`)
} finally { } finally {
@ -171,7 +236,7 @@ export default function BillingPage() {
} }
const handleClearReceipt = async (factorId: number) => { const handleClearReceipt = async (factorId: number) => {
if (!window.confirm('آیا از حذف تصویر فیش بارگذاری‌شده برای این صورت‌حساب اطمینان دارید؟')) {
if (!window.confirm(t('billing.confirmClearReceipt'))) {
return return
} }
setUpdatingId(factorId) setUpdatingId(factorId)
@ -186,9 +251,9 @@ export default function BillingPage() {
if (selectedFactorForLightbox?.id === factorId) { if (selectedFactorForLightbox?.id === factorId) {
setSelectedFactorForLightbox((prev) => (prev ? { ...prev, transaction_receipt: null } : null)) setSelectedFactorForLightbox((prev) => (prev ? { ...prev, transaction_receipt: null } : null))
} }
showToastMessage('فیش واریزی با موفقیت پاک شد.')
showToastMessage(t('billing.receiptClearedSuccess'))
} catch (err: any) { } catch (err: any) {
alert(`خطا در حذف فیش: ${err?.message || 'مشکل غیرمنتظره'}`)
alert(`Error: ${err?.message || 'Unexpected issue'}`)
} finally { } finally {
setUpdatingId(null) setUpdatingId(null)
} }
@ -202,7 +267,7 @@ export default function BillingPage() {
const totalPages = Math.ceil(totalCount / PAGE_SIZE) || 1 const totalPages = Math.ceil(totalCount / PAGE_SIZE) || 1
return ( return (
<div className="p-4 sm:p-6 lg:p-8 space-y-6 max-w-7xl mx-auto">
<div className="space-y-6 pt-2 pb-14">
{/* پیام بازخورد عملیات */} {/* پیام بازخورد عملیات */}
{actionSuccessMessage && ( {actionSuccessMessage && (
<div className="bg-emerald-500/15 border border-emerald-500/30 text-emerald-700 dark:text-emerald-300 px-4 py-3 rounded-xl flex items-center justify-between text-xs font-medium animate-in fade-in slide-in-from-top-2"> <div className="bg-emerald-500/15 border border-emerald-500/30 text-emerald-700 dark:text-emerald-300 px-4 py-3 rounded-xl flex items-center justify-between text-xs font-medium animate-in fade-in slide-in-from-top-2">
@ -219,45 +284,83 @@ export default function BillingPage() {
</div> </div>
)} )}
{/* هدر صفحه */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
{/* سربرگ اصلی صفحه صورت‌حساب‌ها */}
<div className="flex flex-col gap-4 rounded-2xl border border-border-soft bg-card/40 p-6 shadow-xs sm:flex-row sm:items-center sm:justify-between backdrop-blur-xs">
<div className="space-y-1">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="size-9 rounded-xl bg-primary/15 text-primary flex items-center justify-center font-bold">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Ic name="receipt" className="size-5" /> <Ic name="receipt" className="size-5" />
</div> </div>
<h1 className="text-xl font-black tracking-tight">مدیریت صورت‌حساب‌ها و فاکتورها</h1>
<Badge variant="secondary" className="font-mono text-xs">
{totalCount} فاکتور
<h1 className="text-xl font-black text-foreground">
{t('billing.title')}
</h1>
<Badge
variant="outline"
className="border-primary/30 bg-primary/10 text-primary font-bold font-mono text-xs"
>
{totalCount} {t('billing.invoices')}
</Badge> </Badge>
</div> </div>
<p className="text-xs text-muted-foreground mt-1">
بررسی فیش‌های بانکی، تایید یا رد پرداخت‌ها و پیگیری ریز فاکتورهای ۵ سرویس سامانه عقیله
<p className="text-xs text-grey-3">
{t('billing.description')}
</p> </p>
</div> </div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-2.5">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
onClick={loadFactors} onClick={loadFactors}
disabled={isLoading} disabled={isLoading}
className="gap-1.5 text-xs h-9"
className="gap-2 rounded-xl border-border-soft hover:bg-surface-2 text-xs"
> >
<Ic name="refresh" className={`size-3.5 ${isLoading ? 'animate-spin' : ''}`} /> <Ic name="refresh" className={`size-3.5 ${isLoading ? 'animate-spin' : ''}`} />
<span>به‌روزرسانی</span>
<span>{t('common.refresh')}</span>
</Button> </Button>
</div> </div>
</div> </div>
{/* کارت‌های KPI شاخص‌های کلیدی */}
{/* نوار تب‌های تفکیک سرویس‌ها (Service Category Tabs) با اولویت محور اصلی تورها */}
<div className="flex items-center gap-2 overflow-x-auto pb-2 sm:pb-0 scrollbar-none">
{SERVICE_TABS.map((tab) => {
const isActive = selectedService === tab.id
return (
<button
key={tab.id}
type="button"
onClick={() => handleSelectServiceTab(tab.id)}
className={`flex items-center gap-2 px-3.5 py-2 rounded-xl text-xs font-bold transition-all whitespace-nowrap border shrink-0 ${
isActive
? 'bg-primary text-white border-primary shadow-xs'
: 'bg-card/60 text-grey-2 border-border-soft hover:bg-card hover:text-foreground'
}`}
>
<Ic name={tab.icon} className={`size-4 ${isActive ? 'text-white' : 'text-primary'}`} />
<span>{t(tab.titleKey)}</span>
{tab.isCore && (
<span
className={`text-[10px] px-1.5 py-0.2 rounded-md font-bold ${
isActive
? 'bg-white/20 text-white'
: 'bg-primary/15 text-primary'
}`}
>
{t('billing.coreTourBadge')}
</span>
)}
</button>
)
})}
</div>
{/* کارت‌های KPI شاخص‌های کلیدی مالی */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3.5"> <div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3.5">
{/* کل فاکتورها */} {/* کل فاکتورها */}
<Card className="border-border/50 shadow-xs">
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div>
<p className="text-[11px] text-muted-foreground font-medium">کل صورت‌حساب‌ها</p>
<p className="text-xl font-black mt-1 font-mono">{stats.total}</p>
<p className="text-[11px] text-grey-3 font-medium">{t('billing.totalInvoices')}</p>
<p className="text-xl font-black mt-1 font-mono text-foreground">{stats.total}</p>
</div> </div>
<div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground"> <div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground">
<Ic name="receipt" className="size-4" /> <Ic name="receipt" className="size-4" />
@ -270,14 +373,14 @@ export default function BillingPage() {
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div>
<p className="text-[11px] text-blue-600 dark:text-blue-400 font-medium"> <p className="text-[11px] text-blue-600 dark:text-blue-400 font-medium">
در انتظار بررسی فیش
{t('billing.pendingReview')}
</p> </p>
<p className="text-xl font-black mt-1 font-mono text-blue-600 dark:text-blue-400"> <p className="text-xl font-black mt-1 font-mono text-blue-600 dark:text-blue-400">
{stats.pendingReview} {stats.pendingReview}
</p> </p>
</div> </div>
<div className="size-9 rounded-xl bg-blue-500/15 text-blue-600 dark:text-blue-400 flex items-center justify-center"> <div className="size-9 rounded-xl bg-blue-500/15 text-blue-600 dark:text-blue-400 flex items-center justify-center">
<Ic name="clock" className="size-4" />
<Ic name="clock" className="size-4 animate-pulse" />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@ -287,7 +390,7 @@ export default function BillingPage() {
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div>
<p className="text-[11px] text-amber-600 dark:text-amber-400 font-medium"> <p className="text-[11px] text-amber-600 dark:text-amber-400 font-medium">
در انتظار پرداخت مشتری
{t('billing.awaitingPayment')}
</p> </p>
<p className="text-xl font-black mt-1 font-mono text-amber-600 dark:text-amber-400"> <p className="text-xl font-black mt-1 font-mono text-amber-600 dark:text-amber-400">
{stats.awaitingPayment} {stats.awaitingPayment}
@ -304,7 +407,7 @@ export default function BillingPage() {
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div>
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium"> <p className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium">
پرداخت‌های تایید شده
{t('billing.approvedPayments')}
</p> </p>
<p className="text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400"> <p className="text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400">
{stats.approved} {stats.approved}
@ -321,7 +424,7 @@ export default function BillingPage() {
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div>
<p className="text-[11px] text-rose-600 dark:text-rose-400 font-medium"> <p className="text-[11px] text-rose-600 dark:text-rose-400 font-medium">
رد شده / منقضی
{t('billing.rejectedOrExpired')}
</p> </p>
<p className="text-xl font-black mt-1 font-mono text-rose-600 dark:text-rose-400"> <p className="text-xl font-black mt-1 font-mono text-rose-600 dark:text-rose-400">
{stats.rejectedOrExpired} {stats.rejectedOrExpired}
@ -334,101 +437,90 @@ export default function BillingPage() {
</Card> </Card>
</div> </div>
{/* نوار جستجو و فیلترها */}
<Card className="border-border/50 shadow-xs">
{/* نوار جستجو و فیلترهای وضعیت لحظه‌ای */}
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4"> <CardContent className="p-4">
<div className="flex flex-col md:flex-row items-center gap-3">
<div className="flex flex-col md:flex-row items-center justify-between gap-3">
{/* جستجوی متنی */} {/* جستجوی متنی */}
<div className="relative flex-1 w-full"> <div className="relative flex-1 w-full">
<Ic <Ic
name="search" name="search"
className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground"
className="absolute start-3 top-1/2 -translate-y-1/2 size-4 text-grey-3 pointer-events-none"
/> />
<Input <Input
placeholder="جستجو در فاکتورها (نام کاربر، ایمیل، شماره کارت، شماره فاکتور)..."
placeholder={t('billing.searchPlaceholder')}
value={searchQuery} value={searchQuery}
onChange={(e) => {
setSearchQuery(e.target.value)
setCurrentPage(1)
}}
className="pr-9 h-9 text-xs"
onChange={(e) => handleSearchChange(e.target.value)}
className="ps-9 pe-9 h-9 text-xs bg-surface-2 border-border-soft rounded-xl"
/> />
{searchQuery && ( {searchQuery && (
<button <button
onClick={() => {
setSearchQuery('')
setCurrentPage(1)
}}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => handleSearchChange('')}
className="absolute end-3 top-1/2 -translate-y-1/2 text-grey-3 hover:text-foreground"
> >
<Ic name="x" className="size-3" /> <Ic name="x" className="size-3" />
</button> </button>
)} )}
</div> </div>
{/* فیلتر وضعیت */}
<div className="w-full md:w-48">
<FilterSelect
value={selectedStatus}
onChange={handleFilterChange(setSelectedStatus)}
options={[
{ value: 'all', label: 'همه وضعیت‌ها' },
{ value: 'pending', label: 'در انتظار بررسی فیش' },
{ value: 'awaiting_payment', label: 'در انتظار پرداخت' },
{ value: 'approved', label: 'پرداخت تایید شده' },
{ value: 'rejected', label: 'رد شده' },
{ value: 'expired', label: 'منقضی شده' },
]}
allValue="all"
placeholder="وضعیت فاکتور"
size="sm"
/>
</div>
{/* فیلتر سرویس */}
<div className="w-full md:w-48">
<FilterSelect
value={selectedService}
onChange={handleFilterChange(setSelectedService)}
options={[
{ value: 'all', label: 'همه سرویس‌ها' },
{ value: 'tour', label: 'تورهای زیارتی' },
{ value: 'simcard', label: 'سیم‌کارت' },
{ value: 'shop', label: 'فروشگاه سوغات' },
{ value: 'tasrif', label: 'صرافی (تسریف)' },
{ value: 'custom_trip', label: 'سفر اختصاصی' },
]}
allValue="all"
placeholder="نوع سرویس"
size="sm"
/>
{/* چیپ‌های فیلتر سریع وضعیت */}
<div className="flex items-center gap-1.5 w-full md:w-auto overflow-x-auto pb-1 md:pb-0 scrollbar-none">
{[
{ id: 'all', label: t('billing.allStatuses') },
{ id: 'pending', label: t('billing.statusPending') },
{ id: 'awaiting_payment', label: t('billing.statusAwaiting') },
{ id: 'approved', label: t('billing.statusApproved') },
{ id: 'rejected', label: t('billing.statusRejected') },
{ id: 'expired', label: t('billing.statusExpired') },
].map((st) => {
const isSelected = selectedStatus === st.id
return (
<button
key={st.id}
type="button"
onClick={() => handleSelectStatus(st.id)}
className={`px-3 py-1.5 rounded-xl text-xs font-bold transition-all whitespace-nowrap ${
isSelected
? 'bg-primary text-white shadow-xs'
: 'bg-surface-2 text-grey-3 hover:text-foreground hover:bg-surface-3'
}`}
>
{st.label}
</button>
)
})}
</div> </div>
{/* بازنشانی فیلترها */}
{/* دکمه بازنشانی فیلترها */}
{(searchQuery || selectedStatus !== 'all' || selectedService !== 'all') && ( {(searchQuery || selectedStatus !== 'all' || selectedService !== 'all') && (
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={handleResetFilters} onClick={handleResetFilters}
className="h-9 text-xs text-muted-foreground hover:text-foreground shrink-0"
className="h-9 text-xs text-grey-3 hover:text-foreground shrink-0 rounded-xl"
> >
<Ic name="x" className="size-3.5 me-1" /> <Ic name="x" className="size-3.5 me-1" />
<span>پاکسازی فیلترها</span>
<span>{t('billing.clearFilters')}</span>
</Button> </Button>
)} )}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* جدول داده‌ای فاکتورها */}
<Card className="border-border/50 shadow-xs overflow-hidden">
<CardHeader className="py-3 px-4 border-b border-border/40 bg-muted/20 flex flex-row items-center justify-between">
<CardTitle className="text-xs font-bold flex items-center gap-2">
{/* جدول داده‌ای جامع صورت‌حساب‌ها و سفارشات */}
<Card className="border-border-soft bg-card/40 shadow-xs overflow-hidden">
<CardHeader className="py-3 px-4 border-b border-border-soft bg-card/60 flex flex-row items-center justify-between">
<CardTitle className="text-xs font-bold flex items-center gap-2 text-foreground">
<Ic name="list" className="size-4 text-primary" /> <Ic name="list" className="size-4 text-primary" />
<span>فهرست فاکتورها</span>
<span>{t('billing.invoiceList')}</span>
{selectedService !== 'all' && (
<Badge variant="outline" className="text-[10px] border-primary/30 text-primary">
{t(`billing.tab${selectedService.charAt(0).toUpperCase() + selectedService.slice(1)}` as any) || selectedService}
</Badge>
)}
</CardTitle> </CardTitle>
<span className="text-xs text-muted-foreground font-mono">
صفحه {currentPage} از {totalPages}
<span className="text-xs text-grey-3 font-mono">
{t('common.pageOf', { current: currentPage, total: totalPages })}
</span> </span>
</CardHeader> </CardHeader>
@ -439,24 +531,24 @@ export default function BillingPage() {
<div className="p-8 text-center text-rose-500 space-y-2"> <div className="p-8 text-center text-rose-500 space-y-2">
<Ic name="alert" className="size-8 mx-auto" /> <Ic name="alert" className="size-8 mx-auto" />
<p className="text-xs font-bold">{error}</p> <p className="text-xs font-bold">{error}</p>
<Button variant="outline" size="sm" onClick={loadFactors} className="mt-2 text-xs">
تلاش مجدد
<Button variant="outline" size="sm" onClick={loadFactors} className="mt-2 text-xs rounded-xl">
{t('common.retry')}
</Button> </Button>
</div> </div>
) : factors.length === 0 ? ( ) : factors.length === 0 ? (
<div className="p-12 text-center text-muted-foreground space-y-3">
<div className="size-14 rounded-2xl bg-muted/40 flex items-center justify-center mx-auto text-muted-foreground">
<div className="p-12 text-center text-grey-3 space-y-3">
<div className="size-14 rounded-2xl bg-muted/40 flex items-center justify-center mx-auto text-grey-3">
<Ic name="inbox" className="size-7" /> <Ic name="inbox" className="size-7" />
</div> </div>
<p className="text-sm font-bold text-foreground">صورت‌حسابی یافت نشد.</p>
<p className="text-xs text-muted-foreground max-w-sm mx-auto">
<p className="text-sm font-bold text-foreground">{t('billing.noFactorsFound')}</p>
<p className="text-xs text-grey-3 max-w-sm mx-auto">
{searchQuery || selectedStatus !== 'all' || selectedService !== 'all' {searchQuery || selectedStatus !== 'all' || selectedService !== 'all'
? 'با فیلترهای جستجوی فعلی موردی پیدا نشد. می‌توانید فیلترها را پاکسازی نمایید.'
: 'هنوز هیچ صورت‌حسابی در سیستم صادر نشده است.'}
? t('billing.noFactorsFilter')
: t('billing.noFactorsEmpty')}
</p> </p>
{(searchQuery || selectedStatus !== 'all' || selectedService !== 'all') && ( {(searchQuery || selectedStatus !== 'all' || selectedService !== 'all') && (
<Button variant="outline" size="sm" onClick={handleResetFilters} className="text-xs">
پاکسازی فیلترها
<Button variant="outline" size="sm" onClick={handleResetFilters} className="text-xs rounded-xl">
{t('billing.clearFilters')}
</Button> </Button>
)} )}
</div> </div>
@ -464,18 +556,18 @@ export default function BillingPage() {
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="hover:bg-transparent text-xs">
<TableHead className="w-16 text-center">شناسه</TableHead>
<TableHead>مشتری</TableHead>
<TableHead>سرویس</TableHead>
<TableHead>مبلغ فاکتور</TableHead>
<TableHead>وضعیت</TableHead>
<TableHead className="text-center">فیش واریزی</TableHead>
<TableHead>تاریخ صدور</TableHead>
<TableHead className="text-end pl-4">عملیات</TableHead>
<TableRow className="hover:bg-transparent text-xs border-b border-border-soft bg-secondary/15 text-grey-2">
<TableHead className="w-16 text-center">{t('billing.id')}</TableHead>
<TableHead>{t('billing.customer')}</TableHead>
<TableHead>{t('billing.service')}</TableHead>
<TableHead>{t('billing.amount')}</TableHead>
<TableHead>{t('billing.liveStatus')}</TableHead>
<TableHead className="text-center">{t('billing.receipt')}</TableHead>
<TableHead>{t('billing.date')}</TableHead>
<TableHead className="text-end pl-4">{t('common.actions')}</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody className="text-xs">
<TableBody className="text-xs divide-y divide-border-soft">
{factors.map((factor) => { {factors.map((factor) => {
const isPending = factor.status === 'pending' const isPending = factor.status === 'pending'
const hasReceipt = Boolean(factor.transaction_receipt) const hasReceipt = Boolean(factor.transaction_receipt)
@ -484,29 +576,29 @@ export default function BillingPage() {
return ( return (
<TableRow <TableRow
key={factor.id} key={factor.id}
className={`transition-colors ${
className={`transition-colors hover:bg-surface-2/50 ${
isPending ? 'bg-blue-500/[0.03] hover:bg-blue-500/[0.07]' : '' isPending ? 'bg-blue-500/[0.03] hover:bg-blue-500/[0.07]' : ''
}`} }`}
> >
{/* شناسه */} {/* شناسه */}
<TableCell className="text-center font-mono font-bold text-muted-foreground">
<TableCell className="text-center font-mono font-bold text-grey-3">
#{factor.id} #{factor.id}
</TableCell> </TableCell>
{/* مشخصات مشتری */}
{/* مشخصات مشتری و خریدار */}
<TableCell> <TableCell>
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<Avatar <Avatar
className="size-8 border border-border/40 shrink-0 text-[10px] font-bold"
className="size-8 border border-border-soft shrink-0 text-[10px] font-bold"
src={user?.avatar || undefined} src={user?.avatar || undefined}
letter={user?.fullname ? user.fullname.slice(0, 2) : 'ک'}
letter={user?.fullname ? user.fullname.slice(0, 2) : 'U'}
/> />
<div className="min-w-0"> <div className="min-w-0">
<p className="font-bold text-foreground truncate max-w-[140px] sm:max-w-[180px]"> <p className="font-bold text-foreground truncate max-w-[140px] sm:max-w-[180px]">
{user?.fullname || 'نامشخص'}
{user?.fullname || '—'}
</p> </p>
<p className="text-[11px] text-muted-foreground truncate max-w-[140px] sm:max-w-[180px]">
{user?.email || user?.phone_number || '—'}
<p className="text-[11px] text-grey-3 truncate max-w-[140px] sm:max-w-[180px] font-mono dir-ltr text-start">
{user?.phone_number || user?.email || '—'}
</p> </p>
</div> </div>
</div> </div>
@ -520,11 +612,11 @@ export default function BillingPage() {
{/* مبلغ */} {/* مبلغ */}
<TableCell> <TableCell>
<span className="font-bold font-mono text-sm text-foreground"> <span className="font-bold font-mono text-sm text-foreground">
{Number(factor.amount).toLocaleString()} $
${Number(factor.amount).toLocaleString()}
</span> </span>
</TableCell> </TableCell>
{/* وضعیت */}
{/* وضعیت لحظه‌ای */}
<TableCell> <TableCell>
<BillingStatusBadge status={factor.status} /> <BillingStatusBadge status={factor.status} />
</TableCell> </TableCell>
@ -534,12 +626,12 @@ export default function BillingPage() {
{hasReceipt ? ( {hasReceipt ? (
<button <button
onClick={() => setSelectedFactorForLightbox(factor)} onClick={() => setSelectedFactorForLightbox(factor)}
className="relative group size-9 rounded-lg overflow-hidden border border-border/60 mx-auto block hover:scale-105 transition-transform"
title="مشاهده فیش واریزی"
className="relative group size-9 rounded-lg overflow-hidden border border-border-soft mx-auto block hover:scale-105 transition-transform"
title={t('billing.viewReceipt')}
> >
<img <img
src={factor.transaction_receipt!} src={factor.transaction_receipt!}
alt="فیش"
alt={t('billing.receipt')}
className="size-full object-cover" className="size-full object-cover"
/> />
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 flex items-center justify-center text-white transition-opacity"> <div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 flex items-center justify-center text-white transition-opacity">
@ -548,32 +640,34 @@ export default function BillingPage() {
</button> </button>
) : ( ) : (
<span <span
className="text-[10px] text-muted-foreground block text-center"
title="فیش واریزی بارگذاری نشده"
className="text-[10px] text-grey-4 block text-center"
title={t('billing.noReceiptUploaded')}
> >
ندارد
{t('billing.noReceipt')}
</span> </span>
)} )}
</TableCell> </TableCell>
{/* تاریخ صدور */} {/* تاریخ صدور */}
<TableCell className="text-muted-foreground whitespace-nowrap">
{new Date(factor.created_at).toLocaleDateString('fa-IR')}
<TableCell className="text-grey-3 whitespace-nowrap text-[11px] font-medium">
{new Date(factor.created_at).toLocaleDateString(
language === 'fa' ? 'fa-IR' : language === 'ar' ? 'ar-SA' : 'en-US'
)}
</TableCell> </TableCell>
{/* دکمه‌های عملیات */} {/* دکمه‌های عملیات */}
<TableCell className="text-end pl-4"> <TableCell className="text-end pl-4">
<div className="flex items-center justify-end gap-1.5"> <div className="flex items-center justify-end gap-1.5">
{/* دکمه مشاهده جزئیات */}
{/* دکمه مشاهده جزئیات کامل خریدار و پرداخت */}
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-8 px-2.5 text-xs gap-1"
className="h-8 px-2.5 text-xs gap-1 rounded-xl border-border-soft hover:bg-surface-2"
onClick={() => setSelectedFactorForDetail(factor)} onClick={() => setSelectedFactorForDetail(factor)}
title="مشاهده ریز فاکتور"
title={t('billing.viewDetails')}
> >
<Ic name="eye" className="size-3.5" /> <Ic name="eye" className="size-3.5" />
<span className="hidden sm:inline">جزئیات</span>
<span className="hidden sm:inline">{t('billing.viewDetails')}</span>
</Button> </Button>
{/* تایید سریع فیش */} {/* تایید سریع فیش */}
@ -581,10 +675,10 @@ export default function BillingPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-8 text-emerald-600 hover:text-emerald-700 hover:bg-emerald-500/10"
className="size-8 rounded-xl text-emerald-600 hover:text-emerald-700 hover:bg-emerald-500/10"
onClick={() => handleApprove(factor.id)} onClick={() => handleApprove(factor.id)}
disabled={updatingId === factor.id} disabled={updatingId === factor.id}
title="تایید پرداخت"
title={t('billing.approve')}
> >
<Ic name="checkCircle" className="size-4" /> <Ic name="checkCircle" className="size-4" />
</Button> </Button>
@ -595,10 +689,10 @@ export default function BillingPage() {
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="size-8 text-rose-600 hover:text-rose-700 hover:bg-rose-500/10"
className="size-8 rounded-xl text-rose-600 hover:text-rose-700 hover:bg-rose-500/10"
onClick={() => setSelectedFactorForReject(factor)} onClick={() => setSelectedFactorForReject(factor)}
disabled={updatingId === factor.id} disabled={updatingId === factor.id}
title="رد فاکتور"
title={t('billing.reject')}
> >
<Ic name="xCircle" className="size-4" /> <Ic name="xCircle" className="size-4" />
</Button> </Button>
@ -615,20 +709,24 @@ export default function BillingPage() {
{/* صفحه‌بندی */} {/* صفحه‌بندی */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="p-3 border-t border-border/40 flex items-center justify-between text-xs">
<span className="text-muted-foreground">
نمایش {(currentPage - 1) * PAGE_SIZE + 1} تا{' '}
{Math.min(currentPage * PAGE_SIZE, totalCount)} از {totalCount} فاکتور
<div className="p-3 border-t border-border-soft flex items-center justify-between text-xs bg-card/40">
<span className="text-grey-3">
{t('common.showing')} {(currentPage - 1) * PAGE_SIZE + 1} {t('common.to')}{' '}
{Math.min(currentPage * PAGE_SIZE, totalCount)} {t('common.of')} {totalCount} {t('billing.invoices')}
</span> </span>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-7 text-xs"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
className="h-7 text-xs rounded-xl"
onClick={() => {
const p = Math.max(1, currentPage - 1)
setCurrentPage(p)
updateUrlParams(selectedService, selectedStatus, searchQuery, p)
}}
disabled={currentPage === 1 || isLoading} disabled={currentPage === 1 || isLoading}
> >
قبلی
{t('common.previous')}
</Button> </Button>
<span className="px-2 font-mono font-bold"> <span className="px-2 font-mono font-bold">
{currentPage} / {totalPages} {currentPage} / {totalPages}
@ -636,11 +734,15 @@ export default function BillingPage() {
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="h-7 text-xs"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
className="h-7 text-xs rounded-xl"
onClick={() => {
const p = Math.min(totalPages, currentPage + 1)
setCurrentPage(p)
updateUrlParams(selectedService, selectedStatus, searchQuery, p)
}}
disabled={currentPage === totalPages || isLoading} disabled={currentPage === totalPages || isLoading}
> >
بعدی
{t('common.next')}
</Button> </Button>
</div> </div>
</div> </div>
@ -648,7 +750,7 @@ export default function BillingPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* ۱. مودال جزئیات جامع صورت‌حساب */}
{/* ۱. مودال جزئیات جامع صورت‌حساب و شخص خریدار */}
<BillingDetailModal <BillingDetailModal
factor={selectedFactorForDetail} factor={selectedFactorForDetail}
open={Boolean(selectedFactorForDetail)} open={Boolean(selectedFactorForDetail)}

5
src/features/billing/components/BillingDetailModal.tsx

@ -15,6 +15,8 @@ import type { FactorItem } from '../types'
import { BillingStatusBadge } from './BillingStatusBadge' import { BillingStatusBadge } from './BillingStatusBadge'
import { BillingServiceBadge } from './BillingServiceBadge' import { BillingServiceBadge } from './BillingServiceBadge'
import { useTranslation } from '@/i18n'
interface Props { interface Props {
factor: FactorItem | null factor: FactorItem | null
open: boolean open: boolean
@ -36,6 +38,7 @@ export function BillingDetailModal({
onClearReceipt, onClearReceipt,
isUpdating, isUpdating,
}: Props) { }: Props) {
const { t, language } = useTranslation()
const [copiedCard, setCopiedCard] = useState(false) const [copiedCard, setCopiedCard] = useState(false)
if (!factor) return null if (!factor) return null
@ -64,7 +67,7 @@ export function BillingDetailModal({
<span>صورت‌حساب شماره #{factor.id}</span> <span>صورت‌حساب شماره #{factor.id}</span>
</DialogTitle> </DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5"> <DialogDescription className="text-xs text-muted-foreground mt-0.5">
ثبت شده در: {new Date(factor.created_at).toLocaleString('fa-IR')}
ثبت شده در: {new Date(factor.created_at).toLocaleString(language === 'fa' ? 'fa-IR' : language === 'ar' ? 'ar-SA' : 'en-US')}
</DialogDescription> </DialogDescription>
</div> </div>
</div> </div>

21
src/features/billing/components/BillingServiceBadge.tsx

@ -2,6 +2,7 @@ import React from 'react'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Ic } from '@/icons' import { Ic } from '@/icons'
import type { FactorService } from '../types' import type { FactorService } from '../types'
import { useTranslation } from '@/i18n'
interface Props { interface Props {
service: FactorService service: FactorService
@ -9,6 +10,8 @@ interface Props {
} }
export function BillingServiceBadge({ service, className }: Props) { export function BillingServiceBadge({ service, className }: Props) {
const { t } = useTranslation()
switch (service) { switch (service) {
case 'tour': case 'tour':
return ( return (
@ -17,7 +20,7 @@ export function BillingServiceBadge({ service, className }: Props) {
className={`bg-purple-500/15 text-purple-700 dark:text-purple-300 border-purple-500/30 gap-1.5 font-medium ${className || ''}`} className={`bg-purple-500/15 text-purple-700 dark:text-purple-300 border-purple-500/30 gap-1.5 font-medium ${className || ''}`}
> >
<Ic name="map" className="size-3.5 text-purple-500 shrink-0" /> <Ic name="map" className="size-3.5 text-purple-500 shrink-0" />
<span>تور</span>
<span>{t('billing.serviceTour')}</span>
</Badge> </Badge>
) )
case 'simcard': case 'simcard':
@ -26,8 +29,8 @@ export function BillingServiceBadge({ service, className }: Props) {
variant="outline" variant="outline"
className={`bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 border-cyan-500/30 gap-1.5 font-medium ${className || ''}`} className={`bg-cyan-500/15 text-cyan-700 dark:text-cyan-300 border-cyan-500/30 gap-1.5 font-medium ${className || ''}`}
> >
<Ic name="server" className="size-3.5 text-cyan-500 shrink-0" />
<span>سیم‌کارت</span>
<Ic name="simcard" className="size-3.5 text-cyan-500 shrink-0" />
<span>{t('billing.serviceSimcard')}</span>
</Badge> </Badge>
) )
case 'shop': case 'shop':
@ -36,8 +39,8 @@ export function BillingServiceBadge({ service, className }: Props) {
variant="outline" variant="outline"
className={`bg-amber-500/15 text-amber-700 dark:text-amber-300 border-amber-500/30 gap-1.5 font-medium ${className || ''}`} className={`bg-amber-500/15 text-amber-700 dark:text-amber-300 border-amber-500/30 gap-1.5 font-medium ${className || ''}`}
> >
<Ic name="tag" className="size-3.5 text-amber-500 shrink-0" />
<span>فروشگاه</span>
<Ic name="store" className="size-3.5 text-amber-500 shrink-0" />
<span>{t('billing.serviceShop')}</span>
</Badge> </Badge>
) )
case 'tasrif': case 'tasrif':
@ -46,8 +49,8 @@ export function BillingServiceBadge({ service, className }: Props) {
variant="outline" variant="outline"
className={`bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30 gap-1.5 font-medium ${className || ''}`} className={`bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30 gap-1.5 font-medium ${className || ''}`}
> >
<Ic name="dollarSign" className="size-3.5 text-emerald-500 shrink-0" />
<span>صرافی (تسریف)</span>
<Ic name="exchange" className="size-3.5 text-emerald-500 shrink-0" />
<span>{t('billing.serviceExchange')}</span>
</Badge> </Badge>
) )
case 'custom_trip': case 'custom_trip':
@ -56,8 +59,8 @@ export function BillingServiceBadge({ service, className }: Props) {
variant="outline" variant="outline"
className={`bg-indigo-500/15 text-indigo-700 dark:text-indigo-300 border-indigo-500/30 gap-1.5 font-medium ${className || ''}`} className={`bg-indigo-500/15 text-indigo-700 dark:text-indigo-300 border-indigo-500/30 gap-1.5 font-medium ${className || ''}`}
> >
<Ic name="sparkles" className="size-3.5 text-indigo-500 shrink-0" />
<span>سفر اختصاصی</span>
<Ic name="calculator" className="size-3.5 text-indigo-500 shrink-0" />
<span>{t('billing.serviceCustomTrip')}</span>
</Badge> </Badge>
) )
default: default:

14
src/features/billing/components/BillingStatusBadge.tsx

@ -3,12 +3,16 @@ import { Badge } from '@/components/ui/badge'
import { Ic } from '@/icons' import { Ic } from '@/icons'
import type { FactorStatus } from '../types' import type { FactorStatus } from '../types'
import { useTranslation } from '@/i18n'
interface Props { interface Props {
status: FactorStatus status: FactorStatus
className?: string className?: string
} }
export function BillingStatusBadge({ status, className }: Props) { export function BillingStatusBadge({ status, className }: Props) {
const { t } = useTranslation()
switch (status) { switch (status) {
case 'awaiting_payment': case 'awaiting_payment':
return ( return (
@ -17,7 +21,7 @@ export function BillingStatusBadge({ status, className }: Props) {
className={`bg-amber-500/15 text-amber-700 dark:text-amber-300 border-amber-500/30 gap-1.5 font-medium ${className || ''}`} className={`bg-amber-500/15 text-amber-700 dark:text-amber-300 border-amber-500/30 gap-1.5 font-medium ${className || ''}`}
> >
<Ic name="clock" className="size-3.5 text-amber-500 shrink-0" /> <Ic name="clock" className="size-3.5 text-amber-500 shrink-0" />
<span>در انتظار پرداخت</span>
<span>{t('billing.statusAwaiting')}</span>
</Badge> </Badge>
) )
case 'pending': case 'pending':
@ -27,7 +31,7 @@ export function BillingStatusBadge({ status, className }: Props) {
className={`bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30 gap-1.5 font-medium animate-pulse ${className || ''}`} className={`bg-blue-500/15 text-blue-700 dark:text-blue-300 border-blue-500/30 gap-1.5 font-medium animate-pulse ${className || ''}`}
> >
<Ic name="clock" className="size-3.5 text-blue-500 shrink-0" /> <Ic name="clock" className="size-3.5 text-blue-500 shrink-0" />
<span>در انتظار بررسی فیش</span>
<span>{t('billing.statusPending')}</span>
</Badge> </Badge>
) )
case 'approved': case 'approved':
@ -37,7 +41,7 @@ export function BillingStatusBadge({ status, className }: Props) {
className={`bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30 gap-1.5 font-medium ${className || ''}`} className={`bg-emerald-500/15 text-emerald-700 dark:text-emerald-300 border-emerald-500/30 gap-1.5 font-medium ${className || ''}`}
> >
<Ic name="checkCircle" className="size-3.5 text-emerald-500 shrink-0" /> <Ic name="checkCircle" className="size-3.5 text-emerald-500 shrink-0" />
<span>پرداخت تایید شده</span>
<span>{t('billing.statusApproved')}</span>
</Badge> </Badge>
) )
case 'rejected': case 'rejected':
@ -47,7 +51,7 @@ export function BillingStatusBadge({ status, className }: Props) {
className={`bg-rose-500/15 text-rose-700 dark:text-rose-300 border-rose-500/30 gap-1.5 font-medium ${className || ''}`} className={`bg-rose-500/15 text-rose-700 dark:text-rose-300 border-rose-500/30 gap-1.5 font-medium ${className || ''}`}
> >
<Ic name="xCircle" className="size-3.5 text-rose-500 shrink-0" /> <Ic name="xCircle" className="size-3.5 text-rose-500 shrink-0" />
<span>رد شده</span>
<span>{t('billing.statusRejected')}</span>
</Badge> </Badge>
) )
case 'expired': case 'expired':
@ -57,7 +61,7 @@ export function BillingStatusBadge({ status, className }: Props) {
className={`bg-slate-500/15 text-slate-600 dark:text-slate-400 border-slate-500/30 gap-1.5 font-medium ${className || ''}`} className={`bg-slate-500/15 text-slate-600 dark:text-slate-400 border-slate-500/30 gap-1.5 font-medium ${className || ''}`}
> >
<Ic name="clock" className="size-3.5 text-slate-500 shrink-0" /> <Ic name="clock" className="size-3.5 text-slate-500 shrink-0" />
<span>منقضی شده</span>
<span>{t('billing.statusExpired')}</span>
</Badge> </Badge>
) )
default: default:

28
src/features/billing/components/RejectFactorDialog.tsx

@ -11,6 +11,7 @@ import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Ic } from '@/icons' import { Ic } from '@/icons'
import type { FactorItem } from '../types' import type { FactorItem } from '../types'
import { useTranslation } from '@/i18n'
interface Props { interface Props {
factor: FactorItem | null factor: FactorItem | null
@ -27,6 +28,7 @@ export function RejectFactorDialog({
onConfirm, onConfirm,
isSubmitting, isSubmitting,
}: Props) { }: Props) {
const { t, dir } = useTranslation()
const [reason, setReason] = useState<string>('') const [reason, setReason] = useState<string>('')
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -34,7 +36,7 @@ export function RejectFactorDialog({
const handleConfirm = async () => { const handleConfirm = async () => {
if (!reason.trim()) { if (!reason.trim()) {
setError('لطفاً دلیل رد فاکتور را وارد فرمایید تا به مشتری اطلاع داده شود.')
setError(t('billing.rejectReasonPlaceholder'))
return return
} }
setError(null) setError(null)
@ -47,7 +49,7 @@ export function RejectFactorDialog({
<Dialog open={open} onOpenChange={(val) => !val && onClose()}> <Dialog open={open} onOpenChange={(val) => !val && onClose()}>
<DialogContent <DialogContent
className="max-w-md p-0 overflow-hidden rounded-2xl border border-border/60 bg-card shadow-2xl" className="max-w-md p-0 overflow-hidden rounded-2xl border border-border/60 bg-card shadow-2xl"
dir="rtl"
dir={dir}
> >
{/* هدر دیالوگ با پس‌زمینه و حاشیه متناسب */} {/* هدر دیالوگ با پس‌زمینه و حاشیه متناسب */}
<DialogHeader className="p-5 border-b border-border/40 bg-muted/20 flex flex-row items-center justify-between"> <DialogHeader className="p-5 border-b border-border/40 bg-muted/20 flex flex-row items-center justify-between">
@ -57,19 +59,19 @@ export function RejectFactorDialog({
</div> </div>
<div> <div>
<DialogTitle className="text-base font-black text-rose-600 dark:text-rose-400 flex items-center gap-1.5"> <DialogTitle className="text-base font-black text-rose-600 dark:text-rose-400 flex items-center gap-1.5">
<span>رد صورت‌حساب</span>
<span>{t('billing.rejectTitle')}</span>
<span dir="ltr" className="font-mono">#{factor.id}</span> <span dir="ltr" className="font-mono">#{factor.id}</span>
</DialogTitle> </DialogTitle>
<DialogDescription className="text-xs text-muted-foreground mt-0.5 flex items-center gap-2"> <DialogDescription className="text-xs text-muted-foreground mt-0.5 flex items-center gap-2">
<span> <span>
مشتری:{' '}
{t('billing.customer')}:{' '}
<strong className="font-medium text-foreground"> <strong className="font-medium text-foreground">
{factor.user_info?.fullname || factor.user_info?.email || 'نامشخص'}
{factor.user_info?.fullname || factor.user_info?.email || t('common.noData')}
</strong> </strong>
</span> </span>
<span className="text-border-soft">•</span> <span className="text-border-soft">•</span>
<span> <span>
مبلغ:{' '}
{t('billing.amount')}:{' '}
<strong dir="ltr" className="font-mono font-bold text-foreground"> <strong dir="ltr" className="font-mono font-bold text-foreground">
${Number(factor.amount).toLocaleString()} ${Number(factor.amount).toLocaleString()}
</strong> </strong>
@ -82,7 +84,7 @@ export function RejectFactorDialog({
type="button" type="button"
onClick={onClose} onClick={onClose}
className="size-8 rounded-xl flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors" className="size-8 rounded-xl flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
title="بستن"
title={t('common.close')}
> >
<Ic name="x" className="size-4" /> <Ic name="x" className="size-4" />
</button> </button>
@ -93,7 +95,7 @@ export function RejectFactorDialog({
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-bold text-foreground flex items-center gap-1.5"> <label className="text-xs font-bold text-foreground flex items-center gap-1.5">
<Ic name="alert" className="size-3.5 text-rose-500 shrink-0" /> <Ic name="alert" className="size-3.5 text-rose-500 shrink-0" />
<span>دلیل رد صورت‌حساب / فیش پرداختی:</span>
<span>{t('billing.rejectReason')}</span>
</label> </label>
<Textarea <Textarea
rows={4} rows={4}
@ -102,7 +104,7 @@ export function RejectFactorDialog({
setReason(e.target.value) setReason(e.target.value)
if (error) setError(null) if (error) setError(null)
}} }}
placeholder="مثال: فیش ناخوانا است، مبلغ واریزی مغایرت دارد، شماره رهگیری نامعتبر است..."
placeholder={t('billing.rejectReasonPlaceholder')}
className="text-xs leading-relaxed resize-none rounded-xl border-border/60 bg-muted/10 focus:border-rose-500/60 focus:ring-1 focus:ring-rose-500/30 p-3" className="text-xs leading-relaxed resize-none rounded-xl border-border/60 bg-muted/10 focus:border-rose-500/60 focus:ring-1 focus:ring-rose-500/30 p-3"
autoFocus autoFocus
/> />
@ -113,7 +115,7 @@ export function RejectFactorDialog({
</div> </div>
)} )}
<p className="text-[11px] text-muted-foreground leading-relaxed"> <p className="text-[11px] text-muted-foreground leading-relaxed">
این توضیحات در اپلیکیشن مسافر / کلاینت در بخش جزئیات صورت‌حساب نمایش داده می‌شود.
{t('billing.rejectNotice')}
</p> </p>
</div> </div>
</div> </div>
@ -127,7 +129,7 @@ export function RejectFactorDialog({
disabled={isSubmitting} disabled={isSubmitting}
className="rounded-xl h-9 text-xs px-4 border-border/60 hover:bg-muted/50" className="rounded-xl h-9 text-xs px-4 border-border/60 hover:bg-muted/50"
> >
انصراف
{t('common.cancel')}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
@ -137,11 +139,11 @@ export function RejectFactorDialog({
className="rounded-xl h-9 text-xs px-4 gap-1.5 bg-rose-600 hover:bg-rose-700 text-white font-semibold shadow-xs" className="rounded-xl h-9 text-xs px-4 gap-1.5 bg-rose-600 hover:bg-rose-700 text-white font-semibold shadow-xs"
> >
{isSubmitting ? ( {isSubmitting ? (
<span>در حال ثبت...</span>
<span>{t('billing.submitting')}</span>
) : ( ) : (
<> <>
<Ic name="xCircle" className="size-4" /> <Ic name="xCircle" className="size-4" />
<span>ثبت و رد فاکتور</span>
<span>{t('billing.submitReject')}</span>
</> </>
)} )}
</Button> </Button>

47
src/features/global-search/GlobalSearchDialog.tsx

@ -68,19 +68,40 @@ export default function GlobalSearchDialog() {
} }
if (item.children) { if (item.children) {
item.children.forEach((child) => { item.children.forEach((child) => {
entries.push(
<CommandItem
key={child.to}
onSelect={() => runAction(() => navigate(child.to))}
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-xs"
>
<Ic name={child.icon} className="size-4 text-primary" />
<span>{item.title} &gt; {child.title}</span>
{child.badge && (
<span className="ms-auto text-[10px] text-primary">{child.badge}</span>
)}
</CommandItem>
)
if (child.to) {
entries.push(
<CommandItem
key={child.to}
onSelect={() => runAction(() => navigate(child.to!))}
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-xs"
>
{child.icon && <Ic name={child.icon} className="size-4 text-primary" />}
<span>{item.title} &gt; {child.title}</span>
{child.badge && (
<span className="ms-auto text-[10px] text-primary">{child.badge}</span>
)}
</CommandItem>
)
}
if (child.children) {
child.children.forEach((grand) => {
if (grand.to) {
entries.push(
<CommandItem
key={grand.to}
onSelect={() => runAction(() => navigate(grand.to!))}
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-xs"
>
{grand.icon && <Ic name={grand.icon} className="size-4 text-primary" />}
<span>{item.title} &gt; {child.title} &gt; {grand.title}</span>
{grand.badge && (
<span className="ms-auto text-[10px] text-primary">{grand.badge}</span>
)}
</CommandItem>
)
}
})
}
}) })
} }
return entries return entries

402
src/features/services/ServicesPage.tsx

@ -1,5 +1,5 @@
import React, { useState, useEffect, useMemo } from 'react' import React, { useState, useEffect, useMemo } from 'react'
import { useParams, useNavigate, Link } from 'react-router-dom'
import { useParams, useNavigate, Link, Navigate } from 'react-router-dom'
import { Ic, IconName } from '@/icons' import { Ic, IconName } from '@/icons'
import { Card, CardContent } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@ -100,6 +100,20 @@ export const SERVICES_LIST: ServiceMeta[] = [
defaultCount: 6, defaultCount: 6,
badge: 'شهرها و پوشه‌ها', badge: 'شهرها و پوشه‌ها',
}, },
{
key: 'shrines',
title: 'اماکن زیارتی',
titleEn: 'Holy Shrines',
icon: 'shrine',
color: 'text-primary',
bgColor: 'bg-primary/10',
borderColor: 'border-primary/20',
description: 'بانک داینامیک حرم‌ها و اماکن متبرکه، ثبت کشور و شهر، مختصات جغرافیایی و پیوند ادعیه',
endpoint: '/trip-etiquette/shrines/',
statsLabel: 'مکان زیارتی ثبت‌شده',
defaultCount: 3,
badge: 'اماکن و حرم‌ها',
},
{ {
key: 'store', key: 'store',
title: 'فروشگاه معصومه', title: 'فروشگاه معصومه',
@ -160,29 +174,15 @@ export const SERVICES_LIST: ServiceMeta[] = [
export default function ServicesPage() { export default function ServicesPage() {
const { serviceKey } = useParams<{ serviceKey?: string }>() const { serviceKey } = useParams<{ serviceKey?: string }>()
const navigate = useNavigate()
const currentService = useMemo(() => { const currentService = useMemo(() => {
if (!serviceKey) return null if (!serviceKey) return null
return SERVICES_LIST.find((s) => s.key === serviceKey) || null return SERVICES_LIST.find((s) => s.key === serviceKey) || null
}, [serviceKey]) }, [serviceKey])
// فیلتر و جستجو در لیست سرویس‌ها
const [searchTerm, setSearchTerm] = useState('')
const [activeFilter, setActiveFilter] = useState<'ALL' | 'ACTIVE' | 'SPECIAL'>('ALL')
const filteredServices = useMemo(() => {
return SERVICES_LIST.filter((s) => {
if (searchTerm.trim()) {
const query = searchTerm.toLowerCase()
const matchTitle = s.title.toLowerCase().includes(query)
const matchEn = s.titleEn.toLowerCase().includes(query)
const matchDesc = s.description.toLowerCase().includes(query)
if (!matchTitle && !matchEn && !matchDesc) return false
}
return true
})
}, [searchTerm])
if (!serviceKey || !currentService) {
return <Navigate to="/services/simcard" replace />
}
return ( return (
<div className="space-y-6 pt-2 pb-14"> <div className="space-y-6 pt-2 pb-14">
@ -191,239 +191,32 @@ export default function ServicesPage() {
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary"> <div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Ic name="sparkles" className="size-5" />
<Ic name={currentService.icon} className="size-5" />
</div> </div>
<h1 className="text-xl font-black text-foreground"> <h1 className="text-xl font-black text-foreground">
{currentService ? currentService.title : 'مرکز خدمات سامانه عقیله'}
{currentService.title}
</h1> </h1>
<Badge <Badge
variant="outline" variant="outline"
className="border-primary/30 bg-primary/10 text-primary font-bold" className="border-primary/30 bg-primary/10 text-primary font-bold"
> >
{currentService ? `${currentService.titleEn}` : '۷ سرویس هوشمند اپلیکیشن'}
{currentService.titleEn}
</Badge> </Badge>
</div> </div>
<p className="text-xs text-grey-3"> <p className="text-xs text-grey-3">
{currentService
? currentService.description
: 'پایش، مدیریت و پیکربندی کلیه خدمات ارائه شده در اپلیکیشن موبایل زائرین عقیله'}
{currentService.description}
</p> </p>
</div> </div>
{/* دکمه‌های ناوبری سریع */}
<div className="flex flex-wrap items-center gap-2.5">
{currentService ? (
<Button
variant="outline"
size="sm"
onClick={() => navigate('/services')}
className="border-border-soft hover:bg-card/70 text-xs font-bold gap-1.5"
>
<Ic name="grid" className="size-3.5 text-primary" />
مشاهده همه خدمات
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={() => navigate('/tours')}
className="border-border-soft hover:bg-card/70 text-xs font-bold gap-1.5"
>
<Ic name="map" className="size-3.5 text-primary" />
مدیریت تورها
</Button>
)}
</div>
</div>
{/* نوارهای تب سریع بالا برای سوئیچ بین ۷ سرویس */}
<div className="flex items-center gap-2 overflow-x-auto pb-1 scrollbar-none">
<button
onClick={() => navigate('/services')}
className={`flex shrink-0 items-center gap-2 rounded-xl px-3.5 py-2 text-xs font-bold transition ${
!currentService
? 'bg-primary text-primary-foreground shadow-xs'
: 'border border-border-soft bg-card/40 text-grey-3 hover:bg-card hover:text-foreground'
}`}
>
<Ic name="grid" className="size-3.5" />
<span>مرکز خدمات (همه)</span>
</button>
{SERVICES_LIST.map((srv) => {
const isActive = currentService?.key === srv.key
return (
<button
key={srv.key}
onClick={() => navigate(`/services/${srv.key}`)}
className={`flex shrink-0 items-center gap-2 rounded-xl px-3.5 py-2 text-xs font-bold transition ${
isActive
? 'bg-primary text-primary-foreground shadow-xs'
: 'border border-border-soft bg-card/40 text-grey-3 hover:bg-card hover:text-foreground'
}`}
>
<Ic name={srv.icon} className={`size-3.5 ${isActive ? 'text-white' : srv.color}`} />
<span>{srv.title}</span>
</button>
)
})}
</div>
{/* اگر در نمای داشبورد کلی خدمات هستیم */}
{!currentService ? (
<ServicesOverviewHub services={filteredServices} searchTerm={searchTerm} setSearchTerm={setSearchTerm} />
) : (
/* اگر یک سرویس خاص انتخاب شده است */
<ServiceDetailRenderer service={currentService} />
)}
</div>
)
}
// ─────────────────────────────────────────────────────────────────────────────
// بخش ۱: هاب اصلی معرفی و وضعیت ۷ سرویس
// ─────────────────────────────────────────────────────────────────────────────
function ServicesOverviewHub({
services,
searchTerm,
setSearchTerm,
}: {
services: ServiceMeta[]
searchTerm: string
setSearchTerm: (s: string) => void
}) {
const navigate = useNavigate()
return (
<div className="space-y-6">
{/* کارت‌های آماری کلی خدمات */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3.5">
<Card className="border-border/50 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-muted-foreground font-medium">تعداد کل خدمات</p>
<p className="text-xl font-black mt-1 font-mono text-foreground">{toFa(SERVICES_LIST.length)}</p>
</div>
<div className="size-9 rounded-xl bg-primary/10 text-primary flex items-center justify-center">
<Ic name="sparkles" className="size-4" />
</div>
</CardContent>
</Card>
<Card className="border-emerald-500/20 bg-emerald-500/5 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium">وضعیت وب‌سرویس‌ها</p>
<p className="text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400">۱۰۰٪</p>
</div>
<div className="size-9 rounded-xl bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center justify-center">
<Ic name="checkCircle" className="size-4" />
</div>
</CardContent>
</Card>
<Card className="border-border/50 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-muted-foreground font-medium">سفارشات ثبت‌شده</p>
<p className="text-xl font-black mt-1 font-mono text-foreground">{toFa(148)}</p>
</div>
<div className="size-9 rounded-xl bg-muted/60 text-muted-foreground flex items-center justify-center">
<Ic name="receipt" className="size-4" />
</div>
</CardContent>
</Card>
<Card className="border-border/50 shadow-xs">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-muted-foreground font-medium">پوشش کشورها</p>
<p className="text-xl font-black mt-1 font-mono text-foreground">{toFa(4)}</p>
</div>
<div className="size-9 rounded-xl bg-muted/60 text-muted-foreground flex items-center justify-center">
<Ic name="globe" className="size-4" />
</div>
</CardContent>
</Card>
</div> </div>
{/* نوار جستجوی سرویس‌ها */}
<Card className="border-border/50 shadow-xs">
<CardContent className="p-4 flex flex-col sm:flex-row items-center justify-between gap-3">
<div className="text-xs font-bold text-foreground">
فهرست ماژول‌ها و خدمات هوشمند ({toFa(services.length)} مورد)
</div>
<div className="relative w-full sm:w-80">
<Ic name="search" className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="جستجوی سرویس، ماژول، کلمات کلیدی..."
className="h-9 pr-9 text-xs"
/>
</div>
</CardContent>
</Card>
{/* شبکه کارت‌های ۷ سرویس عقیله */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{services.map((srv) => {
return (
<Card
key={srv.key}
onClick={() => navigate(`/services/${srv.key}`)}
className="group relative flex flex-col justify-between overflow-hidden border-border/50 bg-card shadow-xs transition duration-200 hover:-translate-y-0.5 hover:border-primary/40 hover:bg-card/90 hover:shadow-md cursor-pointer"
>
<CardContent className="p-5 space-y-4">
<div className="flex items-start justify-between">
<div className="flex size-11 items-center justify-center rounded-xl bg-primary/10 text-primary border border-primary/20 shadow-xs transition group-hover:scale-105">
<Ic name={srv.icon} className="size-5" />
</div>
<Badge
variant="outline"
className="border-border/50 bg-muted/40 text-[11px] font-medium text-muted-foreground"
>
{srv.badge}
</Badge>
</div>
<div className="space-y-1.5">
<div className="flex items-baseline justify-between">
<h2 className="text-base font-black text-foreground group-hover:text-primary transition">
{srv.title}
</h2>
<span className="text-[11px] font-mono text-muted-foreground">{srv.titleEn}</span>
</div>
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed">
{srv.description}
</p>
</div>
{/* فوتر کارت سرویس */}
<div className="pt-3 border-t border-border/40 flex items-center justify-between text-xs">
<div className="flex items-center gap-1.5 text-muted-foreground">
<span className="font-bold text-foreground font-mono">{toFa(srv.defaultCount)}</span>
<span className="text-[11px]">{srv.statsLabel}</span>
</div>
<div className="flex items-center gap-1 text-primary text-xs font-bold group-hover:underline">
<span>مدیریت</span>
<Ic name="chevDown" className="-rotate-90 size-3" />
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
{/* رندرینگ محتوای اختصاصی سرویس مستقل */}
<ServiceDetailRenderer service={currentService} />
</div> </div>
) )
} }
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
// بخش ۲: رندرینگ محتوای اختصاصی هر سرویس بر اساس کلید آن
// رندرینگ محتوای اختصاصی هر سرویس بر اساس کلید آن
// ───────────────────────────────────────────────────────────────────────────── // ─────────────────────────────────────────────────────────────────────────────
function ServiceDetailRenderer({ service }: { service: ServiceMeta }) { function ServiceDetailRenderer({ service }: { service: ServiceMeta }) {
switch (service.key) { switch (service.key) {
@ -436,8 +229,9 @@ function ServiceDetailRenderer({ service }: { service: ServiceMeta }) {
case 'duas': case 'duas':
return <DuasServiceView /> return <DuasServiceView />
case 'categories': case 'categories':
case 'shrines':
return <CategoriesServiceView /> return <CategoriesServiceView />
case 'shrines':
return <ShrinesServiceView />
case 'trip-etiquette': case 'trip-etiquette':
return <DuasServiceView /> return <DuasServiceView />
case 'consultant': case 'consultant':
@ -478,32 +272,32 @@ function SimcardServiceView({ service }: { service: ServiceMeta }) {
<div className="space-y-6"> <div className="space-y-6">
{/* آمار سیم‌کارت */} {/* آمار سیم‌کارت */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<div className="rounded-2xl border border-amber-500/20 bg-amber-500/5 p-4">
<span className="text-xs text-amber-600 dark:text-amber-400 font-medium">سیم‌کارت‌های تحویل‌شده</span>
<div className="mt-2 text-2xl font-black text-amber-600 dark:text-amber-400 font-mono">{toFa(524)}</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">سیم‌کارت‌های تحویل‌شده</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">{toFa(524)}</div>
</div> </div>
<div className="rounded-2xl border border-sky-500/20 bg-sky-500/5 p-4">
<span className="text-xs text-sky-600 dark:text-sky-400 font-medium">اپراتورهای پشتیبانی‌شده</span>
<div className="mt-2 text-2xl font-black text-sky-600 dark:text-sky-400 font-mono">{toFa(5)}</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">اپراتورهای پشتیبانی‌شده</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">{toFa(5)}</div>
</div> </div>
<div className="rounded-2xl border border-emerald-500/20 bg-emerald-500/5 p-4">
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">سفارش‌های در انتظار فعال‌سازی</span>
<div className="mt-2 text-2xl font-black text-emerald-600 dark:text-emerald-400 font-mono">{toFa(12)}</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">سفارش‌های در انتظار فعال‌سازی</span>
<div className="mt-2 text-2xl font-black text-amber-500 font-mono tabular-nums">{toFa(12)}</div>
</div> </div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">میانگین زمان تحویل در مرز/فرودگاه</span> <span className="text-xs text-grey-3 font-medium">میانگین زمان تحویل در مرز/فرودگاه</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">۱۰ دقیقه</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">۱۰ دقیقه</div>
</div> </div>
</div> </div>
{/* ابزارها و فیلتر */} {/* ابزارها و فیلتر */}
<div className="flex flex-col gap-3 rounded-2xl border border-border-soft bg-card/40 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-col gap-3 rounded-2xl border border-border-soft bg-card/40 p-4 sm:flex-row sm:items-center sm:justify-between shadow-xs">
<div className="flex items-center gap-2 flex-wrap"> <div className="flex items-center gap-2 flex-wrap">
<Button <Button
size="sm" size="sm"
variant={operatorFilter === 'ALL' ? 'secondary' : 'ghost'} variant={operatorFilter === 'ALL' ? 'secondary' : 'ghost'}
onClick={() => setOperatorFilter('ALL')} onClick={() => setOperatorFilter('ALL')}
className="text-xs h-8"
className="text-xs h-9 rounded-xl font-bold"
> >
همه اپراتورها همه اپراتورها
</Button> </Button>
@ -511,7 +305,7 @@ function SimcardServiceView({ service }: { service: ServiceMeta }) {
size="sm" size="sm"
variant={operatorFilter === 'Zain' ? 'secondary' : 'ghost'} variant={operatorFilter === 'Zain' ? 'secondary' : 'ghost'}
onClick={() => setOperatorFilter('Zain')} onClick={() => setOperatorFilter('Zain')}
className="text-xs h-8"
className="text-xs h-9 rounded-xl font-bold"
> >
🇮🇶 زین (Zain) 🇮🇶 زین (Zain)
</Button> </Button>
@ -519,7 +313,7 @@ function SimcardServiceView({ service }: { service: ServiceMeta }) {
size="sm" size="sm"
variant={operatorFilter === 'Asiacell' ? 'secondary' : 'ghost'} variant={operatorFilter === 'Asiacell' ? 'secondary' : 'ghost'}
onClick={() => setOperatorFilter('Asiacell')} onClick={() => setOperatorFilter('Asiacell')}
className="text-xs h-8"
className="text-xs h-9 rounded-xl font-bold"
> >
🇮🇶 آسیاسل (Asiacell) 🇮🇶 آسیاسل (Asiacell)
</Button> </Button>
@ -527,19 +321,19 @@ function SimcardServiceView({ service }: { service: ServiceMeta }) {
size="sm" size="sm"
variant={operatorFilter === 'STC' ? 'secondary' : 'ghost'} variant={operatorFilter === 'STC' ? 'secondary' : 'ghost'}
onClick={() => setOperatorFilter('STC')} onClick={() => setOperatorFilter('STC')}
className="text-xs h-8"
className="text-xs h-9 rounded-xl font-bold"
> >
🇸🇦 STC سعودی 🇸🇦 STC سعودی
</Button> </Button>
</div> </div>
<div className="relative min-w-[200px]">
<Ic name="search" className="absolute right-3 top-1/2 -translate-y-1/2 size-3.5 text-grey-3" />
<div className="relative min-w-[220px]">
<Ic name="search" className="absolute start-3 top-1/2 -translate-y-1/2 size-3.5 text-grey-3" />
<Input <Input
value={searchTerm} value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)} onChange={(e) => setSearchTerm(e.target.value)}
placeholder="جستجوی بسته یا اپراتور..." placeholder="جستجوی بسته یا اپراتور..."
className="h-8 pr-8 text-xs bg-surface-base"
className="h-9 ps-9 text-xs bg-surface-base rounded-xl border-border-soft"
/> />
</div> </div>
</div> </div>
@ -570,10 +364,10 @@ function SimcardServiceView({ service }: { service: ServiceMeta }) {
</td> </td>
<td className="p-3.5 font-bold text-foreground">{item.title}</td> <td className="p-3.5 font-bold text-foreground">{item.title}</td>
<td className="p-3.5 text-grey-2">{item.duration}</td> <td className="p-3.5 text-grey-2">{item.duration}</td>
<td className="p-3.5 font-mono font-bold text-amber-600 dark:text-amber-400">{item.price}</td>
<td className="p-3.5 font-mono font-bold text-foreground tabular-nums">{item.price}</td>
<td className="p-3.5 text-grey-3">{item.stock}</td> <td className="p-3.5 text-grey-3">{item.stock}</td>
<td className="p-3.5"> <td className="p-3.5">
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 text-[10px]">
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 text-[10px] rounded-full">
فعال در اپلیکیشن فعال در اپلیکیشن
</Badge> </Badge>
</td> </td>
@ -612,12 +406,12 @@ function ExchangeServiceView({ service }: { service: ServiceMeta }) {
{/* جدول نرخ‌های لحظه‌ای ارز */} {/* جدول نرخ‌های لحظه‌ای ارز */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
{rates.map((r) => ( {rates.map((r) => (
<div key={r.pair} className="rounded-2xl border border-emerald-500/20 bg-emerald-500/5 p-4">
<div key={r.pair} className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-bold text-foreground font-mono">{r.pair}</span> <span className="text-xs font-bold text-foreground font-mono">{r.pair}</span>
<span className="text-[11px] text-emerald-600 dark:text-emerald-400 font-bold font-mono">{r.change}</span> <span className="text-[11px] text-emerald-600 dark:text-emerald-400 font-bold font-mono">{r.change}</span>
</div> </div>
<div className="mt-2 text-xl font-black text-emerald-600 dark:text-emerald-400 font-mono">{r.rate}</div>
<div className="mt-2 text-xl font-black text-foreground font-mono tabular-nums">{r.rate}</div>
<div className="mt-1 text-[11px] text-grey-3">{r.base}</div> <div className="mt-1 text-[11px] text-grey-3">{r.base}</div>
</div> </div>
))} ))}
@ -643,11 +437,11 @@ function ExchangeServiceView({ service }: { service: ServiceMeta }) {
<tr key={ord.id} className="hover:bg-card/70 transition"> <tr key={ord.id} className="hover:bg-card/70 transition">
<td className="p-3.5 font-mono font-bold text-grey-3">#{ord.id}</td> <td className="p-3.5 font-mono font-bold text-grey-3">#{ord.id}</td>
<td className="p-3.5 font-bold text-foreground">{ord.user}</td> <td className="p-3.5 font-bold text-foreground">{ord.user}</td>
<td className="p-3.5 text-grey-2">{ord.from}</td>
<td className="p-3.5 font-bold text-emerald-600 dark:text-emerald-400">{ord.to}</td>
<td className="p-3.5 text-grey-2 font-mono tabular-nums">{ord.from}</td>
<td className="p-3.5 font-bold text-foreground font-mono tabular-nums">{ord.to}</td>
<td className="p-3.5 text-grey-3">{ord.deliverType}</td> <td className="p-3.5 text-grey-3">{ord.deliverType}</td>
<td className="p-3.5"> <td className="p-3.5">
<Badge className="bg-primary/15 text-primary border border-primary/20 text-[10px]">
<Badge className="bg-primary/15 text-primary border border-primary/20 text-[10px] rounded-full">
{ord.status} {ord.status}
</Badge> </Badge>
</td> </td>
@ -676,29 +470,29 @@ function TripEtiquetteServiceView({ service }: { service: ServiceMeta }) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<div className="rounded-2xl border border-violet-500/20 bg-violet-500/5 p-4">
<span className="text-xs text-violet-600 dark:text-violet-400 font-medium">مجموع زیارات و ادعیه</span>
<div className="mt-2 text-2xl font-black text-violet-600 dark:text-violet-400 font-mono">{toFa(94)}</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">مجموع زیارات و ادعیه</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">{toFa(94)}</div>
</div> </div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">فایل‌های صوتی و صوتی‌متنی</span> <span className="text-xs text-grey-3 font-medium">فایل‌های صوتی و صوتی‌متنی</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">{toFa(86)}</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">{toFa(86)}</div>
</div> </div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">زبان‌های ترجمه</span> <span className="text-xs text-grey-3 font-medium">زبان‌های ترجمه</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">۵ زبان</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">۵ زبان</div>
</div> </div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">شهرهای تحت پوشش</span> <span className="text-xs text-grey-3 font-medium">شهرهای تحت پوشش</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">{toFa(6)}</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">{toFa(6)}</div>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
{rituals.map((r) => ( {rituals.map((r) => (
<div key={r.id} className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-3">
<div key={r.id} className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-3 shadow-xs">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<Badge variant="outline" className="text-[10px] text-violet-500 border-violet-500/30">{r.city}</Badge>
<Badge variant="outline" className="text-[10px] rounded-lg">{r.city}</Badge>
<div className="flex items-center gap-1 text-[11px] text-grey-3"> <div className="flex items-center gap-1 text-[11px] text-grey-3">
<Ic name="music" className="size-3 text-primary" /> <Ic name="music" className="size-3 text-primary" />
<span>همراه با صوت</span> <span>همراه با صوت</span>
@ -729,21 +523,21 @@ function ConsultantServiceView({ service }: { service: ServiceMeta }) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<div className="rounded-2xl border border-rose-500/20 bg-rose-500/5 p-4">
<span className="text-xs text-rose-600 dark:text-rose-400 font-medium">مشاوران شیفت جاری</span>
<div className="mt-2 text-2xl font-black text-rose-600 dark:text-rose-400 font-mono">{toFa(8)}</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">مشاوران شیفت جاری</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">{toFa(8)}</div>
</div> </div>
<div className="rounded-2xl border border-emerald-500/20 bg-emerald-500/5 p-4">
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">مشاوره‌های موفق امروز</span>
<div className="mt-2 text-2xl font-black text-emerald-600 dark:text-emerald-400 font-mono">{toFa(63)}</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">مشاوره‌های موفق امروز</span>
<div className="mt-2 text-2xl font-black text-emerald-600 dark:text-emerald-400 font-mono tabular-nums">{toFa(63)}</div>
</div> </div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">میانگین زمان انتظار زائر</span> <span className="text-xs text-grey-3 font-medium">میانگین زمان انتظار زائر</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">۴۵ ثانیه</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">۴۵ ثانیه</div>
</div> </div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">رضایت‌مندی زائرین</span> <span className="text-xs text-grey-3 font-medium">رضایت‌مندی زائرین</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">۹۸.۵٪</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">۹۸.۵٪</div>
</div> </div>
</div> </div>
@ -751,9 +545,9 @@ function ConsultantServiceView({ service }: { service: ServiceMeta }) {
<h3 className="text-sm font-black text-foreground">کادر مشاوران تخصصی عقیله</h3> <h3 className="text-sm font-black text-foreground">کادر مشاوران تخصصی عقیله</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{consultants.map((c) => ( {consultants.map((c) => (
<div key={c.id} className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-3">
<div key={c.id} className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-3 shadow-xs">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="size-10 rounded-full bg-rose-500/10 text-rose-500 flex items-center justify-center font-bold">
<div className="size-10 rounded-full bg-primary/10 text-primary flex items-center justify-center font-bold">
<Ic name="headset" className="size-5" /> <Ic name="headset" className="size-5" />
</div> </div>
<div> <div>
@ -762,10 +556,10 @@ function ConsultantServiceView({ service }: { service: ServiceMeta }) {
</div> </div>
</div> </div>
<div className="flex items-center justify-between text-xs pt-2 border-t border-border-soft"> <div className="flex items-center justify-between text-xs pt-2 border-t border-border-soft">
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 text-[10px]">
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 text-[10px] rounded-full">
{c.status} {c.status}
</Badge> </Badge>
<span className="text-grey-3 font-mono">{toFa(c.activeChats)} گفتگوی فعال</span>
<span className="text-grey-3 font-mono tabular-nums">{toFa(c.activeChats)} گفتگوی فعال</span>
</div> </div>
</div> </div>
))} ))}
@ -789,19 +583,19 @@ function StoreServiceView({ service }: { service: ServiceMeta }) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<div className="rounded-2xl border border-teal-500/20 bg-teal-500/5 p-4">
<span className="text-xs text-teal-600 dark:text-teal-400 font-medium">کالاهای فعال در فروشگاه</span>
<div className="mt-2 text-2xl font-black text-teal-600 dark:text-teal-400 font-mono">{toFa(48)}</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">کالاهای فعال در فروشگاه</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">{toFa(48)}</div>
</div> </div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">سفارش‌های در حال ارسال</span> <span className="text-xs text-grey-3 font-medium">سفارش‌های در حال ارسال</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">{toFa(19)}</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">{toFa(19)}</div>
</div> </div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">دسته‌بندی‌های فروشگاه</span> <span className="text-xs text-grey-3 font-medium">دسته‌بندی‌های فروشگاه</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">{toFa(6)}</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">{toFa(6)}</div>
</div> </div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">امکان ارسال به هتل‌های عراق</span> <span className="text-xs text-grey-3 font-medium">امکان ارسال به هتل‌های عراق</span>
<div className="mt-2 text-2xl font-black text-emerald-600 font-mono">فعال</div> <div className="mt-2 text-2xl font-black text-emerald-600 font-mono">فعال</div>
</div> </div>
@ -825,10 +619,10 @@ function StoreServiceView({ service }: { service: ServiceMeta }) {
<td className="p-3.5 font-mono font-bold text-grey-3">#{p.id}</td> <td className="p-3.5 font-mono font-bold text-grey-3">#{p.id}</td>
<td className="p-3.5 font-bold text-foreground">{p.title}</td> <td className="p-3.5 font-bold text-foreground">{p.title}</td>
<td className="p-3.5 text-grey-2">{p.category}</td> <td className="p-3.5 text-grey-2">{p.category}</td>
<td className="p-3.5 font-mono font-bold text-teal-600 dark:text-teal-400">{p.price}</td>
<td className="p-3.5 font-mono font-bold text-foreground tabular-nums">{p.price}</td>
<td className="p-3.5 text-grey-3">{toFa(p.stock)} عدد</td> <td className="p-3.5 text-grey-3">{toFa(p.stock)} عدد</td>
<td className="p-3.5"> <td className="p-3.5">
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 text-[10px]">
<Badge className="bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 border border-emerald-500/20 text-[10px] rounded-full">
{p.status} {p.status}
</Badge> </Badge>
</td> </td>
@ -855,19 +649,19 @@ function CostEstimateServiceView({ service }: { service: ServiceMeta }) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<div className="rounded-2xl border border-indigo-500/20 bg-indigo-500/5 p-4">
<span className="text-xs text-indigo-600 dark:text-indigo-400 font-medium">محاسبات انجام شده این ماه</span>
<div className="mt-2 text-2xl font-black text-indigo-600 dark:text-indigo-400 font-mono">{toFa('12.8K')}</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">محاسبات انجام شده این ماه</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">{toFa('12.8K')}</div>
</div> </div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">دقت میانگین برآورد هوشمند</span> <span className="text-xs text-grey-3 font-medium">دقت میانگین برآورد هوشمند</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">۹۵٪</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">۹۵٪</div>
</div> </div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">پکیج‌های پیش‌فرض اقتصادی و ویژه</span> <span className="text-xs text-grey-3 font-medium">پکیج‌های پیش‌فرض اقتصادی و ویژه</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">{toFa(8)}</div>
<div className="mt-2 text-2xl font-black text-foreground font-mono tabular-nums">{toFa(8)}</div>
</div> </div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 shadow-xs">
<span className="text-xs text-grey-3 font-medium">بروزرسانی نرخ‌ها</span> <span className="text-xs text-grey-3 font-medium">بروزرسانی نرخ‌ها</span>
<div className="mt-2 text-2xl font-black text-emerald-600 font-mono">امروز</div> <div className="mt-2 text-2xl font-black text-emerald-600 font-mono">امروز</div>
</div> </div>
@ -887,8 +681,8 @@ function CostEstimateServiceView({ service }: { service: ServiceMeta }) {
{estimateCategories.map((c) => ( {estimateCategories.map((c) => (
<tr key={c.id} className="hover:bg-card/70 transition"> <tr key={c.id} className="hover:bg-card/70 transition">
<td className="p-3.5 font-bold text-foreground">{c.title}</td> <td className="p-3.5 font-bold text-foreground">{c.title}</td>
<td className="p-3.5 font-mono text-grey-2">{c.standard}</td>
<td className="p-3.5 font-mono font-bold text-indigo-600 dark:text-indigo-400">{c.vip}</td>
<td className="p-3.5 font-mono text-grey-2 tabular-nums">{c.standard}</td>
<td className="p-3.5 font-mono font-bold text-foreground tabular-nums">{c.vip}</td>
<td className="p-3.5 text-grey-3">{c.notes}</td> <td className="p-3.5 text-grey-3">{c.notes}</td>
</tr> </tr>
))} ))}

4
src/features/services/components/CategoriesServiceView.tsx

@ -612,7 +612,7 @@ export function CategoriesServiceView() {
{/* ۴. دیالوگ ایجاد و ویرایش دسته‌بندی */} {/* ۴. دیالوگ ایجاد و ویرایش دسته‌بندی */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="sm:max-w-lg rounded-2xl bg-card border-border-soft max-h-[90vh] overflow-y-auto p-6" dir="rtl">
<DialogContent className="w-full max-w-full sm:max-w-xl md:max-w-2xl max-h-[92vh] flex flex-col p-6 rounded-t-2xl sm:rounded-2xl bg-card border-border-soft overflow-y-auto" dir="rtl">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-sm font-black text-foreground"> <DialogTitle className="text-sm font-black text-foreground">
{editingCategory ? 'ویرایش دسته‌بندی ادعیه' : 'افزودن دسته‌بندی جدید برای شهر'} {editingCategory ? 'ویرایش دسته‌بندی ادعیه' : 'افزودن دسته‌بندی جدید برای شهر'}
@ -781,7 +781,7 @@ export function CategoriesServiceView() {
{/* ۵. دیالوگ تخصیص سریع ادعیه به دسته‌بندی */} {/* ۵. دیالوگ تخصیص سریع ادعیه به دسته‌بندی */}
<Dialog open={Boolean(assigningCategory)} onOpenChange={(open) => !open && setAssigningCategory(null)}> <Dialog open={Boolean(assigningCategory)} onOpenChange={(open) => !open && setAssigningCategory(null)}>
<DialogContent className="sm:max-w-md rounded-2xl bg-card border-border-soft max-h-[90vh] overflow-y-auto p-6" dir="rtl">
<DialogContent className="w-full max-w-full sm:max-w-md max-h-[92vh] flex flex-col p-6 rounded-t-2xl sm:rounded-2xl bg-card border-border-soft overflow-y-auto" dir="rtl">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-sm font-black text-foreground"> <DialogTitle className="text-sm font-black text-foreground">
تخصیص ادعیه به «{assigningCategory?.name_display}» ({assigningCategory?.city_info?.name}) تخصیص ادعیه به «{assigningCategory?.name_display}» ({assigningCategory?.city_info?.name})

4
src/features/services/components/CreateLocationDialog.tsx

@ -171,8 +171,8 @@ export function CreateLocationDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl p-0 overflow-hidden max-h-[90vh] flex flex-col">
<DialogHeader className="px-6 pt-5 pb-4 border-b border-border/50 bg-secondary/15">
<DialogContent className="w-full max-w-full sm:max-w-xl md:max-w-2xl max-h-[92vh] flex flex-col p-0 rounded-t-2xl sm:rounded-2xl border-border-soft bg-card overflow-hidden">
<DialogHeader className="px-6 pt-5 pb-4 border-b border-border-soft bg-card/60">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary"> <div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Ic name={editingLocation ? 'pencil' : 'plus'} className="size-5" /> <Ic name={editingLocation ? 'pencil' : 'plus'} className="size-5" />

8
src/features/services/components/DuasServiceView.tsx

@ -312,9 +312,9 @@ export function DuasServiceView() {
<div className="space-y-6" dir="rtl"> <div className="space-y-6" dir="rtl">
{/* آمار خلاصه ادعیه (طراحی خنثی طبق استاندارد) */} {/* آمار خلاصه ادعیه (طراحی خنثی طبق استاندارد) */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<div className="rounded-2xl border border-primary/20 bg-primary/5 p-4">
<span className="text-xs text-primary font-medium">مجموع ادعیه و زیارات ثبت‌شده</span>
<div className="mt-2 text-2xl font-black text-primary font-mono">{toFa(duas.length)}</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">مجموع ادعیه و زیارات ثبت‌شده</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">{toFa(duas.length)}</div>
</div> </div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4"> <div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">فرازها و بندهای قرائت</span> <span className="text-xs text-grey-3 font-medium">فرازها و بندهای قرائت</span>
@ -614,7 +614,7 @@ export function DuasServiceView() {
{/* دیالوگ ایجاد و ویرایش دعا + تخصیص چندگانه حرم‌ها */} {/* دیالوگ ایجاد و ویرایش دعا + تخصیص چندگانه حرم‌ها */}
<Dialog open={isDuaDialogOpen} onOpenChange={setIsDuaDialogOpen}> <Dialog open={isDuaDialogOpen} onOpenChange={setIsDuaDialogOpen}>
<DialogContent className="sm:max-w-lg rounded-2xl bg-card border-border-soft max-h-[90vh] overflow-y-auto p-6" dir="rtl">
<DialogContent className="w-full max-w-full sm:max-w-xl md:max-w-2xl max-h-[92vh] flex flex-col p-6 rounded-t-2xl sm:rounded-2xl bg-card border-border-soft overflow-y-auto" dir="rtl">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-sm font-black text-foreground"> <DialogTitle className="text-sm font-black text-foreground">
{editingDua ? 'ویرایش دعا و تخصیص اماکن زیارتی' : 'ثبت دعای جدید و تخصیص چندگانه حرم‌ها'} {editingDua ? 'ویرایش دعا و تخصیص اماکن زیارتی' : 'ثبت دعای جدید و تخصیص چندگانه حرم‌ها'}

1368
src/features/services/components/FaqServiceView.tsx
File diff suppressed because it is too large
View File

2
src/features/services/components/ShrinesServiceView.tsx

@ -471,7 +471,7 @@ export function ShrinesServiceView() {
{/* دیالوگ ایجاد و ویرایش مکان زیارتی */} {/* دیالوگ ایجاد و ویرایش مکان زیارتی */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="sm:max-w-md rounded-2xl bg-card border-border-soft p-6" dir="rtl">
<DialogContent className="w-full max-w-full sm:max-w-md max-h-[92vh] flex flex-col p-6 rounded-t-2xl sm:rounded-2xl bg-card border-border-soft overflow-y-auto" dir="rtl">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-sm font-black text-foreground"> <DialogTitle className="text-sm font-black text-foreground">
{editingShrine ? 'ویرایش مکان زیارتی' : 'ثبت مکان زیارتی جدید (کاملاً داینامیک)'} {editingShrine ? 'ویرایش مکان زیارتی' : 'ثبت مکان زیارتی جدید (کاملاً داینامیک)'}

26
src/features/tours/OngoingToursPage.tsx

@ -310,14 +310,14 @@ export default function OngoingToursPage() {
{/* کارت‌های شاخص‌های کلیدی آماری (KPIs) */} {/* کارت‌های شاخص‌های کلیدی آماری (KPIs) */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3.5"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3.5">
<Card className="border-border/50 shadow-xs">
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div>
<p className="text-[11px] text-muted-foreground font-medium">کل تورهای در جریان</p> <p className="text-[11px] text-muted-foreground font-medium">کل تورهای در جریان</p>
{isLoading ? ( {isLoading ? (
<Skeleton className="mt-2 h-7 w-12 rounded-lg" /> <Skeleton className="mt-2 h-7 w-12 rounded-lg" />
) : ( ) : (
<p className="text-xl font-black mt-1 font-mono text-foreground">{stats.totalOngoing}</p>
<p className="text-xl font-black mt-1 font-mono text-foreground">{toFa(stats.totalOngoing)}</p>
)} )}
</div> </div>
<div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground"> <div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground">
@ -326,19 +326,19 @@ export default function OngoingToursPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-sky-500/20 bg-sky-500/5 shadow-xs">
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div>
<p className="text-[11px] text-sky-600 dark:text-sky-400 font-medium">هم‌اکنون در حال سفر</p>
<p className="text-[11px] text-muted-foreground font-medium">هم‌اکنون در حال سفر</p>
{isLoading ? ( {isLoading ? (
<Skeleton className="mt-2 h-7 w-12 rounded-lg" /> <Skeleton className="mt-2 h-7 w-12 rounded-lg" />
) : ( ) : (
<p className="text-xl font-black mt-1 font-mono text-sky-600 dark:text-sky-400">
{stats.currentlyTraveling}
<p className="text-xl font-black mt-1 font-mono text-sky-500">
{toFa(stats.currentlyTraveling)}
</p> </p>
)} )}
</div> </div>
<div className="size-9 rounded-xl bg-sky-500/15 text-sky-600 dark:text-sky-400 flex items-center justify-center">
<div className="size-9 rounded-xl bg-sky-500/10 text-sky-500 flex items-center justify-center">
<span className="relative flex size-2"> <span className="relative flex size-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span> <span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-sky-400 opacity-75"></span>
<span className="relative inline-flex rounded-full size-2 bg-sky-500"></span> <span className="relative inline-flex rounded-full size-2 bg-sky-500"></span>
@ -347,32 +347,32 @@ export default function OngoingToursPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-emerald-500/20 bg-emerald-500/5 shadow-xs">
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div>
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium">نزدیک به شروع (۲۴-۴۸ ساعت)</p>
<p className="text-[11px] text-muted-foreground font-medium">نزدیک به شروع (۲۴-۴۸ ساعت)</p>
{isLoading ? ( {isLoading ? (
<Skeleton className="mt-2 h-7 w-12 rounded-lg" /> <Skeleton className="mt-2 h-7 w-12 rounded-lg" />
) : ( ) : (
<p className="text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400"> <p className="text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400">
{stats.startingSoon}
{toFa(stats.startingSoon)}
</p> </p>
)} )}
</div> </div>
<div className="size-9 rounded-xl bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center justify-center">
<div className="size-9 rounded-xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 flex items-center justify-center">
<Ic name="clock" className="size-4" /> <Ic name="clock" className="size-4" />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-border/50 shadow-xs">
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div>
<p className="text-[11px] text-muted-foreground font-medium">مقاصد در حال میزبانی</p> <p className="text-[11px] text-muted-foreground font-medium">مقاصد در حال میزبانی</p>
{isLoading ? ( {isLoading ? (
<Skeleton className="mt-2 h-7 w-12 rounded-lg" /> <Skeleton className="mt-2 h-7 w-12 rounded-lg" />
) : ( ) : (
<p className="text-xl font-black mt-1 font-mono text-foreground">{stats.distinctCountries}</p>
<p className="text-xl font-black mt-1 font-mono text-foreground">{toFa(stats.distinctCountries)}</p>
)} )}
</div> </div>
<div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground"> <div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground">

34
src/features/tours/ToursPage.tsx

@ -220,14 +220,14 @@ export default function ToursPage() {
{/* کارت‌های شاخص‌های کلیدی آماری (KPI Cards) */} {/* کارت‌های شاخص‌های کلیدی آماری (KPI Cards) */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-3 lg:grid-cols-5"> <div className="grid grid-cols-2 gap-3.5 sm:grid-cols-3 lg:grid-cols-5">
<Card className="border-border/50 shadow-xs">
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div>
<p className="text-[11px] text-muted-foreground font-medium">کل تورها</p> <p className="text-[11px] text-muted-foreground font-medium">کل تورها</p>
{isLoading ? ( {isLoading ? (
<Skeleton className="mt-2 h-7 w-12 rounded-lg" /> <Skeleton className="mt-2 h-7 w-12 rounded-lg" />
) : ( ) : (
<p className="text-xl font-black mt-1 font-mono text-foreground">{stats.total}</p>
<p className="text-xl font-black mt-1 font-mono text-foreground">{toFa(stats.total)}</p>
)} )}
</div> </div>
<div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground"> <div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground">
@ -236,50 +236,50 @@ export default function ToursPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-emerald-500/20 bg-emerald-500/5 shadow-xs">
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div>
<p className="text-[11px] text-emerald-600 dark:text-emerald-400 font-medium">در دسترس</p>
<p className="text-[11px] text-muted-foreground font-medium">در دسترس</p>
{isLoading ? ( {isLoading ? (
<Skeleton className="mt-2 h-7 w-12 rounded-lg" /> <Skeleton className="mt-2 h-7 w-12 rounded-lg" />
) : ( ) : (
<p className="text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400"> <p className="text-xl font-black mt-1 font-mono text-emerald-600 dark:text-emerald-400">
{stats.available}
{toFa(stats.available)}
</p> </p>
)} )}
</div> </div>
<div className="size-9 rounded-xl bg-emerald-500/15 text-emerald-600 dark:text-emerald-400 flex items-center justify-center">
<div className="size-9 rounded-xl bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 flex items-center justify-center">
<Ic name="checkCircle" className="size-4" /> <Ic name="checkCircle" className="size-4" />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-rose-500/20 bg-rose-500/5 shadow-xs">
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div>
<p className="text-[11px] text-rose-600 dark:text-rose-400 font-medium">تکمیل ظرفیت</p>
<p className="text-[11px] text-muted-foreground font-medium">تکمیل ظرفیت</p>
{isLoading ? ( {isLoading ? (
<Skeleton className="mt-2 h-7 w-12 rounded-lg" /> <Skeleton className="mt-2 h-7 w-12 rounded-lg" />
) : ( ) : (
<p className="text-xl font-black mt-1 font-mono text-rose-600 dark:text-rose-400">
{stats.soldOut}
<p className="text-xl font-black mt-1 font-mono text-foreground">
{toFa(stats.soldOut)}
</p> </p>
)} )}
</div> </div>
<div className="size-9 rounded-xl bg-rose-500/15 text-rose-600 dark:text-rose-400 flex items-center justify-center">
<div className="size-9 rounded-xl bg-muted text-muted-foreground flex items-center justify-center">
<Ic name="xCircle" className="size-4" /> <Ic name="xCircle" className="size-4" />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-border/50 shadow-xs">
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div>
<p className="text-[11px] text-muted-foreground font-medium">در حال اجرا</p> <p className="text-[11px] text-muted-foreground font-medium">در حال اجرا</p>
{isLoading ? ( {isLoading ? (
<Skeleton className="mt-2 h-7 w-12 rounded-lg" /> <Skeleton className="mt-2 h-7 w-12 rounded-lg" />
) : ( ) : (
<p className="text-xl font-black mt-1 font-mono text-foreground">{stats.traveling}</p>
<p className="text-xl font-black mt-1 font-mono text-foreground">{toFa(stats.traveling)}</p>
)} )}
</div> </div>
<div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground"> <div className="size-9 rounded-xl bg-muted/60 flex items-center justify-center text-muted-foreground">
@ -288,19 +288,19 @@ export default function ToursPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-amber-500/20 bg-amber-500/5 shadow-xs col-span-2 sm:col-span-1">
<Card className="border-border-soft bg-card/40 shadow-xs col-span-2 sm:col-span-1">
<CardContent className="p-4 flex items-center justify-between"> <CardContent className="p-4 flex items-center justify-between">
<div> <div>
<p className="text-[11px] text-amber-600 dark:text-amber-400 font-medium">در انتظار شروع</p>
<p className="text-[11px] text-muted-foreground font-medium">در انتظار شروع</p>
{isLoading ? ( {isLoading ? (
<Skeleton className="mt-2 h-7 w-12 rounded-lg" /> <Skeleton className="mt-2 h-7 w-12 rounded-lg" />
) : ( ) : (
<p className="text-xl font-black mt-1 font-mono text-amber-600 dark:text-amber-400"> <p className="text-xl font-black mt-1 font-mono text-amber-600 dark:text-amber-400">
{stats.pending}
{toFa(stats.pending)}
</p> </p>
)} )}
</div> </div>
<div className="size-9 rounded-xl bg-amber-500/15 text-amber-600 dark:text-amber-400 flex items-center justify-center">
<div className="size-9 rounded-xl bg-amber-500/10 text-amber-600 dark:text-amber-400 flex items-center justify-center">
<Ic name="clock" className="size-4" /> <Ic name="clock" className="size-4" />
</div> </div>
</CardContent> </CardContent>

141
src/i18n/index.ts

@ -0,0 +1,141 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import fa from '@/locales/fa.json'
import ar from '@/locales/ar.json'
import en from '@/locales/en.json'
export type SupportedLanguage = 'fa' | 'ar' | 'en'
export type Direction = 'rtl' | 'ltr'
export interface LanguageOption {
code: SupportedLanguage
label: string
nativeName: string
dir: Direction
flag: string
}
export const SUPPORTED_LANGUAGES: LanguageOption[] = [
{ code: 'fa', label: 'فارسی (FA)', nativeName: 'فارسی', dir: 'rtl', flag: '🇮🇷' },
{ code: 'ar', label: 'العربية (AR)', nativeName: 'العربية', dir: 'rtl', flag: '🇸🇦' },
{ code: 'en', label: 'English (EN)', nativeName: 'English', dir: 'ltr', flag: '🇬🇧' },
]
const dictionaries: Record<SupportedLanguage, Record<string, any>> = {
fa,
ar,
en,
}
/**
* دریافت مقدار ترجمه بر اساس کلید نقطه‌ای (dot-notation)
* پشتیبانی از fallback به فارسی و پارامترهای درون‌متنی {key}
*/
export function translate(
lang: SupportedLanguage,
key: string,
params?: Record<string, string | number>
): string {
const getNestedValue = (obj: any, path: string): string | undefined => {
const keys = path.split('.')
let current = obj
for (const k of keys) {
if (current && typeof current === 'object' && k in current) {
current = current[k]
} else {
return undefined
}
}
return typeof current === 'string' ? current : undefined
}
// تلاش برای خواندن از زبان فعلی
let text = getNestedValue(dictionaries[lang], key)
// در صورت نبودن، تلاش برای خواندن از زبان فارسی (Fallback)
if (!text && lang !== 'fa') {
text = getNestedValue(dictionaries.fa, key)
}
// اگر کلاً پیدا نشد، خود کلید بازگردانده شود
if (!text) {
const lastPart = key.split('.').pop() || key
return lastPart
}
// جایگذاری پارامترها مانند {current} یا {total}
if (params) {
Object.entries(params).forEach(([paramKey, paramVal]) => {
text = text!.replace(new RegExp(`\\{${paramKey}\\}`, 'g'), String(paramVal))
})
}
return text
}
export function applyLanguageToDOM(lang: SupportedLanguage) {
if (typeof document !== 'undefined') {
const isLtr = lang === 'en'
document.documentElement.lang = lang
document.documentElement.dir = isLtr ? 'ltr' : 'rtl'
if (isLtr) {
document.documentElement.classList.add('lang-en')
document.documentElement.classList.remove('lang-rtl')
} else {
document.documentElement.classList.add('lang-rtl')
document.documentElement.classList.remove('lang-en')
}
}
}
interface LanguageState {
language: SupportedLanguage
setLanguage: (lang: SupportedLanguage) => void
}
export const useLanguageStore = create<LanguageState>()(
persist(
(set) => ({
language: 'fa',
setLanguage: (lang: SupportedLanguage) => {
set({ language: lang })
applyLanguageToDOM(lang)
},
}),
{
name: 'aqila_panel_language',
onRehydrateStorage: () => (state) => {
const lang = state?.language || 'fa'
applyLanguageToDOM(lang)
},
}
)
)
/**
* هوک سراسری ترجمه جهت استفاده آسان در تمامی کامپوننت‌های پنل
*/
export function useTranslation() {
const language = useLanguageStore((s) => s.language)
const setLanguage = useLanguageStore((s) => s.setLanguage)
const dir: Direction = language === 'en' ? 'ltr' : 'rtl'
const isRTL = dir === 'rtl'
const t = (key: string, params?: Record<string, string | number>): string => {
return translate(language, key, params)
}
const currentLanguageInfo =
SUPPORTED_LANGUAGES.find((l) => l.code === language) || SUPPORTED_LANGUAGES[0]
return {
t,
language,
setLanguage,
dir,
isRTL,
languages: SUPPORTED_LANGUAGES,
currentLanguageInfo,
}
}

4
src/index.css

@ -1,4 +1,5 @@
@import url('https://cdn.jsdelivr.net/gh/rastikerdar/[email protected]/Vazirmatn-font-face.css'); @import url('https://cdn.jsdelivr.net/gh/rastikerdar/[email protected]/Vazirmatn-font-face.css');
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500;600;700;800&display=swap');
@import 'tailwindcss'; @import 'tailwindcss';
/* ─── Dashboard Design Tokens → shadcn CSS variables (Dark) ─── */ /* ─── Dashboard Design Tokens → shadcn CSS variables (Dark) ─── */
@ -84,7 +85,8 @@ html.light {
} }
@theme inline { @theme inline {
--font-sans: 'Vazirmatn', system-ui, sans-serif;
--font-sans: 'Vazirmatn', 'Inter', system-ui, sans-serif;
--font-mono: 'JetBrains Mono', 'Inter', ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--color-background: var(--background); --color-background: var(--background);
--color-foreground: var(--foreground); --color-foreground: var(--foreground);
--color-card: var(--card); --color-card: var(--card);

133
src/locales/ar.json

@ -0,0 +1,133 @@
{
"navigation": {
"brand": "لوحة إدارة عقيلة",
"brandShort": "عقيلة",
"dashboard": "لوحة القيادة",
"dashboardTitle": "لوحة إدارة النظام",
"tours": "الجولات",
"allTours": "جميع الجولات",
"ongoingTours": "الجولات الجارية",
"tourOrders": "الطلبات والحجوزات",
"services": "مركز الخدمات (الكل)",
"simcard": "شرائح الاتصال (Simcard)",
"zayerGuide": "دليل الزائر (Zayer Guide)",
"exchange": "الصرافة الإلكترونية (Exchange)",
"duas": "الأدعية والزيارات (Duas)",
"duasList": "الأدعية والزيارات",
"categories": "التصنيفات",
"shrines": "المزارات والعتبات (Shrines)",
"store": "متجر معصومة (Store)",
"costEstimate": "تقدير التكاليف (Cost Estimate)",
"support": "الدعم والاستشارات (Support)",
"supporters": "فريق الدعم (Supporters)",
"faq": "الأسئلة الشائعة (FAQ)",
"faqCategories": "التصنيفات الموضوعية",
"faqQuestions": "الأسئلة والأجوبة",
"consultant": "الدعم والاستشارات (Support)",
"billing": "الفواتير والمالية",
"settings": "الإعدادات",
"logout": "تسجيل الخروج",
"soon": "قريباً",
"adminRole": "المدير العام للنظام"
},
"topbar": {
"serverOnline": "الخادم متصل",
"serverOffline": "انقطع الاتصال بالخادم",
"theme": "المظهر",
"language": "تغيير اللغة",
"selectLanguage": "اختر لغة النظام",
"search": "بحث..."
},
"common": {
"save": "حفظ التغييرات",
"cancel": "إلغاء",
"confirm": "تأكيد",
"delete": "حذف",
"edit": "تعديل",
"close": "إغلاق",
"back": "رجوع",
"refresh": "تحديث",
"loading": "جاري التحميل...",
"status": "الحالة",
"actions": "الإجراءات",
"all": "الكل",
"active": "نشط",
"inactive": "غير نشط",
"noData": "لا توجد بيانات للعرض.",
"pageOf": "صفحة {current} من {total}",
"success": "تمت العملية بنجاح.",
"error": "حدث خطأ في الاتصال بالخادم.",
"retry": "إعادة المحاولة",
"previous": "السابق",
"next": "التالي",
"showing": "عرض",
"to": "إلى",
"of": "من"
},
"billing": {
"title": "إدارة الفواتير والمدفوعات",
"description": "مراجعة إيصالات التحويل البنكي، واعتماد أو رفض المدفوعات ومتابعة تفاصيل فواتير خدمات عقيلة",
"totalInvoices": "إجمالي الفواتير",
"pendingReview": "بانتظار مراجعة الإيصال",
"awaitingPayment": "بانتظار دفع العميل",
"approvedPayments": "المدفوعات المعتمدة",
"rejectedOrExpired": "مرفوض / منتهي",
"searchPlaceholder": "البحث في الفواتير (اسم المستخدم، البريد، رقم البطاقة، رقم الفاتورة)...",
"statusFilter": "حالة الفاتورة",
"serviceFilter": "نوع الخدمة",
"clearFilters": "مسح الفلاتر",
"invoiceList": "قائمة الفواتير",
"customer": "العميل",
"amount": "المبلغ",
"service": "الخدمة",
"date": "تاريخ الإصدار",
"receipt": "إيصال الدفع",
"hasReceipt": "عرض الإيصال",
"noReceipt": "لا يوجد",
"approve": "اعتماد الدفع",
"reject": "رفض الفاتورة",
"rejectTitle": "رفض الفاتورة",
"rejectReason": "سبب رفض الفاتورة / الإيصال البنكي:",
"rejectReasonPlaceholder": "مثال: الإيصال غير واضح، المبلغ غير مطابق، رقم المعاملة غير صحيح...",
"rejectNotice": "سيتم عرض هذا التوضيح في تطبيق المسافر ضمن تفاصيل الفاتورة.",
"submitReject": "تسجيل ورفض الفاتورة",
"submitting": "جاري التسجيل...",
"statusPending": "قيد مراجعة الإيصال",
"statusAwaiting": "بانتظار الدفع",
"statusApproved": "تم الدفع والاعتماد",
"statusRejected": "مرفوض",
"statusExpired": "منتهي الصلاحية",
"invoice": "فاتورة",
"invoices": "فواتير",
"id": "المعرف",
"viewDetails": "التفاصيل",
"viewReceipt": "عرض إيصال الدفع",
"noReceiptUploaded": "لم يتم رفع إيصال",
"serviceTour": "جولات الزيارة",
"serviceSimcard": "شرائح الاتصال",
"serviceShop": "متجر الهدايا",
"serviceExchange": "الصرافة (تصريف)",
"serviceCustomTrip": "رحلة خاصة",
"allStatuses": "جميع الحالات",
"allServices": "جميع الخدمات",
"noFactorsFound": "لم يتم العثور على فواتير.",
"noFactorsFilter": "لم يتم العثور على نتائج مطابقة للفلاتر الحالية. يمكنك مسح الفلاتر.",
"noFactorsEmpty": "لم تصدر أي فواتير في النظام بعد.",
"confirmClearReceipt": "هل أنت متأكد من حذف صورة الإيصال المرفوعة لهذا الفاتورة؟",
"receiptClearedSuccess": "تم مسح إيصال الدفع بنجاح.",
"tabAll": "جميع المعاملات",
"tabTour": "طلبات وحجوزات الجولات",
"tabSimcard": "شرائح الاتصال",
"tabExchange": "الصرافة والعملات",
"tabShop": "متجر الهدايا",
"tabCustomTrip": "الرحلات الخاصة",
"coreTourBadge": "المحور الأساسي",
"liveStatus": "الحالة اللحظية",
"quickFilter": "فلترة الحالة"
},
"languages": {
"fa": "فارسی",
"ar": "العربية",
"en": "English"
}
}

133
src/locales/en.json

@ -0,0 +1,133 @@
{
"navigation": {
"brand": "Aqila Admin Panel",
"brandShort": "Aqila",
"dashboard": "Dashboard",
"dashboardTitle": "Admin Dashboard",
"tours": "Tours",
"allTours": "All Tours",
"ongoingTours": "Ongoing Tours",
"tourOrders": "Orders & Bookings",
"services": "Services Center",
"simcard": "SIM Card",
"zayerGuide": "Zayer Guide",
"exchange": "Online Exchange",
"duas": "Duas & Ziyarat",
"duasList": "Duas & Ziyarat",
"categories": "Categories",
"shrines": "Holy Shrines",
"store": "Massoumah Store",
"costEstimate": "Cost Estimate",
"support": "Support Center",
"supporters": "Support Staff",
"faq": "FAQ",
"faqCategories": "Subject Categories",
"faqQuestions": "Questions & Answers",
"consultant": "Support",
"billing": "Billing & Finance",
"settings": "Settings",
"logout": "Logout",
"soon": "Soon",
"adminRole": "System Administrator"
},
"topbar": {
"serverOnline": "Server is Online",
"serverOffline": "Server Disconnected",
"theme": "Theme",
"language": "Language",
"selectLanguage": "Select System Language",
"search": "Search..."
},
"common": {
"save": "Save Changes",
"cancel": "Cancel",
"confirm": "Confirm",
"delete": "Delete",
"edit": "Edit",
"close": "Close",
"back": "Back",
"refresh": "Refresh",
"loading": "Loading...",
"status": "Status",
"actions": "Actions",
"all": "All",
"active": "Active",
"inactive": "Inactive",
"noData": "No records found.",
"pageOf": "Page {current} of {total}",
"success": "Operation completed successfully.",
"error": "Server connection error.",
"retry": "Retry",
"previous": "Previous",
"next": "Next",
"showing": "Showing",
"to": "to",
"of": "of"
},
"billing": {
"title": "Invoices & Billing Management",
"description": "Review bank receipts, approve or reject payments, and track invoices across Aqila services",
"totalInvoices": "Total Invoices",
"pendingReview": "Pending Receipt Review",
"awaitingPayment": "Awaiting Customer Payment",
"approvedPayments": "Approved Payments",
"rejectedOrExpired": "Rejected / Expired",
"searchPlaceholder": "Search invoices (Customer name, email, card number, invoice ID)...",
"statusFilter": "Invoice Status",
"serviceFilter": "Service Type",
"clearFilters": "Clear Filters",
"invoiceList": "Invoice List",
"customer": "Customer",
"amount": "Amount",
"service": "Service",
"date": "Issue Date",
"receipt": "Payment Receipt",
"hasReceipt": "View Receipt",
"noReceipt": "None",
"approve": "Approve Payment",
"reject": "Reject Invoice",
"rejectTitle": "Reject Invoice",
"rejectReason": "Rejection Reason / Bank Receipt:",
"rejectReasonPlaceholder": "e.g., Unreadable receipt, amount mismatch, invalid transaction reference...",
"rejectNotice": "This explanation will be displayed to the traveler in their invoice details.",
"submitReject": "Submit & Reject Invoice",
"submitting": "Submitting...",
"statusPending": "Pending Receipt Review",
"statusAwaiting": "Awaiting Payment",
"statusApproved": "Payment Approved",
"statusRejected": "Rejected",
"statusExpired": "Expired",
"invoice": "Invoice",
"invoices": "Invoices",
"id": "ID",
"viewDetails": "Details",
"viewReceipt": "View Receipt",
"noReceiptUploaded": "No receipt uploaded",
"serviceTour": "Pilgrimage Tours",
"serviceSimcard": "SIM Card",
"serviceShop": "Massoumah Store",
"serviceExchange": "Exchange (Tasreef)",
"serviceCustomTrip": "Custom Trip",
"allStatuses": "All Statuses",
"allServices": "All Services",
"noFactorsFound": "No invoices found.",
"noFactorsFilter": "No records match current filters. You can clear filters.",
"noFactorsEmpty": "No invoices have been issued in the system yet.",
"confirmClearReceipt": "Are you sure you want to remove the uploaded receipt image?",
"receiptClearedSuccess": "Payment receipt cleared successfully.",
"tabAll": "All Transactions",
"tabTour": "Tour Orders & Bookings",
"tabSimcard": "SIM Cards",
"tabExchange": "Currency & Exchange",
"tabShop": "Souvenir Store",
"tabCustomTrip": "Custom Trips",
"coreTourBadge": "Primary Core",
"liveStatus": "Live Status",
"quickFilter": "Status Filter"
},
"languages": {
"fa": "فارسی",
"ar": "العربية",
"en": "English"
}
}

133
src/locales/fa.json

@ -0,0 +1,133 @@
{
"navigation": {
"brand": "پنل مدیریت عقیله",
"brandShort": "عقیله",
"dashboard": "داشبورد",
"dashboardTitle": "داشبورد مدیریت",
"tours": "تورها",
"allTours": "فهرست همه تورها",
"ongoingTours": "تورهای در حال برگزاری",
"tourOrders": "سفارشات و رزروها",
"services": "مرکز خدمات (همه)",
"simcard": "سیم‌کارت (Simcard)",
"zayerGuide": "راهنمای زائر (Zayer Guide)",
"exchange": "صرافی آنلاین (Exchange)",
"duas": "ادعیه و زیارات (Duas)",
"duasList": "ادعیه و زیارات",
"categories": "دسته‌بندی‌ها",
"shrines": "اماکن زیارتی (Shrines)",
"store": "فروشگاه معصومه (Store)",
"costEstimate": "تخمین هزینه (Cost Estimate)",
"support": "پشتیبانی (Support)",
"supporters": "پشتیبان‌ها (Supporters)",
"faq": "سوالات متداول (FAQ)",
"faqCategories": "دسته‌بندی‌های موضوعی",
"faqQuestions": "پرسش و پاسخ‌ها",
"consultant": "پشتیبانی (Support)",
"billing": "صورت‌حساب‌ها و مالی",
"settings": "تنظیمات",
"logout": "خروج از سیستم",
"soon": "به‌زودی",
"adminRole": "مدیر کل سیستم"
},
"topbar": {
"serverOnline": "سرور متصل است",
"serverOffline": "قطع ارتباط با سرور",
"theme": "تغییر تم",
"language": "تغییر زبان",
"selectLanguage": "انتخاب زبان سامانه",
"search": "جستجو..."
},
"common": {
"save": "ذخیره تغییرات",
"cancel": "انصراف",
"confirm": "تایید",
"delete": "حذف",
"edit": "ویرایش",
"close": "بستن",
"back": "بازگشت",
"refresh": "به‌روزرسانی",
"loading": "در حال بارگذاری...",
"status": "وضعیت",
"actions": "عملیات",
"all": "همه",
"active": "فعال",
"inactive": "غیرفعال",
"noData": "موردی برای نمایش یافت نشد.",
"pageOf": "صفحه {current} از {total}",
"success": "عملیات با موفقیت انجام شد.",
"error": "خطا در برقراری ارتباط با سرور.",
"retry": "تلاش مجدد",
"previous": "قبلی",
"next": "بعدی",
"showing": "نمایش",
"to": "تا",
"of": "از"
},
"billing": {
"title": "مدیریت صورت‌حساب‌ها و فاکتورها",
"description": "بررسی فیش‌های بانکی، تایید یا رد پرداخت‌ها و پیگیری ریز فاکتورهای ۵ سرویس سامانه عقیله",
"totalInvoices": "کل صورت‌حساب‌ها",
"pendingReview": "در انتظار بررسی فیش",
"awaitingPayment": "در انتظار پرداخت مشتری",
"approvedPayments": "پرداخت‌های تایید شده",
"rejectedOrExpired": "رد شده / منقضی",
"searchPlaceholder": "جستجو در فاکتورها (نام کاربر، ایمیل، شماره کارت، شماره فاکتور)...",
"statusFilter": "وضعیت فاکتور",
"serviceFilter": "نوع سرویس",
"clearFilters": "پاکسازی فیلترها",
"invoiceList": "فهرست فاکتورها",
"customer": "مشتری",
"amount": "مبلغ",
"service": "سرویس",
"date": "تاریخ صدور",
"receipt": "فیش پرداختی",
"hasReceipt": "مشاهده فیش",
"noReceipt": "ندارد",
"approve": "تایید پرداخت",
"reject": "رد صورت‌حساب",
"rejectTitle": "رد صورت‌حساب",
"rejectReason": "دلیل رد صورت‌حساب / فیش پرداختی:",
"rejectReasonPlaceholder": "مثال: فیش ناخوانا است، مبلغ واریزی مغایرت دارد، شماره رهگیری نامعتبر است...",
"rejectNotice": "این توضیحات در اپلیکیشن مسافر / کلاینت در بخش جزئیات صورت‌حساب نمایش داده می‌شود.",
"submitReject": "ثبت و رد فاکتور",
"submitting": "در حال ثبت...",
"statusPending": "در انتظار بررسی فیش",
"statusAwaiting": "در انتظار پرداخت",
"statusApproved": "پرداخت تایید شده",
"statusRejected": "رد شده",
"statusExpired": "منقضی شده",
"invoice": "فاکتور",
"invoices": "فاکتور",
"id": "شناسه",
"viewDetails": "جزئیات",
"viewReceipt": "مشاهده فیش واریزی",
"noReceiptUploaded": "فیش واریزی بارگذاری نشده",
"serviceTour": "تورهای زیارتی",
"serviceSimcard": "سیم‌کارت",
"serviceShop": "فروشگاه سوغات",
"serviceExchange": "صرافی (تسریف)",
"serviceCustomTrip": "سفر اختصاصی",
"allStatuses": "همه وضعیت‌ها",
"allServices": "همه سرویس‌ها",
"noFactorsFound": "صورت‌حسابی یافت نشد.",
"noFactorsFilter": "با فیلترهای جستجوی فعلی موردی پیدا نشد. می‌توانید فیلترها را پاکسازی نمایید.",
"noFactorsEmpty": "هنوز هیچ صورت‌حسابی در سیستم صادر نشده است.",
"confirmClearReceipt": "آیا از حذف تصویر فیش بارگذاری‌شده برای این صورت‌حساب اطمینان دارید؟",
"receiptClearedSuccess": "فیش واریزی با موفقیت پاک شد.",
"tabAll": "همه تراکنش‌ها",
"tabTour": "سفارشات و رزروهای تور",
"tabSimcard": "خرید سیم‌کارت",
"tabExchange": "صرافی و ارز",
"tabShop": "فروشگاه سوغات",
"tabCustomTrip": "سفر اختصاصی",
"coreTourBadge": "محور اصلی",
"liveStatus": "وضعیت لحظه‌ای",
"quickFilter": "فیلتر وضعیت"
},
"languages": {
"fa": "فارسی",
"ar": "العربية",
"en": "English"
}
}

2
src/main.tsx

@ -3,9 +3,11 @@ import { createRoot } from 'react-dom/client'
import Providers from '@/app/providers' import Providers from '@/app/providers'
import App from '@/app/App' import App from '@/app/App'
import { useThemeStore, applyTheme } from '@/stores/theme' import { useThemeStore, applyTheme } from '@/stores/theme'
import { useLanguageStore, applyLanguageToDOM } from '@/i18n'
import './index.css' import './index.css'
applyTheme(useThemeStore.getState().isDark) applyTheme(useThemeStore.getState().isDark)
applyLanguageToDOM(useLanguageStore.getState().language)
createRoot(document.getElementById('root')!).render( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>

39
src/stores/billing-badge.ts

@ -0,0 +1,39 @@
import { create } from 'zustand'
import { fetchFactors } from '@/features/billing/services/billing-api'
interface BillingBadgeState {
pendingReviewCount: number
awaitingPaymentCount: number
isLoading: boolean
lastFetched: number | null
fetchCounts: () => Promise<void>
setCounts: (pending: number, awaiting: number) => void
}
export const useBillingBadgeStore = create<BillingBadgeState>((set) => ({
pendingReviewCount: 0,
awaitingPaymentCount: 0,
isLoading: false,
lastFetched: null,
fetchCounts: async () => {
try {
set({ isLoading: true })
// دریافت تعداد فاکتورهای در انتظار بررسی فیش (Pending Review)
const pendingRes = await fetchFactors({ status: 'pending', limit: 1 })
// دریافت تعداد فاکتورهای در انتظار پرداخت کاربر (Awaiting Payment)
const awaitingRes = await fetchFactors({ status: 'awaiting_payment', limit: 1 })
set({
pendingReviewCount: pendingRes?.count || 0,
awaitingPaymentCount: awaitingRes?.count || 0,
isLoading: false,
lastFetched: Date.now(),
})
} catch {
set({ isLoading: false })
}
},
setCounts: (pending: number, awaiting: number) => {
set({ pendingReviewCount: pending, awaitingPaymentCount: awaiting })
},
}))
Loading…
Cancel
Save