Browse Source

feat: add custom trip filters, new service views, and update translation rules

master
parent
commit
84676aa106
  1. 7
      .agents/AGENTS.md
  2. 13
      .agents/rules/automatic_rule_propagation.md
  3. 19
      .agents/rules/translation_synchronization.md
  4. 7
      AGENTS.md
  5. 2
      index.html
  6. BIN
      public/aqila-logo-bronze.png
  7. BIN
      public/aqila-logo-white.png
  8. BIN
      public/aqila-logo.png
  9. 3
      public/aqila-logo.svg
  10. 67
      src/components/AqilaLogo.tsx
  11. 59
      src/components/layout/AppSidebar.tsx
  12. 4
      src/components/shared/UniversalDatePicker.tsx
  13. 7
      src/config/navigation.ts
  14. 8
      src/config/site.ts
  15. 35
      src/features/billing/components/BillingDetailModal.tsx
  16. 87
      src/features/custom-trip/CustomTripOrderDetailPage.tsx
  17. 19
      src/features/custom-trip/components/CustomTripOrderReviewDialog.tsx
  18. 151
      src/features/custom-trip/components/CustomTripOrdersTab.tsx
  19. 34
      src/features/services/ServicesPage.tsx
  20. 679
      src/features/services/components/BlogsServiceView.tsx
  21. 863
      src/features/services/components/SurahsServiceView.tsx
  22. 96
      src/features/services/services/blogs-api.ts
  23. 27
      src/features/services/services/trip-etiquette-api.ts
  24. 4
      src/features/settings/components/SocialMediaSettingsTab.tsx
  25. 2
      src/features/tours/OngoingToursPage.tsx
  26. 60
      src/features/tours/ToursPage.tsx
  27. 16
      src/features/tours/components/TourDetailDialog.tsx
  28. 22
      src/features/tours/components/TourDetailSheet.tsx
  29. 48
      src/features/tours/components/TourTranslationsDialog.tsx
  30. 4
      src/features/tours/types.ts
  31. 140
      src/lib/date-utils.ts
  32. 28
      src/lib/utils.ts
  33. 29
      src/locales/ar.json
  34. 27
      src/locales/en.json
  35. 43
      src/locales/fa.json
  36. 41
      src/stores/custom-trip-badge.ts

7
.agents/AGENTS.md

@ -8,14 +8,17 @@
- **ممنوعیت مطلق ایجاد فایل/پوشه خارج از سورس:** هیچ پوشه یا فایل ایجنتی تحت هیچ شرایطی نباید در بیرون از این پروژه یا در روت‌های بالادستی ایجاد شود. کلیه دستورالعمل‌ها، تنظیمات و قوانین ایجنت باید منحصراً در فایل `AGENTS.md` و پوشه `.agents/` درون همین مخزن سورس کد قرار گیرد. - **ممنوعیت مطلق ایجاد فایل/پوشه خارج از سورس:** هیچ پوشه یا فایل ایجنتی تحت هیچ شرایطی نباید در بیرون از این پروژه یا در روت‌های بالادستی ایجاد شود. کلیه دستورالعمل‌ها، تنظیمات و قوانین ایجنت باید منحصراً در فایل `AGENTS.md` و پوشه `.agents/` درون همین مخزن سورس کد قرار گیرد.
- **همکاری چندنفره و همگام‌سازی گیت:** با توجه به کار کردن چندین توسعه‌دهنده روی پروژه، تمام فایل‌های ایجنت باید درون مخزن گیت قرار داشته باشند تا با هر `git pull` روی تمام سیستم‌های تیم بدون نقص همگام شوند. - **همکاری چندنفره و همگام‌سازی گیت:** با توجه به کار کردن چندین توسعه‌دهنده روی پروژه، تمام فایل‌های ایجنت باید درون مخزن گیت قرار داشته باشند تا با هر `git pull` روی تمام سیستم‌های تیم بدون نقص همگام شوند.
- **اعتبار سراسری دستورات:** هر قانونی که برای سامانه عقیله یا رفتار ایجنت تعریف می‌شود، در تمامی پوشه‌ها و پروژه‌ها (بک‌اند، فرانت‌اند وب، اپ فلاتر، پنل مدیریت) صادق است و باید به طور مجزا و دقیق در این مخزن خوانده و اطاعت شود. - **اعتبار سراسری دستورات:** هر قانونی که برای سامانه عقیله یا رفتار ایجنت تعریف می‌شود، در تمامی پوشه‌ها و پروژه‌ها (بک‌اند، فرانت‌اند وب، اپ فلاتر، پنل مدیریت) صادق است و باید به طور مجزا و دقیق در این مخزن خوانده و اطاعت شود.
- **تشخیص و استقرار خودکار قوانین جدید در تمام بخش‌های لازم (Automatic Rule Propagation):** هر زمان قانون جدیدی تعریف، آموخته یا تصویب شود، ایجنت موظف است به‌صورت خودکار و بدون نیاز به تذکر کاربر تشخیص دهد کدام بخش‌های دیگر سامانه (فلاتر، فرانت‌اند وب، بک‌اند) به این قانون نیاز دارند و آن را متناسب با نیاز هر بخش فوراً در فایل‌های راهنمای همان پروژه مستقر کند (مطابق با `.agents/rules/automatic_rule_propagation.md`).
--- ---
## ۲. استانداردهای توسعه رابط کاربری و معماری ## ۲. استانداردهای توسعه رابط کاربری و معماری
- **فناوری و ساختار:** پروژه بر پایه Vite + React + TypeScript توسعه یافته است. - **فناوری و ساختار:** پروژه بر پایه Vite + React + TypeScript توسعه یافته است.
- **تایپوگرافی و جهت متن (RTL/LTR):** رعایت فونت‌های `Vazirmatn` و `Plus Jakarta Sans`، استفاده از `rtlUtils.ts` جهت تشخیص خودکار جهت متن ورودی کاربران و حفظ ثبات کرسر (`unicode-bidi: plaintext !important; text-align: start !important;`). - **تایپوگرافی و جهت متن (RTL/LTR):** رعایت فونت‌های `Vazirmatn` و `Plus Jakarta Sans`، استفاده از `rtlUtils.ts` جهت تشخیص خودکار جهت متن ورودی کاربران و حفظ ثبات کرسر (`unicode-bidi: plaintext !important; text-align: start !important;`).
- **چندزبانه بودن کامل پنل مدیریت (۳ زبان):** پنل مدیریت منحصراً از ۳ زبان (`fa` فارسی، `ar` عربی، `en` انگلیسی) در مسیر `src/locales/` استفاده می‌کند. تمام متون جدول‌ها، فیلدها، دکمه‌ها و نشان‌ها باید در هر ۳ فایل دیکشنری زبان (`fa.json`, `ar.json`, `en.json`) تعریف و همگام شوند.
- **قانون هماهنگی ترجمه:** هیچ تغییری نباید تک‌زبانه باشد؛ هر کلید جدید یا اصلاح‌شده در یکی از این ۳ زبان باید همزمان در دو زبان دیگر اعمال شود تا تست `locales.test.ts` کاملاً پاس شود و هیچ کلید ناقص یا نامتعادلی وجود نداشته باشد.
- **چندزبانه بودن کامل، صفر متن هاردکد و همگام‌سازی صددرصدی (۳ زبان):** پنل مدیریت منحصراً از ۳ زبان (`fa` فارسی، `ar` عربی، `en` انگلیسی) در مسیر `src/locales/` استفاده می‌کند.
- **ممنوعیت مطلق متن هاردکد:** تمام متون جدول‌ها، ستون‌ها، فیلدها، دکمه‌ها، نشان‌ها، پیام‌های مودال و نوتیفیکیشن‌ها باید منحصراً از طریق `useTranslation` فراخوانی شوند.
- **همگام‌سازی همزمان در هر ۳ زبان:** هر کلید جدید یا اصلاح‌شده در یکی از این ۳ زبان باید الزماً و همزمان در دو زبان دیگر (`fa.json`, `ar.json`, `en.json`) با ترجمه حرفه‌ای و بومی اعمال شود تا تست اعتبارسنجی `locales.test.ts` کاملاً پاس شود و هیچ کلید ناقص، خالی یا نامتقارنی باقی نماند.
- **تقارن کامل تغییرات کلمات (Word Modification Parity):** هر زمان کلمه‌ای در یک متن تغییر کند، کلماتی حذف شوند (مانند حذف لیست فرمت‌های مجاز یا کوتاه‌سازی برچسب‌ها) یا به متنی اضافه شوند، باید همان تغییر مفهومی و گرامری بلافاصله در هر ۳ فایل اعمال گردد و قرار دادن متن انگلیسی به عنوان موقت در زبان‌های فارسی یا عربی اکیداً ممنوع است (مطابق قانون اختصاصی `.agents/rules/translation_synchronization.md`).
--- ---

13
.agents/rules/automatic_rule_propagation.md

@ -0,0 +1,13 @@
# Automatic Cross-Project Rule Propagation Protocol (Admin Panel)
Whenever any new rule, standard, or requirement is introduced (via `/learn`, user directives, or bug resolutions):
1. **Proactive Relevance Assessment:**
- The agent MUST automatically determine whether the rule impacts the Admin Panel and/or other subprojects (Flutter, Frontend Web, Backend).
- Never wait for the user to manually ask to copy or add the rule to other folders.
2. **Tailored Admin Panel Implementation:**
- Adapt the rule for Vite + React + TypeScript architecture, 3 languages (`src/locales/fa.json`, `ar.json`, `en.json`), automated Vitest locale checks (`locales.test.ts`), and administrative table/form layouts.
3. **In-Source Persistence:**
- Save updates directly to `aqila_panel/AGENTS.md` and `aqila_panel/.agents/rules/` so the entire team receives them via Git.

19
.agents/rules/translation_synchronization.md

@ -0,0 +1,19 @@
# Multi-Language Translation Synchronization Rule - Aqila Admin Panel
Whenever adding, updating, or modifying any text, table header, button label, modal message, toast notification, or string in `aqila_panel`:
1. **Mandatory 100% Language Parity across 3 Locales:**
- Any new translation key or modified string MUST be updated across **ALL THREE** locale files simultaneously in `src/locales/`:
- `src/locales/fa.json` (Persian)
- `src/locales/ar.json` (Arabic)
- `src/locales/en.json` (English)
- Zero missing, extra, or empty keys (`fa.json`, `ar.json`, and `en.json` must have perfectly symmetrical key trees).
- All changes must pass the automated translation test (`src/locales/__tests__/locales.test.ts` via `scripts/check-translations.js`).
2. **Text Modification & Word Parity Consistency:**
- Whenever words are added, removed, or modified in any text (e.g. omitting file extensions, shortening labels, updating status messages), the exact same conceptual and grammatical change MUST be reflected across all 3 files.
- Do not leave raw English text or placeholder strings in Persian or Arabic files.
3. **Zero Hardcoded Text in UI Components:**
- No hardcoded strings in React components, Ant Design or Tailwind elements, table columns, action buttons, or notification toasts. All copy must be fetched via `useTranslation`.
- Ensure RTL/LTR alignment and smart bidirectional input handling via `rtlUtils.ts`.

7
AGENTS.md

@ -8,14 +8,17 @@
- **ممنوعیت مطلق ایجاد فایل/پوشه خارج از سورس:** هیچ پوشه یا فایل ایجنتی تحت هیچ شرایطی نباید در بیرون از این پروژه یا در روت‌های بالادستی ایجاد شود. کلیه دستورالعمل‌ها، تنظیمات و قوانین ایجنت باید منحصراً در فایل `AGENTS.md` و پوشه `.agents/` درون همین مخزن سورس کد قرار گیرد. - **ممنوعیت مطلق ایجاد فایل/پوشه خارج از سورس:** هیچ پوشه یا فایل ایجنتی تحت هیچ شرایطی نباید در بیرون از این پروژه یا در روت‌های بالادستی ایجاد شود. کلیه دستورالعمل‌ها، تنظیمات و قوانین ایجنت باید منحصراً در فایل `AGENTS.md` و پوشه `.agents/` درون همین مخزن سورس کد قرار گیرد.
- **همکاری چندنفره و همگام‌سازی گیت:** با توجه به کار کردن چندین توسعه‌دهنده روی پروژه، تمام فایل‌های ایجنت باید درون مخزن گیت قرار داشته باشند تا با هر `git pull` روی تمام سیستم‌های تیم بدون نقص همگام شوند. - **همکاری چندنفره و همگام‌سازی گیت:** با توجه به کار کردن چندین توسعه‌دهنده روی پروژه، تمام فایل‌های ایجنت باید درون مخزن گیت قرار داشته باشند تا با هر `git pull` روی تمام سیستم‌های تیم بدون نقص همگام شوند.
- **اعتبار سراسری دستورات:** هر قانونی که برای سامانه عقیله یا رفتار ایجنت تعریف می‌شود، در تمامی پوشه‌ها و پروژه‌ها (بک‌اند، فرانت‌اند وب، اپ فلاتر، پنل مدیریت) صادق است و باید به طور مجزا و دقیق در این مخزن خوانده و اطاعت شود. - **اعتبار سراسری دستورات:** هر قانونی که برای سامانه عقیله یا رفتار ایجنت تعریف می‌شود، در تمامی پوشه‌ها و پروژه‌ها (بک‌اند، فرانت‌اند وب، اپ فلاتر، پنل مدیریت) صادق است و باید به طور مجزا و دقیق در این مخزن خوانده و اطاعت شود.
- **تشخیص و استقرار خودکار قوانین جدید در تمام بخش‌های لازم (Automatic Rule Propagation):** هر زمان قانون جدیدی تعریف، آموخته یا تصویب شود، ایجنت موظف است به‌صورت خودکار و بدون نیاز به تذکر کاربر تشخیص دهد کدام بخش‌های دیگر سامانه (فلاتر، فرانت‌اند وب، بک‌اند) به این قانون نیاز دارند و آن را متناسب با نیاز هر بخش فوراً در فایل‌های راهنمای همان پروژه مستقر کند (مطابق با `.agents/rules/automatic_rule_propagation.md`).
--- ---
## ۲. استانداردهای توسعه رابط کاربری و معماری ## ۲. استانداردهای توسعه رابط کاربری و معماری
- **فناوری و ساختار:** پروژه بر پایه Vite + React + TypeScript توسعه یافته است. - **فناوری و ساختار:** پروژه بر پایه Vite + React + TypeScript توسعه یافته است.
- **تایپوگرافی و جهت متن (RTL/LTR):** رعایت فونت‌های `Vazirmatn` و `Plus Jakarta Sans`، استفاده از `rtlUtils.ts` جهت تشخیص خودکار جهت متن ورودی کاربران و حفظ ثبات کرسر (`unicode-bidi: plaintext !important; text-align: start !important;`). - **تایپوگرافی و جهت متن (RTL/LTR):** رعایت فونت‌های `Vazirmatn` و `Plus Jakarta Sans`، استفاده از `rtlUtils.ts` جهت تشخیص خودکار جهت متن ورودی کاربران و حفظ ثبات کرسر (`unicode-bidi: plaintext !important; text-align: start !important;`).
- **چندزبانه بودن کامل پنل مدیریت (۳ زبان):** پنل مدیریت منحصراً از ۳ زبان (`fa` فارسی، `ar` عربی، `en` انگلیسی) در مسیر `src/locales/` استفاده می‌کند. تمام متون جدول‌ها، فیلدها، دکمه‌ها و نشان‌ها باید در هر ۳ فایل دیکشنری زبان (`fa.json`, `ar.json`, `en.json`) تعریف و همگام شوند.
- **قانون هماهنگی ترجمه:** هیچ تغییری نباید تک‌زبانه باشد؛ هر کلید جدید یا اصلاح‌شده در یکی از این ۳ زبان باید همزمان در دو زبان دیگر اعمال شود تا تست `locales.test.ts` کاملاً پاس شود و هیچ کلید ناقص یا نامتعادلی وجود نداشته باشد.
- **چندزبانه بودن کامل، صفر متن هاردکد و همگام‌سازی صددرصدی (۳ زبان):** پنل مدیریت منحصراً از ۳ زبان (`fa` فارسی، `ar` عربی، `en` انگلیسی) در مسیر `src/locales/` استفاده می‌کند.
- **ممنوعیت مطلق متن هاردکد:** تمام متون جدول‌ها، ستون‌ها، فیلدها، دکمه‌ها، نشان‌ها، پیام‌های مودال و نوتیفیکیشن‌ها باید منحصراً از طریق `useTranslation` فراخوانی شوند.
- **همگام‌سازی همزمان در هر ۳ زبان:** هر کلید جدید یا اصلاح‌شده در یکی از این ۳ زبان باید الزماً و همزمان در دو زبان دیگر (`fa.json`, `ar.json`, `en.json`) با ترجمه حرفه‌ای و بومی اعمال شود تا تست اعتبارسنجی `locales.test.ts` کاملاً پاس شود و هیچ کلید ناقص، خالی یا نامتقارنی باقی نماند.
- **تقارن کامل تغییرات کلمات (Word Modification Parity):** هر زمان کلمه‌ای در یک متن تغییر کند، کلماتی حذف شوند (مانند حذف لیست فرمت‌های مجاز یا کوتاه‌سازی برچسب‌ها) یا به متنی اضافه شوند، باید همان تغییر مفهومی و گرامری بلافاصله در هر ۳ فایل اعمال گردد و قرار دادن متن انگلیسی به عنوان موقت در زبان‌های فارسی یا عربی اکیداً ممنوع است (مطابق قانون اختصاصی `.agents/rules/translation_synchronization.md`).
--- ---

2
index.html

@ -12,7 +12,7 @@
<meta name="darkreader" content="NO-DARK-READER" /> <meta name="darkreader" content="NO-DARK-READER" />
<meta name="color-scheme" content="light dark" /> <meta name="color-scheme" content="light dark" />
<meta name="supported-color-schemes" content="light dark" /> <meta name="supported-color-schemes" content="light dark" />
<title>پنل مدیریت عقیله</title>
<title>پنل مدیریت کاروان نصرت عقیله زینب (س)</title>
<!-- اطمینان از اعمال و رفرش آنی فاوآیکون در کش مرورگر --> <!-- اطمینان از اعمال و رفرش آنی فاوآیکون در کش مرورگر -->
<script> <script>
(function() { (function() {

BIN
public/aqila-logo-bronze.png

Before

Width: 256  |  Height: 335  |  Size: 39 KiB

After

Width: 1043  |  Height: 684  |  Size: 744 KiB

BIN
public/aqila-logo-white.png

Before

Width: 256  |  Height: 335  |  Size: 39 KiB

After

Width: 1043  |  Height: 684  |  Size: 744 KiB

BIN
public/aqila-logo.png

Before

Width: 256  |  Height: 335  |  Size: 54 KiB

After

Width: 1043  |  Height: 684  |  Size: 744 KiB

3
public/aqila-logo.svg
File diff suppressed because it is too large
View File

67
src/components/AqilaLogo.tsx

@ -5,7 +5,7 @@ interface AqilaLogoProps extends React.SVGProps<SVGSVGElement> {
} }
/** /**
* آیکون و نشان رسمی عقیله (برگرفته از اپلیکیشن فلاتر و هویت بصری سامانه)
* آیکون و نشان رسمی عقیله (نسخه جدید با خوشنویسی گنبد و رنگ‌های اصیل فیروزه‌ای و زرین)
*/ */
export function AqilaLogo({ export function AqilaLogo({
className = 'size-6', className = 'size-6',
@ -16,74 +16,13 @@ export function AqilaLogo({
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
viewBox="-91.3 -30.1 60 60"
fill="currentColor"
viewBox="0 0 1043 684"
width={width} width={width}
height={height} height={height}
className={className} className={className}
{...props} {...props}
> >
<g>
<g transform="matrix(1 0 0 1 -61.89 -5.77)" >
<path fill="currentColor" transform=" translate(-20.9, -22.17)" d="M 2.78484 39.2933 C 1.73484 38.0333 1.45484 37.2633 1.31484 35.0933 C 1.03484 30.2633 3.41484 27.3233 12.7248 21.0933 C 15.8748 18.9933 18.8848 16.8233 19.5148 16.1933 L 20.6348 15.1433 L 23.2948 17.3133 C 24.7648 18.5033 27.9148 20.7433 30.2248 22.3533 C 35.5448 25.9233 38.2748 28.5833 39.6748 31.5233 C 40.5848 33.5533 40.7248 34.0433 40.3748 37.0533 C 40.2348 38.8733 39.8148 41.2533 39.5348 42.3033 C 39.1848 43.3533 38.9748 44.3333 39.0448 44.3333 C 39.3248 44.6133 40.5848 41.1133 41.2148 38.4533 C 43.1048 30.8233 40.5848 26.4833 30.4348 19.6933 C 22.8748 14.5833 20.7748 12.0633 20.7748 11.5733 L 20.7748 0.0933333 C 20.7748 0.0933333 20.2148 -0.116667 20.2148 0.0933333 C 20.2148 1.56333 20.2148 0.653333 19.4448 1.14333 C 18.7448 1.63333 17.6948 2.19333 17.9748 0.863333 C 18.1848 -0.0466667 17.4148 -0.186667 16.2248 0.583334 C 15.5248 0.933334 14.4748 1.56333 14.1948 1.91333 C 12.9348 2.96333 10.8348 3.52333 9.29484 3.52333 C 7.75484 3.52333 7.75484 3.87333 8.45484 4.50333 C 9.15484 5.13333 10.6248 7.02333 14.8948 5.97333 C 15.3848 5.83333 15.5948 5.97333 15.5948 6.67333 C 15.5948 7.37333 15.7348 7.58333 17.2748 7.37333 C 18.1848 7.23333 19.8648 6.39333 20.2148 6.25333 C 20.3548 6.25333 20.5648 6.25333 20.4948 6.74333 C 20.4948 6.88333 20.4948 8.07333 20.4948 8.07333 L 20.4948 11.5733 C 20.4948 12.2033 17.0648 14.9333 11.2548 19.2733 C 2.92484 25.2933 0.474838 28.9333 0.0548377 34.7433 C -0.0851623 37.4733 -0.0151621 37.8933 0.964838 39.3633" strokeLinecap="round" />
</g>
<g transform="matrix(1 0 0 1 -53.84 -2.75)" >
<path fill="currentColor" transform=" translate(-28.95, -25.19)" d="M 26.7949 23.7532 C 25.6749 25.9932 25.6749 26.4832 26.4449 26.4832 C 27.2149 26.4832 27.6349 26.9732 28.3349 27.5332 L 29.6649 28.5832 L 30.6449 27.3932 C 31.7649 25.9932 32.1849 25.0832 31.9049 24.4532 C 31.6949 23.9632 28.6149 21.7932 28.0549 21.7932 C 27.4949 21.7932 27.2849 22.6332 26.7949 23.6832 L 26.7949 23.7532 Z" strokeLinecap="round" />
</g>
<g transform="matrix(1 0 0 1 -61.74 -0.27)" >
<path fill="currentColor" transform=" translate(-21.04, -27.67)" d="M 19.0948 25.7834 C 18.6048 26.7634 18.1848 27.8134 18.1848 28.0234 C 18.1848 28.2334 19.0248 29.0034 20.0748 29.5634 C 21.1248 30.1934 21.9648 30.8234 21.9648 31.1034 C 21.9648 31.8734 22.8748 30.9634 23.5048 29.5634 C 24.0648 28.3034 23.9948 27.9534 23.5048 26.9034 C 22.9448 25.8534 20.8448 23.9634 20.2148 23.9634 C 19.5848 23.9634 19.5848 24.7334 19.0948 25.7834 Z" strokeLinecap="round" />
</g>
<g transform="matrix(1 0 0 1 -62.01 -20.5)" >
<path fill="currentColor" transform=" translate(-20.77, -7.44)" d="M 20.7749 8.14341 C 21.2001 8.14341 21.5449 7.83001 21.5449 7.44341 C 21.5449 7.05681 21.2001 6.74341 20.7749 6.74341 C 20.3496 6.74341 20.0049 7.05681 20.0049 7.44341 C 20.0049 7.83001 20.3496 8.14341 20.7749 8.14341 Z" strokeLinecap="round" />
</g>
<g transform="matrix(1 0 0 1 -62.01 -18.12)" >
<path fill="currentColor" transform=" translate(-20.77, -9.82)" d="M 20.7749 10.8734 C 21.3548 10.8734 21.8249 10.4033 21.8249 9.82344 C 21.8249 9.24354 21.3548 8.77344 20.7749 8.77344 C 20.195 8.77344 19.7249 9.24354 19.7249 9.82344 C 19.7249 10.4033 20.195 10.8734 20.7749 10.8734 Z" strokeLinecap="round" />
</g>
<g transform="matrix(1 0 0 1 -53.38 10.83)" >
<path fill="currentColor" transform=" translate(-29.4, -38.77)" d="M 21.4049 43.3533 C 21.0549 43.8433 20.7749 44.2633 20.4949 44.6833 C 20.0049 45.5233 20.1449 46.3633 20.7049 47.1333 C 21.5449 48.1833 22.7349 48.6033 24.0649 48.7433 C 25.6749 48.8833 27.2149 48.4633 28.7549 47.9033 C 31.0649 47.0633 33.1649 45.8033 34.9849 44.1933 C 36.9449 42.5133 37.4349 40.6233 36.3149 37.6833 C 35.7549 36.2133 34.7749 35.0933 33.7249 33.9733 C 33.2349 33.4833 32.8149 32.9933 32.3249 32.5033 C 32.1849 32.3633 32.0449 32.1533 32.1849 31.9433 C 32.8849 30.8933 33.5149 29.8433 34.2149 28.7933 C 34.6349 28.2333 34.9849 28.2333 35.4049 28.7933 C 35.9649 29.4933 36.4549 30.1933 36.8749 30.9633 C 37.5049 32.0833 38.0649 33.2033 38.4849 34.3933 C 38.9749 35.6533 38.9749 37.0533 38.6249 38.3833 C 37.6449 42.0233 35.7549 45.1733 33.3049 48.0433 C 33.0249 48.3933 32.6049 48.6733 32.2549 48.9533 C 32.1149 49.0933 31.9049 49.1633 31.6949 49.1633 C 31.3449 49.1633 31.2049 49.0933 31.2749 48.7433 C 31.3449 48.3933 31.2049 48.2533 30.9249 48.1133 C 30.4349 47.9033 29.9449 48.0433 29.4549 48.1133 C 28.4049 48.3933 27.4249 48.7433 26.3749 48.8833 C 24.9749 49.0933 23.5749 49.0933 22.2449 48.6733 C 22.0349 48.6733 21.7549 48.5333 21.5449 48.4633 C 20.2849 47.9033 19.7249 46.9233 20.0049 45.5933 C 20.1449 44.6833 20.5649 43.9133 21.3349 43.2833 L 21.4049 43.3533 Z" strokeLinecap="round" />
</g>
<g transform="matrix(1 0 0 1 -50.28 24.78)" >
<path fill="currentColor" transform=" translate(-32.51, -52.72)" d="M 35.3348 55.8134 C 35.1248 54.4834 34.1448 53.9234 33.0948 53.4334 C 32.7448 53.2934 32.4648 53.3634 32.1848 53.5734 C 31.6948 54.0634 31.2048 54.6234 30.7148 55.1834 C 30.6448 55.2534 30.5748 55.3934 30.5048 55.4634 C 30.5048 55.3934 30.3648 55.3234 30.3648 55.2534 C 29.7348 54.2734 28.8248 53.7834 27.7048 53.5034 C 27.2148 53.3634 27.0748 53.2234 27.2148 52.6634 C 27.4248 51.6834 27.9848 50.9134 28.6148 50.2134 C 28.9648 49.8634 29.2448 49.7234 29.7348 49.9334 C 30.3648 50.2134 31.0648 50.4234 31.7648 50.7734 C 32.1148 50.9134 32.3948 51.2634 32.7448 51.5434 C 32.8148 51.4034 32.9548 51.1934 33.0948 50.9834 C 33.3748 50.6334 33.5848 50.2134 33.8648 49.8634 C 34.0748 49.5834 34.4248 49.4434 34.7748 49.6534 C 35.4048 50.0034 36.0348 50.2834 36.6648 50.6334 C 36.8748 50.7734 37.0148 50.9134 37.2248 51.1234 C 37.9248 51.8234 38.0648 52.5234 37.5048 53.3634 C 36.9448 54.2034 36.2448 54.9034 35.6148 55.7434 C 35.6148 55.7434 35.4748 55.8134 35.4048 55.8834 L 35.3348 55.8134 Z" strokeLinecap="round" />
</g>
<g transform="matrix(1 0 0 1 -70.52 22.38)" >
<path fill="currentColor" transform=" translate(-12.26, -50.32)" d="M 13.0749 53.2232 C 12.7949 52.3832 12.2349 52.0332 11.5349 51.8232 C 10.9749 51.6832 10.4149 51.5432 9.85489 51.4032 C 9.71489 51.4032 9.57489 51.3332 9.43489 51.2632 C 9.15489 51.1232 9.01489 50.9832 9.22489 50.7032 C 9.78489 49.7932 10.3449 48.8832 10.9049 47.9732 C 10.9749 47.8332 11.1149 47.6932 11.2549 47.6232 C 11.4649 47.4132 11.7449 47.3432 12.0249 47.4832 C 12.7949 47.8332 13.5649 48.1132 14.3349 48.4632 C 15.4549 49.0232 15.7349 50.2132 14.9649 51.1932 C 14.7549 51.4732 14.4749 51.7532 14.1949 52.0332 C 13.8449 52.3832 13.4949 52.8032 13.0749 53.2232 Z" strokeLinecap="round" />
</g>
<g transform="matrix(1 0 0 1 -70.83 5.53)" >
<path fill="currentColor" transform=" translate(-11.95, -33.47)" d="M 10.9048 33.6233 C 10.9048 33.8333 10.9748 34.0433 11.0448 34.1133 C 11.1148 34.2533 11.2548 34.2533 11.3948 34.3233 C 11.3948 34.1833 11.5348 34.0433 11.4648 33.9033 C 11.3248 33.2733 11.0448 32.6433 10.9748 32.0133 C 10.8348 31.1033 11.1848 30.4733 11.8848 30.2633 C 11.4648 31.1733 11.6048 31.6633 12.7248 32.7833 C 12.9348 32.5733 13.1448 32.4333 13.4248 32.2233 C 13.6348 32.0833 13.9148 31.8733 14.1948 31.6633 C 13.9148 31.6633 13.7048 31.6633 13.4948 31.5933 C 13.3548 31.0333 13.6348 30.3333 14.0548 30.2633 C 14.5448 30.1233 15.2448 30.4733 15.3148 30.8933 C 15.4548 31.5233 14.8948 32.2233 14.0548 32.3633 C 14.1948 32.7133 14.3348 33.0633 14.4748 33.4133 C 14.6148 33.7633 14.5448 34.1133 14.3348 34.4633 C 14.3348 34.4633 14.2648 34.5333 14.2648 34.6033 C 14.2648 34.8133 14.1948 35.0233 14.1948 35.3033 C 14.1948 35.3033 14.4048 35.3733 14.4048 35.3033 C 14.6148 35.1633 14.8948 34.9533 14.8948 34.7433 C 14.9648 34.3233 14.8948 33.8333 14.8948 33.4133 C 14.8948 32.9933 14.8948 32.5733 14.9648 32.1533 C 14.9648 32.1533 15.1048 32.1533 15.1748 32.1533 C 15.1748 32.2933 15.2448 32.5033 15.2448 32.6433 C 15.2448 33.2733 15.2448 33.9733 15.2448 34.6033 C 15.2448 35.3733 15.3848 35.5833 16.1548 35.5133 C 17.1348 35.5133 17.9748 35.1633 18.6048 34.4633 C 19.0248 34.0433 19.3748 33.5533 19.7948 33.0633 C 19.9348 32.9233 20.0748 32.7833 20.2148 32.6433 C 20.2148 32.6433 20.2848 32.6433 20.3548 32.7133 C 20.2848 32.9933 20.2148 33.3433 20.0748 33.4833 C 19.3048 34.3933 18.6048 35.3733 17.4848 35.7933 C 16.9948 36.0033 16.4348 36.0733 15.8748 36.1433 C 15.5948 36.1433 15.3148 36.0033 14.8948 35.8633 C 14.8948 35.8633 14.7548 36.0733 14.6148 36.1433 C 14.2648 36.4933 13.9148 36.4233 13.7748 35.9333 C 13.5648 35.4433 13.5648 34.8833 13.9148 34.4633 C 14.3348 33.9033 14.3348 33.4133 13.9148 32.8533 C 13.9148 32.7833 13.8448 32.6433 13.7748 32.5733 C 13.6348 32.6433 13.4948 32.7833 13.3548 32.8533 C 13.0748 33.0633 13.0048 33.2033 13.1448 33.5533 C 13.4248 34.4633 13.2148 35.3033 12.5848 36.0033 C 12.7248 36.0733 12.7948 36.2133 12.9348 36.2833 C 12.3748 35.9333 12.0248 36.2833 11.5348 36.4933 C 10.9048 36.7033 10.2748 36.8433 9.71481 36.4933 C 10.0648 36.4933 10.5548 36.6333 10.9748 36.4933 C 11.3948 36.4233 11.8148 36.1433 12.1648 35.9333 C 12.0248 35.5833 11.8848 35.3033 11.8148 35.0933 C 11.7448 34.8833 11.0448 34.7433 10.9048 34.8833 C 10.8348 34.8833 10.7648 35.0933 10.6948 35.0933 C 10.4148 35.0933 10.1348 35.2333 9.92481 35.1633 C 9.78481 35.0933 9.64481 34.7433 9.64481 34.5333 C 9.64481 34.1133 9.78481 33.6933 9.78481 33.2033 C 9.57481 33.4133 9.29481 33.6933 9.08481 33.9033 C 8.38481 34.6033 7.61481 35.1633 6.63481 35.5133 C 6.28481 35.6533 5.93481 35.7233 5.58481 35.7233 C 4.67481 35.7233 4.04481 35.3033 3.76481 34.4633 C 3.62481 33.9733 3.55481 33.4833 3.55481 32.9933 C 3.55481 31.6633 4.67481 30.8233 6.00481 30.9633 C 6.35481 30.9633 6.70481 31.0333 7.05481 31.1033 C 7.96481 31.1733 8.45481 31.1033 9.08481 30.5433 C 8.80481 31.1733 7.96481 31.5233 7.05481 31.4533 C 6.63481 31.4533 6.14481 31.3133 5.72481 31.3133 C 4.88481 31.2433 4.39481 31.6633 4.04481 32.3633 C 3.69481 32.9933 3.62481 33.6233 4.04481 34.2533 C 4.46481 34.8833 5.16481 35.0233 5.86481 34.9533 C 6.70481 34.8833 7.40481 34.3933 8.03481 33.9033 C 8.45481 33.5533 8.87481 33.1333 9.29481 32.7133 C 9.50481 32.5033 9.71481 32.2933 9.92481 32.1533 C 10.2048 31.9433 10.4148 32.0133 10.3448 32.3633 C 10.3448 32.7833 10.2048 33.1333 10.0648 33.5533 C 9.99481 33.8333 9.71481 34.2533 10.0648 34.4633 C 10.4148 34.6033 10.5548 34.1133 10.6948 33.8333 C 10.6948 33.8333 10.6948 33.6933 10.7648 33.5533 L 10.9048 33.6233 Z M 12.5148 33.7633 C 11.8848 34.5333 11.7448 35.3733 12.3048 35.7933 C 13.0048 35.2333 13.0748 34.6033 12.5148 33.7633 Z M 12.0248 33.6933 C 12.3748 33.2733 12.1648 32.9233 11.5348 32.5033 C 11.7448 32.9233 11.8848 33.3433 12.0248 33.6933 Z" strokeLinecap="round" />
</g>
<g transform="matrix(1 0 0 1 -76.11 5.05)" >
<path fill="currentColor" transform=" translate(-6.67, -32.99)" d="M 8.0348 32.7831 C 8.0348 32.5031 8.0348 32.3631 7.9648 32.1531 C 7.8248 32.1531 7.6848 32.1531 7.6848 32.2231 C 7.6848 32.4331 7.5448 32.6431 7.6848 32.8531 C 7.6848 32.9931 8.0348 33.0631 8.1748 33.0631 C 8.3848 33.0631 8.5248 32.8531 8.6648 32.7131 C 8.4548 33.4131 7.5448 33.6931 6.9848 33.3431 C 6.7748 33.3431 6.6348 33.4131 6.4948 33.4831 C 6.3548 33.4831 6.2148 33.6231 6.0748 33.6931 C 5.9348 33.8331 5.7948 33.9031 5.65481 34.0431 C 5.58481 33.9031 5.44481 33.7631 5.30481 33.5531 C 5.09481 33.5531 4.8848 33.5531 4.6748 33.5531 C 4.6748 33.3431 4.6748 33.0631 4.8148 32.8531 C 5.0248 32.5731 5.30481 32.3631 5.65481 32.1531 C 5.44481 32.3631 5.2348 32.5731 5.0248 32.7831 C 4.8148 32.9931 4.8148 33.2031 5.0948 33.3431 C 5.2348 33.0631 5.3748 32.8531 5.5148 32.7131 C 5.7248 32.9231 5.9348 33.1331 6.1448 33.4131 C 6.1448 33.3431 6.2848 33.2031 6.4248 33.0631 C 6.4248 33.2031 6.5648 33.2731 6.6348 33.3431 C 6.8448 32.8531 7.0548 32.4331 7.2648 31.9431 C 7.2648 31.9431 7.3348 31.9431 7.4048 31.9431 C 7.3348 32.1531 7.1948 32.3631 7.1248 32.5731 C 7.1248 32.7831 7.1248 32.9231 7.1948 33.1331 C 7.2648 32.8531 7.3348 32.5731 7.4048 32.2931 C 7.4048 32.1531 7.6148 32.0131 7.6848 32.0131 C 7.8948 32.0131 8.1048 32.0831 8.3148 32.1531 C 8.2448 32.3631 8.1048 32.5731 8.0348 32.8531 L 8.0348 32.7831 Z" strokeLinecap="round" />
</g>
<g transform="matrix(1 0 0 1 -65.44 6.09)" >
<path fill="currentColor" transform=" translate(-17.34, -34.03)" d="M 18.4648 33.4133 L 18.4648 33.8333 C 18.4648 33.8333 18.5348 33.9033 18.6048 33.9733 C 18.6048 33.8333 18.6748 33.6933 18.7448 33.5533 C 18.8148 33.6933 18.8848 33.7633 19.0248 33.9033 C 18.6748 33.7633 18.3948 34.3933 18.1848 33.8333 C 17.1348 34.5333 16.5048 34.7433 15.6648 34.6033 C 15.9448 34.6033 16.2948 34.5333 16.5748 34.4633 C 16.8548 34.3933 17.2048 34.2533 17.4848 34.0433 C 17.7648 33.9033 18.0448 33.6933 18.3948 33.4133 L 18.4648 33.4133 Z" strokeLinecap="round" />
</g>
<g transform="matrix(1 0 0 1 -72.68 3.55)" >
<path fill="currentColor" transform=" translate(-10.1, -31.49)" d="M 9.64481 31.0332 C 9.64481 31.2432 9.71481 31.4532 9.78481 31.5932 C 9.99481 31.5232 10.2048 31.4532 10.4148 31.3132 C 10.5548 31.3132 10.6948 31.2432 10.7648 31.2432 C 10.7648 31.4532 9.92481 32.0132 9.64481 31.9432 C 9.50481 31.9432 9.43481 31.6632 9.43481 31.5232 C 9.43481 31.3832 9.57481 31.1732 9.64481 31.0332 Z" strokeLinecap="round" />
</g>
<g transform="matrix(1 0 0 1 -64.88 4.91)" >
<path fill="currentColor" transform=" translate(-17.9, -32.85)" d="M 16.7148 33.5533 C 17.4848 33.0633 18.2548 32.6433 19.0248 32.1533 C 19.0248 32.1533 19.0248 32.1533 19.0948 32.1533 C 18.9548 32.2933 18.8848 32.4333 18.7448 32.5033 C 18.1148 32.8533 17.4148 33.2033 16.7148 33.5533 C 16.7148 33.5533 16.7148 33.5533 16.7148 33.4833 L 16.7148 33.5533 Z" strokeLinecap="round" />
</g>
<g transform="matrix(1 0 0 1 -69.95 2.32)" >
<path fill="currentColor" transform=" translate(-12.83, -30.26)" d="M 12.2349 30.8232 C 12.5149 30.6132 12.7949 30.4732 13.0049 30.3332 C 13.0049 30.1232 12.8649 29.9832 12.8649 29.8432 C 12.8649 29.7032 13.1449 29.7032 13.2849 29.5632 C 13.2849 29.9132 13.3549 30.1232 13.4249 30.3332 C 13.0749 30.5432 12.6549 30.7532 12.3049 30.9632 C 12.3049 30.9632 12.3049 30.9632 12.3049 30.8932 L 12.2349 30.8232 Z" strokeLinecap="round" />
</g>
<g transform="matrix(1 0 0 1 -73.84 7.22)" >
<path fill="currentColor" transform=" translate(-8.94, -35.16)" d="M 8.66485 35.1632 C 8.66485 35.1632 8.80485 35.1632 8.87485 35.1632 C 8.87485 35.0232 8.87485 34.9532 8.80485 34.8132 C 8.87485 34.8132 9.01485 34.8132 9.01485 34.8132 C 9.15485 34.9532 9.22485 35.1632 9.29485 35.3732 L 9.15485 35.5132 C 8.94485 35.5132 8.80485 35.4432 8.59485 35.3732 C 8.59485 35.3732 8.59485 35.3032 8.59485 35.2332 L 8.66485 35.1632 Z" strokeLinecap="round" />
</g>
<g transform="matrix(1 0 0 1 -65.26 10.65)" >
<path fill="currentColor" transform=" translate(-17.53, -38.59)" d="M 0.124878 37.6832 C 0.264878 38.5932 1.80488 40.5532 1.80488 40.5532 C 3.06488 42.0932 4.67488 43.1432 6.42488 44.0532 C 6.56488 44.0532 6.63488 44.1932 6.77488 44.1932 C 6.77488 44.1932 6.77488 44.1932 6.77488 44.2632 L 5.51488 43.9132 C 5.72488 44.1232 5.93488 44.1932 6.14488 44.2632 C 7.12488 44.6132 8.17488 44.6832 9.15488 44.6832 C 9.50488 44.6832 9.64488 44.9632 9.50488 45.3132 C 9.29488 45.8732 8.66488 46.2232 8.10488 46.2232 C 7.68488 46.2232 7.19488 46.2232 6.77488 46.2232 C 6.84488 46.2932 6.98488 46.3632 7.12488 46.3632 C 7.82488 46.3632 8.52488 46.3632 9.22488 46.3632 C 10.0649 46.3632 10.8349 46.3632 11.6749 46.2232 C 13.7749 45.8032 15.5949 44.8232 17.2749 43.5632 C 19.3749 42.0232 21.1249 40.2032 22.7349 38.1732 C 23.1549 37.6832 23.6449 37.2632 24.2049 36.9132 C 24.8349 36.4932 25.3949 36.7732 25.6049 37.4032 C 26.0249 38.5932 26.0949 39.7132 25.3249 40.7632 C 24.9049 41.3232 24.4849 41.9532 24.1349 42.5832 C 23.6449 43.4932 23.4349 44.4732 23.6449 45.5232 C 23.7849 46.4332 24.4149 46.8532 25.3249 46.6432 C 25.6749 46.5732 26.0249 46.4332 26.3049 46.2932 C 26.9349 45.9432 27.4949 45.5232 28.0549 45.1732 C 28.3349 44.9632 28.4749 44.9632 28.6849 45.2432 C 29.0349 45.6632 29.5249 45.8032 30.0149 45.8732 C 31.2049 46.0132 32.0449 45.3832 32.7449 44.6132 C 33.7949 43.4932 34.4249 42.1632 34.7749 40.6932 C 35.1249 39.2232 34.9149 37.8232 33.9349 36.5632 C 33.5849 36.0732 33.2349 35.5832 32.8849 35.1632 C 31.6949 33.6232 30.4349 32.0832 29.1749 30.5432 C 29.1749 30.4732 28.8949 30.4732 28.7549 30.5432 C 28.6149 30.5432 28.5449 30.7532 28.4749 30.8932 C 27.9149 31.9432 27.3549 32.9232 26.7949 33.9732 C 26.6549 34.1832 26.7249 34.3932 26.9349 34.5332 C 27.0049 34.5332 27.0749 34.6732 27.2149 34.8132 C 25.8149 34.3232 24.7649 34.8132 23.7849 35.6532 C 22.8049 36.4932 21.8949 37.3332 20.9149 38.1732 C 19.5149 39.3632 18.1149 40.4832 16.3649 41.1832 C 14.0549 42.0932 11.6749 42.7932 9.15488 42.7232 C 5.65488 42.7232 2.36488 39.0132 1.87488 37.8232 C 1.38488 36.6332 0.194878 37.6132 0.194878 37.6132 L 0.124878 37.6832 Z M 32.1849 42.3032 C 31.8349 42.4432 31.4849 42.6532 31.1349 42.3032 C 30.7849 42.0232 30.9949 41.6732 31.1349 41.3232 C 31.1349 41.1132 31.2049 40.9032 31.2049 40.6932 C 31.2049 40.5532 31.2049 40.4132 31.0649 40.4132 C 30.9949 40.4132 30.7849 40.4132 30.7149 40.5532 C 30.4349 40.8332 30.2249 41.1132 29.9449 41.4632 C 29.1749 42.4432 28.1949 42.9332 26.9349 42.7232 C 26.0249 42.5832 25.7449 42.0932 26.0249 41.1832 C 26.3049 40.3432 26.7949 39.6432 27.3549 39.0132 C 27.9149 38.3832 28.3349 37.6832 28.3349 36.8432 C 28.3349 36.6332 28.3349 36.3532 28.3349 36.0732 C 28.6149 36.2832 28.8949 36.4232 29.1749 36.5632 C 30.7849 37.6132 31.8349 39.0832 32.2549 40.9732 C 32.2549 41.2532 32.3949 41.5332 32.4649 41.8132 C 32.6049 42.0932 32.4649 42.1632 32.2549 42.2332 L 32.1849 42.3032 Z" strokeLinecap="round" />
</g>
<g transform="matrix(1 0 0 1 -63.97 -6.87)" >
<path fill="currentColor" transform=" translate(-18.81, -21.08)" d="M 16.6448 20.1134 C 16.6448 19.9034 16.7848 19.5534 16.9948 19.3434 C 17.2048 19.1334 17.6948 19.0634 17.9748 19.4834 C 18.1148 19.6934 18.2548 19.9034 18.3248 20.1834 C 18.4648 20.6034 18.6048 21.0234 18.6748 21.4434 C 18.6748 21.5834 18.6748 21.7234 18.7448 21.8634 C 18.7448 21.8634 18.7448 21.8634 18.8148 21.8634 C 18.8148 21.7934 18.8148 21.7234 18.8148 21.6534 C 18.8148 21.3734 18.8848 21.0234 18.9548 20.7434 C 18.9548 20.7434 18.9548 20.6034 18.9548 20.5334 C 19.0248 20.3234 19.0948 20.1834 19.1648 19.9734 C 19.1648 19.7634 19.2348 19.6234 19.3048 19.4134 C 19.4448 19.2734 19.5848 19.0634 19.7248 18.9934 C 20.0048 18.8534 20.2148 18.8534 20.4948 18.9934 C 20.7048 19.1334 20.8448 19.3434 20.9848 19.6234 C 20.9848 19.6234 20.9848 19.7634 20.9848 19.8334 C 20.8448 20.0434 20.7048 20.1834 20.6348 20.3934 C 20.5648 20.5334 20.4948 20.5334 20.4248 20.3934 C 20.2848 20.2534 20.2148 20.1134 20.0748 19.9734 C 20.0048 19.9034 19.9348 19.9034 19.8648 19.9734 C 19.8648 20.1134 19.7248 20.1834 19.7248 20.3234 C 19.5848 20.6734 19.5148 21.0934 19.4448 21.5134 C 19.4448 21.7234 19.3748 21.9334 19.3048 22.1434 C 19.3048 22.2834 19.2348 22.3534 19.1648 22.4934 C 18.9548 22.7734 18.7448 23.0534 18.4648 23.2634 C 18.4648 23.2634 18.4648 23.2634 18.3948 23.2634 C 18.3248 23.2634 18.3248 23.1934 18.3248 23.0534 C 18.3248 22.8434 18.3248 22.5634 18.3248 22.2834 C 18.3248 22.0734 18.2548 21.8634 18.1848 21.6534 C 18.0448 21.3734 17.9048 21.1634 17.7648 20.9534 C 17.7648 20.9534 17.7648 20.9534 17.6948 20.9534 C 17.6248 20.9534 17.4848 21.0934 17.4148 21.0934 C 17.4148 21.0934 17.3448 21.0934 17.2748 21.0934 C 17.0648 21.0234 16.8548 20.8834 16.7848 20.6734 C 16.7848 20.6034 16.7848 20.4634 16.7848 20.1834 L 16.6448 20.1134 Z" strokeLinecap="round" />
</g>
<g transform="matrix(1 0 0 1 -74.43 10.83)" >
<path fill="currentColor" transform=" translate(-8.35, -38.77)" d="M 6.63489 38.6634 C 6.63489 38.6634 6.77489 38.6634 6.84489 38.6634 C 6.98489 38.6634 7.12489 38.7334 7.19489 38.8034 C 7.40489 38.9434 7.68489 38.9434 7.89489 39.0134 C 8.10489 39.0134 8.24489 39.0134 8.45489 39.0134 C 8.59489 39.0134 8.66489 39.0134 8.73489 38.9434 C 8.87489 38.8734 8.94489 38.8034 9.08489 38.7334 C 9.22489 38.5934 9.22489 38.4534 9.22489 38.2434 C 9.22489 38.2434 9.22489 38.2434 9.22489 38.1734 C 9.22489 38.1034 9.22489 38.0334 9.36489 38.0334 C 9.50489 38.0334 9.64489 38.0334 9.71489 38.0334 C 9.99489 38.0334 10.0649 38.3134 10.0649 38.5234 C 10.0649 38.7334 10.0649 38.8734 9.92489 39.0834 C 9.71489 39.2934 9.43489 39.4334 9.08489 39.5034 C 8.73489 39.5034 8.38489 39.5034 8.03489 39.4334 C 7.82489 39.4334 7.61489 39.2934 7.47489 39.2234 C 7.26489 39.0834 7.05489 39.0134 6.84489 38.8734 C 6.77489 38.8734 6.70489 38.7334 6.70489 38.7334 L 6.63489 38.6634 Z" strokeLinecap="round" />
</g>
<g transform="matrix(1 0 0 1 -74.78 0.4)" >
<path fill="currentColor" transform=" translate(-8, -28.34)" d="M 6.28479 28.2333 C 6.28479 28.2333 6.42479 28.2333 6.49479 28.2333 C 6.63479 28.2333 6.77479 28.3033 6.84479 28.3733 C 7.05479 28.5133 7.33479 28.5133 7.54479 28.5833 C 7.75479 28.5833 7.89479 28.5833 8.10479 28.5833 C 8.24479 28.5833 8.31479 28.5833 8.38479 28.5133 C 8.52479 28.4433 8.59479 28.3733 8.73479 28.3033 C 8.87479 28.1633 8.87479 28.0233 8.87479 27.8133 C 8.87479 27.8133 8.87479 27.8133 8.87479 27.7433 C 8.87479 27.6733 8.87479 27.6033 9.01479 27.6033 C 9.15479 27.6033 9.29479 27.6033 9.36479 27.6033 C 9.64479 27.6033 9.71479 27.8833 9.71479 28.0933 C 9.71479 28.3033 9.71479 28.4433 9.57479 28.6533 C 9.36479 28.8633 9.08479 29.0033 8.73479 29.0733 C 8.38479 29.0733 8.03479 29.0733 7.68479 29.0033 C 7.47479 29.0033 7.26479 28.8633 7.12479 28.7933 C 6.91479 28.6533 6.70479 28.5833 6.49479 28.4433 C 6.42479 28.4433 6.35479 28.3033 6.35479 28.3033 L 6.28479 28.2333 Z" strokeLinecap="round" />
</g>
</g>
<image width="1043" height="684" href="/aqila-logo.png" />
</svg> </svg>
) )
} }

59
src/components/layout/AppSidebar.tsx

@ -20,6 +20,7 @@ import { siteConfig } from '@/config/site'
import { AqilaLogo } from '@/components/AqilaLogo' import { AqilaLogo } from '@/components/AqilaLogo'
import { useTranslation } from '@/i18n' import { useTranslation } from '@/i18n'
import { useBillingBadgeStore } from '@/stores/billing-badge' import { useBillingBadgeStore } from '@/stores/billing-badge'
import { useCustomTripBadgeStore } from '@/stores/custom-trip-badge'
import * as React from 'react' import * as React from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -40,11 +41,11 @@ function BillingSidebarBadges() {
return ( return (
<div className="flex items-center gap-1 ms-auto shrink-0"> <div className="flex items-center gap-1 ms-auto shrink-0">
{/* ۱. دایره نارنجی: در انتظار بررسی ادمین (فیش پرداخت توسط کاربر آپلود شده) */}
{/* ۱. دایره نارنجی: در انتظار تایید/بررسی ادمین */}
{pendingReviewCount > 0 && ( {pendingReviewCount > 0 && (
<span <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" 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"
title={`${t('billing.pendingReview')}: ${pendingReviewCount}`}
title={`${t('billing.statusPending')}: ${pendingReviewCount}`}
> >
{pendingReviewCount} {pendingReviewCount}
</span> </span>
@ -54,7 +55,44 @@ function BillingSidebarBadges() {
{awaitingPaymentCount > 0 && ( {awaitingPaymentCount > 0 && (
<span <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" 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}`}
title={`${t('billing.statusAwaiting')}: ${awaitingPaymentCount}`}
>
{awaitingPaymentCount}
</span>
)}
</div>
)
}
function CustomTripSidebarBadges() {
const { pendingCount, awaitingPaymentCount, fetchCounts } = useCustomTripBadgeStore()
const { t } = useTranslation()
React.useEffect(() => {
fetchCounts()
const interval = setInterval(fetchCounts, 30000)
return () => clearInterval(interval)
}, [fetchCounts])
if (pendingCount === 0 && awaitingPaymentCount === 0) return null
return (
<div className="flex items-center gap-1 ms-auto shrink-0">
{/* ۱. دایره نارنجی: سفارش‌های در انتظار تایید ادمین */}
{pendingCount > 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"
title={`${t('customTrip.pendingOrdersCount')}: ${pendingCount}`}
>
{pendingCount}
</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('customTrip.awaitingPaymentOrdersCount')}: ${awaitingPaymentCount}`}
> >
{awaitingPaymentCount} {awaitingPaymentCount}
</span> </span>
@ -176,6 +214,8 @@ function SubItem({ sub, pathname, dir }: { sub: NavSubItemConfig; pathname: stri
? pathname === sub.to ? pathname === sub.to
: Boolean(sub.to && (pathname === sub.to || pathname.startsWith(sub.to + '/'))) : Boolean(sub.to && (pathname === sub.to || pathname.startsWith(sub.to + '/')))
const isCustomTripOrders = sub.to === '/custom-trip/orders'
return ( return (
<NavLink <NavLink
to={sub.to || '#'} to={sub.to || '#'}
@ -188,10 +228,14 @@ function SubItem({ sub, pathname, dir }: { sub: NavSubItemConfig; pathname: stri
> >
{sub.icon && <Ic name={sub.icon} className="size-3.5 shrink-0" />} {sub.icon && <Ic name={sub.icon} className="size-3.5 shrink-0" />}
<span className="whitespace-nowrap">{sub.title}</span> <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>
{isCustomTripOrders ? (
<CustomTripSidebarBadges />
) : (
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> </NavLink>
) )
@ -229,6 +273,7 @@ function NavItem({ item }: { item: NavItemConfig }) {
<span className="whitespace-nowrap">{item.title}</span> <span className="whitespace-nowrap">{item.title}</span>
</div> </div>
<div className="flex items-center gap-1.5 ms-auto shrink-0"> <div className="flex items-center gap-1.5 ms-auto shrink-0">
{item.titleKey === 'navigation.customTrip' && !isOpen && <CustomTripSidebarBadges />}
<Tail badge={item.badge} soon={item.soon} /> <Tail badge={item.badge} soon={item.soon} />
{!isAlwaysOpen && ( {!isAlwaysOpen && (
<Ic <Ic

4
src/components/shared/UniversalDatePicker.tsx

@ -53,7 +53,7 @@ export function UniversalDatePicker({
required = false, required = false,
size = 'default', size = 'default',
clearable = true, clearable = true,
compact = true,
compact = false,
}: UniversalDatePickerProps) { }: UniversalDatePickerProps) {
const { language, dir, t } = useTranslation() const { language, dir, t } = useTranslation()
const [isOpen, setIsOpen] = useState(false) const [isOpen, setIsOpen] = useState(false)
@ -419,7 +419,7 @@ export function UniversalDatePicker({
: 'text-foreground hover:bg-muted/70', : 'text-foreground hover:bg-muted/70',
isDisabled && 'opacity-30 cursor-not-allowed pointer-events-none' isDisabled && 'opacity-30 cursor-not-allowed pointer-events-none'
)} )}
title={`${monthData.monthName} ${dayNum} ↔ ${secondary.calendarName}: ${secondary.formatted} ${secondary.monthName}`}
title={`${monthData.monthName} ${dayNum} ↔ ${secondary.calendarName}: ${secondary.monthName} ${secondary.formatted}`}
> >
<span className="text-xs font-bold font-mono leading-none"> <span className="text-xs font-bold font-mono leading-none">
{monthData.formatDayNumber(dayNum)} {monthData.formatDayNumber(dayNum)}

7
src/config/navigation.ts

@ -94,6 +94,12 @@ export function getNavigationConfig(t: (key: string) => string): NavGroupConfig[
to: '/services/duas', to: '/services/duas',
icon: 'book', icon: 'book',
}, },
{
title: t('navigation.surahs'),
titleKey: 'navigation.surahs',
to: '/services/surahs',
icon: 'book',
},
{ {
title: t('navigation.categories'), title: t('navigation.categories'),
titleKey: 'navigation.categories', titleKey: 'navigation.categories',
@ -108,6 +114,7 @@ export function getNavigationConfig(t: (key: string) => string): NavGroupConfig[
}, },
], ],
}, },
{ title: t('navigation.blogs'), titleKey: 'navigation.blogs', to: '/services/blogs', icon: 'file' },
{ {
title: t('navigation.store'), title: t('navigation.store'),
titleKey: 'navigation.store', titleKey: 'navigation.store',

8
src/config/site.ts

@ -8,10 +8,10 @@ export interface SiteConfig {
} }
export const siteConfig: SiteConfig = { export const siteConfig: SiteConfig = {
name: 'پنل مدیریت عقیله',
shortName: 'عقیله',
description: 'پنل مدیریت و داشبورد جامع سامانه عقیله',
name: 'پنل مدیریت کاروان نصرت عقیله زینب (س)',
shortName: 'کاروان نصرت عقیله زینب (س)',
description: 'پنل مدیریت و داشبورد جامع سامانه کاروان نصرت عقیله زینب (س)',
version: '1.0.0', version: '1.0.0',
logoText: 'عقیله',
logoText: 'کاروان نصرت عقیله زینب (س)',
defaultTheme: 'dark', defaultTheme: 'dark',
} }

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

@ -1,4 +1,5 @@
import React, { useState, useEffect, useRef } from 'react' import React, { useState, useEffect, useRef } from 'react'
import { useNavigate } from 'react-router-dom'
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@ -94,6 +95,7 @@ export function BillingDetailModal({
onClearReceipt, onClearReceipt,
isUpdating, isUpdating,
}: Props) { }: Props) {
const navigate = useNavigate()
const { t, language } = useTranslation() const { t, language } = useTranslation()
const [copiedCard, setCopiedCard] = useState(false) const [copiedCard, setCopiedCard] = useState(false)
const [currentFactor, setCurrentFactor] = useState<FactorItem | null>(factor) const [currentFactor, setCurrentFactor] = useState<FactorItem | null>(factor)
@ -882,6 +884,39 @@ export function BillingDetailModal({
</p> </p>
</div> </div>
)} )}
{/* دکمه انتقال مستقیم به صفحه مدیریت، بررسی هتل‌ها و تایید سفارش سفر اختصاصی */}
<div className="bg-amber-500/10 border border-amber-500/25 rounded-xl p-3 flex flex-col sm:flex-row items-start sm:items-center justify-between gap-2.5">
<div className="flex items-center gap-2">
<Ic name="compass" className="size-4 text-amber-600 dark:text-amber-400 shrink-0" />
<div className="text-xs">
<span className="font-bold text-foreground block">
مدیریت جزییات و استعلام قیمت سفر اختصاصی
</span>
<span className="text-[11px] text-muted-foreground">
تعیین قیمت هتل‌ها و پروازها، بررسی اطلاعات مسافران و تایید نهایی سفارش
</span>
</div>
</div>
<Button
type="button"
size="sm"
variant="outline"
className="text-xs h-8 gap-1.5 border-amber-500/40 text-amber-700 dark:text-amber-300 hover:bg-amber-500/15 shrink-0"
onClick={() => {
onClose()
const orderTarget = activeFactor.object_id || activeFactor.order_id
if (orderTarget) {
navigate(`/custom-trip/orders/${orderTarget}`)
} else {
navigate('/custom-trip/orders')
}
}}
>
<Ic name="externalLink" className="size-3.5" />
<span>مدیریت سفارش در سفر اختصاصی</span>
</Button>
</div>
</div> </div>
)} )}
</> </>

87
src/features/custom-trip/CustomTripOrderDetailPage.tsx

@ -7,7 +7,7 @@ import { Card } from '@/components/ui/card'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Shimmer, ShimmerBox, ShimmerLine } from '@/components/shared/shimmer_frontend' import { Shimmer, ShimmerBox, ShimmerLine } from '@/components/shared/shimmer_frontend'
import { Ic } from '@/icons' import { Ic } from '@/icons'
import { toFa } from '@/lib/utils'
import { toFa, toEn, formatAmount } from '@/lib/utils'
import { formatDualDate } from '@/lib/date-utils' import { formatDualDate } from '@/lib/date-utils'
import { UniversalDatePicker } from '@/components/shared/UniversalDatePicker' import { UniversalDatePicker } from '@/components/shared/UniversalDatePicker'
import { useTranslation } from '@/i18n' import { useTranslation } from '@/i18n'
@ -1507,7 +1507,7 @@ export default function CustomTripOrderDetailPage() {
)} )}
{cityHotels.map((h) => ( {cityHotels.map((h) => (
<option key={h.id} value={h.name}> <option key={h.id} value={h.name}>
🏨 {h.name} ({toFa(h.stars)}★ - ${h.pricePerDayAdult})
🏨 {h.name} ({toFa(h.stars)}★ - ${formatAmount(h.pricePerDayAdult)})
</option> </option>
))} ))}
</select> </select>
@ -1752,11 +1752,14 @@ export default function CustomTripOrderDetailPage() {
)} )}
</label> </label>
<Input <Input
type="number"
min={1}
type="text"
inputMode="numeric"
dir="ltr" dir="ltr"
value={editPrice > 0 ? editPrice : ''}
onChange={(e) => setEditPrice(Math.max(0, Number(e.target.value) || 0))}
value={editPrice > 0 ? formatAmount(editPrice) : ''}
onChange={(e) => {
const raw = toEn(e.target.value).replace(/[^0-9]/g, '')
setEditPrice(raw ? Number(raw) : 0)
}}
className={`h-10 rounded-xl font-mono font-bold text-start text-sm transition-colors ${ className={`h-10 rounded-xl font-mono font-bold text-start text-sm transition-colors ${
!isPriceValid !isPriceValid
? 'border-rose-500/60 focus:ring-rose-500/30 bg-rose-500/5' ? 'border-rose-500/60 focus:ring-rose-500/30 bg-rose-500/5'
@ -1833,39 +1836,49 @@ export default function CustomTripOrderDetailPage() {
)} )}
{/* Stage 2 Button */} {/* Stage 2 Button */}
<Button
type="button"
onClick={handleFinalizeAndTransferToBilling}
disabled={!isFormValid || isTransferringStage2 || isSavingStage1 || isRejecting}
className={`w-full rounded-xl text-xs font-bold h-10 bg-emerald-600 text-white hover:bg-emerald-700 shadow-xs gap-2 ${
!isFormValid ? 'opacity-50 cursor-not-allowed pointer-events-none' : 'cursor-pointer'
}`}
title={!isFormValid ? t('customTrip.requiredFieldsAlert') : undefined}
>
<Ic name="receipt" className="size-4" />
<span>
{isTransferringStage2
? t('customTrip.submitting')
: isAlreadyInvoiced
? t('customTrip.updateInvoiceAndItems')
: t('customTrip.approveAndTransferToBilling')}
</span>
</Button>
<div className="space-y-1">
<Button
type="button"
onClick={handleFinalizeAndTransferToBilling}
disabled={!isFormValid || isTransferringStage2 || isSavingStage1 || isRejecting}
className={`w-full rounded-xl text-xs font-bold h-10 bg-emerald-600 text-white hover:bg-emerald-700 shadow-xs gap-2 ${
!isFormValid ? 'opacity-50 cursor-not-allowed pointer-events-none' : 'cursor-pointer'
}`}
title={!isFormValid ? t('customTrip.requiredFieldsAlert') : undefined}
>
<Ic name="receipt" className="size-4" />
<span>
{isTransferringStage2
? t('customTrip.submitting')
: isAlreadyInvoiced
? t('customTrip.updateInvoiceAndItems')
: t('customTrip.approveAndTransferToBilling')}
</span>
</Button>
<p className="text-[10px] text-muted-foreground leading-tight px-1 text-center">
{t('customTrip.approveAndTransferToBillingHint')}
</p>
</div>
{/* Stage 1 Button */} {/* Stage 1 Button */}
<Button
type="button"
variant="outline"
onClick={handleSaveStage1Review}
disabled={!isFormValid || isSavingStage1 || isTransferringStage2 || isRejecting}
className={`w-full rounded-xl text-xs font-bold h-10 border-primary/40 text-primary hover:bg-primary/10 gap-2 ${
!isFormValid ? 'opacity-50 cursor-not-allowed pointer-events-none' : 'cursor-pointer'
}`}
title={!isFormValid ? t('customTrip.requiredFieldsAlert') : undefined}
>
<Ic name="save" className="size-4" />
<span>{isSavingStage1 ? t('customTrip.submitting') : t('customTrip.saveStage1Review')}</span>
</Button>
<div className="space-y-1">
<Button
type="button"
variant="outline"
onClick={handleSaveStage1Review}
disabled={!isFormValid || isSavingStage1 || isTransferringStage2 || isRejecting}
className={`w-full rounded-xl text-xs font-bold h-10 border-primary/40 text-primary hover:bg-primary/10 gap-2 ${
!isFormValid ? 'opacity-50 cursor-not-allowed pointer-events-none' : 'cursor-pointer'
}`}
title={!isFormValid ? t('customTrip.requiredFieldsAlert') : undefined}
>
<Ic name="save" className="size-4" />
<span>{isSavingStage1 ? t('customTrip.submitting') : t('customTrip.saveStage1Review')}</span>
</Button>
<p className="text-[10px] text-muted-foreground leading-tight px-1 text-center">
{t('customTrip.saveStage1ReviewHint')}
</p>
</div>
{/* Reject Button */} {/* Reject Button */}
{order.status !== 'REJECTED' && order.status !== 'APPROVED' && ( {order.status !== 'REJECTED' && order.status !== 'APPROVED' && (

19
src/features/custom-trip/components/CustomTripOrderReviewDialog.tsx

@ -13,7 +13,7 @@ import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Ic } from '@/icons' import { Ic } from '@/icons'
import { toFa } from '@/lib/utils'
import { toFa, toEn, formatAmount } from '@/lib/utils'
import { formatDualDate } from '@/lib/date-utils' import { formatDualDate } from '@/lib/date-utils'
import { UniversalDatePicker } from '@/components/shared/UniversalDatePicker' import { UniversalDatePicker } from '@/components/shared/UniversalDatePicker'
import { useTranslation } from '@/i18n' import { useTranslation } from '@/i18n'
@ -1381,7 +1381,7 @@ export function CustomTripOrderReviewDialog({
)} )}
{cityHotels.map((h) => ( {cityHotels.map((h) => (
<option key={h.id} value={h.name}> <option key={h.id} value={h.name}>
🏨 {h.name} ({toFa(h.stars)}★ - ${h.pricePerDayAdult})
🏨 {h.name} ({toFa(h.stars)}★ - ${formatAmount(h.pricePerDayAdult)})
</option> </option>
))} ))}
</select> </select>
@ -1560,11 +1560,14 @@ export function CustomTripOrderReviewDialog({
</label> </label>
<div className="max-w-md"> <div className="max-w-md">
<Input <Input
type="number"
min={1}
type="text"
inputMode="numeric"
dir="ltr" dir="ltr"
value={editPrice > 0 ? editPrice : ''}
onChange={(e) => setEditPrice(Math.max(0, Number(e.target.value) || 0))}
value={editPrice > 0 ? formatAmount(editPrice) : ''}
onChange={(e) => {
const raw = toEn(e.target.value).replace(/[^0-9]/g, '')
setEditPrice(raw ? Number(raw) : 0)
}}
className={`h-10 rounded-xl font-mono font-bold text-start text-sm transition-colors ${ className={`h-10 rounded-xl font-mono font-bold text-start text-sm transition-colors ${
!isPriceValid !isPriceValid
? 'border-rose-500/60 focus:ring-rose-500/30 bg-rose-500/5' ? 'border-rose-500/60 focus:ring-rose-500/30 bg-rose-500/5'
@ -1643,7 +1646,7 @@ export function CustomTripOrderReviewDialog({
className={`rounded-xl text-xs font-bold h-9.5 px-4.5 border-primary/40 text-primary hover:bg-primary/10 gap-1.5 ${ className={`rounded-xl text-xs font-bold h-9.5 px-4.5 border-primary/40 text-primary hover:bg-primary/10 gap-1.5 ${
!isFormValid ? 'opacity-50 cursor-not-allowed pointer-events-none' : 'cursor-pointer' !isFormValid ? 'opacity-50 cursor-not-allowed pointer-events-none' : 'cursor-pointer'
}`} }`}
title={!isFormValid ? t('customTrip.requiredFieldsAlert') : undefined}
title={!isFormValid ? t('customTrip.requiredFieldsAlert') : t('customTrip.saveStage1ReviewHint')}
> >
<Ic name="save" className="size-3.5" /> <Ic name="save" className="size-3.5" />
<span>{isSavingStage1 ? t('customTrip.submitting') : t('customTrip.saveStage1Review')}</span> <span>{isSavingStage1 ? t('customTrip.submitting') : t('customTrip.saveStage1Review')}</span>
@ -1657,7 +1660,7 @@ export function CustomTripOrderReviewDialog({
className={`rounded-xl text-xs font-bold h-9.5 px-5.5 bg-emerald-600 text-white hover:bg-emerald-700 shadow-xs gap-1.5 ${ className={`rounded-xl text-xs font-bold h-9.5 px-5.5 bg-emerald-600 text-white hover:bg-emerald-700 shadow-xs gap-1.5 ${
!isFormValid ? 'opacity-50 cursor-not-allowed pointer-events-none' : 'cursor-pointer' !isFormValid ? 'opacity-50 cursor-not-allowed pointer-events-none' : 'cursor-pointer'
}`} }`}
title={!isFormValid ? t('customTrip.requiredFieldsAlert') : undefined}
title={!isFormValid ? t('customTrip.requiredFieldsAlert') : t('customTrip.approveAndTransferToBillingHint')}
> >
<Ic name="receipt" className="size-4" /> <Ic name="receipt" className="size-4" />
<span> <span>

151
src/features/custom-trip/components/CustomTripOrdersTab.tsx

@ -58,18 +58,18 @@ export function CustomTripOrdersTab({
{t('customTrip.statusApproved')} {t('customTrip.statusApproved')}
</Badge> </Badge>
) )
case 'AWAITING_PAYMENT':
case 'PENDING':
return ( return (
<Badge className="gap-1.5 py-1 text-xs font-semibold bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20"> <Badge className="gap-1.5 py-1 text-xs font-semibold bg-amber-500/10 text-amber-600 dark:text-amber-400 border border-amber-500/20">
<Ic name="calculator" className="size-3.5" />
{t('customTrip.statusAwaitingPayment')}
<Ic name="clock" className="size-3.5" />
{t('customTrip.statusPending')}
</Badge> </Badge>
) )
case 'PENDING':
case 'AWAITING_PAYMENT':
return ( return (
<Badge className="gap-1.5 py-1 text-xs font-semibold bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20"> <Badge className="gap-1.5 py-1 text-xs font-semibold bg-blue-500/10 text-blue-600 dark:text-blue-400 border border-blue-500/20">
<Ic name="history" className="size-3.5" />
{t('customTrip.statusPending')}
<Ic name="creditCard" className="size-3.5" />
{t('customTrip.statusAwaitingPayment')}
</Badge> </Badge>
) )
case 'REJECTED': case 'REJECTED':
@ -126,7 +126,12 @@ export function CustomTripOrdersTab({
{/* KPI Stats */} {/* KPI Stats */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<Card className="p-3.5 border-border/70 shadow-xs">
<Card
onClick={() => setStatusFilter('all')}
className={`p-3.5 border-border/70 shadow-xs cursor-pointer transition-all hover:border-primary/50 ${
statusFilter === 'all' ? 'ring-2 ring-primary/40 bg-primary/5' : ''
}`}
>
<span className="text-[11px] text-muted-foreground font-medium block"> <span className="text-[11px] text-muted-foreground font-medium block">
{t('customTrip.allOrdersCount')} {t('customTrip.allOrdersCount')}
</span> </span>
@ -135,35 +140,59 @@ export function CustomTripOrdersTab({
<span className="text-xs text-muted-foreground">{t('customTrip.ordersUnit')}</span> <span className="text-xs text-muted-foreground">{t('customTrip.ordersUnit')}</span>
</div> </div>
</Card> </Card>
<Card className="p-3.5 border-border/70 shadow-xs">
<span className="text-[11px] text-muted-foreground font-medium block">
{t('customTrip.pendingOrdersCount')}
</span>
<Card
onClick={() => setStatusFilter('PENDING')}
className={`p-3.5 border-border/70 shadow-xs cursor-pointer transition-all hover:border-amber-500/50 ${
statusFilter === 'PENDING' ? 'ring-2 ring-amber-500/40 bg-amber-500/5' : ''
}`}
>
<div className="flex items-center justify-between">
<span className="text-[11px] text-amber-700 dark:text-amber-300 font-semibold block">
{t('customTrip.pendingOrdersCount')}
</span>
<span className="size-2 rounded-full bg-amber-500" />
</div>
<div className="mt-1 flex items-baseline gap-1.5"> <div className="mt-1 flex items-baseline gap-1.5">
<span className="text-xl font-black font-mono text-blue-600 dark:text-blue-400">
<span className="text-xl font-black font-mono text-amber-600 dark:text-amber-400">
{toFa(orders.filter((o) => o.status === 'PENDING').length)} {toFa(orders.filter((o) => o.status === 'PENDING').length)}
</span> </span>
<span className="text-xs text-muted-foreground">{t('customTrip.ordersUnit')}</span> <span className="text-xs text-muted-foreground">{t('customTrip.ordersUnit')}</span>
</div> </div>
</Card> </Card>
<Card className="p-3.5 border-border/70 shadow-xs">
<span className="text-[11px] text-muted-foreground font-medium block">
{t('customTrip.approvedOrdersCount')}
</span>
<Card
onClick={() => setStatusFilter('AWAITING_PAYMENT')}
className={`p-3.5 border-border/70 shadow-xs cursor-pointer transition-all hover:border-blue-500/50 ${
statusFilter === 'AWAITING_PAYMENT' ? 'ring-2 ring-blue-500/40 bg-blue-500/5' : ''
}`}
>
<div className="flex items-center justify-between">
<span className="text-[11px] text-blue-700 dark:text-blue-300 font-semibold block">
{t('customTrip.awaitingPaymentOrdersCount')}
</span>
<span className="size-2 rounded-full bg-blue-500" />
</div>
<div className="mt-1 flex items-baseline gap-1.5"> <div className="mt-1 flex items-baseline gap-1.5">
<span className="text-xl font-black font-mono text-emerald-600 dark:text-emerald-400">
{toFa(orders.filter((o) => o.status === 'APPROVED').length)}
<span className="text-xl font-black font-mono text-blue-600 dark:text-blue-400">
{toFa(orders.filter((o) => o.status === 'AWAITING_PAYMENT').length)}
</span> </span>
<span className="text-xs text-muted-foreground">{t('customTrip.ordersUnit')}</span> <span className="text-xs text-muted-foreground">{t('customTrip.ordersUnit')}</span>
</div> </div>
</Card> </Card>
<Card className="p-3.5 border-border/70 shadow-xs">
<span className="text-[11px] text-muted-foreground font-medium block">
{t('customTrip.awaitingPaymentOrdersCount')}
</span>
<Card
onClick={() => setStatusFilter('APPROVED')}
className={`p-3.5 border-border/70 shadow-xs cursor-pointer transition-all hover:border-emerald-500/50 ${
statusFilter === 'APPROVED' ? 'ring-2 ring-emerald-500/40 bg-emerald-500/5' : ''
}`}
>
<div className="flex items-center justify-between">
<span className="text-[11px] text-emerald-700 dark:text-emerald-300 font-semibold block">
{t('customTrip.approvedOrdersCount')}
</span>
<span className="size-2 rounded-full bg-emerald-500" />
</div>
<div className="mt-1 flex items-baseline gap-1.5"> <div className="mt-1 flex items-baseline gap-1.5">
<span className="text-xl font-black font-mono text-amber-600 dark:text-amber-400">
{toFa(orders.filter((o) => o.status === 'AWAITING_PAYMENT').length)}
<span className="text-xl font-black font-mono text-emerald-600 dark:text-emerald-400">
{toFa(orders.filter((o) => o.status === 'APPROVED').length)}
</span> </span>
<span className="text-xs text-muted-foreground">{t('customTrip.ordersUnit')}</span> <span className="text-xs text-muted-foreground">{t('customTrip.ordersUnit')}</span>
</div> </div>
@ -203,69 +232,83 @@ export function CustomTripOrdersTab({
onClick={() => setStatusFilter('PENDING')} onClick={() => setStatusFilter('PENDING')}
className={`flex items-center gap-1.5 px-3 py-1 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors cursor-pointer ${ className={`flex items-center gap-1.5 px-3 py-1 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors cursor-pointer ${
statusFilter === 'PENDING' statusFilter === 'PENDING'
? 'bg-primary text-white shadow-xs'
? 'bg-amber-600 text-white shadow-xs'
: 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground' : 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground'
}`} }`}
> >
<span className="size-2 rounded-full bg-blue-500" />
<span className="size-2 rounded-full bg-amber-500" />
<span>{t('customTrip.statusPending')}</span> <span>{t('customTrip.statusPending')}</span>
<span className="opacity-75 font-mono text-[10px]">
<span className="opacity-85 font-mono text-[10px]">
({toFa(orders.filter((o) => o.status === 'PENDING').length)}) ({toFa(orders.filter((o) => o.status === 'PENDING').length)})
</span> </span>
</button> </button>
<button <button
onClick={() => setStatusFilter('APPROVED')}
onClick={() => setStatusFilter('AWAITING_PAYMENT')}
className={`flex items-center gap-1.5 px-3 py-1 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors cursor-pointer ${ className={`flex items-center gap-1.5 px-3 py-1 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors cursor-pointer ${
statusFilter === 'APPROVED'
? 'bg-primary text-white shadow-xs'
statusFilter === 'AWAITING_PAYMENT'
? 'bg-blue-600 text-white shadow-xs'
: 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground' : 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground'
}`} }`}
> >
<span className="size-2 rounded-full bg-emerald-500" />
<span>{t('customTrip.statusApproved')}</span>
<span className="opacity-75 font-mono text-[10px]">
({toFa(orders.filter((o) => o.status === 'APPROVED').length)})
<span className="size-2 rounded-full bg-blue-500" />
<span>{t('customTrip.statusAwaitingPayment')}</span>
<span className="opacity-85 font-mono text-[10px]">
({toFa(orders.filter((o) => o.status === 'AWAITING_PAYMENT').length)})
</span> </span>
</button> </button>
<button <button
onClick={() => setStatusFilter('AWAITING_PAYMENT')}
onClick={() => setStatusFilter('APPROVED')}
className={`flex items-center gap-1.5 px-3 py-1 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors cursor-pointer ${ className={`flex items-center gap-1.5 px-3 py-1 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors cursor-pointer ${
statusFilter === 'AWAITING_PAYMENT'
? 'bg-primary text-white shadow-xs'
statusFilter === 'APPROVED'
? 'bg-emerald-600 text-white shadow-xs'
: 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground' : 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground'
}`} }`}
> >
<span className="size-2 rounded-full bg-amber-500" />
<span>{t('customTrip.statusAwaitingPayment')}</span>
<span className="opacity-75 font-mono text-[10px]">
({toFa(orders.filter((o) => o.status === 'AWAITING_PAYMENT').length)})
<span className="size-2 rounded-full bg-emerald-500" />
<span>{t('customTrip.statusApproved')}</span>
<span className="opacity-85 font-mono text-[10px]">
({toFa(orders.filter((o) => o.status === 'APPROVED').length)})
</span> </span>
</button> </button>
<button <button
onClick={() => setStatusFilter('REJECTED')} onClick={() => setStatusFilter('REJECTED')}
className={`flex items-center gap-1.5 px-3 py-1 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors cursor-pointer ${ className={`flex items-center gap-1.5 px-3 py-1 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors cursor-pointer ${
statusFilter === 'REJECTED' statusFilter === 'REJECTED'
? 'bg-primary text-white shadow-xs'
? 'bg-rose-600 text-white shadow-xs'
: 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground' : 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground'
}`} }`}
> >
<span className="size-2 rounded-full bg-rose-500" /> <span className="size-2 rounded-full bg-rose-500" />
<span>{t('customTrip.statusRejected')}</span> <span>{t('customTrip.statusRejected')}</span>
<span className="opacity-75 font-mono text-[10px]">
<span className="opacity-85 font-mono text-[10px]">
({toFa(orders.filter((o) => o.status === 'REJECTED').length)}) ({toFa(orders.filter((o) => o.status === 'REJECTED').length)})
</span> </span>
</button> </button>
<button
onClick={() => setStatusFilter('EXPIRED')}
className={`flex items-center gap-1.5 px-3 py-1 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors cursor-pointer ${
statusFilter === 'EXPIRED'
? 'bg-slate-600 text-white shadow-xs'
: 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground'
}`}
>
<span className="size-2 rounded-full bg-slate-400" />
<span>{t('customTrip.statusExpired')}</span>
<span className="opacity-85 font-mono text-[10px]">
({toFa(orders.filter((o) => o.status === 'EXPIRED').length)})
</span>
</button>
<button <button
onClick={() => setStatusFilter('CANCELLED')} onClick={() => setStatusFilter('CANCELLED')}
className={`flex items-center gap-1.5 px-3 py-1 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors cursor-pointer ${ className={`flex items-center gap-1.5 px-3 py-1 rounded-xl text-xs font-semibold whitespace-nowrap transition-colors cursor-pointer ${
statusFilter === 'CANCELLED' statusFilter === 'CANCELLED'
? 'bg-primary text-white shadow-xs'
? 'bg-red-700 text-white shadow-xs'
: 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground' : 'bg-muted/70 text-muted-foreground hover:bg-muted hover:text-foreground'
}`} }`}
> >
<span className="size-2 rounded-full bg-slate-500" />
<span className="size-2 rounded-full bg-red-500" />
<span>{t('customTrip.statusCancelled')}</span> <span>{t('customTrip.statusCancelled')}</span>
<span className="opacity-75 font-mono text-[10px]">
<span className="opacity-85 font-mono text-[10px]">
({toFa(orders.filter((o) => o.status === 'CANCELLED').length)}) ({toFa(orders.filter((o) => o.status === 'CANCELLED').length)})
</span> </span>
</button> </button>
@ -385,11 +428,19 @@ export function CustomTripOrdersTab({
<TableCell className="px-4 py-3.5 text-center"> <TableCell className="px-4 py-3.5 text-center">
<div className="flex flex-col items-center gap-1"> <div className="flex flex-col items-center gap-1">
{getStatusBadge(order.status)} {getStatusBadge(order.status)}
{order.factorId && order.status !== 'PENDING' ? (
<span className="inline-flex items-center gap-1 text-[10px] font-mono font-bold text-emerald-600 dark:text-emerald-400 bg-emerald-500/10 px-2 py-0.5 rounded-md">
{order.factorId ? (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
navigate(`/billing/${order.factorNumber || order.factorId}`)
}}
className="inline-flex items-center gap-1 text-[10px] font-mono font-bold text-primary hover:underline bg-primary/10 px-2 py-0.5 rounded-md cursor-pointer transition-colors"
title="مشاهده صورت‌حساب در بخش مالی"
>
<Ic name="receipt" className="size-3" /> <Ic name="receipt" className="size-3" />
<span>فاکتور #{toFa(order.factorNumber || order.factorId)}</span>
</span>
<span>صورت‌حساب #{toFa(order.factorNumber || order.factorId)}</span>
</button>
) : ( ) : (
<span className="text-[10px] text-muted-foreground"> <span className="text-[10px] text-muted-foreground">
{t('customTrip.notInvoicedYetBadge')} {t('customTrip.notInvoicedYetBadge')}

34
src/features/services/ServicesPage.tsx

@ -10,11 +10,13 @@ import { toFa } from '@/lib/utils'
import { SimcardServiceView } from './components/SimcardServiceView' import { SimcardServiceView } from './components/SimcardServiceView'
import { ZayerGuideServiceView } from './components/ZayerGuideServiceView' import { ZayerGuideServiceView } from './components/ZayerGuideServiceView'
import { DuasServiceView } from './components/DuasServiceView' import { DuasServiceView } from './components/DuasServiceView'
import { SurahsServiceView } from './components/SurahsServiceView'
import { CategoriesServiceView } from './components/CategoriesServiceView' import { CategoriesServiceView } from './components/CategoriesServiceView'
import { CitiesServiceView } from './components/CitiesServiceView' import { CitiesServiceView } from './components/CitiesServiceView'
import { FaqServiceView } from './components/FaqServiceView' import { FaqServiceView } from './components/FaqServiceView'
import { StoreServiceView } from './components/StoreServiceView' import { StoreServiceView } from './components/StoreServiceView'
import { SupportServiceView } from './components/SupportServiceView' import { SupportServiceView } from './components/SupportServiceView'
import { BlogsServiceView } from './components/BlogsServiceView'
// تعریف ساختار داده‌های سرویس‌ها // تعریف ساختار داده‌های سرویس‌ها
export interface ServiceMeta { export interface ServiceMeta {
@ -103,6 +105,20 @@ export const SERVICES_LIST: ServiceMeta[] = [
defaultCount: 4, defaultCount: 4,
badge: 'فرهنگی و معنوی', badge: 'فرهنگی و معنوی',
}, },
{
key: 'surahs',
title: 'سوره‌های منتخب قرآن کریم',
titleEn: 'Quran Surahs',
icon: 'book',
color: 'text-primary',
bgColor: 'bg-primary/10',
borderColor: 'border-primary/20',
description: 'مدیریت ۲۳ سوره منتخب قرآن کریم به همراه متن عربی با اعراب کامل و ترجمه آیات در ۵ زبان (فارسی، عربی، انگلیسی، روسی، اندونزیایی)',
endpoint: '/trip-etiquette/duas/?type=quran',
statsLabel: 'سوره مبارکه ثبت‌شده',
defaultCount: 23,
badge: 'قرآن کریم (۵ زبانه)',
},
{ {
key: 'categories', key: 'categories',
title: 'دسته‌بندی‌های ادعیه', title: 'دسته‌بندی‌های ادعیه',
@ -173,6 +189,20 @@ export const SERVICES_LIST: ServiceMeta[] = [
defaultCount: 5, defaultCount: 5,
badge: 'پشتیبانی زنده', badge: 'پشتیبانی زنده',
}, },
{
key: 'blogs',
title: 'وبلاگ و مقالات',
titleEn: 'Blogs & Articles',
icon: 'file',
color: 'text-primary',
bgColor: 'bg-primary/10',
borderColor: 'border-primary/20',
description: 'بانک مقالات سئو، دانشنامه اماکن متبرکه، راهنمای زیارت و سفر به عتبات عالیات و مشهد مقدس در ۵ زبان',
endpoint: '/blogs/admin/',
statsLabel: 'مقالات ثبت‌شده',
defaultCount: 52,
badge: 'محتوا و سئو',
},
] ]
export default function ServicesPage() { export default function ServicesPage() {
@ -283,6 +313,8 @@ function ServiceDetailRenderer({
return <CitiesServiceView /> return <CitiesServiceView />
case 'duas': case 'duas':
return <DuasServiceView /> return <DuasServiceView />
case 'surahs':
return <SurahsServiceView />
case 'categories': case 'categories':
return <CategoriesServiceView /> return <CategoriesServiceView />
case 'shrines': case 'shrines':
@ -291,6 +323,8 @@ function ServiceDetailRenderer({
return <DuasServiceView /> return <DuasServiceView />
case 'consultant': case 'consultant':
return <SupportServiceView /> return <SupportServiceView />
case 'blogs':
return <BlogsServiceView />
case 'faq': case 'faq':
return <FaqServiceView /> return <FaqServiceView />
case 'store': case 'store':

679
src/features/services/components/BlogsServiceView.tsx

@ -0,0 +1,679 @@
import React, { useState, useMemo } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Ic } from '@/icons'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { toFa } from '@/lib/utils'
import {
fetchAdminBlogs,
createAdminBlog,
updateAdminBlog,
deleteAdminBlog,
BlogItem,
} from '../services/blogs-api'
const LANGUAGE_OPTIONS = [
{ code: 'ALL', label: 'همه زبان‌ها', flag: '🌐' },
{ code: 'fa', label: 'فارسی', flag: '🇮🇷' },
{ code: 'ar', label: 'العربية', flag: '🇸🇦' },
{ code: 'en', label: 'English', flag: '🇬🇧' },
{ code: 'ru', label: 'Русский', flag: '🇷🇺' },
{ code: 'id', label: 'Indonesia', flag: '🇮🇩' },
]
export function BlogsServiceView() {
const qc = useQueryClient()
const [selectedLang, setSelectedLang] = useState<string>('ALL')
const [statusFilter, setStatusFilter] = useState<string>('ALL')
const [searchQuery, setSearchQuery] = useState<string>('')
const [editingBlog, setEditingBlog] = useState<Partial<BlogItem> | null>(null)
const [isDrawerOpen, setIsDrawerOpen] = useState<boolean>(false)
const [previewMode, setPreviewMode] = useState<boolean>(false)
// Query blogs
const { data: blogs = [], isLoading, refetch } = useQuery({
queryKey: ['admin-blogs', selectedLang, statusFilter],
queryFn: () => fetchAdminBlogs({
language_code: selectedLang !== 'ALL' ? selectedLang : undefined,
status: statusFilter !== 'ALL' ? statusFilter : undefined,
}),
})
// Mutations
const saveMutation = useMutation({
mutationFn: (payload: Partial<BlogItem>) => {
if (payload.id) {
return updateAdminBlog(payload.id, payload)
} else {
return createAdminBlog(payload)
}
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-blogs'] })
setIsDrawerOpen(false)
setEditingBlog(null)
},
})
const deleteMutation = useMutation({
mutationFn: (id: number) => deleteAdminBlog(id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-blogs'] })
},
})
const toggleStatusMutation = useMutation({
mutationFn: ({ id, status }: { id: number; status: boolean }) =>
updateAdminBlog(id, { status }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['admin-blogs'] })
},
})
// Filtered list
const filteredBlogs = useMemo(() => {
let list = blogs
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase().trim()
list = list.filter(
(b) =>
b.title?.toLowerCase().includes(q) ||
b.slug?.toLowerCase().includes(q) ||
b.summary?.toLowerCase().includes(q)
)
}
return list
}, [blogs, searchQuery])
// Stats
const totalCount = blogs.length
const activeCount = blogs.filter((b) => b.status).length
const draftCount = blogs.filter((b) => !b.status).length
const handleOpenAdd = () => {
setEditingBlog({
title: '',
slug: '',
summary: '',
content: '',
status: true,
is_top_list: false,
language_code: selectedLang !== 'ALL' ? selectedLang : 'fa',
cover: '',
tag_names: ['زیارت', 'عقیله'],
})
setPreviewMode(false)
setIsDrawerOpen(true)
}
const handleOpenEdit = (blog: BlogItem) => {
setEditingBlog({
...blog,
cover_input: blog.cover || '',
tag_names: blog.tags?.map((t) => t.name) || [],
language_code: blog.language_code_display || 'fa',
})
setPreviewMode(false)
setIsDrawerOpen(true)
}
return (
<div className="space-y-6">
{/* 1. معرفی و آمار کلان مقالات وبلاگ */}
<div className="relative overflow-hidden rounded-2xl border border-border-soft bg-gradient-to-br from-card/80 via-card/50 to-primary/5 p-6 backdrop-blur shadow-xs">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="space-y-1.5">
<div className="flex items-center gap-2.5">
<span className="flex size-10 items-center justify-center rounded-xl bg-primary/10 text-primary shadow-xs">
<Ic name="file" className="size-5" />
</span>
<div>
<h1 className="text-xl font-black text-foreground">وبلاگ، مقالات و دانشنامه زیارتی</h1>
<p className="text-xs font-mono text-grey-2">Aqila Pilgrimage Blog & Articles Management</p>
</div>
</div>
<p className="max-w-3xl text-xs text-grey-3 leading-relaxed">
بانک مقالات سئومحور و راهنمای اماکن مقدسه در ۵ زبان (فارسی، عربی، انگلیسی، روسی و اندونزیایی). همگام با مدل داده فرانت‌اند و اندپوینت‌های عمومی وب‌سایت عقیله.
</p>
</div>
<div className="flex items-center gap-2.5">
<Button
onClick={handleOpenAdd}
className="gap-2 bg-primary text-primary-foreground hover:bg-primary/90 text-xs font-bold h-10 px-4 rounded-xl shadow-md"
>
<Ic name="plus" className="size-4" />
افزودن مقاله جدید
</Button>
</div>
</div>
{/* کارت‌های آماری */}
<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4">
<Card className="border-border-soft bg-card/60 backdrop-blur">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-grey-3">کل مقالات ثبت‌شده</p>
<p className="text-xl font-black text-foreground mt-1 tabular-nums">{toFa(totalCount)}</p>
</div>
<span className="p-2 rounded-xl bg-primary/10 text-primary">
<Ic name="file" className="size-4" />
</span>
</CardContent>
</Card>
<Card className="border-border-soft bg-card/60 backdrop-blur">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-grey-3">مقالات منتشرشده (فعال)</p>
<p className="text-xl font-black text-emerald-400 mt-1 tabular-nums">{toFa(activeCount)}</p>
</div>
<span className="p-2 rounded-xl bg-emerald-500/10 text-emerald-400">
<Ic name="check" className="size-4" />
</span>
</CardContent>
</Card>
<Card className="border-border-soft bg-card/60 backdrop-blur">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-grey-3">پیش‌نویس / غیرفعال</p>
<p className="text-xl font-black text-amber-400 mt-1 tabular-nums">{toFa(draftCount)}</p>
</div>
<span className="p-2 rounded-xl bg-amber-500/10 text-amber-400">
<Ic name="clock" className="size-4" />
</span>
</CardContent>
</Card>
<Card className="border-border-soft bg-card/60 backdrop-blur">
<CardContent className="p-4 flex items-center justify-between">
<div>
<p className="text-[11px] text-grey-3">زبان‌های تحت پوشش</p>
<p className="text-xl font-black text-sky-400 mt-1">۵ زبان</p>
</div>
<span className="p-2 rounded-xl bg-sky-500/10 text-sky-400">
<Ic name="globe" className="size-4" />
</span>
</CardContent>
</Card>
</div>
</div>
{/* 2. تب‌های فیلتر زبان و نوار جستجو */}
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
{/* زبان‌ها */}
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 scrollbar-none">
{LANGUAGE_OPTIONS.map((lang) => (
<button
key={lang.code}
onClick={() => setSelectedLang(lang.code)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition-all shrink-0 ${
selectedLang === lang.code
? 'bg-primary text-primary-foreground shadow-sm'
: 'bg-card/60 text-grey-2 hover:bg-card/90 border border-border-soft'
}`}
>
<span>{lang.flag}</span>
<span>{lang.label}</span>
</button>
))}
</div>
{/* جستجو و وضعیت */}
<div className="flex items-center gap-2.5">
<div className="relative w-full sm:w-64">
<Ic name="search" className="absolute start-3 top-1/2 -translate-y-1/2 size-3.5 text-grey-3" />
<Input
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="جستجو در عنوان یا متن مقاله..."
className="h-9 ps-9 text-xs bg-surface-base rounded-xl border-border-soft"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
aria-label="فیلتر وضعیت انتشار مقاله"
className="h-9 px-3 text-xs bg-surface-base rounded-xl border border-border-soft text-foreground focus:outline-none"
>
<option value="ALL">همه وضعیت‌ها</option>
<option value="true">فقط فعال</option>
<option value="false">پیش‌نویس</option>
</select>
<Button
variant="outline"
size="sm"
onClick={() => refetch()}
className="h-9 gap-1 text-xs border-border-soft"
>
<Ic name="refresh" className={`size-3.5 ${isLoading ? 'animate-spin' : ''}`} />
بروزرسانی
</Button>
</div>
</div>
{/* 3. لیست مقالات در قالب جدول استاندارد */}
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40 shadow-xs">
<div className="flex items-center justify-between border-b border-border-soft bg-card/60 px-5 py-3.5">
<div className="flex items-center gap-2">
<span className="text-xs font-bold text-foreground">فهرست مقالات دانشنامه عقیله</span>
<Badge variant="secondary" className="bg-primary/10 text-primary border-0 text-[11px] font-mono">
{toFa(filteredBlogs.length)} مورد
</Badge>
</div>
<span className="text-[11px] text-grey-3">اتصال زنده به /api/blogs/admin/</span>
</div>
{isLoading ? (
<div className="p-8 text-center text-xs text-grey-3">در حال دریافت مقالات از پایگاه‌داده...</div>
) : filteredBlogs.length === 0 ? (
<div className="p-12 text-center space-y-2">
<Ic name="file" className="size-8 mx-auto text-grey-3 opacity-40" />
<p className="text-xs font-bold text-foreground">هیچ مقاله‌ای یافت نشد</p>
<p className="text-[11px] text-grey-3">با تغییر فیلترها یا افزودن مقاله جدید لیست را کامل کنید.</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-start text-xs">
<thead className="border-b border-border-soft bg-card/80 text-grey-3 font-bold">
<tr>
<th className="p-3.5 text-start w-16">شناسه</th>
<th className="p-3.5 text-start">تصویر و عنوان سئو</th>
<th className="p-3.5 text-start w-28">زبان</th>
<th className="p-3.5 text-start w-48">برچسب‌ها</th>
<th className="p-3.5 text-center w-28">وضعیت انتشار</th>
<th className="p-3.5 text-center w-36">تاریخ ثبت</th>
<th className="p-3.5 text-end w-28">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-border-soft">
{filteredBlogs.map((b, idx) => {
const langOpt = LANGUAGE_OPTIONS.find((l) => l.code === (b.language_code_display || 'fa'))
return (
<tr
key={b.id}
style={{
contentVisibility: idx >= 6 ? 'auto' : 'visible',
containIntrinsicSize: idx >= 6 ? 'auto none auto 80px' : undefined,
}}
className="hover:bg-card/70 transition-colors"
>
<td className="p-3.5 font-mono text-grey-3">#{toFa(b.id)}</td>
<td className="p-3.5">
<div className="flex items-center gap-3">
{b.cover ? (
<img
src={b.cover}
alt={b.title}
className="size-12 rounded-xl object-cover border border-border-soft shrink-0 bg-muted/20"
onError={(e) => {
(e.currentTarget as HTMLElement).style.display = 'none'
}}
/>
) : (
<div className="size-12 rounded-xl border border-dashed border-border-soft flex items-center justify-center shrink-0 text-grey-3">
<Ic name="image" className="size-5" />
</div>
)}
<div className="space-y-1 min-w-0">
<p className="font-bold text-foreground text-sm line-clamp-1 hover:text-primary transition-colors cursor-pointer" onClick={() => handleOpenEdit(b)}>
{b.title}
</p>
<p className="font-mono text-[11px] text-grey-3 truncate max-w-md" dir="ltr">
/blog/{b.slug}
</p>
{b.summary && (
<p className="text-[11px] text-grey-2 line-clamp-1 max-w-xl">
{b.summary}
</p>
)}
</div>
</div>
</td>
<td className="p-3.5">
<Badge variant="outline" className="gap-1 border-border-soft bg-surface-base">
<span>{langOpt?.flag || '🌐'}</span>
<span className="font-mono text-[10px] uppercase font-bold">{b.language_code_display || 'FA'}</span>
</Badge>
</td>
<td className="p-3.5">
<div className="flex flex-wrap gap-1 max-w-xs">
{b.tags && b.tags.length > 0 ? (
b.tags.slice(0, 3).map((t) => (
<span key={t.id || t.name} className="px-2 py-0.5 rounded-md bg-muted/30 text-[10px] text-grey-2 border border-border-soft">
#{t.name}
</span>
))
) : (
<span className="text-[10px] text-grey-3">-</span>
)}
{b.tags && b.tags.length > 3 && (
<span className="text-[10px] text-grey-3 font-mono">+{b.tags.length - 3}</span>
)}
</div>
</td>
<td className="p-3.5 text-center">
<button
onClick={() => toggleStatusMutation.mutate({ id: b.id, status: !b.status })}
className={`px-2.5 py-1 rounded-full text-[11px] font-bold transition-all ${
b.status
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 hover:bg-emerald-500/20'
: 'bg-zinc-800 text-zinc-400 border border-zinc-700 hover:bg-zinc-700'
}`}
>
{b.status ? 'منتشرشده' : 'پیش‌نویس'}
</button>
</td>
<td className="p-3.5 text-center font-mono text-grey-3 text-[11px]">
{b.created_at ? new Date(b.created_at).toLocaleDateString('fa-IR') : '-'}
</td>
<td className="p-3.5 text-end">
<div className="flex items-center justify-end gap-1.5">
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenEdit(b)}
className="size-8 p-0 text-grey-2 hover:text-primary hover:bg-primary/10 rounded-lg"
title="ویرایش مقاله"
>
<Ic name="pencil" className="size-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (confirm(`آیا از حذف مقاله «${b.title}» اطمینان دارید؟`)) {
deleteMutation.mutate(b.id)
}
}}
className="size-8 p-0 text-grey-2 hover:text-red-400 hover:bg-red-500/10 rounded-lg"
title="حذف مقاله"
>
<Ic name="trash" className="size-4" />
</Button>
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
{/* 4. دراور افزودن / ویرایش مقاله سئومحور */}
{isDrawerOpen && editingBlog && (
<div className="fixed inset-0 z-50 flex items-center justify-end bg-black/60 backdrop-blur-xs transition-opacity animate-in fade-in">
<div className="relative flex h-full w-full max-w-3xl flex-col bg-card border-s border-border-soft shadow-2xl overflow-hidden animate-in slide-in-from-right duration-200">
{/* Header */}
<div className="flex items-center justify-between border-b border-border-soft bg-card/80 px-6 py-4">
<div className="flex items-center gap-2.5">
<span className="p-2 rounded-xl bg-primary/10 text-primary">
<Ic name="file" className="size-5" />
</span>
<div>
<h2 className="text-base font-bold text-foreground">
{editingBlog.id ? 'ویرایش مقاله سئو' : 'افزودن مقاله جدید'}
</h2>
<p className="text-[11px] font-mono text-grey-3">
{editingBlog.id ? `ID: #${editingBlog.id}` : 'پست جدید در پایگاه‌داده مقالات'}
</p>
</div>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => setIsDrawerOpen(false)}
className="size-8 p-0 rounded-lg text-grey-3 hover:text-foreground"
>
<Ic name="close" className="size-4" />
</Button>
</div>
{/* Body */}
<div className="flex-1 overflow-y-auto p-6 space-y-5">
{/* تب‌های زبان */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-grey-2">زبان مقاله (Language):</label>
<div className="flex items-center gap-2">
{LANGUAGE_OPTIONS.filter((l) => l.code !== 'ALL').map((lang) => (
<button
key={lang.code}
type="button"
onClick={() => setEditingBlog({ ...editingBlog, language_code: lang.code })}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition-all border ${
editingBlog.language_code === lang.code
? 'bg-primary/15 border-primary text-primary'
: 'border-border-soft bg-surface-base text-grey-2 hover:bg-muted/30'
}`}
>
<span>{lang.flag}</span>
<span>{lang.label}</span>
</button>
))}
</div>
</div>
{/* عنوان سئو */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-grey-2">عنوان سئو مقاله (H1 Title):</label>
<Input
value={editingBlog.title || ''}
onChange={(e) => setEditingBlog({ ...editingBlog, title: e.target.value })}
placeholder="مثال: راهنمای کامل اماکن زیارتی و تاریخی نجف اشرف..."
className="bg-surface-base border-border-soft rounded-xl text-sm"
/>
</div>
{/* نامک اسلاگ */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-grey-2">نامک URL یکتا (Slug):</label>
<Input
value={editingBlog.slug || ''}
onChange={(e) => setEditingBlog({ ...editingBlog, slug: e.target.value })}
placeholder="najaf-holy-sites-guide"
dir="ltr"
className="bg-surface-base border-border-soft rounded-xl text-xs font-mono"
/>
</div>
{/* آدرس تصویر شاخص */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-grey-2">آدرس تصویر شاخص (Cover Image URL):</label>
<div className="flex gap-3">
<Input
value={editingBlog.cover_input || ''}
onChange={(e) => setEditingBlog({ ...editingBlog, cover_input: e.target.value })}
placeholder="https://... یا /media/blog/cover/..."
dir="ltr"
className="bg-surface-base border-border-soft rounded-xl text-xs font-mono flex-1"
/>
{editingBlog.cover_input && (
<img
src={editingBlog.cover_input}
alt="پیش‌نمایش کاور"
className="size-10 rounded-lg object-cover border border-border-soft shrink-0 bg-muted/20"
/>
)}
</div>
</div>
{/* خلاصه سئو */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<label className="text-xs font-bold text-grey-2">خلاصه متای سئو (Meta Description):</label>
<span className="text-[10px] font-mono text-grey-3">
{editingBlog.summary?.length || 0} / ۱۶۰ کاراکتر
</span>
</div>
<textarea
value={editingBlog.summary || ''}
onChange={(e) => setEditingBlog({ ...editingBlog, summary: e.target.value })}
rows={2}
placeholder="خلاصه جذاب برای نمایش در نتایج گوگل و کارت مقالات..."
className="w-full rounded-xl border border-border-soft bg-surface-base p-3 text-xs text-foreground focus:outline-none focus:border-primary/50"
/>
</div>
{/* برچسب‌ها */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-grey-2">برچسب‌ها (با کاما جدا کنید):</label>
<Input
value={editingBlog.tag_names?.join('، ') || ''}
onChange={(e) =>
setEditingBlog({
...editingBlog,
tag_names: e.target.value.split(/[،,]+/).map((s) => s.trim()).filter(Boolean),
})
}
placeholder="نجف، حرم امام علی، وادی السلام، زیارت"
className="bg-surface-base border-border-soft rounded-xl text-xs"
/>
</div>
{/* وضعیت و ویژگی مقاله ویژه */}
<div className="flex items-center gap-6 rounded-xl border border-border-soft bg-surface-base p-3.5">
<label className="flex items-center gap-2 cursor-pointer text-xs font-bold text-foreground">
<input
type="checkbox"
checked={editingBlog.status ?? true}
onChange={(e) => setEditingBlog({ ...editingBlog, status: e.target.checked })}
className="size-4 rounded accent-primary cursor-pointer"
/>
<span>انتشار عمومی در وب‌سایت</span>
</label>
<label className="flex items-center gap-2 cursor-pointer text-xs font-bold text-foreground">
<input
type="checkbox"
checked={editingBlog.is_top_list ?? false}
onChange={(e) => setEditingBlog({ ...editingBlog, is_top_list: e.target.checked })}
className="size-4 rounded accent-primary cursor-pointer"
/>
<span>نمایش در بخش مقالات ویژه (Top List)</span>
</label>
</div>
{/* ویرایشگر محتوای اصلی مقاله */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs font-bold text-grey-2">محتوای مقاله (Semantic HTML):</label>
<div className="flex items-center gap-1.5">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setPreviewMode(!previewMode)}
className="h-7 text-[11px] gap-1 border-border-soft"
>
<Ic name="eye" className="size-3" />
{previewMode ? 'حالت کد HTML' : 'پیش‌نمایش خروجی'}
</Button>
</div>
</div>
{!previewMode ? (
<div className="space-y-1.5">
{/* Quick HTML tags helper */}
<div className="flex flex-wrap gap-1 p-2 rounded-lg bg-muted/20 border border-border-soft text-[11px]">
<button
type="button"
onClick={() =>
setEditingBlog({
...editingBlog,
content: (editingBlog.content || '') + '\n<h2>عنوان بخش جدید</h2>\n<p>متن بخش...</p>',
})
}
className="px-2 py-0.5 rounded bg-card hover:bg-card/80 border border-border-soft text-grey-2"
>
+ تیتر H2
</button>
<button
type="button"
onClick={() =>
setEditingBlog({
...editingBlog,
content: (editingBlog.content || '') + '\n<h3>زیرتیتر</h3>\n<p>متن توضیحات...</p>',
})
}
className="px-2 py-0.5 rounded bg-card hover:bg-card/80 border border-border-soft text-grey-2"
>
+ زیرتیتر H3
</button>
<button
type="button"
onClick={() =>
setEditingBlog({
...editingBlog,
content: (editingBlog.content || '') + '\n<ul>\n <li>مورد اول</li>\n <li>مورد دوم</li>\n</ul>',
})
}
className="px-2 py-0.5 rounded bg-card hover:bg-card/80 border border-border-soft text-grey-2"
>
+ لیست آیتم‌ها
</button>
<button
type="button"
onClick={() =>
setEditingBlog({
...editingBlog,
content:
(editingBlog.content || '') +
'\n<div class="callout-box bg-primary/10 border-r-4 border-primary p-4 rounded-xl my-4">\n <p>نکته زیارتی مهم برای زائران محترم...</p>\n</div>',
})
}
className="px-2 py-0.5 rounded bg-card hover:bg-card/80 border border-border-soft text-primary font-bold"
>
+ جعبه نکته کاربردی
</button>
</div>
<textarea
value={editingBlog.content || ''}
onChange={(e) => setEditingBlog({ ...editingBlog, content: e.target.value })}
rows={14}
dir="auto"
placeholder="متن کامل مقاله به همراه تگ‌های استاندارد سئو..."
className="w-full rounded-xl border border-border-soft bg-surface-base p-4 text-xs font-mono text-foreground focus:outline-none focus:border-primary/50 leading-relaxed"
/>
</div>
) : (
<div
className="max-h-96 overflow-y-auto rounded-xl border border-border-soft bg-card/60 p-5 prose dark:prose-invert prose-sm text-xs leading-relaxed"
dangerouslySetInnerHTML={{ __html: editingBlog.content || '<p class="text-grey-3">محتوایی وارد نشده است.</p>' }}
/>
)}
</div>
</div>
{/* Footer */}
<div className="flex items-center justify-end gap-3 border-t border-border-soft bg-card/80 px-6 py-4">
<Button
variant="outline"
size="sm"
onClick={() => setIsDrawerOpen(false)}
className="text-xs border-border-soft"
>
انصراف
</Button>
<Button
size="sm"
onClick={() => saveMutation.mutate(editingBlog)}
disabled={saveMutation.isPending || !editingBlog.title?.trim()}
className="gap-1.5 bg-primary text-primary-foreground hover:bg-primary/90 text-xs font-bold px-5"
>
{saveMutation.isPending && <Ic name="refresh" className="size-3 animate-spin" />}
{editingBlog.id ? 'ذخیره تغییرات' : 'ثبت مقاله در دیتابیس'}
</Button>
</div>
</div>
</div>
)}
</div>
)
}

863
src/features/services/components/SurahsServiceView.tsx

@ -0,0 +1,863 @@
import React, { useState, useEffect, useMemo } from 'react'
import { Ic } from '@/icons'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogBody,
DialogFooter,
} from '@/components/ui/dialog'
import { ShimmerBox, ShimmerLine } from '@/components/shared/shimmer_frontend'
import { toFa } from '@/lib/utils'
import type { DuaItem, DuaDetailItem } from '../types/trip-etiquette'
import {
fetchSurahs,
fetchDuaDetail,
updateDua,
updateVerse,
} from '../services/trip-etiquette-api'
import { toast } from '@/stores/toast'
interface SurahStaticMeta {
surahNumber: number
revelationType: 'مکی' | 'مدنی'
juzLabel: string
virtueNote: string
}
const SURAH_META_BY_NUMBER: Record<number, SurahStaticMeta> = {
1: { surahNumber: 1, revelationType: 'مکی', juzLabel: 'جزء ۱', virtueNote: 'فاتحة الکتاب و ام‌القرآن' },
18: { surahNumber: 18, revelationType: 'مکی', juzLabel: 'جزء ۱۵ و ۱۶', virtueNote: 'تلاوت در شب و روز جمعه' },
29: { surahNumber: 29, revelationType: 'مکی', juzLabel: 'جزء ۲۰ و ۲۱', virtueNote: 'از سوره‌های شب‌های قدر (شب ۲۳ رمضان)' },
30: { surahNumber: 30, revelationType: 'مکی', juzLabel: 'جزء ۲۱', virtueNote: 'از سوره‌های شب‌های قدر (شب ۲۳ رمضان)' },
36: { surahNumber: 36, revelationType: 'مکی', juzLabel: 'جزء ۲۲ و ۲۳', virtueNote: 'قلب قرآن کریم و زیارت اهل قبور' },
44: { surahNumber: 44, revelationType: 'مکی', juzLabel: 'جزء ۲۵', virtueNote: 'از سوره‌های شب‌های قدر و شب‌های جمعه' },
55: { surahNumber: 55, revelationType: 'مدنی', juzLabel: 'جزء ۲۷', virtueNote: 'عروس قرآن کریم' },
56: { surahNumber: 56, revelationType: 'مکی', juzLabel: 'جزء ۲۷', virtueNote: 'سوره وسعت رزق در هر شب' },
62: { surahNumber: 62, revelationType: 'مدنی', juzLabel: 'جزء ۲۸', virtueNote: 'تلاوت در نماز و روز جمعه' },
67: { surahNumber: 67, revelationType: 'مکی', juzLabel: 'جزء ۲۹', virtueNote: 'سوره منجیه و هدیه به اموات' },
76: { surahNumber: 76, revelationType: 'مدنی', juzLabel: 'جزء ۲۹', virtueNote: 'سوره هل أتی در شأن اهل‌بیت (ع)' },
78: { surahNumber: 78, revelationType: 'مکی', juzLabel: 'جزء ۳۰', virtueNote: 'سوره عمّ یتساءلون (توفیق زیارت خانه خدا)' },
87: { surahNumber: 87, revelationType: 'مکی', juzLabel: 'جزء ۳۰', virtueNote: 'سوره سبح اسم ربک الاعلی در نمازهای مستحبی' },
91: { surahNumber: 91, revelationType: 'مکی', juzLabel: 'جزء ۳۰', virtueNote: 'تلاوت در نمازهای مستحبی زیارت' },
97: { surahNumber: 97, revelationType: 'مکی', juzLabel: 'جزء ۳۰', virtueNote: 'سوره انا انزلناه در اعمال حرم‌های مطهر' },
99: { surahNumber: 99, revelationType: 'مدنی', juzLabel: 'جزء ۳۰', virtueNote: 'تلاوت در نوافل و نمازهای زیارت' },
100: { surahNumber: 100, revelationType: 'مکی', juzLabel: 'جزء ۳۰', virtueNote: 'ثواب معادل ختم قرآن در روایات' },
102: { surahNumber: 102, revelationType: 'مکی', juzLabel: 'جزء ۳۰', virtueNote: 'تلاوت پیش از خواب و در مشاهد مشرفه' },
109: { surahNumber: 109, revelationType: 'مکی', juzLabel: 'جزء ۳۰', virtueNote: 'از سوره‌های چهارقل (برائت از شرک)' },
110: { surahNumber: 110, revelationType: 'مدنی', juzLabel: 'جزء ۳۰', virtueNote: 'سوره استغفار و نصرت الهی' },
112: { surahNumber: 112, revelationType: 'مکی', juzLabel: 'جزء ۳۰', virtueNote: 'سوره توحید (معادل یک‌سوم قرآن)' },
113: { surahNumber: 113, revelationType: 'مکی', juzLabel: 'جزء ۳۰', virtueNote: 'از سوره‌های معوذتین و چهارقل' },
114: { surahNumber: 114, revelationType: 'مکی', juzLabel: 'جزء ۳۰', virtueNote: 'از سوره‌های معوذتین و چهارقل' },
}
function extractLangName(nameField: any, lang: string): string {
if (!nameField) return ''
if (typeof nameField === 'object' && !Array.isArray(nameField)) {
return nameField[lang] || ''
}
if (Array.isArray(nameField)) {
const found = nameField.find((x: any) => x?.language_code === lang)
return found?.name || ''
}
return ''
}
export function SurahsServiceView() {
const [surahs, setSurahs] = useState<DuaItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
// Filters
const [searchTerm, setSearchTerm] = useState('')
// Modal State
const [selectedSurah, setSelectedSurah] = useState<DuaItem | null>(null)
const [modalTab, setModalTab] = useState<'verses' | 'info'>('verses')
const [surahDetail, setSurahDetail] = useState<DuaDetailItem | null>(null)
const [isLoadingDetail, setIsLoadingDetail] = useState(false)
// Tab 1 (5-lang Names)
const [formNameFa, setFormNameFa] = useState('')
const [formNameAr, setFormNameAr] = useState('')
const [formNameEn, setFormNameEn] = useState('')
const [formNameRu, setFormNameRu] = useState('')
const [formNameId, setFormNameId] = useState('')
const [formIsActive, setFormIsActive] = useState(true)
const [isSavingInfo, setIsSavingInfo] = useState(false)
const [togglingIndex, setTogglingIndex] = useState<number | null>(null)
// Tab 2 (Verses & 4-lang translations)
const [verseSearch, setVerseSearch] = useState('')
const [activeTransLang, setActiveTransLang] = useState<'fa' | 'en' | 'ru' | 'id'>('fa')
const [verseDrafts, setVerseDrafts] = useState<
Record<number, { text: string; translations: Record<string, string> }>
>({})
const [isSavingAllVerses, setIsSavingAllVerses] = useState(false)
const loadSurahs = async (manual = false) => {
if (manual) setIsRefreshing(true)
else setIsLoading(true)
setErrorMessage(null)
try {
const data = await fetchSurahs()
setSurahs(data)
} catch (err: any) {
setErrorMessage(err?.message || 'خطا در دریافت فهرست سوره‌های قرآن کریم از سرور.')
} finally {
setIsLoading(false)
setIsRefreshing(false)
}
}
useEffect(() => {
loadSurahs()
}, [])
const filteredSurahs = useMemo(() => {
return surahs.filter((s) => {
const sNum = s.index >= 6000 ? s.index - 6000 : s.index
if (searchTerm.trim()) {
const q = searchTerm.trim().toLowerCase()
const fa = extractLangName(s.name, 'fa').toLowerCase()
const ar = extractLangName(s.name, 'ar').toLowerCase()
const en = extractLangName(s.name, 'en').toLowerCase()
const ru = extractLangName(s.name, 'ru').toLowerCase()
const id = extractLangName(s.name, 'id').toLowerCase()
const disp = (s.name_display || '').toLowerCase()
if (
!fa.includes(q) &&
!ar.includes(q) &&
!en.includes(q) &&
!ru.includes(q) &&
!id.includes(q) &&
!disp.includes(q) &&
!String(sNum).includes(q) &&
!String(s.index).includes(q)
) {
return false
}
}
return true
})
}, [surahs, searchTerm])
const totalVersesCount = useMemo(
() => surahs.reduce((sum, s) => sum + (s.verses_count || 0), 0),
[surahs]
)
const activeSurahsCount = useMemo(
() => surahs.filter((s) => s.is_active !== false).length,
[surahs]
)
const handleOpenSurahModal = async (surah: DuaItem, initialTab: 'verses' | 'info' = 'verses') => {
setSelectedSurah(surah)
setModalTab(initialTab)
setVerseSearch('')
setFormNameFa(extractLangName(surah.name, 'fa') || surah.name_display || '')
setFormNameAr(extractLangName(surah.name, 'ar') || '')
setFormNameEn(extractLangName(surah.name, 'en') || '')
setFormNameRu(extractLangName(surah.name, 'ru') || '')
setFormNameId(extractLangName(surah.name, 'id') || '')
setFormIsActive(surah.is_active !== false)
setIsLoadingDetail(true)
setSurahDetail(null)
setVerseDrafts({})
try {
const detail = await fetchDuaDetail(surah.index)
setSurahDetail(detail)
const drafts: Record<number, { text: string; translations: Record<string, string> }> = {}
;(detail.verses || []).forEach((v) => {
drafts[v.index] = {
text: v.text || '',
translations: {
fa: v.translations?.fa || '',
en: v.translations?.en || '',
ru: v.translations?.ru || '',
id: v.translations?.id || '',
},
}
})
setVerseDrafts(drafts)
} catch (err: any) {
toast.error(err?.message || 'خطا در دریافت آیات و ترجمه‌های سوره.')
} finally {
setIsLoadingDetail(false)
}
}
const handleToggleSurahStatus = async (surah: DuaItem) => {
const nextStatus = !(surah.is_active !== false)
setTogglingIndex(surah.index)
try {
await updateDua(surah.index, { is_active: nextStatus })
setSurahs((prev) =>
prev.map((item) => (item.index === surah.index ? { ...item, is_active: nextStatus } : item))
)
toast.success(
nextStatus
? `${surah.name_display} فعال شد.`
: `${surah.name_display} غیرفعال شد.`
)
} catch (err: any) {
toast.error(err?.message || 'خطا در تغییر وضعیت سوره.')
} finally {
setTogglingIndex(null)
}
}
const handleSaveSurahInfo = async () => {
if (!selectedSurah) return
if (!formNameFa.trim() || !formNameAr.trim()) {
toast.error('نام فارسی و عربی سوره الزامی است.')
return
}
setIsSavingInfo(true)
try {
const updatedName = {
fa: formNameFa.trim(),
ar: formNameAr.trim(),
en: formNameEn.trim(),
ru: formNameRu.trim(),
id: formNameId.trim(),
}
const updated = await updateDua(selectedSurah.index, {
name: updatedName,
is_active: formIsActive,
})
setSurahs((prev) =>
prev.map((s) =>
s.index === selectedSurah.index
? {
...s,
name: updatedName,
name_display: updated.name_display || formNameFa.trim(),
is_active: formIsActive,
}
: s
)
)
toast.success('اطلاعات ۵ زبانه سوره با موفقیت ذخیره شد.')
} catch (err: any) {
toast.error(err?.message || 'خطا در ذخیره اطلاعات سوره.')
} finally {
setIsSavingInfo(false)
}
}
const handleSaveAllVerses = async () => {
if (!surahDetail?.verses || surahDetail.verses.length === 0) return
// Find all verses that have modifications compared to original
const modifiedVerses = surahDetail.verses.filter((v) => {
const draft = verseDrafts[v.index]
if (!draft) return false
const textChanged = (draft.text ?? '') !== (v.text ?? '')
const faChanged = (draft.translations?.fa ?? '') !== (v.translations?.fa ?? '')
const enChanged = (draft.translations?.en ?? '') !== (v.translations?.en ?? '')
const ruChanged = (draft.translations?.ru ?? '') !== (v.translations?.ru ?? '')
const idChanged = (draft.translations?.id ?? '') !== (v.translations?.id ?? '')
return textChanged || faChanged || enChanged || ruChanged || idChanged
})
if (modifiedVerses.length === 0) {
toast.info('هیچ تغییری در متن آیات یا ترجمه‌ها اعمال نشده است.')
return
}
setIsSavingAllVerses(true)
try {
await Promise.all(
modifiedVerses.map((v) => {
const draft = verseDrafts[v.index]
return updateVerse(v.index, {
text: draft.text,
translations: draft.translations,
})
})
)
// Update local surahDetail state so changes are reflected
setSurahDetail((prev) => {
if (!prev) return null
return {
...prev,
verses: prev.verses.map((v) => {
const draft = verseDrafts[v.index]
if (!draft) return v
return {
...v,
text: draft.text,
translations: { ...v.translations, ...draft.translations },
}
}),
}
})
toast.success(`تغییرات ${toFa(modifiedVerses.length)} آیه با موفقیت ذخیره شد.`)
} catch (err: any) {
toast.error(err?.message || 'خطا در ذخیره‌سازی دسته جمعی آیات.')
} finally {
setIsSavingAllVerses(false)
}
}
const filteredVerses = useMemo(() => {
if (!surahDetail?.verses) return []
if (!verseSearch.trim()) return surahDetail.verses
const q = verseSearch.trim().toLowerCase()
return surahDetail.verses.filter((v, idx) => {
const draft = verseDrafts[v.index]
const text = (draft?.text ?? v.text ?? '').toLowerCase()
const fa = (draft?.translations?.fa ?? v.translations?.fa ?? '').toLowerCase()
const en = (draft?.translations?.en ?? v.translations?.en ?? '').toLowerCase()
const ru = (draft?.translations?.ru ?? v.translations?.ru ?? '').toLowerCase()
const id = (draft?.translations?.id ?? v.translations?.id ?? '').toLowerCase()
return (
text.includes(q) ||
fa.includes(q) ||
en.includes(q) ||
ru.includes(q) ||
id.includes(q) ||
String(idx + 1).includes(q)
)
})
}, [surahDetail, verseSearch, verseDrafts])
return (
<div className="space-y-6">
{/* KPI Cards */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-2xl border border-border-soft bg-card/50 p-4 shadow-2xs">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-grey-3">سوره‌های منتخب قرآن</span>
<div className="flex size-8 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Ic name="book" className="size-4" />
</div>
</div>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-2xl font-black text-foreground">{toFa(surahs.length)}</span>
<span className="text-xs text-grey-3">سوره مبارکه</span>
</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/50 p-4 shadow-2xs">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-grey-3">مجموع آیات ثبت‌شده</span>
<div className="flex size-8 items-center justify-center rounded-xl bg-emerald-500/10 text-emerald-500">
<Ic name="sparkles" className="size-4" />
</div>
</div>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-2xl font-black text-foreground">{toFa(totalVersesCount)}</span>
<span className="text-xs text-grey-3">آیه با اعراب کامل</span>
</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/50 p-4 shadow-2xs">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-grey-3">پوشش ترجمه چندزبانه</span>
<div className="flex size-8 items-center justify-center rounded-xl bg-amber-500/10 text-amber-500">
<Ic name="globe" className="size-4" />
</div>
</div>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-2xl font-black text-foreground">{toFa(5)}</span>
<span className="text-xs text-grey-3">زبان (FA, AR, EN, RU, ID)</span>
</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/50 p-4 shadow-2xs">
<div className="flex items-center justify-between">
<span className="text-xs font-medium text-grey-3">سوره‌های فعال در دیتابیس</span>
<div className="flex size-8 items-center justify-center rounded-xl bg-sky-500/10 text-sky-500">
<Ic name="check" className="size-4" />
</div>
</div>
<div className="mt-2 flex items-baseline gap-2">
<span className="text-2xl font-black text-foreground">{toFa(activeSurahsCount)}</span>
<span className="text-xs text-grey-3">آماده اتصال به اپلیکیشن</span>
</div>
</div>
</div>
{/* Filters & Search Bar */}
<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="relative flex-1 max-w-md">
<Ic
name="search"
className="pointer-events-none absolute start-3 top-1/2 size-4 -translate-y-1/2 text-grey-3"
/>
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="جستجوی نام سوره (فارسی، عربی، انگلیسی، روسی، اندونزیایی) یا شماره سوره..."
className="ps-9 rounded-xl border-border-soft bg-background/60 text-xs"
/>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => loadSurahs(true)}
disabled={isRefreshing}
className="rounded-xl gap-1.5 text-xs"
>
<Ic name="refresh" className={`size-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
<span>بروزرسانی</span>
</Button>
</div>
</div>
{/* Error State */}
{errorMessage && (
<div className="rounded-2xl border border-rose-500/30 bg-rose-500/10 p-4 text-xs text-rose-400 flex items-center justify-between">
<span>{errorMessage}</span>
<Button variant="outline" size="sm" onClick={() => loadSurahs(true)}>
تلاش مجدد
</Button>
</div>
)}
{/* Surahs Table */}
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40 shadow-xs">
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow className="border-border-soft bg-muted/30">
<TableHead className="w-16 text-center text-xs font-bold">شماره</TableHead>
<TableHead className="min-w-[240px] text-start text-xs font-bold">نام سوره</TableHead>
<TableHead className="w-36 text-center text-xs font-bold">نزول و جزء</TableHead>
<TableHead className="w-28 text-center text-xs font-bold">تعداد آیات</TableHead>
<TableHead className="w-24 text-center text-xs font-bold">وضعیت</TableHead>
<TableHead className="w-36 text-end text-xs font-bold">عملیات</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
Array.from({ length: 6 }).map((_, idx) => (
<TableRow key={idx} className="border-border-soft">
<TableCell><ShimmerBox className="mx-auto h-7 w-7 rounded-lg" /></TableCell>
<TableCell><ShimmerLine className="h-5 w-48" /></TableCell>
<TableCell><ShimmerBox className="mx-auto h-6 w-20 rounded-lg" /></TableCell>
<TableCell><ShimmerBox className="mx-auto h-5 w-14 rounded-lg" /></TableCell>
<TableCell><ShimmerBox className="mx-auto h-5 w-14 rounded-lg" /></TableCell>
<TableCell><ShimmerBox className="ms-auto h-8 w-28 rounded-xl" /></TableCell>
</TableRow>
))
) : filteredSurahs.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="py-12 text-center text-xs text-grey-3">
سوره‌ای مطابق با فیلترهای انتخابی یافت نشد.
</TableCell>
</TableRow>
) : (
filteredSurahs.map((surah) => {
const sNum = surah.index >= 6000 ? surah.index - 6000 : surah.index
const meta = SURAH_META_BY_NUMBER[sNum]
const nameFa = extractLangName(surah.name, 'fa') || surah.name_display
const nameAr = extractLangName(surah.name, 'ar')
const nameEn = extractLangName(surah.name, 'en')
const isActive = surah.is_active !== false
return (
<TableRow
key={surah.index}
className="border-border-soft transition-colors hover:bg-muted/20"
>
{/* شماره سوره */}
<TableCell className="text-center align-middle">
<span
className="inline-flex size-7 items-center justify-center rounded-lg bg-muted/60 font-mono text-xs font-bold text-foreground"
title={`شناسه دیتابیس: #${surah.index}`}
>
{toFa(sNum)}
</span>
</TableCell>
{/* نام سوره (فارسی، عربی و انگلیسی) */}
<TableCell className="align-middle">
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<span className="font-bold text-sm text-foreground">{nameFa}</span>
{nameAr && (
<span className="text-xs font-semibold text-primary/80">
({nameAr})
</span>
)}
</div>
{nameEn && (
<span className="text-[11px] text-grey-3 font-medium">
{nameEn}
</span>
)}
</div>
</TableCell>
{/* نوع نزول و جزء */}
<TableCell className="text-center align-middle">
<div className="inline-flex items-center justify-center gap-2">
<Badge
variant="outline"
className={
meta?.revelationType === 'مدنی'
? 'border-emerald-500/25 bg-emerald-500/10 text-emerald-500 text-[11px] px-2 py-0.5'
: 'border-amber-500/25 bg-amber-500/10 text-amber-500 text-[11px] px-2 py-0.5'
}
>
{meta?.revelationType || 'مکی'}
</Badge>
{meta?.juzLabel && (
<span className="text-[11px] text-grey-3">
{meta.juzLabel}
</span>
)}
</div>
</TableCell>
{/* تعداد آیات */}
<TableCell className="text-center align-middle">
<span className="text-xs font-semibold text-foreground">
{toFa(surah.verses_count || 0)} آیه
</span>
</TableCell>
{/* وضعیت فعال / غیرفعال */}
<TableCell className="text-center align-middle">
<button
type="button"
disabled={togglingIndex === surah.index}
onClick={() => handleToggleSurahStatus(surah)}
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium transition-all ${
isActive
? 'bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500/15'
: 'bg-muted text-grey-3 hover:bg-muted/80'
}`}
>
<span
className={`size-1.5 rounded-full ${
isActive ? 'bg-emerald-500' : 'bg-grey-3'
}`}
/>
<span>{isActive ? 'فعال' : 'غیرفعال'}</span>
</button>
</TableCell>
{/* عملیات */}
<TableCell className="text-end align-middle">
<Button
variant="outline"
size="sm"
onClick={() => handleOpenSurahModal(surah, 'verses')}
className="h-8 rounded-xl gap-1.5 text-xs border-primary/25 bg-primary/5 text-primary hover:bg-primary/15"
>
<Ic name="book" className="size-3.5" />
<span>آیات و ترجمه‌ها</span>
</Button>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</div>
</div>
{/* Modal for Editing Surah Titles & 5-Language Verses */}
<Dialog
open={Boolean(selectedSurah)}
onOpenChange={(open) => {
if (!open) setSelectedSurah(null)
}}
>
<DialogContent className="max-w-4xl max-h-[90vh]">
<DialogHeader className="p-5 sm:p-6 pb-4">
<DialogTitle className="flex flex-wrap items-center justify-between gap-3 text-base font-black">
<div className="flex items-center gap-2">
<Ic name="book" className="size-5 text-primary" />
<span>{selectedSurah?.name_display}</span>
{selectedSurah && (
<Badge variant="outline" className="font-mono text-xs">
سوره {toFa(selectedSurah.index - 6000)} (#{selectedSurah.index})
</Badge>
)}
</div>
<div className="flex items-center gap-1.5 rounded-xl border border-border-soft bg-background/60 p-1">
<button
type="button"
onClick={() => setModalTab('verses')}
className={`rounded-lg px-3 py-1.5 text-xs font-bold transition-all ${
modalTab === 'verses'
? 'bg-primary text-primary-foreground'
: 'text-grey-2 hover:text-foreground'
}`}
>
آیات و ترجمه‌های ۵ زبانه ({toFa(surahDetail?.verses?.length || selectedSurah?.verses_count || 0)})
</button>
<button
type="button"
onClick={() => setModalTab('info')}
className={`rounded-lg px-3 py-1.5 text-xs font-bold transition-all ${
modalTab === 'info'
? 'bg-primary text-primary-foreground'
: 'text-grey-2 hover:text-foreground'
}`}
>
عناوین ۵ زبانه سوره
</button>
</div>
</DialogTitle>
</DialogHeader>
<DialogBody className="p-5 sm:p-6 space-y-4">
{modalTab === 'info' ? (
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">نام سوره (فارسی - FA)</label>
<Input
value={formNameFa}
onChange={(e) => setFormNameFa(e.target.value)}
className="rounded-xl text-xs"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">نام سوره (عربی - AR)</label>
<Input
value={formNameAr}
onChange={(e) => setFormNameAr(e.target.value)}
className="rounded-xl text-xs"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">نام سوره (انگلیسی - EN)</label>
<Input
dir="ltr"
value={formNameEn}
onChange={(e) => setFormNameEn(e.target.value)}
className="rounded-xl text-xs"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">نام سوره (روسی - RU)</label>
<Input
dir="ltr"
value={formNameRu}
onChange={(e) => setFormNameRu(e.target.value)}
className="rounded-xl text-xs"
/>
</div>
<div className="space-y-1.5 sm:col-span-2">
<label className="text-xs font-bold text-foreground">نام سوره (اندونزیایی - ID)</label>
<Input
dir="ltr"
value={formNameId}
onChange={(e) => setFormNameId(e.target.value)}
className="rounded-xl text-xs"
/>
</div>
</div>
</div>
) : (
<div className="space-y-4">
{/* Language Switcher & Verse Search */}
<div className="sticky top-0 z-10 -mx-5 sm:-mx-6 -mt-5 sm:-mt-6 p-4 px-5 sm:px-6 bg-card/95 backdrop-blur-xs border-b border-border-soft flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between shadow-2xs">
<div className="relative flex-1">
<Ic
name="search"
className="pointer-events-none absolute start-3 top-1/2 size-4 -translate-y-1/2 text-grey-3"
/>
<Input
value={verseSearch}
onChange={(e) => setVerseSearch(e.target.value)}
placeholder="جستجو در متن عربی یا ترجمه‌های آیات این سوره..."
className="ps-9 rounded-xl text-xs"
/>
</div>
<div className="flex flex-wrap items-center gap-1 rounded-xl border border-border-soft bg-background/60 p-1">
{(
[
{ code: 'fa', label: 'ترجمه فارسی (FA)' },
{ code: 'en', label: 'English (EN)' },
{ code: 'ru', label: 'Русский (RU)' },
{ code: 'id', label: 'Indonesia (ID)' },
] as const
).map((l) => (
<button
key={l.code}
type="button"
onClick={() => setActiveTransLang(l.code)}
className={`rounded-lg px-2.5 py-1 text-xs font-bold transition-all ${
activeTransLang === l.code
? 'bg-primary text-primary-foreground'
: 'text-grey-2 hover:text-foreground'
}`}
>
{l.label}
</button>
))}
</div>
</div>
{isLoadingDetail ? (
<div className="space-y-3">
{Array.from({ length: 4 }).map((_, i) => (
<ShimmerBox key={i} className="h-32 w-full rounded-2xl" />
))}
</div>
) : (
<div className="space-y-3 pe-1">
{filteredVerses.map((v, idx) => {
const draft = verseDrafts[v.index] || {
text: v.text,
translations: v.translations || {},
}
const ayahNum = v.index % 1000
const isVerseModified =
(draft.text ?? '') !== (v.text ?? '') ||
(draft.translations?.fa ?? '') !== (v.translations?.fa ?? '') ||
(draft.translations?.en ?? '') !== (v.translations?.en ?? '') ||
(draft.translations?.ru ?? '') !== (v.translations?.ru ?? '') ||
(draft.translations?.id ?? '') !== (v.translations?.id ?? '')
return (
<div
key={v.index}
style={
idx >= 4
? {
contentVisibility: 'auto',
containIntrinsicSize: 'auto none auto 260px',
}
: undefined
}
className="rounded-2xl border border-border-soft bg-background/50 p-4 space-y-3"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Badge
variant="outline"
className="border-primary/30 bg-primary/10 text-primary font-bold text-xs"
>
آیه {toFa(ayahNum)}
</Badge>
<Badge variant="outline" className="text-[11px] text-grey-3">
جزء {toFa(v.juz || 1)}
</Badge>
</div>
{isVerseModified && (
<span className="text-[10px] font-bold text-amber-500 bg-amber-500/10 border border-amber-500/20 px-2 py-0.5 rounded-lg flex items-center gap-1">
<span className="size-1.5 rounded-full bg-amber-500 animate-pulse" />
ویرایش‌شده
</span>
)}
</div>
<div className="space-y-1">
<label className="text-[11px] font-bold text-grey-3">
متن عربی آیه (AR):
</label>
<textarea
dir="rtl"
rows={2}
value={draft.text}
onChange={(e) =>
setVerseDrafts((prev) => ({
...prev,
[v.index]: { ...draft, text: e.target.value },
}))
}
className="w-full rounded-xl border border-border-soft bg-card p-2.5 text-sm font-semibold leading-relaxed text-foreground focus:outline-none focus:ring-2 focus:ring-primary/40"
/>
</div>
<div className="space-y-1">
<label className="text-[11px] font-bold text-grey-3">
ترجمه ({activeTransLang.toUpperCase()}):
</label>
<textarea
dir={activeTransLang === 'fa' ? 'rtl' : 'ltr'}
rows={2}
value={draft.translations?.[activeTransLang] || ''}
onChange={(e) =>
setVerseDrafts((prev) => ({
...prev,
[v.index]: {
...draft,
translations: {
...draft.translations,
[activeTransLang]: e.target.value,
},
},
}))
}
className="w-full rounded-xl border border-border-soft bg-card p-2.5 text-xs leading-relaxed text-foreground focus:outline-none focus:ring-2 focus:ring-primary/40"
/>
</div>
</div>
)
})}
</div>
)}
</div>
)}
</DialogBody>
{/* Footer actions pinned at the bottom of the dialog */}
<DialogFooter className="p-4 sm:p-5 border-t border-border-soft bg-card/95 backdrop-blur-xs flex-row items-center justify-end gap-2 shrink-0">
<Button
variant="outline"
onClick={() => setSelectedSurah(null)}
className="rounded-xl text-xs"
>
بستن
</Button>
{modalTab === 'info' ? (
<Button
onClick={handleSaveSurahInfo}
disabled={isSavingInfo}
className="rounded-xl text-xs gap-1.5"
>
<Ic name="check" className="size-4" />
<span>{isSavingInfo ? 'در حال ذخیره...' : 'ذخیره عناوین ۵ زبانه'}</span>
</Button>
) : (
<Button
onClick={handleSaveAllVerses}
disabled={isSavingAllVerses || isLoadingDetail}
className="rounded-xl text-xs gap-1.5"
>
<Ic name="check" className="size-4" />
<span>
{isSavingAllVerses
? 'در حال ذخیره...'
: 'ذخیره آیات و ترجمه‌ها'}
</span>
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

96
src/features/services/services/blogs-api.ts

@ -0,0 +1,96 @@
import { apiFetch } from '@/services/http'
export interface BlogTag {
id: number
name: string
slug: string
}
export interface BlogItem {
id: number
title: string
slug: string
content: string
summary: string
tags: BlogTag[]
tag_ids?: number[]
tag_names?: string[]
status: boolean
is_top_list: boolean
cover: string | null
cover_input?: string
language: number
language_code?: string
language_code_display?: string
created_at: string
updated_at: string
}
export interface BlogListResponse {
count?: number
next?: string | null
previous?: string | null
results: BlogItem[]
}
export async function fetchAdminBlogs(params?: {
language_code?: string
status?: string
search?: string
tag?: string
}): Promise<BlogItem[]> {
const query = new URLSearchParams()
if (params?.language_code && params.language_code !== 'ALL') {
query.set('language_code', params.language_code)
}
if (params?.status && params.status !== 'ALL') {
query.set('status', params.status)
}
if (params?.search) {
query.set('search', params.search)
}
if (params?.tag && params.tag !== 'ALL') {
query.set('tag', params.tag)
}
const qs = query.toString()
const path = qs ? `blogs/admin/?${qs}` : 'blogs/admin/'
const data = await apiFetch<any>(path)
return Array.isArray(data) ? data : data?.results ?? []
}
export async function fetchAdminBlog(id: number): Promise<BlogItem> {
return await apiFetch<BlogItem>(`blogs/admin/${id}/`)
}
export async function createAdminBlog(payload: Partial<BlogItem>): Promise<BlogItem> {
return await apiFetch<BlogItem>('blogs/admin/', {
method: 'POST',
body: payload,
})
}
export async function updateAdminBlog(id: number, payload: Partial<BlogItem>): Promise<BlogItem> {
return await apiFetch<BlogItem>(`blogs/admin/${id}/`, {
method: 'PATCH',
body: payload,
})
}
export async function deleteAdminBlog(id: number): Promise<void> {
await apiFetch(`blogs/admin/${id}/`, {
method: 'DELETE',
})
}
export async function fetchBlogTags(): Promise<BlogTag[]> {
const data = await apiFetch<any>('blogs/admin/tags/')
return Array.isArray(data) ? data : data?.results ?? []
}
export async function createBlogTag(name: string): Promise<BlogTag> {
return await apiFetch<BlogTag>('blogs/admin/tags/', {
method: 'POST',
body: { name },
})
}

27
src/features/services/services/trip-etiquette-api.ts

@ -196,6 +196,33 @@ export async function fetchDuas(params?: {
}) })
} }
/**
* دریافت لیست سوره‌های منتخب قرآن کریم (index >= 6000)
*/
export async function fetchSurahs(params?: {
search?: string
}): Promise<DuaItem[]> {
const q = new URLSearchParams()
q.set('type', 'quran')
if (params?.search) q.set('search', params.search)
const url = `trip-etiquette/duas/?${q.toString()}`
const res: any = await apiFetch<any>(url)
const list: DuaItem[] = Array.isArray(res)
? res
: res && Array.isArray(res.results)
? res.results
: []
const overrides = getDuaStatusOverrides()
return list.map((surah) => {
if (overrides[surah.index] !== undefined) {
return { ...surah, is_active: overrides[surah.index] }
}
return { ...surah, is_active: surah.is_active !== undefined ? surah.is_active : true }
})
}
/** /**
* دریافت جزئیات دعا به همراه فرازها و تمام ترجمه‌ها * دریافت جزئیات دعا به همراه فرازها و تمام ترجمه‌ها
*/ */

4
src/features/settings/components/SocialMediaSettingsTab.tsx

@ -479,7 +479,7 @@ export function SocialMediaSettingsTab() {
type="text" type="text"
value={item.name} value={item.name}
onChange={(e) => handleItemChange(index, 'name', e.target.value)} onChange={(e) => handleItemChange(index, 'name', e.target.value)}
placeholder="مثال: کانال رسمی اطلاع‌رسانی عقیله"
placeholder="مثال: کانال رسمی اطلاع‌رسانی کاروان نصرت عقیله زینب (س)"
className="w-full h-9.5 rounded-xl bg-[#14161c] border border-white/[0.1] px-3.5 text-xs font-medium text-slate-100 placeholder:text-slate-500 outline-none transition-colors focus:border-primary focus:ring-1 focus:ring-primary/40" className="w-full h-9.5 rounded-xl bg-[#14161c] border border-white/[0.1] px-3.5 text-xs font-medium text-slate-100 placeholder:text-slate-500 outline-none transition-colors focus:border-primary focus:ring-1 focus:ring-primary/40"
/> />
</div> </div>
@ -594,7 +594,7 @@ export function SocialMediaSettingsTab() {
rows={2} rows={2}
value={contactus.address || ''} value={contactus.address || ''}
onChange={(e) => setContactus((prev) => ({ ...prev, address: e.target.value }))} onChange={(e) => setContactus((prev) => ({ ...prev, address: e.target.value }))}
placeholder="آدرس دقیق دفتر مرکزی عقیله جهت نمایش در بخش ارتباط با ما..."
placeholder="آدرس دقیق دفتر مرکزی جهت نمایش در بخش ارتباط با ما..."
className="w-full rounded-xl bg-[#14161c] border border-white/[0.1] p-3 text-xs font-medium text-slate-100 placeholder:text-slate-500 outline-none transition-colors focus:border-primary focus:ring-1 focus:ring-primary/40 resize-none" className="w-full rounded-xl bg-[#14161c] border border-white/[0.1] p-3 text-xs font-medium text-slate-100 placeholder:text-slate-500 outline-none transition-colors focus:border-primary focus:ring-1 focus:ring-primary/40 resize-none"
/> />
</div> </div>

2
src/features/tours/OngoingToursPage.tsx

@ -557,7 +557,7 @@ export default function OngoingToursPage() {
className="h-full w-full object-cover" className="h-full w-full object-cover"
onError={(e) => { onError={(e) => {
;(e.target as HTMLImageElement).src = ;(e.target as HTMLImageElement).src =
'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200" fill="%2318181b"><rect width="200" height="200"/><text x="50%" y="50%" fill="%2371717a" dominant-baseline="middle" text-anchor="middle" font-size="12" font-family="sans-serif">Aqila</text></svg>'
'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="200" height="200" viewBox="0 0 200 200" fill="%2318181b"><rect width="200" height="200"/><text x="50%" y="50%" fill="%2371717a" dominant-baseline="middle" text-anchor="middle" font-size="12" font-family="sans-serif">Tour</text></svg>'
}} }}
/> />
</div> </div>

60
src/features/tours/ToursPage.tsx

@ -490,7 +490,7 @@ export default function ToursPage() {
className="h-full w-full object-cover transition duration-300 group-hover:scale-105" className="h-full w-full object-cover transition duration-300 group-hover:scale-105"
onError={(e) => { onError={(e) => {
;(e.target as HTMLImageElement).src = ;(e.target as HTMLImageElement).src =
'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="400" height="250" viewBox="0 0 400 250" fill="%2318181b"><rect width="400" height="250"/><text x="50%" y="50%" fill="%2371717a" dominant-baseline="middle" text-anchor="middle" font-size="14" font-family="sans-serif">Aqila Tour</text></svg>'
'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="400" height="250" viewBox="0 0 400 250" fill="%2318181b"><rect width="400" height="250"/><text x="50%" y="50%" fill="%2371717a" dominant-baseline="middle" text-anchor="middle" font-size="14" font-family="sans-serif">Tour</text></svg>'
}} }}
/> />
{/* وضعیت تور و تاگل تعاملی نمایش */} {/* وضعیت تور و تاگل تعاملی نمایش */}
@ -534,6 +534,37 @@ export default function ToursPage() {
</span> </span>
</div> </div>
{/* ظرفیت و تعداد نفرات باقی مانده */}
{tour.capacity !== undefined && (
(() => {
const rem = tour.remaining_capacity !== undefined
? tour.remaining_capacity
: Math.max(0, (tour.capacity || 0) - (tour.number_sold || 0))
const isLow = rem < 5 && rem > 0
const isSoldOut = rem <= 0
return (
<div
className={`flex items-center justify-between text-xs px-2.5 py-1.5 rounded-xl border transition-colors ${
isSoldOut
? 'bg-rose-500/10 border-rose-500/30 text-rose-500 dark:text-rose-400 font-bold'
: isLow
? 'bg-amber-500/15 border-amber-500/40 text-amber-600 dark:text-amber-400 font-black animate-pulse'
: 'bg-surface-base border-border-soft text-grey-2'
}`}
>
<span className="flex items-center gap-1.5 text-[11px]">
<Ic name="users" className={`size-3.5 ${isLow ? 'text-amber-500' : isSoldOut ? 'text-rose-500' : 'text-primary'}`} />
<span>ظرفیت باقیمانده:</span>
</span>
<span className="font-mono text-xs font-black">
{isSoldOut ? 'تکمیل ظرفیت' : `${rem} نفر`}
</span>
</div>
)
})()
)}
{/* جدول ۳ ردیف قیمت تور بر اساس رده سنی */} {/* جدول ۳ ردیف قیمت تور بر اساس رده سنی */}
<div className="space-y-1.5 pt-2 border-t border-border-soft text-xs"> <div className="space-y-1.5 pt-2 border-t border-border-soft text-xs">
<div className="flex items-center justify-between text-grey-2"> <div className="flex items-center justify-between text-grey-2">
@ -580,6 +611,7 @@ export default function ToursPage() {
<TableHead className="text-right text-xs font-bold">شناسه</TableHead> <TableHead className="text-right text-xs font-bold">شناسه</TableHead>
<TableHead className="text-right text-xs font-bold">عنوان تور</TableHead> <TableHead className="text-right text-xs font-bold">عنوان تور</TableHead>
<TableHead className="text-right text-xs font-bold">تاریخ برگزاری</TableHead> <TableHead className="text-right text-xs font-bold">تاریخ برگزاری</TableHead>
<TableHead className="text-right text-xs font-bold">ظرفیت باقیمانده</TableHead>
<TableHead className="text-right text-xs font-bold">نرخ‌ها (بزرگسال / کودک / نوزاد)</TableHead> <TableHead className="text-right text-xs font-bold">نرخ‌ها (بزرگسال / کودک / نوزاد)</TableHead>
<TableHead className="text-right text-xs font-bold">وضعیت نمایش</TableHead> <TableHead className="text-right text-xs font-bold">وضعیت نمایش</TableHead>
<TableHead className="text-right text-xs font-bold">وضعیت سفر</TableHead> <TableHead className="text-right text-xs font-bold">وضعیت سفر</TableHead>
@ -587,7 +619,14 @@ export default function ToursPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{paginatedTours.map((tour) => (
{paginatedTours.map((tour) => {
const rem = tour.remaining_capacity !== undefined
? tour.remaining_capacity
: Math.max(0, (tour.capacity || 0) - (tour.number_sold || 0))
const isLow = rem < 5 && rem > 0
const isSoldOut = rem <= 0
return (
<TableRow <TableRow
key={tour.id} key={tour.id}
className="transition hover:bg-card/70 cursor-pointer" className="transition hover:bg-card/70 cursor-pointer"
@ -601,6 +640,20 @@ export default function ToursPage() {
<TableCell className="text-xs text-grey-2"> <TableCell className="text-xs text-grey-2">
{formatDateRangeByLanguage(tour.started_at, tour.ended_at, language as any)} {formatDateRangeByLanguage(tour.started_at, tour.ended_at, language as any)}
</TableCell> </TableCell>
<TableCell className="text-xs">
<span
className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-bold ${
isSoldOut
? 'bg-rose-500/10 text-rose-500 border border-rose-500/20'
: isLow
? 'bg-amber-500/15 text-amber-600 dark:text-amber-400 border border-amber-500/30 animate-pulse'
: 'bg-surface-base text-foreground border border-border-soft'
}`}
>
<Ic name="users" className={`size-3 ${isLow ? 'text-amber-500' : isSoldOut ? 'text-rose-500' : 'text-primary'}`} />
<span className="font-mono">{isSoldOut ? 'تکمیل' : `${rem} نفر`}</span>
</span>
</TableCell>
<TableCell className="text-xs font-mono"> <TableCell className="text-xs font-mono">
<div className="font-black text-foreground">${parseFloat(tour.price || '0').toLocaleString()}</div> <div className="font-black text-foreground">${parseFloat(tour.price || '0').toLocaleString()}</div>
<div className="text-[10px] text-grey-3"> <div className="text-[10px] text-grey-3">
@ -638,7 +691,8 @@ export default function ToursPage() {
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
))}
)
})}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>

16
src/features/tours/components/TourDetailDialog.tsx

@ -1411,8 +1411,14 @@ export function TourDetailDialog({
style={{ width: `${percentSold}%` }} style={{ width: `${percentSold}%` }}
/> />
</div> </div>
<div className="flex justify-between text-[11px] text-grey-3">
<span>ظرفیت باقیمانده: {remainingCap} صندلی</span>
<div className={`flex justify-between text-[11px] font-medium ${
remainingCap < 5 && remainingCap > 0
? 'text-amber-500 font-bold'
: remainingCap === 0
? 'text-rose-500 font-bold'
: 'text-grey-3'
}`}>
<span>ظرفیت باقیمانده: {remainingCap} صندلی {remainingCap < 5 && remainingCap > 0 ? '(رو به اتمام)' : remainingCap === 0 ? '(تکمیل شده)' : ''}</span>
<span>فروخته شده: {soldCount} نفر | ظرفیت کل: {capCount} نفر</span> <span>فروخته شده: {soldCount} نفر | ظرفیت کل: {capCount} نفر</span>
</div> </div>
</div> </div>
@ -1902,6 +1908,12 @@ export function TourDetailDialog({
<span>Passport: {p.passport_number || '-'}</span> <span>Passport: {p.passport_number || '-'}</span>
<span>DOB: {p.birthdate || '-'}</span> <span>DOB: {p.birthdate || '-'}</span>
</div> </div>
{p.phone_number && (
<div className="flex justify-between items-center text-grey-3 font-mono text-[11px] pt-1 border-t border-border-soft/60" dir="ltr">
<span className="text-[10px] text-muted-foreground">WhatsApp:</span>
<span className="text-foreground font-semibold">{p.phone_number}</span>
</div>
)}
</div> </div>
))} ))}
</div> </div>

22
src/features/tours/components/TourDetailSheet.tsx

@ -266,10 +266,22 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp
}} }}
/> />
</div> </div>
<div className="flex justify-between text-[11px] text-grey-3">
<span>ظرفیت باقیمانده: {Math.max(0, tour.capacity - tour.number_sold)} صندلی</span>
<span>ظرفیت کل: {tour.capacity}</span>
</div>
{(() => {
const rem = tour.remaining_capacity !== undefined
? tour.remaining_capacity
: Math.max(0, tour.capacity - tour.number_sold)
const isLow = rem < 5 && rem > 0
const isSoldOut = rem === 0
return (
<div className="flex justify-between text-[11px]">
<span className={isLow ? 'text-amber-500 font-bold' : isSoldOut ? 'text-rose-500 font-bold' : 'text-grey-3'}>
ظرفیت باقیمانده: {rem} صندلی {isLow ? '(کمتر از ۵ نفر)' : isSoldOut ? '(تکمیل شده)' : ''}
</span>
<span className="text-grey-3">ظرفیت کل: {tour.capacity}</span>
</div>
)
})()}
</div> </div>
{/* تفکیک نرخ‌ها و قیمت‌گذاری */} {/* تفکیک نرخ‌ها و قیمت‌گذاری */}
@ -394,7 +406,7 @@ export function TourDetailSheet({ tourId, isOpen, onClose }: TourDetailSheetProp
<span className="font-mono">{p.birthdate}</span> <span className="font-mono">{p.birthdate}</span>
</div> </div>
<div className="text-[11px] text-grey-3 flex justify-between"> <div className="text-[11px] text-grey-3 flex justify-between">
<span>تماس:</span>
<span>واتساپ:</span>
<span className="font-mono" dir="ltr">{p.phone_number}</span> <span className="font-mono" dir="ltr">{p.phone_number}</span>
</div> </div>
</div> </div>

48
src/features/tours/components/TourTranslationsDialog.tsx

@ -5,6 +5,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogDescription, DialogDescription,
DialogBody,
DialogFooter, DialogFooter,
DialogClose, DialogClose,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
@ -264,9 +265,9 @@ export function TourTranslationsDialog({
return ( return (
<Dialog open={open} onOpenChange={onOpenChange}> <Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent showCloseButton={false} className="max-w-[760px] p-0 overflow-hidden max-h-[90vh] flex flex-col">
<DialogContent className="max-w-[760px] max-h-[90vh]">
{/* Dialog Header */} {/* Dialog Header */}
<DialogHeader className="px-4 sm:px-6 pt-4 sm:pt-5 pb-4 border-b border-border/50 bg-secondary/15">
<DialogHeader className="px-4 sm:px-6 pt-4 sm:pt-5 pb-4">
<div className="flex flex-wrap items-center justify-between gap-2"> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2.5 min-w-0"> <div className="flex items-center gap-2.5 min-w-0">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary shrink-0"> <div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary shrink-0">
@ -276,35 +277,22 @@ export function TourTranslationsDialog({
<DialogTitle className="text-sm sm:text-base font-extrabold text-foreground"> <DialogTitle className="text-sm sm:text-base font-extrabold text-foreground">
مدیریت ترجمه‌های 5 زبانه تور مدیریت ترجمه‌های 5 زبانه تور
</DialogTitle> </DialogTitle>
<DialogDescription className="text-xs text-grey-3 mt-0.5 truncate">
<DialogDescription className="text-xs text-muted-foreground mt-0.5 truncate">
{tourTitle || `تور شناسه #${tourId}`} &bull; ذخیره‌سازی پویا در سرور و پایگاه‌داده {tourTitle || `تور شناسه #${tourId}`} &bull; ذخیره‌سازی پویا در سرور و پایگاه‌داده
</DialogDescription> </DialogDescription>
</div> </div>
</div> </div>
<div className="flex items-center gap-2 ms-auto">
{/* Primary Language Indicator */}
{transData && (
<div className="flex items-center gap-1.5 rounded-full bg-surface-2 px-3 py-1 text-xs text-grey-2 border border-border/40">
<span className="text-grey-3">زبان پایه دیتابیس:</span>
<span className="font-bold text-foreground">
{SUPPORTED_LANGUAGES.find((l) => l.code === transData.primary_language)?.label ||
transData.primary_language}
</span>
</div>
)}
{/* دکمه ضربدر بالای مودال */}
<button
type="button"
onClick={() => onOpenChange(false)}
aria-label="بستن پنجره"
title="بستن"
className="flex size-8 items-center justify-center rounded-lg text-grey-3 hover:text-foreground hover:bg-white/10 transition-colors shrink-0 cursor-pointer ms-1"
>
<Ic name="x" className="size-4.5" />
</button>
</div>
{/* Primary Language Indicator */}
{transData && (
<div className="flex items-center gap-1.5 rounded-full bg-surface-2 px-3 py-1 text-xs text-muted-foreground border border-border-soft ms-auto">
<span className="text-muted-foreground">زبان پایه دیتابیس:</span>
<span className="font-bold text-foreground">
{SUPPORTED_LANGUAGES.find((l) => l.code === transData.primary_language)?.label ||
transData.primary_language}
</span>
</div>
)}
</div> </div>
{/* 5-Language Selector Bar via ServiceTranslation */} {/* 5-Language Selector Bar via ServiceTranslation */}
@ -329,7 +317,7 @@ export function TourTranslationsDialog({
</DialogHeader> </DialogHeader>
{/* Dialog Body */} {/* Dialog Body */}
<div className="flex-1 overflow-y-auto px-4 sm:px-6 py-5 space-y-5">
<DialogBody className="px-4 sm:px-6 py-5 space-y-5">
{isLoading ? ( {isLoading ? (
<TourDetailOverviewShimmer /> <TourDetailOverviewShimmer />
) : ( ) : (
@ -599,11 +587,11 @@ export function TourTranslationsDialog({
</div> </div>
</> </>
)} )}
</div>
</DialogBody>
{/* Dialog Footer */} {/* Dialog Footer */}
<DialogFooter className="px-6 py-4 bg-secondary/10 border-t border-border/50 flex items-center justify-between">
<div className="text-xs text-grey-3">
<DialogFooter className="px-6 py-4 flex-row items-center justify-between">
<div className="text-xs text-muted-foreground">
زبان در حال ویرایش: <span className="font-bold text-primary">{activeLangConfig?.label}</span> زبان در حال ویرایش: <span className="font-bold text-primary">{activeLangConfig?.label}</span>
</div> </div>

4
src/features/tours/types.ts

@ -37,6 +37,9 @@ export interface TourListItem {
destination_country: string | null destination_country: string | null
is_access: boolean is_access: boolean
visibility?: boolean visibility?: boolean
capacity?: number
remaining_capacity?: number
number_sold?: number
} }
export interface TourFeatureItem { export interface TourFeatureItem {
@ -58,6 +61,7 @@ export interface TourDetailItem {
started_at: string started_at: string
ended_at: string ended_at: string
capacity: number capacity: number
remaining_capacity?: number
number_sold: number number_sold: number
tour_features: TourFeatureItem[] tour_features: TourFeatureItem[]
travel_tips: TravelTipItem[] travel_tips: TravelTipItem[]

140
src/lib/date-utils.ts

@ -395,10 +395,10 @@ export const HIJRI_MONTH_NAMES_FA = [
export const HIJRI_MONTH_NAMES_EN = [ export const HIJRI_MONTH_NAMES_EN = [
'Muharram', 'Muharram',
'Safar', 'Safar',
'Rabi I',
'Rabi II',
'Jumada I',
'Jumada II',
'Rabi al-Awwal',
'Rabi al-Thani',
'Jumada al-Awwal',
'Jumada al-Thani',
'Rajab', 'Rajab',
'Shaban', 'Shaban',
'Ramadan', 'Ramadan',
@ -422,6 +422,36 @@ export const GREGORIAN_MONTH_NAMES = [
'December', 'December',
] ]
export const GREGORIAN_MONTH_NAMES_FA = [
'ژانویه',
'فوریه',
'مارس',
'آوریل',
'مه',
'ژوئن',
'ژوئیه',
'اوت',
'سپتامبر',
'اکتبر',
'نوامبر',
'دسامبر',
]
export const GREGORIAN_MONTH_NAMES_AR = [
'يناير',
'فبراير',
'مارس',
'أبريل',
'مايو',
'يونيو',
'يوليو',
'أغسطس',
'سبتمبر',
'أكتوبر',
'نوفمبر',
'ديسمبر',
]
export const WEEKDAY_NAMES_FA = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج'] export const WEEKDAY_NAMES_FA = ['ش', 'ی', 'د', 'س', 'چ', 'پ', 'ج']
export const WEEKDAY_NAMES_AR = ['س', 'ح', 'ن', 'ث', 'ر', 'خ', 'ج'] export const WEEKDAY_NAMES_AR = ['س', 'ح', 'ن', 'ث', 'ر', 'خ', 'ج']
export const WEEKDAY_NAMES_EN = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'] export const WEEKDAY_NAMES_EN = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
@ -434,42 +464,100 @@ export interface SecondaryDayInfo {
} }
/** /**
* نمایش تاریخ به صورت دوگانه (هم میلادی و هم هجری قمری)
* خروجی نمونه: 2026/10/25 (۱۴ جمادی‌الاول ۱۴۴۸)
* قالب‌بندی تاریخ میلادی با تقدم ماه در ابتدا (Month Day, Year)
* مثال:
* EN: October 29, 2026
* FA: اکتبر 29، 2026
* AR: أكتوبر 29، 2026
*/ */
export function formatDualDate(
export function formatGregorianDate(
dateInput: string | number | Date | null | undefined, dateInput: string | number | Date | null | undefined,
lang: SupportedLanguage = 'fa', lang: SupportedLanguage = 'fa',
options: { withTime?: boolean; time?: string | null; compact?: boolean } = {}
options: { compact?: boolean } = {}
): string { ): string {
const d = parseSafeDate(dateInput) const d = parseSafeDate(dateInput)
if (!d) return '—' if (!d) return '—'
const gy = d.getFullYear()
const gm = String(d.getMonth() + 1).padStart(2, '0')
const gd = String(d.getDate()).padStart(2, '0')
const gregDateStr = `${gy}/${gm}/${gd}`
const monthIdx = d.getMonth()
const day = d.getDate()
const year = d.getFullYear()
if (lang === 'fa') {
const mName = GREGORIAN_MONTH_NAMES_FA[monthIdx] || GREGORIAN_MONTH_NAMES[monthIdx]
return options.compact
? `${mName} ${day}`
: `${mName} ${day}، ${year}`
} else if (lang === 'ar') {
const mName = GREGORIAN_MONTH_NAMES_AR[monthIdx] || GREGORIAN_MONTH_NAMES[monthIdx]
return options.compact
? `${mName} ${day}`
: `${mName} ${day}، ${year}`
} else {
const mName = GREGORIAN_MONTH_NAMES[monthIdx]
return options.compact
? `${mName} ${day}`
: `${mName} ${day}, ${year}`
}
}
/**
* قالب‌بندی تاریخ هجری قمری با تقدم ماه در ابتدا (Month Day, Year)
* مثال:
* EN: Jumada al-Awwal 18, 1448 AH
* FA: جمادی‌الاول 18، 1448
* AR: جمادى الأولى 18، 1448 هـ
*/
export function formatHijriDate(
dateInput: string | number | Date | null | undefined,
lang: SupportedLanguage = 'fa',
options: { compact?: boolean; withSuffix?: boolean } = {}
): string {
const d = parseSafeDate(dateInput)
if (!d) return '—'
const h = gregorianToHijri(d) const h = gregorianToHijri(d)
let hijriStr = ''
const monthIdx = h.month - 1
if (lang === 'fa') { if (lang === 'fa') {
const mName = HIJRI_MONTH_NAMES_FA[h.month - 1] || HIJRI_MONTH_NAMES[h.month - 1]
hijriStr = options.compact
? `${toFa(h.day)} ${mName}`
: `${toFa(h.day)} ${mName} ${toFa(h.year)}`
const mName = HIJRI_MONTH_NAMES_FA[monthIdx] || HIJRI_MONTH_NAMES[monthIdx]
const suffix = options.withSuffix ? ' هـ' : ''
return options.compact
? `${mName} ${h.day}`
: `${mName} ${h.day}، ${h.year}${suffix}`
} else if (lang === 'ar') { } else if (lang === 'ar') {
const mName = HIJRI_MONTH_NAMES[h.month - 1]
hijriStr = options.compact
? `${h.day} ${mName}`
: `${h.day} ${mName} ${h.year} هـ`
const mName = HIJRI_MONTH_NAMES[monthIdx]
const suffix = options.withSuffix !== false ? ' هـ' : ''
return options.compact
? `${mName} ${h.day}`
: `${mName} ${h.day}، ${h.year}${suffix}`
} else { } else {
const mName = HIJRI_MONTH_NAMES_EN[h.month - 1] || HIJRI_MONTH_NAMES[h.month - 1]
hijriStr = options.compact
? `${h.day} ${mName}`
: `${h.day} ${mName} ${h.year} AH`
const mName = HIJRI_MONTH_NAMES_EN[monthIdx] || HIJRI_MONTH_NAMES[monthIdx]
const suffix = options.withSuffix !== false ? ' AH' : ''
return options.compact
? `${mName} ${h.day}`
: `${mName} ${h.day}, ${h.year}${suffix}`
} }
}
/**
* نمایش تاریخ به صورت دوگانه با تقدم نام ماه در ابتدا برای هر دو تقویم میلادی و هجری قمری
* مثال:
* انگلیسی: October 29, 2026 (Jumada al-Awwal 18, 1448 AH)
* فارسی: اکتبر 29، 2026 (جمادی‌الاول 18، 1448)
* عربی: أكتوبر 29، 2026 (جمادى الأولى 18، 1448 هـ)
*/
export function formatDualDate(
dateInput: string | number | Date | null | undefined,
lang: SupportedLanguage = 'fa',
options: { withTime?: boolean; time?: string | null; compact?: boolean } = {}
): string {
const d = parseSafeDate(dateInput)
if (!d) return '—'
const gregStr = formatGregorianDate(d, lang, { compact: options.compact })
const hijriStr = formatHijriDate(d, lang, { compact: options.compact })
let result = `\u200E${gregDateStr}\u200E (${hijriStr})`
let result = `\u200E${gregStr}\u200E (${hijriStr})`
if (options.withTime && options.time) { if (options.withTime && options.time) {
const timeFormatted = lang === 'fa' ? toFa(options.time) : options.time const timeFormatted = lang === 'fa' ? toFa(options.time) : options.time
result += ` | ${timeFormatted}` result += ` | ${timeFormatted}`

28
src/lib/utils.ts

@ -95,3 +95,31 @@ export function formatTime(seconds: number): string {
const s = Math.floor(seconds % 60) const s = Math.floor(seconds % 60)
return toFa(`${m}:${String(s).padStart(2, '0')}`) return toFa(`${m}:${String(s).padStart(2, '0')}`)
} }
/**
* فرمت‌بندی مبالغ و اعداد به صورت ۳ رقم ۳ رقم جدا شده با کاما (مانند ۱,۵۲۰,۰۰۰ یا 1,520,000)
*/
export function formatAmount(value: number | string | null | undefined): string {
if (value == null || value === '') return ''
const str = toEn(value).trim()
if (!str) return ''
const parts = str.split('.')
const integerPart = parts[0].replace(/\D/g, '')
if (!integerPart && parts.length === 1) return ''
const formattedInt = integerPart ? Number(integerPart).toLocaleString('en-US') : ''
if (parts.length > 1) {
const decimalPart = parts[1].replace(/\D/g, '').slice(0, 2)
return `${formattedInt || '0'}.${decimalPart}`
}
return formattedInt
}
/**
* استخراج عدد خام از رشته ورودیِ فرمت‌بندی‌شده
*/
export function parseAmount(value: string | null | undefined): number {
if (!value) return 0
const clean = toEn(value).replace(/,/g, '').trim()
const num = Number(clean)
return isNaN(num) ? 0 : num
}

29
src/locales/ar.json

@ -1,7 +1,7 @@
{ {
"navigation": { "navigation": {
"brand": "لوحة تحكم عقيلة",
"brandShort": "عقيلة",
"brand": "لوحة تحكم حملة نصرة العقيلة زينب عليها السلام",
"brandShort": "حملة نصرة العقيلة زينب عليها السلام",
"dashboard": "الرئيسية", "dashboard": "الرئيسية",
"dashboardTitle": "لوحة التحكم", "dashboardTitle": "لوحة التحكم",
"users": "المستخدمون", "users": "المستخدمون",
@ -21,8 +21,10 @@
"exchange": "الصرافة الإلكترونية", "exchange": "الصرافة الإلكترونية",
"duas": "الأدعية والزيارات", "duas": "الأدعية والزيارات",
"duasList": "الأدعية والزيارات", "duasList": "الأدعية والزيارات",
"surahs": "السور القرآنية",
"categories": "التصنيفات", "categories": "التصنيفات",
"cities": "المدن المقدسة", "cities": "المدن المقدسة",
"blogs": "المقالات والمدونة",
"store": "متجر نصرة العقيلة", "store": "متجر نصرة العقيلة",
"storeProducts": "المنتجات", "storeProducts": "المنتجات",
"storeCategories": "التصنيفات", "storeCategories": "التصنيفات",
@ -72,7 +74,7 @@
"readyBadge": "النظام نشط ومتصل", "readyBadge": "النظام نشط ومتصل",
"welcomeSubtitle": "متابعة شاملة للمؤشرات الإحصائية والمالية والتشغيلية للمنصة", "welcomeSubtitle": "متابعة شاملة للمؤشرات الإحصائية والمالية والتشغيلية للمنصة",
"cleanBoardTitle": "لوحة تحكم نظيفة وجاهزة للتطوير", "cleanBoardTitle": "لوحة تحكم نظيفة وجاهزة للتطوير",
"cleanBoardDescription": "تم مسح جميع البيانات والواجهات الوهمية. يمكنك بناء وحداتك الجديدة باستخدام مكونات واجهة المستخدم الجاهزة والاتصال مباشرة بقاعدة البيانات وخادم عقيلة.",
"cleanBoardDescription": "تم مسح جميع البيانات والواجهات الوهمية. يمكنك بناء وحداتك الجديدة باستخدام مكونات واجهة المستخدم الجاهزة والاتصال مباشرة بقاعدة البيانات وخادم حملة نصرة العقيلة زينب عليها السلام.",
"liveMonitor": "متابعة حية ومتزامنة", "liveMonitor": "متابعة حية ومتزامنة",
"lastSynced": "آخر مزامنة:", "lastSynced": "آخر مزامنة:",
"refreshData": "تحديث البيانات", "refreshData": "تحديث البيانات",
@ -263,7 +265,7 @@
}, },
"billing": { "billing": {
"title": "إدارة الفواتير والمدفوعات", "title": "إدارة الفواتير والمدفوعات",
"description": "مراجعة إيصالات التحويل البنكي، واعتماد أو رفض المدفوعات ومتابعة تفاصيل فواتير خدمات عقيلة",
"description": "مراجعة إيصالات التحويل البنكي، واعتماد أو رفض المدفوعات ومتابعة تفاصيل فواتير خدمات حملة نصرة العقيلة زينب عليها السلام",
"totalInvoices": "إجمالي الفواتير", "totalInvoices": "إجمالي الفواتير",
"pendingReview": "بانتظار مراجعة الإيصال", "pendingReview": "بانتظار مراجعة الإيصال",
"awaitingPayment": "بانتظار دفع العميل", "awaitingPayment": "بانتظار دفع العميل",
@ -450,7 +452,7 @@
"settings": { "settings": {
"pageTitle": "إعدادات النظام", "pageTitle": "إعدادات النظام",
"pageDescription": "إدارة المشرفين وتعيين وتغيير كلمات المرور وتهيئة وحدات النظام", "pageDescription": "إدارة المشرفين وتعيين وتغيير كلمات المرور وتهيئة وحدات النظام",
"pageSubtitle": "إدارة المشرفين وشبكات التواصل الاجتماعي والإعدادات الشاملة لمنظومة عقيلة",
"pageSubtitle": "إدارة المشرفين وشبكات التواصل الاجتماعي والإعدادات الشاملة لمنظومة حملة نصرة العقيلة زينب عليها السلام",
"badgeRoleControl": "الصلاحيات والإعدادات", "badgeRoleControl": "الصلاحيات والإعدادات",
"tabAdmins": "إدارة المشرفين والصلاحيات", "tabAdmins": "إدارة المشرفين والصلاحيات",
"tabNotifications": "نظام الإشعارات", "tabNotifications": "نظام الإشعارات",
@ -474,10 +476,10 @@
"socialSaveError": "خطأ أثناء حفظ إعدادات شبكات التواصل الاجتماعي.", "socialSaveError": "خطأ أثناء حفظ إعدادات شبكات التواصل الاجتماعي.",
"socialFetchError": "خطأ في جلب إعدادات شبكات التواصل الاجتماعي من الخادم.", "socialFetchError": "خطأ في جلب إعدادات شبكات التواصل الاجتماعي من الخادم.",
"contactInfoCardTitle": "معلومات الاتصال الإضافية (اتصل بنا)", "contactInfoCardTitle": "معلومات الاتصال الإضافية (اتصل بنا)",
"contactInfoCardDesc": "يتم إرسال هذه المعلومات مع روابط شبكات التواصل في واجهات التطبيق والموقع الرسمية لعقيلة.",
"contactInfoCardDesc": "يتم إرسال هذه المعلومات مع روابط شبكات التواصل في واجهات التطبيق والموقع الرسمية لحملة نصرة العقيلة زينب عليها السلام.",
"phoneNumberLabel": "أرقام هواتف الدعم الفني", "phoneNumberLabel": "أرقام هواتف الدعم الفني",
"whatsappNumberLabel": "رقم الدعم المخصص عبر واتساب", "whatsappNumberLabel": "رقم الدعم المخصص عبر واتساب",
"addressLabel": "عنوان المكتب الرئيسي لعقيلة",
"addressLabel": "عنوان المكتب الرئيسي لحملة نصرة العقيلة زينب عليها السلام",
"testLinkTooltip": "تجربة وفتح الرابط في نافذة جديدة", "testLinkTooltip": "تجربة وفتح الرابط في نافذة جديدة",
"copyLinkTooltip": "نسخ الرابط إلى الحافظة", "copyLinkTooltip": "نسخ الرابط إلى الحافظة",
"linkCopied": "تم نسخ الرابط بنجاح.", "linkCopied": "تم نسخ الرابط بنجاح.",
@ -573,9 +575,9 @@
"status": "حالة الطلب", "status": "حالة الطلب",
"createdDate": "تاريخ الطلب", "createdDate": "تاريخ الطلب",
"actions": "الإجراءات", "actions": "الإجراءات",
"statusAwaitingPayment": "في انتظار الدفع",
"statusAwaitingPayment": "تمت الموافقة (في انتظار الدفع)",
"statusPending": "قيد المراجعة", "statusPending": "قيد المراجعة",
"statusApproved": "تمت الموافقة",
"statusApproved": "تمت الموافقة (تم الدفع)",
"statusRejected": "مرفوض", "statusRejected": "مرفوض",
"statusExpired": "منتهي الصلاحية", "statusExpired": "منتهي الصلاحية",
"statusCancelled": "ملغى", "statusCancelled": "ملغى",
@ -656,7 +658,7 @@
"rowNumber": "الرقم", "rowNumber": "الرقم",
"orderDetailDesc": "راجع تفاصيل الطلب المقدم من المسافر وحدد السعر النهائي والملاحظات.", "orderDetailDesc": "راجع تفاصيل الطلب المقدم من المسافر وحدد السعر النهائي والملاحظات.",
"passengerName": "اسم المسافر", "passengerName": "اسم المسافر",
"contactPhone": "رقم الهاتف",
"contactPhone": "رقم الواتساب",
"adults": "بالغ", "adults": "بالغ",
"children": "طفل", "children": "طفل",
"infants": "رضيع", "infants": "رضيع",
@ -676,9 +678,10 @@
"stage2Desc": "تمت مراجعة هذا الطلب واعتماده ونقل فاتورته إلى قسم الفواتير المالية.", "stage2Desc": "تمت مراجعة هذا الطلب واعتماده ونقل فاتورته إلى قسم الفواتير المالية.",
"invoiceTransferredBadge": "تم النقل إلى الفواتير المالية", "invoiceTransferredBadge": "تم النقل إلى الفواتير المالية",
"notInvoicedYetBadge": "بانتظار المراجعة وإصدار الفاتورة", "notInvoicedYetBadge": "بانتظار المراجعة وإصدار الفاتورة",
"viewInBilling": "عرض في الفواتير المالية",
"saveStage1Review": "حفظ تعديلات المراجعة (المرحلة ١)",
"approveAndTransferToBilling": "التأكيد النهائي والنقل إلى الفواتير المالية",
"saveStage1Review": "حفظ المسودة (بدون إرسال للدفع)",
"saveStage1ReviewHint": "حفظ تعديلات خطة الرحلة والفنادق دون إصدار فاتورة دفع للمسافر",
"approveAndTransferToBilling": "الموافقة النهائية وإصدار الفاتورة للدفع",
"approveAndTransferToBillingHint": "تحديد السعر النهائي وتفعيل فاتورة الدفع لمدة 3 أيام في تطبيق المسافر",
"updateInvoiceAndItems": "تحديث العناصر والفاتورة المالية", "updateInvoiceAndItems": "تحديث العناصر والفاتورة المالية",
"rejectOrderBtn": "رفض الطلب", "rejectOrderBtn": "رفض الطلب",
"addDestinationLeg": "إضافة وجهة جديدة لبرنامج الرحلة", "addDestinationLeg": "إضافة وجهة جديدة لبرنامج الرحلة",

27
src/locales/en.json

@ -1,7 +1,7 @@
{ {
"navigation": { "navigation": {
"brand": "Aqila Admin Panel",
"brandShort": "Aqila",
"brand": "Hamlat Nusrat Al Aqila Zainab s.a Admin Panel",
"brandShort": "Hamlat Nusrat Al Aqila Zainab s.a",
"dashboard": "Dashboard", "dashboard": "Dashboard",
"dashboardTitle": "Admin Dashboard", "dashboardTitle": "Admin Dashboard",
"users": "Users", "users": "Users",
@ -21,8 +21,10 @@
"exchange": "Online Exchange", "exchange": "Online Exchange",
"duas": "Duas & Ziyarat", "duas": "Duas & Ziyarat",
"duasList": "Duas & Ziyarat", "duasList": "Duas & Ziyarat",
"surahs": "Quran Surahs",
"categories": "Categories", "categories": "Categories",
"cities": "Holy Cities", "cities": "Holy Cities",
"blogs": "Blogs & Articles",
"store": "Nosrat Al-Aqila Store", "store": "Nosrat Al-Aqila Store",
"storeProducts": "Products", "storeProducts": "Products",
"storeCategories": "Categories", "storeCategories": "Categories",
@ -72,7 +74,7 @@
"readyBadge": "System Active & Online", "readyBadge": "System Active & Online",
"welcomeSubtitle": "Comprehensive monitoring of statistical, financial and operational metrics", "welcomeSubtitle": "Comprehensive monitoring of statistical, financial and operational metrics",
"cleanBoardTitle": "Clean Dashboard Ready for Development", "cleanBoardTitle": "Clean Dashboard Ready for Development",
"cleanBoardDescription": "All mock data and dummy UIs have been cleaned up. You can build your new modules using UI components and connect directly to Aqila database and backend.",
"cleanBoardDescription": "All mock data and dummy UIs have been cleaned up. You can build your new modules using UI components and connect directly to the Hamlat Nusrat Al Aqila Zainab s.a database and backend.",
"liveMonitor": "Live & Synchronized", "liveMonitor": "Live & Synchronized",
"lastSynced": "Last synced:", "lastSynced": "Last synced:",
"refreshData": "Refresh Data", "refreshData": "Refresh Data",
@ -263,7 +265,7 @@
}, },
"billing": { "billing": {
"title": "Invoices & Billing Management", "title": "Invoices & Billing Management",
"description": "Review bank receipts, approve or reject payments, and track invoices across Aqila services",
"description": "Review bank receipts, approve or reject payments, and track invoices across Hamlat Nusrat Al Aqila Zainab s.a services",
"totalInvoices": "Total Invoices", "totalInvoices": "Total Invoices",
"pendingReview": "Pending Receipt Review", "pendingReview": "Pending Receipt Review",
"awaitingPayment": "Awaiting Customer Payment", "awaitingPayment": "Awaiting Customer Payment",
@ -474,10 +476,10 @@
"socialSaveError": "Error saving social media settings.", "socialSaveError": "Error saving social media settings.",
"socialFetchError": "Error fetching social media settings from server.", "socialFetchError": "Error fetching social media settings from server.",
"contactInfoCardTitle": "Additional Contact Details (Contact Us)", "contactInfoCardTitle": "Additional Contact Details (Contact Us)",
"contactInfoCardDesc": "This info is returned alongside social links in Aqila's public mobile & web APIs.",
"contactInfoCardDesc": "This info is returned alongside social links in Hamlat Nusrat Al Aqila Zainab s.a's public mobile & web APIs.",
"phoneNumberLabel": "Support Phone Numbers", "phoneNumberLabel": "Support Phone Numbers",
"whatsappNumberLabel": "Dedicated WhatsApp Support Number", "whatsappNumberLabel": "Dedicated WhatsApp Support Number",
"addressLabel": "Aqila Headquarters Address",
"addressLabel": "Hamlat Nusrat Al Aqila Zainab s.a Headquarters Address",
"testLinkTooltip": "Test and open link in new tab", "testLinkTooltip": "Test and open link in new tab",
"copyLinkTooltip": "Copy link to clipboard", "copyLinkTooltip": "Copy link to clipboard",
"linkCopied": "Link copied to clipboard successfully.", "linkCopied": "Link copied to clipboard successfully.",
@ -573,9 +575,9 @@
"status": "Order Status", "status": "Order Status",
"createdDate": "Created Date", "createdDate": "Created Date",
"actions": "Actions", "actions": "Actions",
"statusAwaitingPayment": "Awaiting Payment",
"statusAwaitingPayment": "Approved (Awaiting Payment)",
"statusPending": "Pending Review", "statusPending": "Pending Review",
"statusApproved": "Approved",
"statusApproved": "Approved (Paid)",
"statusRejected": "Rejected", "statusRejected": "Rejected",
"statusExpired": "Expired", "statusExpired": "Expired",
"statusCancelled": "Cancelled", "statusCancelled": "Cancelled",
@ -656,7 +658,7 @@
"rowNumber": "Row", "rowNumber": "Row",
"orderDetailDesc": "Review order details submitted by traveler, set final price estimate and feedback.", "orderDetailDesc": "Review order details submitted by traveler, set final price estimate and feedback.",
"passengerName": "Passenger Name", "passengerName": "Passenger Name",
"contactPhone": "Contact Phone",
"contactPhone": "WhatsApp Number",
"adults": "Adults", "adults": "Adults",
"children": "Children", "children": "Children",
"infants": "Infants", "infants": "Infants",
@ -676,9 +678,10 @@
"stage2Desc": "This order has been finalized and its invoice has been transferred to Financial Invoices.", "stage2Desc": "This order has been finalized and its invoice has been transferred to Financial Invoices.",
"invoiceTransferredBadge": "Transferred to Financial Invoices", "invoiceTransferredBadge": "Transferred to Financial Invoices",
"notInvoicedYetBadge": "Pending Review & Invoice Issuance", "notInvoicedYetBadge": "Pending Review & Invoice Issuance",
"viewInBilling": "View in Financial Invoices",
"saveStage1Review": "Save Review Changes (Stage 1)",
"approveAndTransferToBilling": "Final Approve & Transfer to Financial Invoices",
"saveStage1Review": "Save Draft (Keep in Review)",
"saveStage1ReviewHint": "Save itinerary and hotel changes without issuing payment invoice to customer",
"approveAndTransferToBilling": "Approve & Issue Invoice for Payment",
"approveAndTransferToBillingHint": "Set final price and activate 3-day payment invoice in customer's app",
"updateInvoiceAndItems": "Update Items & Financial Invoice", "updateInvoiceAndItems": "Update Items & Financial Invoice",
"rejectOrderBtn": "Reject Order", "rejectOrderBtn": "Reject Order",
"addDestinationLeg": "Add New Destination Leg", "addDestinationLeg": "Add New Destination Leg",

43
src/locales/fa.json

@ -1,7 +1,7 @@
{ {
"navigation": { "navigation": {
"brand": "پنل مدیریت عقیله",
"brandShort": "عقیله",
"brand": "پنل مدیریت کاروان نصرت عقیله زینب (س)",
"brandShort": "کاروان نصرت عقیله زینب (س)",
"dashboard": "داشبورد", "dashboard": "داشبورد",
"dashboardTitle": "داشبورد مدیریت", "dashboardTitle": "داشبورد مدیریت",
"users": "کاربران", "users": "کاربران",
@ -21,8 +21,10 @@
"exchange": "صرافی آنلاین", "exchange": "صرافی آنلاین",
"duas": "ادعیه و زیارات", "duas": "ادعیه و زیارات",
"duasList": "ادعیه و زیارات", "duasList": "ادعیه و زیارات",
"surahs": "سوره‌ها",
"categories": "دسته‌بندی‌ها", "categories": "دسته‌بندی‌ها",
"cities": "شهرهای زیارتی", "cities": "شهرهای زیارتی",
"blogs": "وبلاگ و مقالات",
"store": "فروشگاه نصرت عقیله", "store": "فروشگاه نصرت عقیله",
"storeProducts": "محصولات", "storeProducts": "محصولات",
"storeCategories": "دسته‌بندی‌ها", "storeCategories": "دسته‌بندی‌ها",
@ -72,7 +74,7 @@
"readyBadge": "سامانه فعال و آنلاین", "readyBadge": "سامانه فعال و آنلاین",
"welcomeSubtitle": "پایش جامع شاخص‌های آماری، مالی و عملیاتی سامانه", "welcomeSubtitle": "پایش جامع شاخص‌های آماری، مالی و عملیاتی سامانه",
"cleanBoardTitle": "داشبورد تمیز و آماده توسعه", "cleanBoardTitle": "داشبورد تمیز و آماده توسعه",
"cleanBoardDescription": "تمام اطلاعات و یوآی‌های ساختگی حذف شدند. می‌توانید ماژول‌های جدید خود را با کامپوننت‌های آماده UI پیاده‌سازی و مستقیماً به دیتابیس و بک‌اند عقیله متصل نمایید.",
"cleanBoardDescription": "تمام اطلاعات و یوآی‌های ساختگی حذف شدند. می‌توانید ماژول‌های جدید خود را با کامپوننت‌های آماده UI پیاده‌سازی و مستقیماً به دیتابیس و بک‌اند سامانه کاروان نصرت عقیله زینب (س) متصل نمایید.",
"liveMonitor": "پایش زنده و همگام", "liveMonitor": "پایش زنده و همگام",
"lastSynced": "آخرین همگام‌سازی:", "lastSynced": "آخرین همگام‌سازی:",
"refreshData": "بروزرسانی داده‌ها", "refreshData": "بروزرسانی داده‌ها",
@ -263,7 +265,7 @@
}, },
"billing": { "billing": {
"title": "مدیریت صورت‌حساب‌ها و فاکتورها", "title": "مدیریت صورت‌حساب‌ها و فاکتورها",
"description": "بررسی فیش‌های بانکی، تایید یا رد پرداخت‌ها و پیگیری ریز فاکتورهای 5 سرویس سامانه عقیله",
"description": "بررسی فیش‌های بانکی، تایید یا رد پرداخت‌ها و پیگیری ریز فاکتورهای 5 سرویس سامانه کاروان نصرت عقیله زینب (س)",
"totalInvoices": "کل صورت‌حساب‌ها", "totalInvoices": "کل صورت‌حساب‌ها",
"pendingReview": "در انتظار بررسی فیش", "pendingReview": "در انتظار بررسی فیش",
"awaitingPayment": "در انتظار پرداخت مشتری", "awaitingPayment": "در انتظار پرداخت مشتری",
@ -289,7 +291,7 @@
"rejectNotice": "این توضیح به مسافر در بخش وضعیت فاکتور نمایش داده خواهد شد.", "rejectNotice": "این توضیح به مسافر در بخش وضعیت فاکتور نمایش داده خواهد شد.",
"submitReject": "ثبت و رد فاکتور", "submitReject": "ثبت و رد فاکتور",
"submitting": "در حال ثبت...", "submitting": "در حال ثبت...",
"statusPending": "در انتظار بررسی فیش",
"statusPending": "در انتظار تایید",
"statusAwaiting": "در انتظار پرداخت", "statusAwaiting": "در انتظار پرداخت",
"statusApproved": "پرداخت تایید شده", "statusApproved": "پرداخت تایید شده",
"statusRejected": "رد شده", "statusRejected": "رد شده",
@ -450,7 +452,7 @@
"settings": { "settings": {
"pageTitle": "تنظیمات سامانه", "pageTitle": "تنظیمات سامانه",
"pageDescription": "مدیریت مدیران، تعیین و تغییر رمزهای عبور و پیکربندی ماژول‌های سامانه", "pageDescription": "مدیریت مدیران، تعیین و تغییر رمزهای عبور و پیکربندی ماژول‌های سامانه",
"pageSubtitle": "مدیریت مدیران، شبکه‌های اجتماعی و تنظیمات جامع سامانه عقیله",
"pageSubtitle": "مدیریت مدیران، شبکه‌های اجتماعی و تنظیمات جامع سامانه کاروان نصرت عقیله زینب (س)",
"badgeRoleControl": "کنترل دسترسی و تنظیمات", "badgeRoleControl": "کنترل دسترسی و تنظیمات",
"tabAdmins": "مدیریت مدیران و دسترسی‌ها", "tabAdmins": "مدیریت مدیران و دسترسی‌ها",
"tabNotifications": "سامانه اعلان‌ها", "tabNotifications": "سامانه اعلان‌ها",
@ -474,10 +476,10 @@
"socialSaveError": "خطا در ذخیره‌سازی تنظیمات شبکه‌های اجتماعی.", "socialSaveError": "خطا در ذخیره‌سازی تنظیمات شبکه‌های اجتماعی.",
"socialFetchError": "خطا در بارگذاری تنظیمات شبکه‌های اجتماعی از سرور.", "socialFetchError": "خطا در بارگذاری تنظیمات شبکه‌های اجتماعی از سرور.",
"contactInfoCardTitle": "اطلاعات تماس تکمیلی (Contact Us)", "contactInfoCardTitle": "اطلاعات تماس تکمیلی (Contact Us)",
"contactInfoCardDesc": "این اطلاعات در کنار شبکه‌های اجتماعی در APIهای عمومی اپ و وب عقیله ارسال می‌شود.",
"contactInfoCardDesc": "این اطلاعات در کنار شبکه‌های اجتماعی در APIهای عمومی اپ و وب سامانه کاروان نصرت عقیله زینب (س) ارسال می‌شود.",
"phoneNumberLabel": "شماره تلفن‌های پشتیبانی", "phoneNumberLabel": "شماره تلفن‌های پشتیبانی",
"whatsappNumberLabel": "شماره اختصاصی پشتیبانی واتساپ", "whatsappNumberLabel": "شماره اختصاصی پشتیبانی واتساپ",
"addressLabel": "آدرس دفتر مرکزی عقیله",
"addressLabel": "آدرس دفتر مرکزی سامانه کاروان نصرت عقیله زینب (س)",
"testLinkTooltip": "تست و بازکردن لینک در تب جدید", "testLinkTooltip": "تست و بازکردن لینک در تب جدید",
"copyLinkTooltip": "کپی کردن لینک", "copyLinkTooltip": "کپی کردن لینک",
"linkCopied": "لینک با موفقیت کپی شد.", "linkCopied": "لینک با موفقیت کپی شد.",
@ -573,9 +575,9 @@
"status": "وضعیت سفارش", "status": "وضعیت سفارش",
"createdDate": "تاریخ ثبت", "createdDate": "تاریخ ثبت",
"actions": "عملیات", "actions": "عملیات",
"statusAwaitingPayment": "در انتظار پرداخت",
"statusPending": "در حال بررسی",
"statusApproved": "تأیید شده",
"statusAwaitingPayment": "تأیید شده (در انتظار پرداخت)",
"statusPending": "در انتظار بررسی",
"statusApproved": "تأیید شده (پرداخت شده)",
"statusRejected": "رد شده", "statusRejected": "رد شده",
"statusExpired": "منقضی شده", "statusExpired": "منقضی شده",
"statusCancelled": "لغو شده", "statusCancelled": "لغو شده",
@ -645,7 +647,7 @@
"allDestinationsCount": "کل مقاصد زیارتی", "allDestinationsCount": "کل مقاصد زیارتی",
"destCities": "شهر مقصد", "destCities": "شهر مقصد",
"allOrdersCount": "کل سفارش‌های ثبت‌شده", "allOrdersCount": "کل سفارش‌های ثبت‌شده",
"pendingOrdersCount": "در حال بررسی",
"pendingOrdersCount": "در انتظار تایید",
"approvedOrdersCount": "تأیید شده", "approvedOrdersCount": "تأیید شده",
"awaitingPaymentOrdersCount": "در انتظار پرداخت", "awaitingPaymentOrdersCount": "در انتظار پرداخت",
"ordersUnit": "سفارش", "ordersUnit": "سفارش",
@ -656,7 +658,7 @@
"rowNumber": "ردیف", "rowNumber": "ردیف",
"orderDetailDesc": "اطلاعات سفارش ثبت‌شده توسط مسافر را بررسی کنید، قیمت نهایی و فیدبک را ثبت نمایید.", "orderDetailDesc": "اطلاعات سفارش ثبت‌شده توسط مسافر را بررسی کنید، قیمت نهایی و فیدبک را ثبت نمایید.",
"passengerName": "نام مسافر", "passengerName": "نام مسافر",
"contactPhone": "تلفن تماس",
"contactPhone": "شماره واتساپ",
"adults": "بزرگسال", "adults": "بزرگسال",
"children": "کودک", "children": "کودک",
"infants": "نوزاد", "infants": "نوزاد",
@ -670,15 +672,16 @@
"passportNumber": "شماره پاسپورت", "passportNumber": "شماره پاسپورت",
"viewPassportImage": "مشاهده پاسپورت", "viewPassportImage": "مشاهده پاسپورت",
"noPassportImage": "بدون تصویر پاسپورت", "noPassportImage": "بدون تصویر پاسپورت",
"stage1Title": "مرحله ۱: بررسی و اعمال تغییرات در سفر اختصاصی",
"stage1Desc": "پروازها، هتل‌ها، ترتیب مقاصد و قیمت را بررسی و اصلاح کنید. تا پیش از تأیید نهایی، صورت‌حساب مالی صادر نمی‌شود.",
"stage2Title": "مرحله ۲: تأیید نهایی و انتقال به صورت‌حساب‌های مالی",
"stage2Desc": "این سفارش بررسی و نهایی شده و فاکتور آن به بخش صورت‌حساب‌های مالی منتقل شده است.",
"stage1Title": "گام ۱: بررسی و تنظیم برنامه سفر (پیش‌نویس)",
"stage1Desc": "پروازها، هتل‌ها، تاریخ‌ها و قیمت را بررسی و اصلاح کنید. در این حالت سفارش هنوز جهت پرداخت برای مسافر ارسال نمی‌شود.",
"stage2Title": "گام ۲: تأیید نهایی و صدور فاکتور جهت پرداخت مسافر",
"stage2Desc": "قیمت قطعی ثبت شده و صورت‌حساب با مهلت پرداخت ۳ روزه در اپلیکیشن مسافر فعال می‌گردد.",
"invoiceTransferredBadge": "منتقل‌شده به صورت‌حساب مالی", "invoiceTransferredBadge": "منتقل‌شده به صورت‌حساب مالی",
"notInvoicedYetBadge": "در انتظار بررسی و صدور فاکتور", "notInvoicedYetBadge": "در انتظار بررسی و صدور فاکتور",
"viewInBilling": "مشاهده در صورت‌حساب‌های مالی",
"saveStage1Review": "ذخیره تغییرات بررسی (مرحله ۱)",
"approveAndTransferToBilling": "تأیید نهایی و انتقال به صورت‌حساب‌های مالی",
"saveStage1Review": "ذخیره پیش‌نویس (بدون ارسال جهت پرداخت)",
"saveStage1ReviewHint": "ذخیره تغییرات برنامه و هتل‌ها بدون صدور فاکتور پرداخت برای مسافر",
"approveAndTransferToBilling": "تأیید نهایی و صدور فاکتور جهت پرداخت مسافر",
"approveAndTransferToBillingHint": "تعیین قیمت قطعی و فعال‌سازی صورت‌حساب ۳ روزه در اپلیکیشن مسافر",
"updateInvoiceAndItems": "به‌روزرسانی آیتم‌ها و فاکتور مالی", "updateInvoiceAndItems": "به‌روزرسانی آیتم‌ها و فاکتور مالی",
"rejectOrderBtn": "رد سفارش", "rejectOrderBtn": "رد سفارش",
"addDestinationLeg": "افزودن مقصد جدید به برنامه سفر", "addDestinationLeg": "افزودن مقصد جدید به برنامه سفر",

41
src/stores/custom-trip-badge.ts

@ -0,0 +1,41 @@
import { create } from 'zustand'
import { fetchCustomTripOrders } from '@/features/custom-trip/services/custom-trip-api'
interface CustomTripBadgeState {
pendingCount: number
awaitingPaymentCount: number
isLoading: boolean
lastFetched: number | null
fetchCounts: () => Promise<void>
setCounts: (pending: number, awaiting: number) => void
}
export const useCustomTripBadgeStore = create<CustomTripBadgeState>((set, get) => ({
pendingCount: 0,
awaitingPaymentCount: 0,
isLoading: false,
lastFetched: null,
fetchCounts: async () => {
const now = Date.now()
const last = get().lastFetched
if (last && now - last < 8000) return
try {
set({ isLoading: true })
const orders = await fetchCustomTripOrders()
const pending = orders.filter((o) => o.status === 'PENDING').length
const awaiting = orders.filter((o) => o.status === 'AWAITING_PAYMENT').length
set({
pendingCount: pending,
awaitingPaymentCount: awaiting,
isLoading: false,
lastFetched: Date.now(),
})
} catch {
set({ isLoading: false })
}
},
setCounts: (pending: number, awaiting: number) => {
set({ pendingCount: pending, awaitingPaymentCount: awaiting })
},
}))
Loading…
Cancel
Save