2 Commits

  1. 1
      package.json
  2. 135
      scripts/check-translations.js
  3. 856
      scripts/generate-locales.js
  4. 178
      src/components/layout/ServerStatusDialog.tsx
  5. 26
      src/components/shared/DeleteConfirmDialog.tsx
  6. 26
      src/features/auth/LoginPage.tsx
  7. 14
      src/features/dashboard/DashboardHome.tsx
  8. 20
      src/features/global-search/GlobalSearchDialog.tsx
  9. 40
      src/features/services/ServicesPage.tsx
  10. 13
      src/features/services/components/CategoriesServiceView.tsx
  11. 237
      src/features/services/components/CitiesServiceView.tsx
  12. 468
      src/features/services/components/CreateLocationDialog.tsx
  13. 932
      src/features/services/components/DuasServiceView.tsx
  14. 144
      src/features/services/components/LocationDetailSheet.tsx
  15. 407
      src/features/services/components/ZayerGuideServiceView.tsx
  16. 126
      src/features/services/services/trip-etiquette-api.ts
  17. 426
      src/features/services/services/zayer-guide-api.ts
  18. 34
      src/features/services/types/trip-etiquette.ts
  19. 33
      src/features/services/types/zayer-guide.ts
  20. 44
      src/features/tours/components/TourStatusBadge.tsx
  21. 12
      src/i18n/index.ts
  22. 12
      src/locales/__tests__/locales.test.ts
  23. 220
      src/locales/ar.json
  24. 144
      src/locales/en.json
  25. 202
      src/locales/fa.json

1
package.json

@ -7,6 +7,7 @@
"dev": "vite",
"build": "tsc --noEmit && vite build",
"typecheck": "tsc --noEmit",
"check:i18n": "node scripts/check-translations.js",
"test": "vitest run",
"lint": "eslint .",
"preview": "vite preview"

135
scripts/check-translations.js

@ -0,0 +1,135 @@
#!/usr/bin/env node
/**
* Translation Synchronization Checker for aqila_panel.
* Validates that fa.json, en.json, and ar.json:
* 1. Exist and have valid JSON syntax.
* 2. Contain identical keys (nested dot-notation paths).
* 3. Have no null, undefined, or empty/whitespace values.
*/
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const LOCALES_DIR = path.resolve(__dirname, '../src/locales')
const TARGET_LANGS = ['fa', 'en', 'ar']
function loadJson(filePath) {
try {
const raw = fs.readFileSync(filePath, 'utf-8')
return JSON.parse(raw)
} catch (err) {
console.error(`❌ Failed to read or parse JSON file: ${filePath}`)
console.error(err.message)
process.exit(1)
}
}
/**
* Recursively flattens an object to dot-notation paths and validates values.
* Returns { keys: Map<string, string>, emptyKeys: string[] }
*/
function extractKeysAndValidate(obj, prefix = '') {
const keys = new Map()
const emptyKeys = []
for (const [key, value] of Object.entries(obj)) {
const fullPath = prefix ? `${prefix}.${key}` : key
if (value === null || value === undefined) {
emptyKeys.push(fullPath)
keys.set(fullPath, '')
} else if (typeof value === 'object' && !Array.isArray(value)) {
const child = extractKeysAndValidate(value, fullPath)
for (const [childKey, childVal] of child.keys.entries()) {
keys.set(childKey, childVal)
}
emptyKeys.push(...child.emptyKeys)
} else if (typeof value === 'string') {
if (value.trim() === '') {
emptyKeys.push(fullPath)
}
keys.set(fullPath, value)
} else {
keys.set(fullPath, String(value))
}
}
return { keys, emptyKeys }
}
export function validateTranslations() {
console.log('🔍 Checking translation synchronization for aqila_panel locales...')
console.log(`📁 Directory: ${LOCALES_DIR}\n`)
const data = {}
const extracted = {}
for (const lang of TARGET_LANGS) {
const filePath = path.join(LOCALES_DIR, `${lang}.json`)
if (!fs.existsSync(filePath)) {
console.error(`❌ Missing locale file: ${filePath}`)
return { success: false, errors: [`Missing locale file: ${filePath}`] }
}
data[lang] = loadJson(filePath)
extracted[lang] = extractKeysAndValidate(data[lang])
}
const allKeyPaths = new Set()
for (const lang of TARGET_LANGS) {
for (const key of extracted[lang].keys.keys()) {
allKeyPaths.add(key)
}
}
const errors = []
// Check 1: Empty values
for (const lang of TARGET_LANGS) {
if (extracted[lang].emptyKeys.length > 0) {
errors.push(
`[${lang}.json] Contains ${extracted[lang].emptyKeys.length} empty value(s):\n - ` +
extracted[lang].emptyKeys.join('\n - ')
)
}
}
// Check 2: Missing keys across languages
for (const key of allKeyPaths) {
const missingIn = TARGET_LANGS.filter((lang) => !extracted[lang].keys.has(key))
if (missingIn.length > 0) {
errors.push(
`Key "${key}" is missing in: [${missingIn.map((l) => `${l}.json`).join(', ')}]`
)
}
}
// Check 3: Key counts
const counts = TARGET_LANGS.map((lang) => `${lang}.json: ${extracted[lang].keys.size} keys`).join(' | ')
if (errors.length > 0) {
console.error('❌ Translation synchronization check FAILED!\n')
console.error(`📊 Key counts: ${counts}\n`)
console.error('🚨 Details:')
errors.forEach((err, idx) => console.error(`${idx + 1}. ${err}`))
console.error('\n⚠️ Please ensure all keys exist and have non-empty values in fa.json, en.json, and ar.json.\n')
return { success: false, errors }
}
console.log('✅ Translation synchronization check PASSED!')
console.log(`📊 Total keys verified: ${allKeyPaths.size} keys`)
console.log(` ${counts}\n`)
return { success: true, count: allKeyPaths.size }
}
// Run if called directly from CLI
if (process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1])) {
const result = validateTranslations()
if (!result.success) {
process.exit(1)
}
}

856
scripts/generate-locales.js

@ -0,0 +1,856 @@
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
const LOCALES_DIR = path.resolve(__dirname, '../src/locales')
export const enLocale = {
navigation: {
brand: 'Aqila Admin Panel',
brandShort: 'Aqila',
dashboard: 'Dashboard',
dashboardTitle: 'Admin Dashboard',
tours: 'Tours',
allTours: 'All Tours',
ongoingTours: 'Ongoing Tours',
tourOrders: 'Orders & Bookings',
services: 'Services Center',
simcard: 'SIM Card',
zayerGuide: 'Zayer Guide',
exchange: 'Online Exchange',
duas: 'Duas & Ziyarat',
duasList: 'Duas & Ziyarat',
categories: 'Categories',
cities: 'Holy Cities',
store: 'Massoumah Store',
storeProducts: 'Products',
storeCategories: 'Categories',
costEstimate: 'Cost Estimate',
support: 'Support Center',
supporters: 'Support Staff',
faq: 'FAQ',
faqCategories: 'Subject Categories',
faqQuestions: 'Questions & Answers',
consultant: 'Support',
billing: 'Billing & Finance',
settings: 'Settings',
logout: 'Logout',
soon: 'Soon',
adminRole: 'System Administrator',
},
topbar: {
serverOnline: 'Server is Online',
serverOffline: 'Server Disconnected',
theme: 'Theme',
language: 'Language',
selectLanguage: 'Select System Language',
search: 'Search...',
},
auth: {
loginTitle: 'Login to Admin Dashboard',
loginDescription: 'Enter your credentials to access the management portal',
demoBoxTitle: 'Demo Ready Account:',
autoFill: 'Auto Fill',
emailLabel: 'Account Email',
emailPlaceholder: '[email protected]',
passwordLabel: 'Password',
passwordPlaceholder: '••••••••',
showPassword: 'Show password',
hidePassword: 'Hide password',
submitLogin: 'Login to Account',
loggingIn: 'Logging in...',
loginFailed: 'Login failed. Please try again.',
footerNote: 'Supports custom templates and direct API connection',
},
dashboard: {
welcome: 'Welcome, {name}',
readyBadge: 'Ready for Development',
welcomeSubtitle: 'Aqila admin panel is ready for your new widgets, tables, and modules.',
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.',
},
serverStatus: {
title: 'System & Server Status',
description: 'Server connection summary, API configuration, and account details',
backendStatus: 'Django Server Status (Backend)',
connected: 'Online & Connected',
checking: 'Checking...',
disconnected: 'Disconnected',
recheck: 'Re-check',
rechecking: 'Testing...',
recheckFull: 'Re-check Server',
apiConfigTitle: 'API & Server Configuration',
targetServer: 'Target Server',
apiPrefix: 'API Prefix',
authHeader: 'Auth Header',
currentUserTitle: 'Current User Account',
roleSuperuser: 'Super Administrator (Superuser)',
roleStaff: 'System Administrator (Staff)',
roleUser: 'User',
fullName: 'Full Name:',
email: 'Email:',
userId: 'User ID:',
connectionStable: 'Connection to server is stable',
connectionTesting: 'Testing connection...',
connectionUnreachable: 'Server is unreachable',
},
globalSearch: {
placeholder: 'Search pages, actions, or commands... (Ctrl+K)',
empty: 'No results found.',
systemSections: 'System Sections',
toggleTheme: 'Toggle Theme',
toggleThemeDesc: 'Switch theme between dark and light mode',
logoutAccount: 'Logout from account',
},
common: {
save: 'Save Changes',
cancel: 'Cancel',
confirm: 'Confirm',
delete: 'Delete',
edit: 'Edit',
close: 'Close',
back: 'Back',
refresh: 'Refresh',
loading: 'Loading...',
status: 'Status',
actions: 'Actions',
all: 'All',
active: 'Active',
inactive: 'Inactive',
noData: 'No records found.',
pageOf: 'Page {current} of {total}',
success: 'Operation completed successfully.',
error: 'Server connection error.',
retry: 'Retry',
previous: 'Previous',
next: 'Next',
showing: 'Showing',
to: 'to',
of: 'of',
testSyncNotice: 'This is a test message for language synchronization verification.',
deleteConfirmTitle: 'Are you sure you want to delete this item?',
deleteConfirmDesc: 'This action cannot be undone and the record will be permanently deleted.',
yesDelete: 'Yes, Delete',
deleting: 'Deleting...',
filter: 'Filter',
search: 'Search...',
export: 'Export',
import: 'Import',
create: 'Create',
add: 'Add',
view: 'View',
details: 'Details',
operations: 'Operations',
enabled: 'Enabled',
disabled: 'Disabled',
unknown: 'Unknown',
days: '{count} Days',
dayOne: '1 Day',
},
tours: {
title: 'Tour Management',
subtitle: 'Live monitoring of sales, travel itineraries, passengers, pricing, and multi-language translations',
totalRegistered: '{count} Tours Registered',
ongoingTitle: 'Ongoing Tours',
ongoingSubtitle: 'Live tracking of ongoing trips, daily itinerary schedules, current stage position, and passengers',
totalOngoing: '{count} Active Ongoing Tours',
ordersTitle: 'Tour Orders & Bookings',
ordersSubtitle: 'Order tracking, payment monitoring, document validation, and traveler deposit receipts',
totalOrders: 'Total Registered Orders',
approvedBookings: 'Approved Bookings',
pendingReviewOrders: 'Pending Receipt Review',
searchOrdersPlaceholder: 'Search orders (Passenger name, phone, order ID)...',
filterSaleStatus: 'Sale Status',
filterTripStatus: 'Trip Status',
filterOrderStatus: 'Order Status',
createTour: 'Create New Tour',
tourDetails: 'Tour Details',
manageTranslations: 'Manage Translations (5 Languages)',
tabOverview: 'Specifications',
tabItinerary: 'Itinerary',
tabPassengers: 'Passengers',
tabFeatures: 'Amenities',
saleAvailable: 'Available',
saleSoldOut: 'Sold Out',
saleNoShow: 'Hidden',
saleUnknown: 'Unknown',
tripPending: 'Upcoming',
tripTraveling: 'Ongoing',
tripFinished: 'Completed',
orderApproved: 'Approved',
orderAwaitingPayment: 'Awaiting Payment',
orderPending: 'Pending Receipt Review',
orderRejected: 'Rejected',
orderExpired: 'Expired',
countryIraq: 'Iraq',
countryIran: 'Iran',
countrySaudi: 'Saudi Arabia',
countrySyria: 'Syria',
countryOman: 'Oman',
},
services: {
title: 'Services Center',
subtitle: 'Management of pilgrimage services, SIM cards, Zayer guide, exchange, store, and religious rituals',
allServices: 'All Services',
searchPlaceholder: 'Search services...',
simcardTitle: 'SIM Card',
simcardDesc: 'Internet packages, international SIM cards, and traveler orders (Zain, Asiacell, Korek, STC)',
zayerGuideTitle: 'Zayer Guide',
zayerGuideDesc: 'Holy shrines, historical sites, hospitals, mawakib, and city locations',
exchangeTitle: 'Online Exchange',
exchangeDesc: 'Exchange rates, currency requests, cash reserves, and traveler transfers (USD, IQD, IRR)',
duasTitle: 'Duas & Ziyarat',
duasDesc: 'Mafatih al-Jinan bank, audios, translations, and authentic ziyarats',
categoriesTitle: 'Categories',
categoriesDesc: 'Management of religious, shrine, store, and app content categories',
citiesTitle: 'Holy Cities',
citiesDesc: 'Pilgrimage cities (Najaf, Karbala, Kadhimiya, Samarra, Mashhad, Qom, Mecca, Medina)',
storeTitle: 'Massoumah Store',
storeDesc: 'Massoumah online store products, souvenirs, inventory, and order fulfillment',
costEstimateTitle: 'Cost Estimate',
costEstimateDesc: 'Estimation of total pilgrimage trip costs according to season, services, and hotel class',
supportTitle: 'Support',
supportDesc: 'Direct ticketing system, user requests, and operator responses',
faqTitle: 'FAQ',
faqDesc: 'Frequently asked questions bank, travel guides, and religious rules',
},
billing: {
title: 'Invoices & Billing Management',
description: 'Review bank receipts, approve or reject payments, and track invoices across Aqila services',
totalInvoices: 'Total Invoices',
pendingReview: 'Pending Receipt Review',
awaitingPayment: 'Awaiting Customer Payment',
approvedPayments: 'Approved Payments',
rejectedOrExpired: 'Rejected / Expired',
searchPlaceholder: 'Search invoices (Customer name, email, card number, invoice ID)...',
statusFilter: 'Invoice Status',
serviceFilter: 'Service Type',
clearFilters: 'Clear Filters',
invoiceList: 'Invoice List',
customer: 'Customer',
amount: 'Amount',
service: 'Service',
date: 'Issue Date',
receipt: 'Payment Receipt',
hasReceipt: 'View Receipt',
noReceipt: 'None',
approve: 'Approve Payment',
reject: 'Reject Invoice',
rejectTitle: 'Reject Invoice',
rejectReason: 'Rejection Reason / Bank Receipt:',
rejectReasonPlaceholder: 'e.g., Unreadable receipt, amount mismatch, invalid transaction reference...',
rejectNotice: 'This explanation will be displayed to the traveler in their invoice details.',
submitReject: 'Submit & Reject Invoice',
submitting: 'Submitting...',
statusPending: 'Pending Receipt Review',
statusAwaiting: 'Awaiting Payment',
statusApproved: 'Payment Approved',
statusRejected: 'Rejected',
statusExpired: 'Expired',
invoice: 'Invoice',
invoices: 'Invoices',
id: 'ID',
viewDetails: 'View Details',
viewReceipt: 'View Receipt',
noReceiptUploaded: 'No receipt uploaded',
serviceTour: 'Pilgrimage Tours',
serviceSimcard: 'SIM Card',
serviceShop: 'Massoumah Store',
serviceExchange: 'Exchange (Tasreef)',
serviceCustomTrip: 'Custom Trip',
allStatuses: 'All Statuses',
allServices: 'All Services',
noFactorsFound: 'No invoices found.',
noFactorsFilter: 'No records match current filters. You can clear filters.',
noFactorsEmpty: 'No invoices have been issued in the system yet.',
confirmClearReceipt: 'Are you sure you want to remove the uploaded receipt image?',
receiptClearedSuccess: 'Payment receipt cleared successfully.',
tabAll: 'All Transactions',
tabTour: 'Tour Orders & Bookings',
tabSimcard: 'SIM Cards',
tabExchange: 'Currency & Exchange',
tabShop: 'Souvenir Store',
tabCustomTrip: 'Custom Trips',
coreTourBadge: 'Primary Core',
liveStatus: 'Live Status',
quickFilter: 'Status Filter',
},
languages: {
fa: 'فارسی',
ar: 'العربية',
en: 'English',
},
}
export const faLocale = {
navigation: {
brand: 'پنل مدیریت عقیله',
brandShort: 'عقیله',
dashboard: 'داشبورد',
dashboardTitle: 'داشبورد مدیریت',
tours: 'تورها',
allTours: 'فهرست همه تورها',
ongoingTours: 'تورهای در حال برگزاری',
tourOrders: 'سفارشات و رزروها',
services: 'مرکز خدمات (همه)',
simcard: 'سیم‌کارت (Simcard)',
zayerGuide: 'راهنمای زائر (Zayer Guide)',
exchange: 'صرافی آنلاین (Exchange)',
duas: 'ادعیه و زیارات (Duas)',
duasList: 'ادعیه و زیارات',
categories: 'دسته‌بندی‌ها',
cities: 'شهرهای زیارتی',
store: 'فروشگاه معصومه (Store)',
storeProducts: 'محصولات',
storeCategories: 'دسته‌بندی‌ها',
costEstimate: 'تخمین هزینه (Cost Estimate)',
support: 'پشتیبانی (Support)',
supporters: 'پشتیبان‌ها (Supporters)',
faq: 'سوالات متداول (FAQ)',
faqCategories: 'دسته‌بندی‌های موضوعی',
faqQuestions: 'پرسش و پاسخ‌ها',
consultant: 'پشتیبانی (Support)',
billing: 'صورت‌حساب‌ها و مالی',
settings: 'تنظیمات',
logout: 'خروج از سیستم',
soon: 'به‌زودی',
adminRole: 'مدیر کل سیستم',
},
topbar: {
serverOnline: 'سرور متصل است',
serverOffline: 'قطع ارتباط با سرور',
theme: 'تغییر تم',
language: 'تغییر زبان',
selectLanguage: 'انتخاب زبان سامانه',
search: 'جستجو...',
},
auth: {
loginTitle: 'ورود به پنل مدیریت و داشبورد',
loginDescription: 'برای دسترسی به پرتال مدیریت، اطلاعات ورود خود را وارد نمایید',
demoBoxTitle: 'حساب آزمایشی آماده:',
autoFill: 'پر کردن خودکار',
emailLabel: 'ایمیل حساب کاربری',
emailPlaceholder: '[email protected]',
passwordLabel: 'رمز عبور',
passwordPlaceholder: '••••••••',
showPassword: 'نمایش رمز',
hidePassword: 'مخفی‌کردن رمز',
submitLogin: 'ورود به حساب کاربری',
loggingIn: 'در حال ورود...',
loginFailed: 'ورود ناموفق بود. دوباره تلاش کنید.',
footerNote: 'پشتیبانی از قالب‌های سفارشی و اتصال مستقیم به API',
},
dashboard: {
welcome: 'خوش آمدید، {name}',
readyBadge: 'آماده توسعه',
welcomeSubtitle: 'پنل مدیریت عقیله آماده اضافه کردن ویجت‌ها، جداول و ماژول‌های جدید شماست.',
cleanBoardTitle: 'داشبورد تمیز و آماده توسعه',
cleanBoardDescription:
'تمام اطلاعات و یوآی‌های ساختگی حذف شدند. می‌توانید ماژول‌های جدید خود را با کامپوننت‌های آماده UI پیاده‌سازی و مستقیماً به دیتابیس و بک‌اند عقیله متصل نمایید.',
},
serverStatus: {
title: 'وضعیت سیستم و سرور',
description: 'خلاصه ارتباط سرور، پیکربندی API و اطلاعات حساب',
backendStatus: 'وضعیت سرور جنگو (Backend)',
connected: 'آنلاین و متصل',
checking: 'در حال بررسی...',
disconnected: 'قطع ارتباط',
recheck: 'بررسی مجدد',
rechecking: 'در حال تست...',
recheckFull: 'بررسی مجدد سرور',
apiConfigTitle: 'پیکربندی API و سرور',
targetServer: 'سرور هدف',
apiPrefix: 'پیشوند API',
authHeader: 'هدر احراز هویت',
currentUserTitle: 'اطلاعات حساب کاربری جاری',
roleSuperuser: 'مدیر ارشد (Superuser)',
roleStaff: 'مدیر سیستم (Staff)',
roleUser: 'کاربر',
fullName: 'نام و نام خانوادگی:',
email: 'ایمیل:',
userId: 'شناسه کاربری (ID):',
connectionStable: 'ارتباط با سرور پایدار است',
connectionTesting: 'در حال آزمایش اتصال...',
connectionUnreachable: 'سرور در دسترس نیست',
},
globalSearch: {
placeholder: 'جستجوی صفحات، عملیات‌ها یا فرمان‌ها... (Ctrl+K)',
empty: 'هیچ نتیجه‌ای یافت نشد.',
systemSections: 'بخش‌های سیستم',
toggleTheme: 'تغییر پوسته (Theme)',
toggleThemeDesc: 'تغییر تم به حالت تیره یا روشن',
logoutAccount: 'خروج از حساب کاربری',
},
common: {
save: 'ذخیره تغییرات',
cancel: 'انصراف',
confirm: 'تایید',
delete: 'حذف',
edit: 'ویرایش',
close: 'بستن',
back: 'بازگشت',
refresh: 'به‌روزرسانی',
loading: 'در حال بارگذاری...',
status: 'وضعیت',
actions: 'عملیات',
all: 'همه',
active: 'فعال',
inactive: 'غیرفعال',
noData: 'موردی برای نمایش یافت نشد.',
pageOf: 'صفحه {current} از {total}',
success: 'عملیات با موفقیت انجام شد.',
error: 'خطا در برقراری ارتباط با سرور.',
retry: 'تلاش مجدد',
previous: 'قبلی',
next: 'بعدی',
showing: 'نمایش',
to: 'تا',
of: 'از',
testSyncNotice: 'این یک پیام آزمایشی برای تست هماهنگی زبان‌هاست',
deleteConfirmTitle: 'آیا از حذف این مورد اطمینان دارید؟',
deleteConfirmDesc: 'این عملیات غیرقابل بازگشت است و رکورد مورد نظر به طور کامل حذف خواهد شد.',
yesDelete: 'بله، حذف شود',
deleting: 'در حال حذف...',
filter: 'فیلتر',
search: 'جستجو...',
export: 'خروجی اکسل',
import: 'ورود اطلاعات',
create: 'ایجاد جدید',
add: 'افزودن',
view: 'مشاهده',
details: 'جزئیات',
operations: 'عملیات',
enabled: 'فعال',
disabled: 'غیرفعال',
unknown: 'نامشخص',
days: '{count} روز',
dayOne: '۱ روز',
},
tours: {
title: 'مدیریت تورها',
subtitle: 'پایش وضعیت زنده فروش، برنامه‌های سفر، مسافران، اطلاعات قیمتی و ترجمه‌های چندزبانه',
totalRegistered: '{count} تور ثبت‌شده',
ongoingTitle: 'تورهای در حال برگزاری',
ongoingSubtitle: 'پایش زنده وضعیت سفرهای جاری، زمان‌بندی روزانه (Itinerary)، موقعیت مرحله فعلی و وضعیت مسافران',
totalOngoing: '{count} تور فعال و در جریان',
ordersTitle: 'سفارشات و رزروهای تور',
ordersSubtitle: 'پیگیری سفارشات، نظارت بر پرداخت‌ها و اعتبارسنجی مدارک و فیش‌های واریزی مسافران',
totalOrders: 'کل سفارشات ثبت‌شده',
approvedBookings: 'رزروهای تأییدشده',
pendingReviewOrders: 'در انتظار بررسی فیش',
searchOrdersPlaceholder: 'جستجوی سفارشات (نام مسافر، شماره تماس، کد سفارش)...',
filterSaleStatus: 'وضعیت فروش',
filterTripStatus: 'وضعیت سفر',
filterOrderStatus: 'وضعیت سفارش',
createTour: 'ایجاد تور جدید',
tourDetails: 'جزئیات تور',
manageTranslations: 'مدیریت ترجمه‌ها (۵ زبان)',
tabOverview: 'مشخصات',
tabItinerary: 'برنامه سفر',
tabPassengers: 'مسافران',
tabFeatures: 'امکانات',
saleAvailable: 'در دسترس',
saleSoldOut: 'تکمیل ظرفیت',
saleNoShow: 'عدم نمایش',
saleUnknown: 'نامشخص',
tripPending: 'در انتظار اجرا',
tripTraveling: 'در حال برگزاری',
tripFinished: 'پایان‌یافته',
orderApproved: 'تایید شده',
orderAwaitingPayment: 'در انتظار پرداخت',
orderPending: 'در انتظار بررسی فیش',
orderRejected: 'رد شده',
orderExpired: 'منقضی شده',
countryIraq: 'عراق',
countryIran: 'ایران',
countrySaudi: 'عربستان',
countrySyria: 'سوریه',
countryOman: 'عمان',
},
services: {
title: 'مرکز خدمات',
subtitle: 'مدیریت جامع خدمات زیارتی، سیم‌کارت، راهنمای زائر، صرافی، فروشگاه و مناسک',
allServices: 'همه خدمات',
searchPlaceholder: 'جستجوی خدمات...',
simcardTitle: 'سیم‌کارت',
simcardDesc: 'مدیریت بسته‌های اینترنت، سیم‌کارت‌های بین‌المللی و سفارش‌های زائرین (زین، آسیاسل، کورک، STC)',
zayerGuideTitle: 'راهنمای زائر',
zayerGuideDesc: 'بانک جامع اماکن متبرکه، راهنمای اماکن تاریخی، بیمارستان‌ها، مواکب و موقعیت مکانی شهرها',
exchangeTitle: 'صرافی آنلاین',
exchangeDesc: 'نرخ لحظه‌ای ارزها، درخواست‌های ارزی، مدیریت موجودی نقدی و حواله‌های زائران (دلار، دینار، تومان)',
duasTitle: 'ادعیه و زیارات',
duasDesc: 'بانک مفاتیح‌الجنان، ادعیه صوتی، ترجمه و شرح متون ادعیه و زیارات معتبر',
categoriesTitle: 'دسته‌بندی‌ها',
categoriesDesc: 'مدیریت دسته‌بندی‌های موضوعی خدمات، اماکن، فروشگاه و محتواهای اپلیکیشن',
citiesTitle: 'شهرهای زیارتی',
citiesDesc: 'شهرهای زیارتی (نجف، کربلا، کاظمین، سامرا، مشهد، قم، مکه، مدینه)',
storeTitle: 'فروشگاه معصومه',
storeDesc: 'محصولات فروشگاه آنلاین معصومه، سوغات زیارتی، مدیریت انبار و سفارشات خرید',
costEstimateTitle: 'تخمین هزینه',
costEstimateDesc: 'محاسبه آنلاین هزینه‌های سفر انفرادی و خانوادگی به تفکیک فصل، خدمات و سطح هتل',
supportTitle: 'پشتیبانی',
supportDesc: 'سیستم تیکتینگ مستقیم، پشتیبانی از زائران و مدیریت پاسخگویی اپراتورها',
faqTitle: 'سوالات متداول',
faqDesc: 'بانک پرسش و پاسخ‌های پرتکرار زائرین، راهنمای سفر و احکام شرعی',
},
billing: {
title: 'مدیریت صورت‌حساب‌ها و فاکتورها',
description: 'بررسی فیش‌های بانکی، تایید یا رد پرداخت‌ها و پیگیری ریز فاکتورهای ۵ سرویس سامانه عقیله',
totalInvoices: 'کل صورت‌حساب‌ها',
pendingReview: 'در انتظار بررسی فیش',
awaitingPayment: 'در انتظار پرداخت مشتری',
approvedPayments: 'پرداخت‌های تایید شده',
rejectedOrExpired: 'رد شده / منقضی',
searchPlaceholder: 'جستجو در صورت‌حساب‌ها (نام مشتری، ایمیل، شماره کارت، شماره فاکتور)...',
statusFilter: 'وضعیت صورت‌حساب',
serviceFilter: 'نوع سرویس',
clearFilters: 'پاک کردن فیلترها',
invoiceList: 'فهرست صورت‌حساب‌ها',
customer: 'مشتری',
amount: 'مبلغ',
service: 'سرویس',
date: 'تاریخ صدور',
receipt: 'فیش پرداخت',
hasReceipt: 'مشاهده فیش',
noReceipt: 'ندارد',
approve: 'تایید پرداخت',
reject: 'رد فاکتور',
rejectTitle: 'رد صورت‌حساب',
rejectReason: 'علت رد فاکتور / فیش بانکی:',
rejectReasonPlaceholder: 'مثال: تصویر فیش ناخواناست، مبلغ فیش با فاکتور همخوانی ندارد، کد رهگیری نامعتبر است...',
rejectNotice: 'این توضیح به مسافر در بخش وضعیت فاکتور نمایش داده خواهد شد.',
submitReject: 'ثبت و رد فاکتور',
submitting: 'در حال ثبت...',
statusPending: 'در انتظار بررسی فیش',
statusAwaiting: 'در انتظار پرداخت',
statusApproved: 'پرداخت تایید شده',
statusRejected: 'رد شده',
statusExpired: 'منقضی شده',
invoice: 'صورت‌حساب',
invoices: 'صورت‌حساب‌ها',
id: 'شناسه',
viewDetails: 'مشاهده جزئیات',
viewReceipt: 'مشاهده فیش',
noReceiptUploaded: 'فیشی بارگذاری نشده است',
serviceTour: 'تور زیارتی',
serviceSimcard: 'سیم‌کارت',
serviceShop: 'فروشگاه معصومه',
serviceExchange: 'صرافی (تصریف)',
serviceCustomTrip: 'سفر اختصاصی',
allStatuses: 'همه وضعیت‌ها',
allServices: 'همه سرویس‌ها',
noFactorsFound: 'هیچ صورت‌حسابی یافت نشد.',
noFactorsFilter: 'رکوردی با فیلترهای فعلی همخوانی ندارد. می‌توانید فیلترها را پاک کنید.',
noFactorsEmpty: 'تاکنون هیچ صورت‌حسابی در سیستم صادر نشده است.',
confirmClearReceipt: 'آیا از حذف تصویر فیش بارگذاری شده اطمینان دارید؟',
receiptClearedSuccess: 'تصویر فیش پرداخت با موفقیت حذف شد.',
tabAll: 'کل تراکنش‌ها',
tabTour: 'سفارشات و رزرو تور',
tabSimcard: 'سیم‌کارت',
tabExchange: 'ارز و صرافی',
tabShop: 'فروشگاه سوغات',
tabCustomTrip: 'سفرهای اختصاصی',
coreTourBadge: 'هسته اصلی',
liveStatus: 'وضعیت زنده',
quickFilter: 'فیلتر وضعیت',
},
languages: {
fa: 'فارسی',
ar: 'العربية',
en: 'English',
},
}
export const arLocale = {
navigation: {
brand: 'لوحة تحكم عقيلة',
brandShort: 'عقيلة',
dashboard: 'الرئيسية',
dashboardTitle: 'لوحة التحكم',
tours: 'الرحلات',
allTours: 'جميع الرحلات',
ongoingTours: 'الرحلات الجارية',
tourOrders: 'الطلبات والحجوزات',
services: 'مركز الخدمات (الكل)',
simcard: 'شريحة الاتصال (Simcard)',
zayerGuide: 'دليل الزائر (Zayer Guide)',
exchange: 'الصرافة الإلكترونية (Exchange)',
duas: 'الأدعية والزيارات (Duas)',
duasList: 'الأدعية والزيارات',
categories: 'التصنيفات',
cities: 'المدن المقدسة',
store: 'متجر معصومة (Store)',
storeProducts: 'المنتجات',
storeCategories: 'التصنيفات',
costEstimate: 'تقدير التكلفة (Cost Estimate)',
support: 'الدعم الفني (Support)',
supporters: 'فريق الدعم (Supporters)',
faq: 'الأسئلة الشائعة (FAQ)',
faqCategories: 'التصنيفات الموضوعية',
faqQuestions: 'الأسئلة والأجوبة',
consultant: 'الدعم الفني (Support)',
billing: 'الفواتير والمالية',
settings: 'الإعدادات',
logout: 'تسجيل الخروج',
soon: 'قريباً',
adminRole: 'مدير النظام العام',
},
topbar: {
serverOnline: 'الخادم متصل',
serverOffline: 'انقطع الاتصال بالخادم',
theme: 'المظهر',
language: 'اللغة',
selectLanguage: 'اختر لغة النظام',
search: 'بحث...',
},
auth: {
loginTitle: 'تسجيل الدخول إلى لوحة الإدارة',
loginDescription: 'أدخل بيانات الاعتماد الخاصة بك للوصول إلى بوابة الإدارة',
demoBoxTitle: 'حساب تجريبي جاهز:',
autoFill: 'تعبئة تلقائية',
emailLabel: 'البريد الإلكتروني للحساب',
emailPlaceholder: '[email protected]',
passwordLabel: 'كلمة المرور',
passwordPlaceholder: '••••••••',
showPassword: 'عرض كلمة المرور',
hidePassword: 'إخفاء كلمة المرور',
submitLogin: 'تسجيل الدخول إلى الحساب',
loggingIn: 'جاري تسجيل الدخول...',
loginFailed: 'فشل تسجيل الدخول. يرجى المحاولة مرة أخرى.',
footerNote: 'يدعم القوالب المخصصة والاتصال المباشر بواجهة برمجة التطبيقات (API)',
},
dashboard: {
welcome: 'مرحباً، {name}',
readyBadge: 'جاهز للتطوير',
welcomeSubtitle: 'لوحة تحكم عقيلة جاهزة لإضافة أدواتك وجداولك ووحداتك الجديدة.',
cleanBoardTitle: 'لوحة تحكم نظيفة وجاهزة للتطوير',
cleanBoardDescription:
'تم مسح جميع البيانات والواجهات الوهمية. يمكنك بناء وحداتك الجديدة باستخدام مكونات واجهة المستخدم الجاهزة والاتصال مباشرة بقاعدة البيانات وخادم عقيلة.',
},
serverStatus: {
title: 'حالة النظام والخادم',
description: 'ملخص اتصال الخادم، وتكوين واجهة برمجة التطبيقات وبيانات الحساب',
backendStatus: 'حالة خادم جانغو (Backend)',
connected: 'متصل ونشط',
checking: 'جاري الفحص...',
disconnected: 'انقطع الاتصال',
recheck: 'إعادة الفحص',
rechecking: 'جاري الاختبار...',
recheckFull: 'إعادة فحص الخادم',
apiConfigTitle: 'تكوين واجهة برمجة التطبيقات والخادم',
targetServer: 'الخادم الهدف',
apiPrefix: 'بادئة API',
authHeader: 'ترويسة المصادقة',
currentUserTitle: 'بيانات الحساب الحالي',
roleSuperuser: 'المدير العام (Superuser)',
roleStaff: 'مدير النظام (Staff)',
roleUser: 'مستخدم',
fullName: 'الاسم الكامل:',
email: 'البريد الإلكتروني:',
userId: 'معرف المستخدم (ID):',
connectionStable: 'الاتصال بالخادم مستقر',
connectionTesting: 'جاري اختبار الاتصال...',
connectionUnreachable: 'الخادم غير متاح',
},
globalSearch: {
placeholder: 'البحث في الصفحات والعمليات والأوامر... (Ctrl+K)',
empty: 'لم يتم العثور على أي نتائج.',
systemSections: 'أقسام النظام',
toggleTheme: 'تغيير السمة (Theme)',
toggleThemeDesc: 'تبديل المظهر بين الوضع الداكن والفاتح',
logoutAccount: 'تسجيل الخروج من الحساب',
},
common: {
save: 'حفظ التغييرات',
cancel: 'إلغاء',
confirm: 'تأكيد',
delete: 'حذف',
edit: 'تعديل',
close: 'إغلاق',
back: 'رجوع',
refresh: 'تحديث',
loading: 'جاري التحميل...',
status: 'الحالة',
actions: 'الإجراءات',
all: 'الكل',
active: 'نشط',
inactive: 'غير نشط',
noData: 'لا توجد بيانات للعرض.',
pageOf: 'صفحة {current} من {total}',
success: 'تمت العملية بنجاح.',
error: 'حدث خطأ في الاتصال بالخادم.',
retry: 'إعادة المحاولة',
previous: 'السابق',
next: 'التالي',
showing: 'عرض',
to: 'إلى',
of: 'من',
testSyncNotice: 'هذه رسالة تجريبية لاختبار مزامنة اللغات.',
deleteConfirmTitle: 'هل أنت متأكد من حذف هذا العنصر؟',
deleteConfirmDesc: 'هذا الإجراء لا يمكن التراجع عنه وسيتم حذف السجل نهائياً.',
yesDelete: 'نعم، احذف',
deleting: 'جاري الحذف...',
filter: 'تصفية',
search: 'بحث...',
export: 'تصدير إكسل',
import: 'استيراد بيانات',
create: 'إنشاء جديد',
add: 'إضافة',
view: 'عرض',
details: 'تفاصيل',
operations: 'العمليات',
enabled: 'مفعل',
disabled: 'معطل',
unknown: 'غير محدد',
days: '{count} أيام',
dayOne: 'يوم واحد',
},
tours: {
title: 'إدارة الرحلات',
subtitle: 'متابعة مباشرة لحالة المبيعات وجداول الرحلات والمسافرين وبيانات الأسعار والترجمات متعددة اللغات',
totalRegistered: '{count} رحلة مسجلة',
ongoingTitle: 'الرحلات الجارية',
ongoingSubtitle: 'متابعة مباشرة للرحلات الحالية والجدول اليومي والموقع الحالي وحالة المسافرين',
totalOngoing: '{count} رحلة نشطة وجارية',
ordersTitle: 'طلبات وحجوزات الرحلات',
ordersSubtitle: 'متابعة الطلبات ومراقبة المدفوعات والتحقق من وثائق وإيصالات التحويل للمسافرين',
totalOrders: 'إجمالي الطلبات المسجلة',
approvedBookings: 'الحجوزات المؤكدة',
pendingReviewOrders: 'بانتظار مراجعة الإيصال',
searchOrdersPlaceholder: 'البحث في الطلبات (اسم المسافر، الهاتف، معرف الطلب)...',
filterSaleStatus: 'حالة البيع',
filterTripStatus: 'حالة السفر',
filterOrderStatus: 'حالة الطلب',
createTour: 'إنشاء رحلة جديدة',
tourDetails: 'تفاصيل الرحلة',
manageTranslations: 'إدارة الترجمات (٥ لغات)',
tabOverview: 'المواصفات',
tabItinerary: 'برنامج الرحلة',
tabPassengers: 'المسافرون',
tabFeatures: 'المميزات',
saleAvailable: 'متاح',
saleSoldOut: 'اكتمل العدد',
saleNoShow: 'مخفي',
saleUnknown: 'غير محدد',
tripPending: 'بانتظار الانطلاق',
tripTraveling: 'جارية حالياً',
tripFinished: 'منتهية',
orderApproved: 'تمت الموافقة',
orderAwaitingPayment: 'بانتظار الدفع',
orderPending: 'بانتظار مراجعة الإيصال',
orderRejected: 'مرفوض',
orderExpired: 'منتهي الصلاحية',
countryIraq: 'العراق',
countryIran: 'إيران',
countrySaudi: 'السعودية',
countrySyria: 'سوريا',
countryOman: 'عمان',
},
services: {
title: 'مركز الخدمات',
subtitle: 'إدارة شاملة لخدمات الزوار والشرائح ودليل الزائر والصرافة والمتجر والمناسك',
allServices: 'جميع الخدمات',
searchPlaceholder: 'البحث في الخدمات...',
simcardTitle: 'شريحة الاتصال',
simcardDesc: 'إدارة باقات الإنترنت والشرائح الدولية وطلبات الزوار (زين، آسیاسیل، كورك، STC)',
zayerGuideTitle: 'دليل الزائر',
zayerGuideDesc: 'دليل شامل للعتبات المقدسة والمعالم التاريخية والمستشفيات والمواكب ومواقع المدن',
exchangeTitle: 'الصرافة الإلكترونية',
exchangeDesc: 'أسعار الصرف المباشرة وطلبات العملات وإدارة السيولة النقدية وحوالات الزوار (دولار، دينار، تومان)',
duasTitle: 'الأدعية والزيارات',
duasDesc: 'بنك مفاتيح الجنان والتسجيلات الصوتية والترجمات وشروح الأدعية والزيارات المعتبرة',
categoriesTitle: 'التصنيفات',
categoriesDesc: 'إدارة التصنيفات الموضوعية للخدمات والأماكن والمتجر ومحتويات التطبيق',
citiesTitle: 'المدن المقدسة',
citiesDesc: 'المدن المقدسة (النجف، كربلاء، الكاظمية، سامراء، مشهد، قم، مكة، المدينة)',
storeTitle: 'متجر معصومة',
storeDesc: 'منتجات متجر معصومة الإلكتروني وهدايا الزيارة وإدارة المخزون وطلبات الشراء',
costEstimateTitle: 'تقدير التكلفة',
costEstimateDesc: 'حساب تكاليف الرحلات الفردية والعائلية حسب الموسم والخدمات ومستوى الفندق',
supportTitle: 'الدعم الفني',
supportDesc: 'نظام التذاكر المباشر ودعم الزوار وإدارة استجابات فريق العمل',
faqTitle: 'الأسئلة الشائعة',
faqDesc: 'بنك الأسئلة المتكررة للزوار وإرشادات السفر والأحكام الشرعية',
},
billing: {
title: 'إدارة الفواتير والمدفوعات',
description: 'مراجعة إيصالات التحويل البنكي، واعتماد أو رفض المدفوعات ومتابعة تفاصيل فواتير خدمات عقيلة',
totalInvoices: 'إجمالي الفواتير',
pendingReview: 'بانتظار مراجعة الإيصال',
awaitingPayment: 'بانتظار دفع العميل',
approvedPayments: 'المدفوعات المعتمدة',
rejectedOrExpired: 'مرفوض / منتهي الصلاحية',
searchPlaceholder: 'البحث في الفواتير (اسم العميل، البريد، رقم البطاقة، رقم الفاتورة)...',
statusFilter: 'حالة الفاتورة',
serviceFilter: 'نوع الخدمة',
clearFilters: 'إعادة ضبط التصفية',
invoiceList: 'قائمة الفواتير',
customer: 'العميل',
amount: 'المبلغ',
service: 'الخدمة',
date: 'تاريخ الإصدار',
receipt: 'إيصال الدفع',
hasReceipt: 'عرض الإيصال',
noReceipt: 'لا يوجد',
approve: 'اعتماد الدفع',
reject: 'رفض الفاتورة',
rejectTitle: 'رفض الفاتورة',
rejectReason: 'سبب رفض الفاتورة / الإيصال البنكي:',
rejectReasonPlaceholder: 'مثال: صورة الإيصال غير واضحة، المبلغ غير متطابق، الرقم المرجعي غير صالح...',
rejectNotice: 'سيظهر هذا الشرح للمسافر في تفاصيل الفاتورة.',
submitReject: 'تأكيد ورفض الفاتورة',
submitting: 'جاري الإرسال...',
statusPending: 'بانتظار مراجعة الإيصال',
statusAwaiting: 'بانتظار الدفع',
statusApproved: 'تم اعتماد الدفع',
statusRejected: 'مرفوض',
statusExpired: 'منتهي الصلاحية',
invoice: 'فاتورة',
invoices: 'الفواتير',
id: 'المعرف',
viewDetails: 'عرض التفاصيل',
viewReceipt: 'عرض الإيصال',
noReceiptUploaded: 'لم يتم رفع إيصال',
serviceTour: 'رحلة زيارة',
serviceSimcard: 'شريحة اتصال',
serviceShop: 'متجر معصومة',
serviceExchange: 'صرافة (تصريف)',
serviceCustomTrip: 'رحلة مخصصة',
allStatuses: 'جميع الحالات',
allServices: 'جميع الخدمات',
noFactorsFound: 'لم يتم العثور على فواتير.',
noFactorsFilter: 'لا توجد نتائج تطابق معايير التصفية الحالية.',
noFactorsEmpty: 'لم يتم إصدار أي فواتير في النظام بعد.',
confirmClearReceipt: 'هل أنت متأكد من حذف صورة الإيصال المرفوعة؟',
receiptClearedSuccess: 'تم حذف صورة الإيصال بنجاح.',
tabAll: 'جميع المعاملات',
tabTour: 'طلبات وحجوزات الرحلات',
tabSimcard: 'شرائح الاتصال',
tabExchange: 'العملات والصرافة',
tabShop: 'متجر الهدايا',
tabCustomTrip: 'الرحلات الخاصة',
coreTourBadge: 'الخدمة الأساسية',
liveStatus: 'الحالة المباشرة',
quickFilter: 'تصفية الحالة',
},
languages: {
fa: 'فارسی',
ar: 'العربية',
en: 'English',
},
}
function writeJson(filePath, data) {
fs.writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n', 'utf8')
console.log(`✓ Generated: ${filePath}`)
}
writeJson(path.join(LOCALES_DIR, 'en.json'), enLocale)
writeJson(path.join(LOCALES_DIR, 'fa.json'), faLocale)
writeJson(path.join(LOCALES_DIR, 'ar.json'), arLocale)
console.log('\n🎉 Successfully synchronized all 3 locale files with en.json as Source of Truth!')

178
src/components/layout/ServerStatusDialog.tsx

@ -5,6 +5,7 @@ import {
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
DialogClose,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
@ -12,6 +13,8 @@ import { Badge } from '@/components/ui/badge'
import { Ic } from '@/icons'
import { useServerHealthStore } from '@/stores/server-health'
import { useAuthStore } from '@/stores/auth'
import { cn } from '@/lib/utils'
import { useTranslation } from '@/i18n'
interface ServerStatusDialogProps {
open: boolean
@ -21,6 +24,7 @@ interface ServerStatusDialogProps {
export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusDialogProps) {
const health = useServerHealthStore()
const user = useAuthStore((s) => s.user)
const { t } = useTranslation()
const [isRefreshing, setIsRefreshing] = useState(false)
const handleRefresh = async () => {
@ -33,53 +37,25 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[540px] p-6 space-y-5">
<DialogHeader className="pb-3 border-b border-border-soft">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div
className={`flex size-9 items-center justify-center rounded-xl ${
health.status === 'connected'
? 'bg-emerald-500/15 text-emerald-400'
: health.status === 'checking'
? 'bg-amber-500/15 text-amber-400'
: 'bg-rose-500/15 text-rose-400'
}`}
>
<Ic name="server" className="size-5" />
</div>
<div>
<DialogTitle className="text-base font-bold text-foreground">
وضعیت سیستم و سرور
</DialogTitle>
<DialogDescription className="text-xs text-grey-3 mt-0.5">
خلاصه ارتباط سرور، پیکربندی API و اطلاعات حساب
</DialogDescription>
</div>
<div className="flex items-center gap-3">
<div
className={`flex size-10 shrink-0 items-center justify-center rounded-xl ${
health.status === 'connected'
? 'bg-emerald-500/15 text-emerald-400'
: health.status === 'checking'
? 'bg-amber-500/15 text-amber-400'
: 'bg-rose-500/15 text-rose-400'
}`}
>
<Ic name="server" className="size-5" />
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={handleRefresh}
isLoading={isRefreshing}
loadingText="در حال تست..."
className="h-8 gap-1.5 text-xs"
>
<Ic name="refresh" className="size-3.5" />
<span>بررسی مجدد</span>
</Button>
<DialogClose asChild>
<button
type="button"
className="size-8 rounded-xl flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors cursor-pointer"
title="بستن"
aria-label="بستن پنجره"
>
<Ic name="x" className="size-4" />
</button>
</DialogClose>
<div>
<DialogTitle className="text-base font-bold text-foreground">
{t('serverStatus.title')}
</DialogTitle>
<DialogDescription className="text-xs text-grey-3 mt-0.5">
{t('serverStatus.description')}
</DialogDescription>
</div>
</div>
</DialogHeader>
@ -98,24 +74,37 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
: 'bg-rose-500 shadow-[0_0_8px_#f43f5e]'
}`}
/>
وضعیت سرور جنگو (Backend)
{t('serverStatus.backendStatus')}
</span>
<Badge
variant="outline"
className={
health.status === 'connected'
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400'
<div className="flex items-center gap-2">
<Badge
variant="outline"
className={
health.status === 'connected'
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400'
: health.status === 'checking'
? 'border-amber-500/30 bg-amber-500/10 text-amber-400'
: 'border-rose-500/30 bg-rose-500/10 text-rose-400'
}
>
{health.status === 'connected'
? t('serverStatus.connected')
: health.status === 'checking'
? 'border-amber-500/30 bg-amber-500/10 text-amber-400'
: 'border-rose-500/30 bg-rose-500/10 text-rose-400'
}
>
{health.status === 'connected'
? 'آنلاین و متصل'
: health.status === 'checking'
? 'در حال بررسی...'
: 'قطع ارتباط'}
</Badge>
? t('serverStatus.checking')
: t('serverStatus.disconnected')}
</Badge>
<Button
type="button"
variant="ghost"
size="icon"
onClick={handleRefresh}
disabled={isRefreshing}
className="size-7 rounded-lg text-grey-3 hover:text-foreground hover:bg-muted/80 cursor-pointer"
title={t('serverStatus.recheck')}
>
<Ic name="refresh" className={cn('size-3.5', isRefreshing && 'animate-spin')} />
</Button>
</div>
</div>
<div className="flex items-center justify-between text-xs text-grey-2 pt-1 border-t border-border-soft">
<span className="text-grey-3">{health.message}</span>
@ -132,20 +121,20 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
<div className="flex items-center justify-between pb-1 border-b border-border-soft">
<span className="text-xs font-semibold text-foreground flex items-center gap-1.5">
<Ic name="code" className="size-3.5 text-grey-3" />
پیکربندی API و سرور
{t('serverStatus.apiConfigTitle')}
</span>
</div>
<div className="space-y-1.5 font-mono text-[11px]" dir="ltr">
<div className="flex justify-between items-center text-grey-2">
<span className="text-grey-3 font-sans text-xs">Target Server:</span>
<span className="text-grey-3 font-sans text-xs">{t('serverStatus.targetServer')}:</span>
<span className="text-foreground">http://127.0.0.1:8000</span>
</div>
<div className="flex justify-between items-center text-grey-2">
<span className="text-grey-3 font-sans text-xs">API Prefix:</span>
<span className="text-grey-3 font-sans text-xs">{t('serverStatus.apiPrefix')}:</span>
<span className="text-foreground">/api</span>
</div>
<div className="flex justify-between items-center text-grey-2">
<span className="text-grey-3 font-sans text-xs">Auth Header:</span>
<span className="text-grey-3 font-sans text-xs">{t('serverStatus.authHeader')}:</span>
<span className="text-primary font-semibold">Token (DRF)</span>
</div>
</div>
@ -156,29 +145,29 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
<div className="flex items-center justify-between pb-1 border-b border-border-soft">
<span className="text-xs font-semibold text-foreground flex items-center gap-1.5">
<Ic name="users" className="size-3.5 text-grey-3" />
اطلاعات حساب کاربری جاری
{t('serverStatus.currentUserTitle')}
</span>
<span className="text-[11px] text-primary font-medium">
{user?.role === 'superadmin'
? 'مدیر ارشد (Superuser)'
? t('serverStatus.roleSuperuser')
: user?.role === 'admin'
? 'مدیر سیستم (Staff)'
: 'کاربر'}
? t('serverStatus.roleStaff')
: t('serverStatus.roleUser')}
</span>
</div>
<div className="space-y-1.5 text-xs">
<div className="flex justify-between items-center">
<span className="text-grey-3">نام و نام خانوادگی:</span>
<span className="font-semibold text-foreground">{user?.name || 'مدیر سیستم'}</span>
<span className="text-grey-3">{t('serverStatus.fullName')}</span>
<span className="font-semibold text-foreground">{user?.name || t('navigation.adminRole')}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-grey-3">ایمیل:</span>
<span className="text-grey-3">{t('serverStatus.email')}</span>
<span dir="ltr" className="font-mono text-[11px] text-grey-2">
{user?.email || '-'}
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-grey-3">شناسه کاربری (ID):</span>
<span className="text-grey-3">{t('serverStatus.userId')}</span>
<span dir="ltr" className="font-mono text-[11px] text-grey-3">
#{user?.id ?? 1}
</span>
@ -186,6 +175,47 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
</div>
</div>
</div>
<DialogFooter className="flex-row items-center justify-between pt-4 border-t border-border-soft">
<div className="text-xs text-grey-3 flex items-center gap-1.5">
<span
className={`size-2 rounded-full ${
health.status === 'connected'
? 'bg-emerald-500 shadow-[0_0_6px_#10b981]'
: health.status === 'checking'
? 'bg-amber-400'
: 'bg-rose-500 shadow-[0_0_6px_#f43f5e]'
}`}
/>
<span>
{health.status === 'connected'
? t('serverStatus.connectionStable')
: health.status === 'checking'
? t('serverStatus.connectionTesting')
: t('serverStatus.connectionUnreachable')}
</span>
</div>
<div className="flex items-center gap-2">
<DialogClose asChild>
<Button type="button" variant="outline" size="sm" className="rounded-xl h-9 px-4 text-xs cursor-pointer">
{t('common.close')}
</Button>
</DialogClose>
<Button
type="button"
variant="default"
size="sm"
onClick={handleRefresh}
isLoading={isRefreshing}
loadingText={t('serverStatus.rechecking')}
className="rounded-xl h-9 px-4 text-xs gap-1.5 cursor-pointer"
>
<Ic name="refresh" className="size-3.5" />
<span>{t('serverStatus.recheck')}</span>
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)

26
src/components/shared/DeleteConfirmDialog.tsx

@ -9,6 +9,7 @@ import {
AlertDialogTitle,
} from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { useTranslation } from '@/i18n'
interface DeleteConfirmDialogProps {
open: boolean
@ -24,38 +25,45 @@ interface DeleteConfirmDialogProps {
export default function DeleteConfirmDialog({
open,
onOpenChange,
title = 'آیا از حذف این مورد اطمینان دارید؟',
description = 'این عملیات غیرقابل بازگشت است و رکورد مورد نظر به طور کامل حذف خواهد شد.',
confirmText = 'بله، حذف شود',
cancelText = 'انصراف',
title,
description,
confirmText,
cancelText,
isLoading = false,
onConfirm,
}: DeleteConfirmDialogProps) {
const { t } = useTranslation()
const finalTitle = title || t('common.deleteConfirmTitle')
const finalDescription = description || t('common.deleteConfirmDesc')
const finalConfirmText = confirmText || t('common.yesDelete')
const finalCancelText = cancelText || t('common.cancel')
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="sm:max-w-[425px]">
<AlertDialogHeader className="text-start">
<AlertDialogTitle className="text-base font-bold text-foreground">
{title}
{finalTitle}
</AlertDialogTitle>
<AlertDialogDescription className="text-xs leading-relaxed text-grey-3">
{description}
{finalDescription}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter className="mt-4 gap-2 sm:justify-start">
<Button
variant="destructive"
isLoading={isLoading}
loadingText="در حال حذف..."
loadingText={t('common.deleting')}
onClick={() => {
onConfirm()
}}
>
{confirmText}
{finalConfirmText}
</Button>
<AlertDialogCancel asChild>
<Button variant="outline" disabled={isLoading}>
{cancelText}
{finalCancelText}
</Button>
</AlertDialogCancel>
</AlertDialogFooter>

26
src/features/auth/LoginPage.tsx

@ -8,6 +8,7 @@ import { useAuth, useLoginMutation } from '@/features/auth/use-auth'
import { ApiError } from '@/services/http'
import { siteConfig } from '@/config/site'
import { AqilaLogo } from '@/components/AqilaLogo'
import { useTranslation } from '@/i18n'
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
@ -22,6 +23,7 @@ export default function LoginPage() {
const location = useLocation()
const { isAuthenticated } = useAuth()
const loginMutation = useLoginMutation()
const { t } = useTranslation()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
@ -50,7 +52,7 @@ export default function LoginPage() {
loginMutation.error instanceof ApiError
? loginMutation.error.message
: loginMutation.error
? 'ورود ناموفق بود. دوباره تلاش کنید.'
? t('auth.loginFailed')
: null
return (
@ -67,7 +69,7 @@ export default function LoginPage() {
</div>
<div>
<h1 className="text-xl font-black text-foreground">{siteConfig.name}</h1>
<p className="text-xs text-grey-3 mt-1">ورود به پنل مدیریت و داشبورد</p>
<p className="text-xs text-grey-3 mt-1">{t('auth.loginDescription')}</p>
</div>
</div>
@ -79,7 +81,7 @@ export default function LoginPage() {
{/* راهنمای لاگین دمو */}
<div className="flex items-center justify-between rounded-xl border border-primary/20 bg-primary/10 p-3 text-xs">
<div>
<span className="font-bold text-primary block">حساب آزمایشی آماده:</span>
<span className="font-bold text-primary block">{t('auth.demoBoxTitle')}</span>
<span dir="ltr" className="text-[11px] text-grey-2 font-mono">admin@gmail.com / admin</span>
</div>
<Button
@ -89,31 +91,31 @@ export default function LoginPage() {
onClick={fillDemo}
className="h-7 text-[11px]"
>
پر کردن خودکار
{t('auth.autoFill')}
</Button>
</div>
<Field label="ایمیل حساب کاربری">
<Field label={t('auth.emailLabel')}>
<Input
type="email"
dir="ltr"
className="text-left"
autoComplete="username"
placeholder="[email protected]"
placeholder={t('auth.emailPlaceholder')}
value={email}
onChange={(e) => setEmail(e.target.value)}
autoFocus
/>
</Field>
<Field label="رمز عبور">
<Field label={t('auth.passwordLabel')}>
<div className="relative">
<Input
type={showPassword ? 'text' : 'password'}
dir="ltr"
className="text-left pe-11"
autoComplete="current-password"
placeholder="••••••••"
placeholder={t('auth.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
@ -121,7 +123,7 @@ export default function LoginPage() {
type="button"
onClick={() => setShowPassword((v) => !v)}
className="absolute inset-y-0 start-0 flex w-11 items-center justify-center text-grey-3 transition-colors hover:text-grey-1 [&_svg]:size-4"
aria-label={showPassword ? 'مخفی‌کردن رمز' : 'نمایش رمز'}
aria-label={showPassword ? t('auth.hidePassword') : t('auth.showPassword')}
tabIndex={-1}
>
<Ic name={showPassword ? 'eyeOff' : 'eye'} />
@ -142,14 +144,14 @@ export default function LoginPage() {
className="w-full font-bold"
disabled={!valid}
isLoading={loginMutation.isPending}
loadingText="در حال ورود…"
loadingText={t('auth.loggingIn')}
>
ورود به حساب کاربری
{t('auth.submitLogin')}
</Button>
</form>
<p className="mt-4 text-center text-xs text-grey-4">
پشتیبانی از قالب‌های سفارشی و اتصال مستقیم به API
{t('auth.footerNote')}
</p>
</div>
</div>

14
src/features/dashboard/DashboardHome.tsx

@ -1,9 +1,11 @@
import { Ic } from '@/icons'
import { Badge } from '@/components/ui/badge'
import { useAuthStore } from '@/stores/auth'
import { useTranslation } from '@/i18n'
export default function DashboardHome() {
const user = useAuthStore((s) => s.user)
const { t } = useTranslation()
return (
<div className="space-y-6 pt-2">
@ -12,14 +14,14 @@ export default function DashboardHome() {
<div className="space-y-1">
<div className="flex items-center gap-2">
<h1 className="text-xl font-black text-foreground">
خوش آمدید، {user?.name || 'مدیر سیستم'}
{t('dashboard.welcome', { name: user?.name || t('navigation.adminRole') })}
</h1>
<Badge variant="outline" className="border-primary/30 bg-primary/10 text-primary">
آماده توسعه
{t('dashboard.readyBadge')}
</Badge>
</div>
<p className="text-xs text-grey-3">
پنل مدیریت عقیله آماده اضافه کردن ویجت‌ها، جداول و ماژول‌های جدید شماست.
{t('dashboard.welcomeSubtitle')}
</p>
</div>
</div>
@ -29,9 +31,11 @@ export default function DashboardHome() {
<div className="mx-auto flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Ic name="sparkles" className="size-6" />
</div>
<h2 className="mt-4 text-base font-bold text-foreground">داشبورد تمیز و آماده توسعه</h2>
<h2 className="mt-4 text-base font-bold text-foreground">
{t('dashboard.cleanBoardTitle')}
</h2>
<p className="mx-auto mt-2 max-w-md text-xs leading-relaxed text-grey-3">
تمام اطلاعات و یوآی‌های ساختگی حذف شدند. می‌توانید ماژول‌های جدید خود را با کامپوننت‌های آماده UI پیاده‌سازی و مستقیماً به دیتابیس و بک‌اند عقیله متصل نمایید.
{t('dashboard.cleanBoardDescription')}
</p>
</div>
</div>

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

@ -1,4 +1,4 @@
import { useEffect } from 'react'
import { useEffect, useMemo } from 'react'
import { useNavigate } from 'react-router-dom'
import { Ic } from '@/icons'
import {
@ -11,13 +11,17 @@ import {
CommandSeparator,
} from '@/components/ui/command'
import { useGlobalSearchStore } from '@/stores/global-search'
import { navigationConfig } from '@/config/navigation'
import { getNavigationConfig } from '@/config/navigation'
import { useLogout } from '@/features/auth/use-auth'
import { useTranslation } from '@/i18n'
export default function GlobalSearchDialog() {
const { open, setOpen } = useGlobalSearchStore()
const navigate = useNavigate()
const logout = useLogout()
const { t } = useTranslation()
const navConfig = useMemo(() => getNavigationConfig(t), [t])
// میانبر صفحه‌کلید Cmd+K یا Ctrl+K
useEffect(() => {
@ -38,15 +42,15 @@ export default function GlobalSearchDialog() {
return (
<CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="جستجوی صفحات، عملیات‌ها یا فرمان‌ها... (Ctrl+K)" />
<CommandInput placeholder={t('globalSearch.placeholder')} />
<CommandList className="max-h-[350px] overflow-y-auto p-2">
<CommandEmpty className="py-6 text-center text-xs text-grey-3">
هیچ نتیجه‌ای یافت نشد.
{t('globalSearch.empty')}
</CommandEmpty>
{/* گروه‌های نویگیشن */}
{navigationConfig.map((group) => (
<CommandGroup key={group.title} heading={group.title || 'بخش‌های سیستم'}>
{navConfig.map((group) => (
<CommandGroup key={group.title} heading={group.title || t('globalSearch.systemSections')}>
{group.items.flatMap((item) => {
const entries: React.ReactNode[] = []
if (item.to) {
@ -112,13 +116,13 @@ export default function GlobalSearchDialog() {
<CommandSeparator className="my-2" />
{/* اکشن‌های سریع */}
<CommandGroup heading="عملیات سریع">
<CommandGroup heading={t('common.actions')}>
<CommandItem
onSelect={() => runAction(() => logout())}
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-xs text-rose-400"
>
<Ic name="logout" className="size-4 text-rose-400" />
<span>خروج از حساب کاربری</span>
<span>{t('globalSearch.logoutAccount')}</span>
</CommandItem>
</CommandGroup>
</CommandList>

40
src/features/services/ServicesPage.tsx

@ -214,28 +214,30 @@ export default function ServicesPage() {
return (
<div className="space-y-6 pt-2 pb-14">
{/* سربرگ بخش خدمات */}
<div className="flex flex-col gap-4 rounded-2xl border border-border-soft bg-card/40 p-6 shadow-sm sm:flex-row sm:items-center sm:justify-between backdrop-blur-xs">
<div className="space-y-1">
<div className="flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Ic name={currentService.icon} className="size-5" />
{/* سربرگ بخش خدمات (برای شهرهای زیارتی به صورت یکپارچه داخل کامپوننت با دکمه لود مجدد مدیریت می‌شود) */}
{serviceKey !== 'cities' && (
<div className="flex flex-col gap-4 rounded-2xl border border-border-soft bg-card/40 p-6 shadow-sm sm:flex-row sm:items-center sm:justify-between backdrop-blur-xs">
<div className="space-y-1">
<div className="flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Ic name={currentService.icon} className="size-5" />
</div>
<h1 className="text-xl font-black text-foreground">
{currentService.title}
</h1>
<Badge
variant="outline"
className="border-primary/30 bg-primary/10 text-primary font-bold"
>
{currentService.titleEn}
</Badge>
</div>
<h1 className="text-xl font-black text-foreground">
{currentService.title}
</h1>
<Badge
variant="outline"
className="border-primary/30 bg-primary/10 text-primary font-bold"
>
{currentService.titleEn}
</Badge>
<p className="text-xs text-grey-3">
{currentService.description}
</p>
</div>
<p className="text-xs text-grey-3">
{currentService.description}
</p>
</div>
</div>
)}
{/* رندرینگ محتوای اختصاصی سرویس مستقل */}
<ServiceDetailRenderer service={currentService} subKey={subKey} />

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

@ -21,6 +21,7 @@ import {
deleteCategory,
fetchDuas,
} from '../services/trip-etiquette-api'
import { toast } from '@/stores/toast'
export const PILGRIMAGE_CITIES = [
{ id: 5, name: 'Karbala', nameFa: 'کربلای معلی', flag: '🇮🇶' },
{ id: 6, name: 'Najaf', nameFa: 'نجف اشرف', flag: '🇮🇶' },
@ -270,8 +271,9 @@ export function CategoriesServiceView() {
})
setCategories((prev) => prev.map((item) => (item.id === updated.id ? updated : item)))
setAssigningCategory(null)
toast.success('تخصیص ادعیه به دسته‌بندی با موفقیت ذخیره شد.')
} catch (err: any) {
alert(err?.message || 'خطا در تخصیص ادعیه.')
toast.error(err?.message || 'خطا در تخصیص ادعیه.')
} finally {
setIsSavingQuickAssign(false)
}
@ -281,7 +283,7 @@ export function CategoriesServiceView() {
const handleSaveCategory = async (e: React.FormEvent) => {
e.preventDefault()
if (!formNameAr.trim()) {
alert('لطفاً عنوان دسته‌بندی (عربی) را وارد کنید. زبان عربی پیش‌فرض و الزامی است.')
toast.warning('لطفاً عنوان دسته‌بندی (عربی) را وارد کنید. زبان عربی پیش‌فرض و الزامی است.')
return
}
@ -305,13 +307,15 @@ export function CategoriesServiceView() {
if (editingCategory) {
const updated = await updateCategory(editingCategory.id, payload)
setCategories((prev) => prev.map((item) => (item.id === updated.id ? updated : item)))
toast.success('دسته‌بندی با موفقیت به‌روزرسانی شد.')
} else {
const created = await createCategory(payload as CreateCategoryInput)
setCategories((prev) => [created, ...prev])
toast.success('دسته‌بندی جدید با موفقیت ایجاد شد.')
}
setIsDialogOpen(false)
} catch (err: any) {
alert(err?.message || 'خطا در ذخیره‌سازی دسته‌بندی.')
toast.error(err?.message || 'خطا در ذخیره‌سازی دسته‌بندی.')
} finally {
setIsSaving(false)
}
@ -326,8 +330,9 @@ export function CategoriesServiceView() {
try {
await deleteCategory(c.id)
setCategories((prev) => prev.filter((item) => item.id !== c.id))
toast.success('دسته‌بندی با موفقیت حذف شد.')
} catch (err: any) {
alert(err?.message || 'خطا در حذف دسته‌بندی.')
toast.error(err?.message || 'خطا در حذف دسته‌بندی.')
}
}

237
src/features/services/components/CitiesServiceView.tsx

@ -1,18 +1,18 @@
import React, { useState, useEffect, useMemo } from 'react'
import { Link } from 'react-router-dom'
import { Ic } from '@/icons'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { ShimmerBox, ShimmerLine } from '@/components/shared/shimmer_frontend'
import { toFa } from '@/lib/utils'
import type {
PilgrimageCityItem,
CategoryItem,
DuaItem,
} from '../types/trip-etiquette'
import {
fetchTripEtiquetteCities,
fetchCategories,
fetchDuas,
} from '../services/trip-etiquette-api'
// نام‌های روان و استاندارد فارسی برای شهرهای زیارتی
@ -48,6 +48,7 @@ const COUNTRY_NAMES_FA: Record<string, string> = {
export function CitiesServiceView() {
const [cities, setCities] = useState<PilgrimageCityItem[]>([])
const [categories, setCategories] = useState<CategoryItem[]>([])
const [duas, setDuas] = useState<DuaItem[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
@ -62,12 +63,14 @@ export function CitiesServiceView() {
setErrorMessage(null)
try {
const [citiesRes, categoriesRes] = await Promise.all([
const [citiesRes, categoriesRes, duasRes] = await Promise.all([
fetchTripEtiquetteCities(),
fetchCategories(),
fetchDuas(),
])
setCities(citiesRes)
setCategories(categoriesRes)
setDuas(duasRes)
} catch (err: any) {
setErrorMessage(err?.message || 'خطا در بارگذاری فهرست شهرهای زیارتی.')
} finally {
@ -125,44 +128,27 @@ export function CitiesServiceView() {
return (
<div className="space-y-6">
{/* سربرگ معرفی شناسه شهرها و وضعیت استاتیک */}
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 sm:p-5 flex flex-col md:flex-row items-start md:items-center justify-between gap-4 shadow-xs">
<div className="space-y-1">
<div className="flex items-center gap-2.5">
<div className="size-9 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary">
<Ic name="map" className="size-5" />
</div>
<div>
<h2 className="text-base sm:text-lg font-black text-foreground">
شهرهای زیارتی مرجع (شناسه‌های ثابت دیتابیس)
</h2>
<p className="text-xs text-grey-3">
شهرهای استاتیک اپلیکیشن عقیله؛ جهت تخصیص دسته‌بندی‌ها و ادعیه از شناسه‌های زیر استفاده می‌شود.
</p>
</div>
{/* سربرگ شهرهای زیارتی به همراه دکمه تازه‌سازی (لود مجدد) */}
<div className="flex items-center justify-between rounded-2xl border border-border-soft bg-card/40 p-4 sm:p-5 shadow-xs backdrop-blur-xs">
<div className="flex items-center gap-2.5">
<div className="flex size-9 items-center justify-center rounded-xl bg-primary/10 text-primary">
<Ic name="map" className="size-5" />
</div>
<h1 className="text-lg sm:text-xl font-black text-foreground">
شهرهای زیارتی
</h1>
</div>
<div className="flex items-center flex-wrap gap-2">
<Badge
variant="outline"
className="border-primary/30 bg-primary/5 text-primary text-xs px-3 py-1 rounded-xl font-bold gap-1.5"
>
<Ic name="shield" className="size-3.5" />
<span>شهرهای پایه استاتیک (بدون امکان حذف یا تغییر)</span>
</Badge>
<Button
variant="outline"
size="sm"
onClick={() => loadData(true)}
disabled={isRefreshing}
className="rounded-xl border-border-soft text-xs gap-1.5 h-8 hover:bg-card/80"
>
<Ic name="refresh" className={`size-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
<span>تازه‌سازی</span>
</Button>
</div>
<Button
variant="outline"
size="sm"
onClick={() => loadData(true)}
disabled={isRefreshing}
className="rounded-xl border-border-soft text-xs gap-1.5 h-8.5 px-3 hover:bg-card/80 cursor-pointer"
>
<Ic name="refresh" className={`size-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
<span>تازه‌سازی</span>
</Button>
</div>
{/* نوار فیلتر و جستجو */}
@ -232,50 +218,24 @@ export function CitiesServiceView() {
</div>
)}
{/* وضعیت لودینگ اسکلتون منطبق با ساختار واقعی کارت‌های شهر */}
{/* وضعیت لودینگ اسکلتون منطبق با ساختار کارت‌های ساده‌شده شهر */}
{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-4 shadow-xs flex flex-col justify-between"
className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3.5 shadow-xs"
>
<div className="space-y-3.5">
{/* شناسه و کشور */}
<div className="flex items-center justify-between">
<ShimmerBox width={120} height={24} rounded="lg" />
<ShimmerLine width={60} height={14} rounded="sm" />
</div>
{/* عنوان و اسلاگ */}
<div className="space-y-1.5">
<ShimmerLine width="55%" height={22} rounded="md" />
<ShimmerLine width="35%" height={12} rounded="sm" />
</div>
{/* دسته‌بندی‌ها */}
<div className="space-y-2 pt-2 border-t border-border-soft/60">
<div className="flex items-center justify-between">
<ShimmerLine width={100} height={12} rounded="sm" />
<ShimmerLine width={40} height={12} rounded="sm" />
</div>
<div className="flex gap-2">
<ShimmerBox width={75} height={22} rounded="md" />
<ShimmerBox width={95} height={22} rounded="md" />
</div>
</div>
{/* دسترسی‌های حمل‌ونقل و موقعیت */}
<div className="pt-2 border-t border-border-soft/60 flex items-center justify-between">
<ShimmerLine width={110} height={12} rounded="sm" />
<ShimmerLine width={70} height={12} rounded="sm" />
</div>
{/* عنوان و کشور */}
<div className="flex items-center justify-between">
<ShimmerLine width={110} height={20} rounded="md" />
<ShimmerBox width={65} height={24} rounded="xl" />
</div>
{/* دکمه‌های فوتر */}
<div className="pt-3 border-t border-border-soft flex items-center gap-2">
<ShimmerBox height={32} rounded="xl" className="flex-1" />
<ShimmerBox height={32} rounded="xl" className="flex-1" />
{/* آمار دو ستونه */}
<div className="grid grid-cols-2 gap-2.5 pt-3 border-t border-border-soft/60">
<ShimmerBox height={52} rounded="xl" />
<ShimmerBox height={52} rounded="xl" />
</div>
</div>
))}
@ -286,111 +246,64 @@ export function CitiesServiceView() {
<p className="text-sm font-bold text-foreground">هیچ شهری با این مشخصات یافت نشد</p>
</div>
) : (
/* شبکه کارت‌های شهرهای زیارتی */
/* شبکه کارت‌های شهرهای زیارتی (ساده‌شده، فقط خواندنی و بدون واکنش کلیک) */
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredCities.map((city) => {
const faName = CITY_FA_NAMES[city.slug.toLowerCase()] || city.name
const flag = COUNTRY_FLAGS[city.country_code || ''] || '🌍'
const countryFa = COUNTRY_NAMES_FA[city.country_name || ''] || city.country_name || ''
// دسته‌بندی‌های متصل به این شهر در دیتابیس
const cityCategories = categories.filter(
(c) => c.city === city.id || c.city_info?.id === city.id
)
// ۱. تعداد دسته‌بندی‌های متصل به این شهر در دیتابیس
const cityCategoriesCount = categories.filter((c) => {
if (c.city === city.id || c.city_info?.id === city.id) return true
if (c.cities && c.cities.some((ct) => ct.id === city.id)) return true
return false
}).length
// ۲. تعداد کل ادعیه متصل به این شهر در دیتابیس
const cityDuasCount = duas.filter((d) => {
if (d.cities && d.cities.some((c) => c.id === city.id)) return true
if (d.categories && d.categories.some((cat) => {
if (cat.city_id === city.id) return true
if (cat.cities && cat.cities.some((c) => c.id === city.id)) return true
return false
})) return true
if (d.shrines && d.shrines.some((s: any) => s.city_id === city.id || s.city === city.id)) return true
return false
}).length
return (
<div
key={city.id}
className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-4 hover:border-primary/40 transition shadow-xs flex flex-col justify-between"
className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-3.5 shadow-xs select-none cursor-default"
>
<div className="space-y-3.5">
{/* شناسه دیتابیس و کشور */}
<div className="flex items-center justify-between">
<Badge
variant="outline"
className="border-primary/40 bg-primary/10 text-primary font-mono text-xs px-2.5 py-0.5 rounded-lg font-bold"
>
شناسه دیتابیس: #{city.id}
</Badge>
<div className="flex items-center gap-1.5 text-xs text-grey-3 font-medium">
<span>{flag}</span>
<span>{countryFa}</span>
</div>
</div>
{/* بخش اول: نشان کشور به همراه پرچم (سمت چپ) و عنوان شهر (سمت راست) */}
<div className="flex items-center justify-between">
<h3 className="text-base sm:text-lg font-black text-foreground">
{faName}
</h3>
{/* نام شهر و اسلاگ */}
<div>
<h3 className="text-lg font-black text-foreground">{faName}</h3>
<div className="flex items-center gap-2 mt-1 text-xs text-grey-3 font-mono">
<span>{city.name}</span>
<span>•</span>
<span className="text-primary/80">slug: {city.slug}</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-grey-2 bg-card/80 border border-border-soft/60 px-2.5 py-1 rounded-xl font-medium">
<span>{countryFa}</span>
<span className="text-sm leading-none">{flag}</span>
</div>
</div>
{/* دسته‌بندی‌های متصل به این شهر */}
<div className="space-y-1.5 pt-1 border-t border-border-soft/60">
<div className="flex items-center justify-between text-[11px] text-grey-3 font-bold">
<span>دسته‌بندی‌های اعمال و آداب:</span>
<span className="font-mono text-primary">{toFa(cityCategories.length)} دسته</span>
</div>
<div className="flex flex-wrap gap-1.5">
{cityCategories.length > 0 ? (
cityCategories.map((c) => (
<Badge
key={c.id}
variant="secondary"
className="bg-card/90 text-foreground border border-border-soft text-[10px] px-2 py-0.5 rounded-md"
>
📁 {c.name_display} <span className="font-mono text-grey-3 me-1">#{c.id}</span>
</Badge>
))
) : (
<span className="text-[11px] text-grey-3 italic">بدون دسته‌بندی مستقیم</span>
)}
</div>
{/* بخش دوم: دو آمار تکمیلی صرفاً نمایشی */}
<div className="grid grid-cols-2 gap-2.5 pt-3 border-t border-border-soft/60">
<div className="rounded-xl border border-border-soft/60 bg-card/60 p-2.5 flex flex-col items-center justify-center text-center">
<span className="text-[11px] text-grey-3 font-medium">دسته‌بندی‌ها</span>
<span className="text-sm font-black text-foreground font-mono mt-0.5 tabular-nums">
{toFa(cityCategoriesCount)}
</span>
</div>
{/* دسترسی‌های حمل‌ونقل و موقعیت */}
<div className="pt-2 border-t border-border-soft/60 flex items-center justify-between text-[10px] text-grey-3 font-mono">
<div className="flex items-center gap-2">
<span title={city.has_airport ? 'دارای فرودگاه' : 'فاقد فرودگاه'} className={city.has_airport ? 'text-primary' : 'opacity-40'}>
✈️ فرودگاه
</span>
<span title={city.has_train_station ? 'دارای راه‌آهن' : 'فاقد راه‌آهن'} className={city.has_train_station ? 'text-primary' : 'opacity-40'}>
🚆 قطار
</span>
<span title={city.has_road_access ? 'دسترسی جاده‌ای' : ''} className={city.has_road_access ? 'text-primary' : 'opacity-40'}>
🚗 جاده
</span>
</div>
{city.latitude && city.longitude && (
<span dir="ltr">
{city.latitude.toFixed(2)}, {city.longitude.toFixed(2)}
</span>
)}
<div className="rounded-xl border border-border-soft/60 bg-card/60 p-2.5 flex flex-col items-center justify-center text-center">
<span className="text-[11px] text-grey-3 font-medium">کل ادعیه و زیارات</span>
<span className="text-sm font-black text-foreground font-mono mt-0.5 tabular-nums">
{toFa(cityDuasCount)}
</span>
</div>
</div>
{/* پیوندهای دسترسی سریع به ادعیه و دسته‌بندی‌ها */}
<div className="pt-3 border-t border-border-soft flex items-center gap-2">
<Link
to="/services/categories"
className="flex-1 inline-flex items-center justify-center rounded-xl border border-border-soft text-xs h-8 hover:border-primary/40 hover:text-primary gap-1 transition bg-card/60"
>
<Ic name="folder" className="size-3 text-primary" />
<span>دسته‌بندی‌ها ({toFa(cityCategories.length)})</span>
</Link>
<Link
to="/services/duas"
className="flex-1 inline-flex items-center justify-center rounded-xl border border-border-soft text-xs h-8 hover:border-primary/40 hover:text-primary gap-1 transition bg-card/60"
>
<Ic name="book" className="size-3 text-primary" />
<span>ادعیه و زیارات</span>
</Link>
</div>
</div>
)
})}

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

@ -10,7 +10,6 @@ import {
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Ic } from '@/icons'
import type {
CityGuideItem,
@ -25,7 +24,8 @@ interface CreateLocationDialogProps {
categories: CityGuideCategory[]
countries: CityGuideCountry[]
editingLocation?: CityGuideItem | null
onSave: (data: CreateCityGuideInput, editingId?: number) => void
isSaving?: boolean
onSave: (data: CreateCityGuideInput, editingId?: number) => void | Promise<void>
}
const WEEK_DAYS = [
@ -44,11 +44,28 @@ export function CreateLocationDialog({
categories,
countries,
editingLocation,
isSaving = false,
onSave,
}: CreateLocationDialogProps) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [address, setAddress] = useState('')
// زبان فعال در تب‌های چندزبانه
const [activeLangTab, setActiveLangTab] = useState<'fa' | 'ar' | 'en'>('fa')
// عناوین چندزبانه
const [titleFa, setTitleFa] = useState('')
const [titleAr, setTitleAr] = useState('')
const [titleEn, setTitleEn] = useState('')
// توضیحات چندزبانه
const [descFa, setDescFa] = useState('')
const [descAr, setDescAr] = useState('')
const [descEn, setDescEn] = useState('')
// آدرس‌های چندزبانه
const [addrFa, setAddrFa] = useState('')
const [addrAr, setAddrAr] = useState('')
const [addrEn, setAddrEn] = useState('')
// مشخصات عمومی و مکانی
const [phoneNumber, setPhoneNumber] = useState('')
const [categoryId, setCategoryId] = useState<number>(categories[0]?.id || 1)
const [countryId, setCountryId] = useState<number>(countries[0]?.id || 1)
@ -58,25 +75,35 @@ export function CreateLocationDialog({
const [daysOff, setDaysOff] = useState<string[]>([])
const [latitude, setLatitude] = useState('32.6160')
const [longitude, setLongitude] = useState('44.0244')
const [isActive, setIsActive] = useState(true)
const [imageUrl, setImageUrl] = useState('')
const [validationError, setValidationError] = useState<string | null>(null)
// لیست شهرهای کشور انتخاب‌شده
// لیست کلیه شهرهای تابعه کشور انتخابی
const currentCountry = countries.find((c) => c.id === countryId) || countries[0]
const availableCities = currentCountry?.city || []
useEffect(() => {
if (editingLocation) {
setTitle(editingLocation.title || '')
setDescription(editingLocation.description || '')
setAddress(editingLocation.address || '')
const trans = editingLocation.translations || {}
setTitleFa(trans.fa?.title || editingLocation.title || '')
setTitleAr(trans.ar?.title || '')
setTitleEn(trans.en?.title || '')
setDescFa(trans.fa?.description || editingLocation.description || '')
setDescAr(trans.ar?.description || '')
setDescEn(trans.en?.description || '')
setAddrFa(trans.fa?.address || editingLocation.address || '')
setAddrAr(trans.ar?.address || '')
setAddrEn(trans.en?.address || '')
setPhoneNumber(editingLocation.phone_number || '')
setCategoryId(editingLocation.category?.id || categories[0]?.id || 1)
setCountryId(editingLocation.country?.id || countries[0]?.id || 1)
setCityId(editingLocation.city?.id || 1)
setWorkingHoursFrom(editingLocation.working_hours_from || '08:00')
setWorkingHoursTo(editingLocation.working_hours_to || '23:00')
setCategoryId(editingLocation.category_id || editingLocation.category?.id || categories[0]?.id || 1)
setCountryId(editingLocation.country_id || editingLocation.country?.id || countries[0]?.id || 1)
setCityId(editingLocation.city_id || editingLocation.city?.id || 1)
setWorkingHoursFrom(editingLocation.working_hours_from ? editingLocation.working_hours_from.substring(0, 5) : '08:00')
setWorkingHoursTo(editingLocation.working_hours_to ? editingLocation.working_hours_to.substring(0, 5) : '23:00')
const parsedDays = Array.isArray(editingLocation.days_off)
? editingLocation.days_off
@ -85,9 +112,8 @@ export function CreateLocationDialog({
: []
setDaysOff(parsedDays)
setLatitude(String(editingLocation.latitude || '32.6160'))
setLongitude(String(editingLocation.longitude || '44.0244'))
setIsActive(editingLocation.is_active ?? true)
setLatitude(String(editingLocation.latitude ?? '32.6160'))
setLongitude(String(editingLocation.longitude ?? '44.0244'))
const img =
(typeof editingLocation.image?.image_url === 'object'
@ -96,9 +122,15 @@ export function CreateLocationDialog({
setImageUrl(typeof img === 'string' ? img : '')
} else {
// مقادیر پیش‌فرض برای ایجاد مکان جدید
setTitle('')
setDescription('')
setAddress('')
setTitleFa('')
setTitleAr('')
setTitleEn('')
setDescFa('')
setDescAr('')
setDescEn('')
setAddrFa('')
setAddrAr('')
setAddrEn('')
setPhoneNumber('')
setCategoryId(categories[0]?.id || 1)
const firstCountry = countries[0]
@ -113,30 +145,23 @@ export function CreateLocationDialog({
setDaysOff([])
setLatitude('32.6160')
setLongitude('44.0244')
setIsActive(true)
setImageUrl('')
}
setValidationError(null)
}, [editingLocation, open, categories, countries])
// به‌روزرسانی شهر پیش‌فرض هنگام تغییر کشور
const handleCountryChange = (newCountryId: number) => {
setCountryId(newCountryId)
const targetCountry = countries.find((c) => c.id === newCountryId)
if (targetCountry && targetCountry.city && targetCountry.city.length > 0) {
setCityId(targetCountry.city[0].id)
}
}
const toggleDayOff = (dayCode: string) => {
setDaysOff((prev) =>
prev.includes(dayCode) ? prev.filter((d) => d !== dayCode) : [...prev, dayCode]
)
}
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim()) {
// حداقل عنوان فارسی یا اصلی باید پر باشد
const mainTitle = titleFa.trim() || titleAr.trim() || titleEn.trim()
if (!mainTitle) {
setValidationError('لطفاً عنوان مکان را وارد کنید.')
return
}
@ -148,10 +173,28 @@ export function CreateLocationDialog({
return
}
const titles: Record<string, string> = {}
if (titleFa.trim()) titles.fa = titleFa.trim()
if (titleAr.trim()) titles.ar = titleAr.trim()
if (titleEn.trim()) titles.en = titleEn.trim()
const descriptions: Record<string, string> = {}
if (descFa.trim()) descriptions.fa = descFa.trim()
if (descAr.trim()) descriptions.ar = descAr.trim()
if (descEn.trim()) descriptions.en = descEn.trim()
const addresses: Record<string, string> = {}
if (addrFa.trim()) addresses.fa = addrFa.trim()
if (addrAr.trim()) addresses.ar = addrAr.trim()
if (addrEn.trim()) addresses.en = addrEn.trim()
const inputData: CreateCityGuideInput = {
title: title.trim(),
description: description.trim(),
address: address.trim(),
title: mainTitle,
description: descFa.trim() || descAr.trim() || descEn.trim() || '',
address: addrFa.trim() || addrAr.trim() || addrEn.trim() || '',
titles,
descriptions,
addresses,
phone_number: phoneNumber.trim(),
category_id: categoryId,
country_id: countryId,
@ -161,11 +204,10 @@ export function CreateLocationDialog({
days_off: daysOff,
latitude: latNum,
longitude: lngNum,
is_active: isActive,
image_url: imageUrl.trim() || undefined,
}
onSave(inputData, editingLocation?.id)
await onSave(inputData, editingLocation?.id)
onOpenChange(false)
}
@ -182,7 +224,7 @@ export function CreateLocationDialog({
{editingLocation ? `ویرایش مکان: ${editingLocation.title}` : 'افزودن مکان جدید به راهنمای زائر'}
</DialogTitle>
<DialogDescription className="text-xs text-grey-3 mt-0.5">
فیلدها منطبق بر ساختار رسمی مدل پایگاه‌داده و وب‌سرویس CityGuide
ثبت مستقیم و پایدار در دیتابیس سامانه و همگام با اپلیکیشن زائرین
</DialogDescription>
</div>
</div>
@ -196,34 +238,155 @@ export function CreateLocationDialog({
</div>
)}
{/* ۱. عنوان و وضعیت فعال بودن */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="sm:col-span-2 space-y-1.5">
<label className="text-xs font-bold text-foreground">
عنوان مکان / مرکز <span className="text-rose-500">*</span>
</label>
<Input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="مثلاً: حرم مطهر امام حسین (ع) یا بیمارستان سفیر"
className="text-xs bg-surface-base"
required
/>
{/* تب‌های انتخاب زبان برای محتوای متنی */}
<div className="space-y-3 rounded-2xl border border-border-soft bg-card/40 p-4">
<div className="flex items-center justify-between border-b border-border-soft pb-2.5">
<div className="text-xs font-bold text-foreground">اطلاعات متنی مکان</div>
<div className="flex items-center gap-1 rounded-xl bg-surface-base p-1 border border-border-soft">
<button
type="button"
onClick={() => setActiveLangTab('fa')}
className={`px-2.5 py-1 text-xs rounded-lg transition-colors font-medium ${
activeLangTab === 'fa'
? 'bg-primary text-white shadow-xs font-bold'
: 'text-grey-2 hover:text-foreground'
}`}
>
فارسی (پیش‌فرض)
</button>
<button
type="button"
onClick={() => setActiveLangTab('ar')}
className={`px-2.5 py-1 text-xs rounded-lg transition-colors font-medium ${
activeLangTab === 'ar'
? 'bg-primary text-white shadow-xs font-bold'
: 'text-grey-2 hover:text-foreground'
}`}
>
العربية
</button>
<button
type="button"
onClick={() => setActiveLangTab('en')}
className={`px-2.5 py-1 text-xs rounded-lg transition-colors font-medium ${
activeLangTab === 'en'
? 'bg-primary text-white shadow-xs font-bold'
: 'text-grey-2 hover:text-foreground'
}`}
>
English
</button>
</div>
</div>
<div className="space-y-1.5 flex flex-col justify-end">
<label className="text-xs font-bold text-foreground mb-2">وضعیت انتشار</label>
<div className="flex items-center gap-2.5 h-9 rounded-xl border border-border-soft bg-surface-base px-3">
<Switch checked={isActive} onCheckedChange={setIsActive} />
<span className="text-xs font-medium text-grey-2">
{isActive ? 'فعال در سامانه' : 'غیرفعال'}
</span>
{/* بخش زبان فارسی */}
{activeLangTab === 'fa' && (
<div className="space-y-3">
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">
عنوان مکان (فارسی) <span className="text-rose-500">*</span>
</label>
<Input
value={titleFa}
onChange={(e) => setTitleFa(e.target.value)}
placeholder="مثلاً: حرم مطهر امام حسین (ع) یا بیمارستان سفیر"
className="text-xs bg-surface-base"
required
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">شرح و توضیحات (فارسی)</label>
<textarea
value={descFa}
onChange={(e) => setDescFa(e.target.value)}
rows={3}
placeholder="توضیحات تاریخی، معنوی، خدمات یا امکانات این مکان..."
className="w-full rounded-xl border border-border-soft bg-surface-base p-3 text-xs text-foreground focus:border-primary focus:outline-none resize-none"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">آدرس دقیق (فارسی)</label>
<Input
value={addrFa}
onChange={(e) => setAddrFa(e.target.value)}
placeholder="خیابان، میدان، پلاک یا نشانی راهنمای زائر"
className="text-xs bg-surface-base"
/>
</div>
</div>
</div>
)}
{/* بخش زبان عربی */}
{activeLangTab === 'ar' && (
<div className="space-y-3" dir="rtl">
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">اسم المكان (بالعربية)</label>
<Input
value={titleAr}
onChange={(e) => setTitleAr(e.target.value)}
placeholder="مثال: حرم الإمام الحسين (ع)"
className="text-xs bg-surface-base"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">الوصف والخدمات (بالعربية)</label>
<textarea
value={descAr}
onChange={(e) => setDescAr(e.target.value)}
rows={3}
placeholder="شرح موجز عن المكان والخدمات..."
className="w-full rounded-xl border border-border-soft bg-surface-base p-3 text-xs text-foreground focus:border-primary focus:outline-none resize-none"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">العنوان (بالعربية)</label>
<Input
value={addrAr}
onChange={(e) => setAddrAr(e.target.value)}
placeholder="الشارع، المنطقة أو الحي"
className="text-xs bg-surface-base"
/>
</div>
</div>
)}
{/* بخش زبان انگلیسی */}
{activeLangTab === 'en' && (
<div className="space-y-3" dir="ltr">
<div className="space-y-1.5 text-start">
<label className="text-xs font-bold text-foreground">Place Title (English)</label>
<Input
value={titleEn}
onChange={(e) => setTitleEn(e.target.value)}
placeholder="e.g. Imam Hussain Holy Shrine"
className="text-xs bg-surface-base"
/>
</div>
<div className="space-y-1.5 text-start">
<label className="text-xs font-bold text-foreground">Description (English)</label>
<textarea
value={descEn}
onChange={(e) => setDescEn(e.target.value)}
rows={3}
placeholder="Brief description of the place..."
className="w-full rounded-xl border border-border-soft bg-surface-base p-3 text-xs text-foreground focus:border-primary focus:outline-none resize-none"
/>
</div>
<div className="space-y-1.5 text-start">
<label className="text-xs font-bold text-foreground">Address (English)</label>
<Input
value={addrEn}
onChange={(e) => setAddrEn(e.target.value)}
placeholder="Street, District or City location"
className="text-xs bg-surface-base"
/>
</div>
</div>
)}
</div>
{/* ۲. دسته‌بندی و شهر/کشور مقصد */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{/* دسته‌بندی و شهر/کشور مقصد */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">
دسته‌بندی <span className="text-rose-500">*</span>
@ -241,125 +404,96 @@ export function CreateLocationDialog({
</select>
</div>
<div className="space-y-1.5 sm:col-span-2">
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">
شهر و کشور مقصد <span className="text-rose-500">*</span>
</label>
<select
value={`${countryId}_${cityId}`}
onChange={(e) => {
const [cId, ctId] = e.target.value.split('_').map(Number)
setCountryId(cId)
setCityId(ctId)
const [coId, ciId] = e.target.value.split('_').map(Number)
setCountryId(coId)
setCityId(ciId)
}}
className="w-full h-9 rounded-xl border border-border-soft bg-surface-base px-3 text-xs text-foreground focus:border-primary focus:outline-none"
>
{countries.map((country) => (
<optgroup key={country.id} label={`${country.icon ? `${country.icon} ` : ''}${country.name}`}>
{country.city && country.city.length > 0 ? (
country.city.map((city) => (
<option key={`${country.id}_${city.id}`} value={`${country.id}_${city.id}`}>
{city.name} ({country.name})
</option>
))
) : (
<option value={`${country.id}_1`}>
{country.name} (عمومی)
{countries.map((co) => (
<optgroup key={co.id} label={`${co.icon || '📍'} ${co.name}`}>
{(co.city || []).map((ci) => (
<option key={`${co.id}_${ci.id}`} value={`${co.id}_${ci.id}`}>
{ci.name} ({co.name})
</option>
)}
))}
</optgroup>
))}
</select>
</div>
</div>
{/* ۳. آدرس و شماره تماس */}
{/* ساعات کاری و تلفن تماس */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="sm:col-span-2 space-y-1.5">
<label className="text-xs font-bold text-foreground">آدرس کامل پستی و محلی</label>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">ساعت شروع فعالیت</label>
<Input
type="time"
value={workingHoursFrom}
onChange={(e) => setWorkingHoursFrom(e.target.value)}
className="text-xs bg-surface-base font-mono"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">ساعت پایان فعالیت</label>
<Input
value={address}
onChange={(e) => setAddress(e.target.value)}
placeholder="مثلاً: کربلای معلی، شارع الحسین (ع)، جنب باب القبله"
className="text-xs bg-surface-base"
type="time"
value={workingHoursTo}
onChange={(e) => setWorkingHoursTo(e.target.value)}
className="text-xs bg-surface-base font-mono"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">شماره تماس پشتیبانی</label>
<label className="text-xs font-bold text-foreground">تلفن تماس</label>
<Input
value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)}
placeholder="+964 780 000 0000"
dir="ltr"
placeholder="+964 780 123 4567"
className="text-xs bg-surface-base font-mono"
dir="ltr"
/>
</div>
</div>
{/* ۴. شرح و توضیحات مکان */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">شرح و راهنمای تفصیلی مکان</label>
<textarea
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="توضیحات تاریخی، معنوی یا راهنمای حضور زائرین..."
className="w-full rounded-xl border border-border-soft bg-surface-base px-3.5 py-2.5 text-xs text-foreground placeholder:text-grey-3 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
{/* ۵. ساعات کاری و روزهای تعطیل */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 rounded-xl border border-border-soft bg-card/30 p-4">
<div className="space-y-2">
<label className="text-xs font-bold text-foreground">بازه ساعات فعالیت</label>
<div className="flex items-center gap-2">
<div className="flex-1">
<span className="text-[10px] text-grey-3 block mb-1">از ساعت</span>
<Input
type="time"
value={workingHoursFrom}
onChange={(e) => setWorkingHoursFrom(e.target.value)}
className="text-xs bg-surface-base font-mono h-8"
/>
</div>
<div className="flex-1">
<span className="text-[10px] text-grey-3 block mb-1">تا ساعت</span>
<Input
type="time"
value={workingHoursTo}
onChange={(e) => setWorkingHoursTo(e.target.value)}
className="text-xs bg-surface-base font-mono h-8"
/>
</div>
</div>
{/* روزهای تعطیل هفته */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<label className="text-xs font-bold text-foreground">روزهای تعطیل هفته</label>
<span className="text-[11px] text-grey-3">
{daysOff.length === 0 ? 'بدون تعطیلی (شبانه‌روزی یا همه‌روزه)' : `${daysOff.length} روز تعطیل`}
</span>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-foreground">روزهای تعطیل هفتگی</label>
<div className="flex flex-wrap gap-1.5">
{WEEK_DAYS.map((d) => {
const isOff = daysOff.includes(d.code)
return (
<button
key={d.code}
type="button"
onClick={() => toggleDayOff(d.code)}
className={`px-2.5 py-1 text-[11px] rounded-lg border font-medium transition ${
isOff
? 'bg-rose-500/15 border-rose-500/30 text-rose-500 font-bold'
: 'bg-surface-base border-border-soft text-grey-3 hover:text-foreground'
}`}
>
{d.label}
</button>
)
})}
</div>
<div className="grid grid-cols-4 sm:grid-cols-7 gap-2">
{WEEK_DAYS.map((day) => {
const isSelected = daysOff.includes(day.code)
return (
<button
key={day.code}
type="button"
onClick={() => toggleDayOff(day.code)}
className={`h-8 rounded-xl border text-xs font-bold transition-all flex items-center justify-center ${
isSelected
? 'border-rose-500/40 bg-rose-500/10 text-rose-500 shadow-xs'
: 'border-border-soft bg-surface-base text-grey-3 hover:text-foreground hover:bg-card'
}`}
>
{day.label}
</button>
)
})}
</div>
</div>
{/* ۶. مختصات جغرافیایی (Latitude / Longitude) */}
{/* مختصات جغرافیایی */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">
@ -368,9 +502,9 @@ export function CreateLocationDialog({
<Input
value={latitude}
onChange={(e) => setLatitude(e.target.value)}
placeholder="مثلاً: 32.6160"
dir="ltr"
placeholder="32.6160"
className="text-xs bg-surface-base font-mono"
dir="ltr"
required
/>
</div>
@ -382,41 +516,37 @@ export function CreateLocationDialog({
<Input
value={longitude}
onChange={(e) => setLongitude(e.target.value)}
placeholder="مثلاً: 44.0244"
dir="ltr"
placeholder="44.0244"
className="text-xs bg-surface-base font-mono"
dir="ltr"
required
/>
</div>
</div>
{/* ۷. آدرس اینترنتی تصویر شاخص */}
<div className="space-y-1.5">
<label className="text-xs font-bold text-foreground">آدرس تصویر شاخص (URL)</label>
<Input
value={imageUrl}
onChange={(e) => setImageUrl(e.target.value)}
placeholder="https://example.com/images/place.jpg"
dir="ltr"
className="text-xs bg-surface-base font-mono"
/>
</div>
</form>
<DialogFooter className="px-6 py-4 bg-secondary/10 border-t border-border/50 flex items-center justify-between">
<DialogFooter className="px-6 py-4 border-t border-border-soft bg-card/60 flex items-center justify-between gap-3">
<DialogClose asChild>
<Button type="button" variant="outline" size="sm" className="rounded-xl">
<Button type="button" variant="outline" size="sm" className="rounded-xl text-xs" disabled={isSaving}>
انصراف
</Button>
</DialogClose>
<Button
type="button"
size="sm"
onClick={handleSubmit}
className="rounded-xl bg-primary text-white hover:bg-primary/90 gap-1.5 shadow-sm"
size="sm"
disabled={isSaving}
className="rounded-xl text-xs gap-1.5 px-5 bg-primary text-white hover:bg-primary/90"
>
<Ic name="check" className="size-4" />
<span>{editingLocation ? 'ذخیره تغییرات مکان' : 'ثبت مکان در راهنمای زائر'}</span>
{isSaving ? (
<span className="flex items-center gap-1.5">
<span className="size-3.5 animate-spin rounded-full border-2 border-white border-t-transparent" />
در حال ذخیره‌سازی...
</span>
) : (
<span>{editingLocation ? 'ثبت تغییرات' : 'افزودن مکان'}</span>
)}
</Button>
</DialogFooter>
</DialogContent>

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

144
src/features/services/components/LocationDetailSheet.tsx

@ -1,4 +1,4 @@
import React from 'react'
import React, { useState } from 'react'
import {
Sheet,
SheetContent,
@ -16,9 +16,10 @@ interface LocationDetailSheetProps {
location: CityGuideItem | null
open: boolean
onOpenChange: (open: boolean) => void
onToggleStatus: (id: number, currentStatus: boolean) => void
isLoadingStatus?: boolean
onEdit: (location: CityGuideItem) => void
onDelete?: (id: number, title: string) => void
onToggleActive?: (location: CityGuideItem) => void
isToggling?: boolean
}
const DAY_LABELS: Record<string, string> = {
@ -35,10 +36,13 @@ export function LocationDetailSheet({
location,
open,
onOpenChange,
onToggleStatus,
isLoadingStatus,
onEdit,
onDelete,
onToggleActive,
isToggling,
}: LocationDetailSheetProps) {
const [activeLang, setActiveLang] = useState<'fa' | 'ar' | 'en'>('fa')
if (!location) return null
const daysOffList = Array.isArray(location.days_off)
@ -58,6 +62,13 @@ export function LocationDetailSheet({
? location.image?.image_url?.original || location.image?.image_url?.lg || location.image?.image_url?.md || location.image?.image_url?.sm
: location.image?.image_url) || null
const trans = location.translations || {}
const currentTrans = trans[activeLang]
const displayTitle = currentTrans?.title || location.title
const displayDesc = currentTrans?.description || location.description
const displayAddress = currentTrans?.address || location.address
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="left" className="w-full sm:max-w-xl overflow-y-auto p-0 border-r border-border-soft bg-background">
@ -67,7 +78,7 @@ export function LocationDetailSheet({
<div className="space-y-1 text-start">
<div className="flex items-center gap-2">
<SheetTitle className="text-lg font-black text-foreground">
{location.title}
{displayTitle}
</SheetTitle>
</div>
<SheetDescription className="text-xs text-grey-3 font-mono" dir="ltr">
@ -78,13 +89,9 @@ export function LocationDetailSheet({
<div className="flex items-center gap-2 shrink-0">
<Badge
variant="outline"
className={
location.is_active
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 font-bold text-xs'
: 'border-slate-500/30 bg-slate-500/10 text-slate-500 dark:text-slate-400 font-bold text-xs'
}
className="border-primary/30 bg-primary/10 text-primary font-bold text-xs"
>
{location.is_active ? 'فعال در سامانه' : 'غیرفعال / پنهان'}
{location.category?.name || 'مکان زیارتی'}
</Badge>
</div>
</div>
@ -98,37 +105,61 @@ export function LocationDetailSheet({
</div>
)}
{/* نوار وضعیت تعاملی و عملیات سریع */}
<div className="flex items-center justify-between rounded-2xl border border-border-soft bg-card/40 p-4">
{/* نوار وضعیت انتشار و عملیات (ویرایش و حذف) */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center justify-between gap-3 rounded-2xl border border-border-soft bg-card/40 p-4">
<div className="flex items-center gap-3">
<Switch
checked={location.is_active ?? true}
isLoading={isLoadingStatus}
onCheckedChange={() => onToggleStatus(location.id, location.is_active ?? true)}
checked={location.is_active !== false}
disabled={isToggling}
isLoading={isToggling}
onCheckedChange={() => onToggleActive?.(location)}
aria-label={`تغییر وضعیت فعال‌سازی ${location.title}`}
/>
<div className="text-xs">
<div className="font-bold text-foreground">وضعیت نمایش در اپلیکیشن</div>
<div className="font-bold text-foreground flex items-center gap-1.5">
<span>وضعیت در اپلیکیشن:</span>
<span className={location.is_active !== false ? 'text-emerald-500 font-black' : 'text-grey-3 font-black'}>
{location.is_active !== false ? 'فعال و نمایان' : 'غیرفعال و مخفی'}
</span>
</div>
<div className="text-[11px] text-grey-3">
{location.is_active ? 'این مکان هم‌اکنون برای زائرین قابل مشاهده است' : 'این مکان از دید زائرین مخفی شده است'}
{location.is_active !== false ? 'این مکان به زائران در اپلیکیشن نمایش داده می‌شود.' : 'این مکان از دید کاربران اپلیکیشن کاملاً پنهان است.'}
</div>
</div>
</div>
<Button
size="sm"
variant="outline"
onClick={() => {
onOpenChange(false)
onEdit(location)
}}
className="gap-1.5 text-xs rounded-xl h-8"
>
<Ic name="pencil" className="size-3.5" />
<span>ویرایش مشخصات</span>
</Button>
<div className="flex items-center justify-end gap-2 shrink-0">
{onDelete && (
<Button
size="sm"
variant="outline"
onClick={() => {
onOpenChange(false)
onDelete(location.id, location.title)
}}
className="gap-1.5 text-xs rounded-xl h-8 text-rose-500 hover:text-rose-600 hover:bg-rose-500/10 border-rose-500/20"
>
<Ic name="trash" className="size-3.5" />
<span>حذف</span>
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={() => {
onOpenChange(false)
onEdit(location)
}}
className="gap-1.5 text-xs rounded-xl h-8 bg-primary/10 text-primary hover:bg-primary/20 border-primary/20"
>
<Ic name="pencil" className="size-3.5" />
<span>ویرایش مشخصات</span>
</Button>
</div>
</div>
{/* کارت‌های شاخص (دسته‌بندی، شهر، امتیاز) */}
{/* کارت‌های شاخص (دسته‌بندی، شهر، کشور) */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3">
<div className="rounded-xl border border-border-soft bg-card/30 p-3.5 text-center">
<span className="text-[11px] text-grey-3">دسته‌بندی</span>
@ -138,28 +169,50 @@ export function LocationDetailSheet({
</div>
<div className="rounded-xl border border-border-soft bg-card/30 p-3.5 text-center">
<span className="text-[11px] text-grey-3">کشور و شهر</span>
<div className="mt-1 text-xs font-bold text-foreground flex items-center justify-center gap-1">
<span>{location.country?.icon}</span>
<span>{location.city?.name || 'نامشخص'}</span>
<span className="text-[11px] text-grey-3">شهر مقصد</span>
<div className="mt-1 text-xs font-bold text-foreground">
{location.city?.name || 'نامشخص'}
</div>
</div>
<div className="rounded-xl border border-border-soft bg-card/30 p-3.5 text-center col-span-2 sm:col-span-1">
<span className="text-[11px] text-grey-3">امتیاز زائرین</span>
<div className="mt-1 text-xs font-black text-amber-500 font-mono flex items-center justify-center gap-1">
<span>★</span>
<span>{location.average_score ? location.average_score.toFixed(1) : '5.0'}</span>
<span className="text-[11px] text-grey-3">کشور</span>
<div className="mt-1 text-xs font-bold text-foreground flex items-center justify-center gap-1">
<span>{location.country?.icon}</span>
<span>{location.country?.name || 'نامشخص'}</span>
</div>
</div>
</div>
{/* انتخاب زبان نمایش شرح و آدرس در صورت وجود ترجمه */}
{Object.keys(trans).length > 0 && (
<div className="flex items-center justify-between border-b border-border-soft pb-2">
<span className="text-xs font-bold text-foreground">مشاهده زبان ترجمه</span>
<div className="flex items-center gap-1 rounded-xl bg-surface-base p-1 border border-border-soft text-xs">
{(['fa', 'ar', 'en'] as const).map((l) => (
<button
key={l}
type="button"
onClick={() => setActiveLang(l)}
className={`px-2 py-0.5 rounded-lg transition-colors font-medium ${
activeLang === l
? 'bg-primary text-white font-bold'
: 'text-grey-3 hover:text-foreground'
}`}
>
{l === 'fa' ? 'فارسی' : l === 'ar' ? 'العربية' : 'EN'}
</button>
))}
</div>
</div>
)}
{/* شرح و توضیحات کامل */}
{location.description && (
{displayDesc && (
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 space-y-2">
<h4 className="text-xs font-extrabold text-foreground">درباره این مکان</h4>
<p className="text-xs leading-relaxed text-grey-2 whitespace-pre-line">
{location.description}
<p className="text-xs leading-relaxed text-grey-2 whitespace-pre-line text-start">
{displayDesc}
</p>
</div>
)}
@ -172,7 +225,7 @@ export function LocationDetailSheet({
<div className="flex items-start justify-between gap-2 border-b border-border-soft/60 pb-2.5">
<span className="text-grey-3 shrink-0">آدرس دقیق:</span>
<span className="font-medium text-foreground text-start">
{location.address || 'آدرس ثبت نشده است.'}
{displayAddress || 'آدرس ثبت نشده است.'}
</span>
</div>
@ -198,7 +251,8 @@ export function LocationDetailSheet({
<span className="text-emerald-600 dark:text-emerald-400">۲۴ ساعته (شبانه‌روزی)</span>
) : (
<span className="font-mono" dir="ltr">
{location.working_hours_from || '08:00'} - {location.working_hours_to || '23:00'}
{location.working_hours_from ? location.working_hours_from.substring(0, 5) : '08:00'} -{' '}
{location.working_hours_to ? location.working_hours_to.substring(0, 5) : '23:00'}
</span>
)}
</span>

407
src/features/services/components/ZayerGuideServiceView.tsx

@ -4,8 +4,8 @@ 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 { Switch } from '@/components/ui/switch'
import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { Shimmer } from '@/components/shared/shimmer_frontend'
import { toFa } from '@/lib/utils'
import { toast } from '@/stores/toast'
@ -20,10 +20,11 @@ import {
fetchCityGuides,
fetchCityGuideCategories,
fetchCityGuideCountries,
toggleCityGuideStatusLocal,
bulkUpdateCityGuidesStatusLocal,
bulkDeleteCityGuidesLocal,
saveCityGuideItemLocal,
createCityGuide,
updateCityGuide,
deleteCityGuide,
bulkDeleteCityGuides,
toggleCityGuideActive,
} from '../services/zayer-guide-api'
import { LocationDetailSheet } from './LocationDetailSheet'
import { CreateLocationDialog } from './CreateLocationDialog'
@ -38,15 +39,52 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
const [countries, setCountries] = useState<CityGuideCountry[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false)
const [isSaving, setIsSaving] = useState(false)
const [deletingId, setDeletingId] = useState<number | null>(null)
const [togglingId, setTogglingId] = useState<number | null>(null)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
const [loadingLocationIds, setLoadingLocationIds] = useState<Set<number>>(new Set())
// تغییر وضعیت فعال / مخفی کردن مکان (تاگل خاموش و روشن در دیتابیس و اپلیکیشن)
const handleToggleActive = async (item: CityGuideItem, e?: React.MouseEvent) => {
if (e) e.stopPropagation()
const currentActive = item.is_active !== false
const nextActive = !currentActive
setTogglingId(item.id)
// به‌روزرسانی آنی و اپتیمیستیک در استیت فرانت‌اند
setLocations((prev) =>
prev.map((loc) => (loc.id === item.id ? { ...loc, is_active: nextActive } : loc))
)
if (detailLocation && detailLocation.id === item.id) {
setDetailLocation({ ...detailLocation, is_active: nextActive })
}
try {
await toggleCityGuideActive(item.id, nextActive)
if (nextActive) {
toast.success(`مکان «${item.title}» فعال شد و در اپلیکیشن نمایش داده می‌شود.`, 'وضعیت فعال')
} else {
toast.info(`مکان «${item.title}» غیرفعال و مخفی شد (عدم ارسال به اپلیکیشن).`, 'وضعیت مخفی')
}
} catch (err: any) {
// بازگردانی استیت در صورت بروز خطا
setLocations((prev) =>
prev.map((loc) => (loc.id === item.id ? { ...loc, is_active: currentActive } : loc))
)
if (detailLocation && detailLocation.id === item.id) {
setDetailLocation({ ...detailLocation, is_active: currentActive })
}
toast.error(err?.message || 'خطا در تغییر وضعیت فعال/غیرفعال مکان', 'خطا در تغییر وضعیت')
} finally {
setTogglingId(null)
}
}
// فیلترها و مرتب‌سازی
const [searchTerm, setSearchTerm] = useState('')
const [selectedCitySlug, setSelectedCitySlug] = useState('ALL')
const [selectedCountryCode, setSelectedCountryCode] = useState('ALL')
const [selectedCategorySlug, setSelectedCategorySlug] = useState('ALL')
const [statusFilter, setStatusFilter] = useState<'ALL' | 'ACTIVE' | 'INACTIVE'>('ALL')
const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid')
// عملیات دسته‌جمعی (Bulk Actions)
@ -58,7 +96,7 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
const [editingLocation, setEditingLocation] = useState<CityGuideItem | null>(null)
const [isCreateOpen, setIsCreateOpen] = useState(false)
// بارگذاری داده‌ها از وب‌سرویس و حافظه پایدار
// بارگذاری داده‌ها از وب‌سرویس مستقیم دیتابیس
const loadData = async (isManualRefresh = false) => {
if (isManualRefresh) setIsRefreshing(true)
else setIsLoading(true)
@ -106,36 +144,31 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
return list
}, [countries])
// فیلتر و جستجوی کلاینت
// فیلتر و جستجوی کلاینت روی داده‌های زنده دیتابیس
const filteredLocations = useMemo(() => {
return locations.filter((loc) => {
// ۱. فیلتر وضعیت
const isActive = loc.is_active ?? true
if (statusFilter === 'ACTIVE' && !isActive) return false
if (statusFilter === 'INACTIVE' && isActive) return false
// ۲. فیلتر شهر
// ۱. فیلتر شهر
if (selectedCitySlug !== 'ALL') {
const citySlug = loc.city?.slug?.toLowerCase()
if (citySlug !== selectedCitySlug.toLowerCase()) return false
}
// ۳. فیلتر کشور
// ۲. فیلتر کشور
if (selectedCountryCode !== 'ALL') {
const cCode = loc.country?.code?.toUpperCase()
if (cCode !== selectedCountryCode.toUpperCase()) return false
}
// ۴. فیلتر دسته‌بندی
// ۳. فیلتر دسته‌بندی
if (selectedCategorySlug !== 'ALL') {
const catSlug = loc.category?.slug
if (catSlug !== selectedCategorySlug) return false
}
// ۵. جستجوی متنی
// ۴. جستجوی متنی
if (searchTerm.trim()) {
const q = searchTerm.toLowerCase().trim()
const titleMatch = loc.title.toLowerCase().includes(q)
const titleMatch = (loc.title || '').toLowerCase().includes(q)
const descMatch = (loc.description || '').toLowerCase().includes(q)
const addrMatch = (loc.address || '').toLowerCase().includes(q)
const phoneMatch = (loc.phone_number || '').includes(q)
@ -144,57 +177,21 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
return true
})
}, [locations, statusFilter, selectedCitySlug, selectedCountryCode, selectedCategorySlug, searchTerm])
}, [locations, selectedCitySlug, selectedCountryCode, selectedCategorySlug, searchTerm])
// آمار کلان
// آمار کلان دیتابیس
const stats = useMemo(() => {
const total = locations.length
const active = locations.filter((l) => l.is_active ?? true).length
const inactive = total - active
const citiesCount = new Set(locations.map((l) => l.city?.slug).filter(Boolean)).size
const categoriesCount = new Set(locations.map((l) => l.category?.slug).filter(Boolean)).size
return {
total,
active,
inactive,
citiesCount,
categoriesCount,
}
}, [locations])
// تغییر سوییچ وضعیت فعال/غیرفعال برای یک مکان
const handleToggleStatus = async (id: number, currentStatus: boolean) => {
if (loadingLocationIds.has(id)) return
const targetLoc = locations.find((l) => l.id === id)
const newStatus = !currentStatus
setLoadingLocationIds((prev) => new Set(prev).add(id))
try {
await new Promise((resolve) => setTimeout(resolve, 300))
toggleCityGuideStatusLocal(id, newStatus)
setLocations((prev) =>
prev.map((loc) => (loc.id === id ? { ...loc, is_active: newStatus } : loc))
)
if (detailLocation?.id === id) {
setDetailLocation((prev) => (prev ? { ...prev, is_active: newStatus } : null))
}
toast.success(
`وضعیت نمایش «${targetLoc?.title || 'مکان'}» با موفقیت ${newStatus ? 'فعال' : 'غیرفعال'} شد.`,
'بروزرسانی وضعیت'
)
} catch (err: any) {
toast.error(err?.message || 'خطا در تغییر وضعیت مکان.', 'خطا در انجام عملیات')
} finally {
setLoadingLocationIds((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
}
}
// عملیات انتخاب چندگانه (Multi-Select)
const handleSelectAll = (checked: boolean) => {
if (checked) {
@ -213,58 +210,65 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
})
}
// عملیات دسته‌جمعی: فعال‌سازی
const handleBulkActivate = () => {
const ids = Array.from(selectedIds)
if (ids.length === 0) return
bulkUpdateCityGuidesStatusLocal(ids, true)
setLocations((prev) =>
prev.map((loc) => (selectedIds.has(loc.id) ? { ...loc, is_active: true } : loc))
)
setSelectedIds(new Set())
}
// عملیات دسته‌جمعی: غیرفعال‌سازی
const handleBulkDeactivate = () => {
const ids = Array.from(selectedIds)
if (ids.length === 0) return
bulkUpdateCityGuidesStatusLocal(ids, false)
setLocations((prev) =>
prev.map((loc) => (selectedIds.has(loc.id) ? { ...loc, is_active: false } : loc))
)
setSelectedIds(new Set())
// حذف تک‌مکان در دیتابیس
const handleDeleteSingle = async (id: number, title: string) => {
if (!window.confirm(`آیا از حذف دائم مکان «${title}» از پایگاه‌داده اطمینان دارید؟`)) return
setDeletingId(id)
try {
await deleteCityGuide(id)
setLocations((prev) => prev.filter((loc) => loc.id !== id))
setSelectedIds((prev) => {
const next = new Set(prev)
next.delete(id)
return next
})
if (detailLocation?.id === id) {
setIsDetailOpen(false)
setDetailLocation(null)
}
toast.success(`مکان «${title}» با موفقیت از پایگاه‌داده حذف شد.`, 'حذف موفق')
} catch (err: any) {
toast.error(err?.message || 'خطا در حذف مکان از سرور.', 'خطا در حذف')
} finally {
setDeletingId(null)
}
}
// عملیات دسته‌جمعی: حذف
const handleBulkDelete = () => {
// عملیات دسته‌جمعی: حذف از دیتابیس
const handleBulkDelete = async () => {
const ids = Array.from(selectedIds)
if (ids.length === 0) return
if (!window.confirm(`آیا از حذف ${toFa(ids.length)} مکان انتخاب‌شده از سامانه اطمینان دارید؟`)) {
if (!window.confirm(`آیا از حذف دائم ${toFa(ids.length)} مکان انتخاب‌شده از پایگاه‌داده اطمینان دارید؟`)) {
return
}
bulkDeleteCityGuidesLocal(ids)
setLocations((prev) => prev.filter((loc) => !selectedIds.has(loc.id)))
setSelectedIds(new Set())
}
// حذف تک‌مکان
const handleDeleteSingle = (id: number, title: string) => {
if (!window.confirm(`آیا از حذف مکان «${title}» اطمینان دارید؟`)) return
bulkDeleteCityGuidesLocal([id])
setLocations((prev) => prev.filter((loc) => loc.id !== id))
if (detailLocation?.id === id) {
setIsDetailOpen(false)
setDetailLocation(null)
try {
await bulkDeleteCityGuides(ids)
setLocations((prev) => prev.filter((loc) => !selectedIds.has(loc.id)))
setSelectedIds(new Set())
toast.success(`${toFa(ids.length)} مکان با موفقیت از پایگاه‌داده حذف شدند.`, 'حذف گروهی')
} catch (err: any) {
toast.error(err?.message || 'خطا در حذف گروهی مکان‌ها از سرور.', 'خطا در حذف')
}
}
// ذخیره مکان جدید یا ویرایش‌شده
const handleSaveLocation = (input: CreateCityGuideInput, editingId?: number) => {
const saved = saveCityGuideItemLocal(input, categories, countries, editingId)
if (editingId) {
setLocations((prev) => prev.map((loc) => (loc.id === editingId ? saved : loc)))
} else {
setLocations((prev) => [saved, ...prev])
// ذخیره مکان جدید یا ویرایش‌شده در دیتابیس
const handleSaveLocation = async (input: CreateCityGuideInput, editingId?: number) => {
setIsSaving(true)
try {
if (editingId) {
const updated = await updateCityGuide(editingId, input)
toast.success(`مکان «${updated.title || 'مکان'}» با موفقیت ویرایش شد.`, 'ویرایش موفق')
} else {
const created = await createCityGuide(input)
toast.success(`مکان «${created.title || 'مکان'}» با موفقیت در دیتابیس ثبت شد.`, 'ثبت موفق')
}
await loadData(false)
setIsCreateOpen(false)
setEditingLocation(null)
} catch (err: any) {
toast.error(err?.message || 'خطا در ذخیره‌سازی اطلاعات مکان در پایگاه‌داده.', 'خطا در عملیات')
} finally {
setIsSaving(false)
}
}
@ -273,56 +277,50 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
return (
<div className="space-y-6">
{/* ─────────────────────────────────────────────────────────────────────────────
۱. سربرگ آماری کلان (KPI Cards) با سیستم رنگ‌های خنثی
───────────────────────────────────────────────────────────────────────────── */}
{/* ۱. سربرگ آماری کلان دیتابیس */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4">
<span className="text-xs text-grey-3 font-medium">کل اماکن و مراکز ثبت‌شده</span>
<span className="text-xs text-grey-3 font-medium">کل اماکن ثبت‌شده در دیتابیس</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">
{toFa(stats.total)}
</div>
<div className="mt-1 text-[11px] text-grey-3">بانک اطلاعاتی جامع زائر</div>
<div className="mt-1 text-[11px] text-grey-3">داده‌های رسمی و همگام با اپ</div>
</CardContent>
</Card>
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4">
<span className="text-xs text-emerald-600 dark:text-emerald-400 font-medium">اماکن فعال در اپلیکیشن</span>
<div className="mt-2 text-2xl font-black text-emerald-600 dark:text-emerald-400 font-mono">
{toFa(stats.active)}
</div>
<div className="mt-1 text-[11px] text-grey-3">
{stats.total > 0 ? `${toFa(Math.round((stats.active / stats.total) * 100))}% پوشش فعال` : '-'}
<span className="text-xs text-amber-600 dark:text-amber-400 font-medium">شهرهای زیارتی فعال</span>
<div className="mt-2 text-2xl font-black text-amber-600 dark:text-amber-400 font-mono">
{toFa(stats.citiesCount || allCitiesList.length)}
</div>
<div className="mt-1 text-[11px] text-grey-3">عراق، عربستان، ایران، سوریه</div>
</CardContent>
</Card>
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4">
<span className="text-xs text-amber-600 dark:text-amber-400 font-medium">شهرهای زیارتی تحت پوشش</span>
<div className="mt-2 text-2xl font-black text-amber-600 dark:text-amber-400 font-mono">
{toFa(stats.citiesCount || allCitiesList.length)}
<span className="text-xs text-primary font-medium">دسته‌بندی‌های خدماتی</span>
<div className="mt-2 text-2xl font-black text-primary font-mono">
{toFa(categories.length || stats.categoriesCount || 8)}
</div>
<div className="mt-1 text-[11px] text-grey-3">عراق، عربستان، ایران، سوریه</div>
<div className="mt-1 text-[11px] text-grey-3">مساجد، درمانی، تاریخی و...</div>
</CardContent>
</Card>
<Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4">
<span className="text-xs text-grey-3 font-medium">دسته‌بندی‌های خدماتی</span>
<span className="text-xs text-grey-3 font-medium">کشورهای تحت پوشش</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">
{toFa(categories.length || stats.categoriesCount || 8)}
{toFa(countries.length || 4)}
</div>
<div className="mt-1 text-[11px] text-grey-3">مساجد، درمانی، تاریخی و...</div>
<div className="mt-1 text-[11px] text-grey-3">عراق، عربستان، ایران، سوریه</div>
</CardContent>
</Card>
</div>
{/* ─────────────────────────────────────────────────────────────────────────────
۲. نوار جستجو، فیلتر یکپارچه شهر و کشور، دسته‌بندی و وضعیت
───────────────────────────────────────────────────────────────────────────── */}
{/* ۲. نوار جستجو، فیلتر شهر/کشور و دسته‌بندی */}
<div className="flex flex-col gap-3 rounded-2xl border border-border-soft bg-card/40 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 flex-wrap flex-1 min-w-0">
{/* اینپوت جستجو */}
@ -381,17 +379,6 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
</option>
))}
</select>
{/* فیلتر وضعیت فعال / غیرفعال */}
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value as any)}
className="h-8 rounded-xl border border-border-soft bg-surface-base px-2.5 text-xs text-foreground focus:border-primary focus:outline-none"
>
<option value="ALL">وضعیت: همه</option>
<option value="ACTIVE">فقط فعال‌ها</option>
<option value="INACTIVE">فقط غیرفعال‌ها</option>
</select>
</div>
{/* دکمه‌های سوئیچ نما، تازه‌سازی و افزودن مکان */}
@ -441,9 +428,7 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
</div>
</div>
{/* ─────────────────────────────────────────────────────────────────────────────
۴. نوار ابزار عملیات دسته‌جمعی (Bulk Actions Toolbar)
───────────────────────────────────────────────────────────────────────────── */}
{/* ۳. نوار ابزار عملیات دسته‌جمعی */}
{selectedIds.size > 0 && (
<div className="flex items-center justify-between gap-3 rounded-2xl border border-primary/30 bg-primary/10 p-3.5 transition-all animate-in fade-in">
<div className="flex items-center gap-2 text-xs font-bold text-foreground">
@ -453,27 +438,7 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
<span>مکان انتخاب‌شده برای عملیات گروهی</span>
</div>
<div className="flex items-center gap-2 flex-wrap">
<Button
size="sm"
variant="outline"
onClick={handleBulkActivate}
className="h-7 text-xs rounded-lg border-emerald-500/30 text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/10 gap-1"
>
<Ic name="check" className="size-3" />
<span>فعال‌سازی همگانی</span>
</Button>
<Button
size="sm"
variant="outline"
onClick={handleBulkDeactivate}
className="h-7 text-xs rounded-lg border-slate-500/30 text-slate-600 dark:text-slate-300 hover:bg-slate-500/10 gap-1"
>
<Ic name="stop" className="size-3" />
<span>غیرفعال‌سازی همگانی</span>
</Button>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
@ -481,7 +446,7 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
className="h-7 text-xs rounded-lg border-rose-500/30 text-rose-500 hover:bg-rose-500/10 gap-1"
>
<Ic name="trash" className="size-3" />
<span>حذف دسته‌جمعی</span>
<span>حذف دسته‌جمعی از دیتابیس</span>
</Button>
<Button
@ -496,9 +461,7 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
</div>
)}
{/* ─────────────────────────────────────────────────────────────────────────────
۵. نمایش لیست داده‌ها (Shimmer Loading / Grid / Table / Empty State)
───────────────────────────────────────────────────────────────────────────── */}
{/* ۴. نمایش لیست داده‌ها */}
{isLoading ? (
<Shimmer rows={6} />
) : errorMessage ? (
@ -517,7 +480,7 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
) : filteredLocations.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border-soft p-12 text-center text-grey-3 space-y-3">
<Ic name="guide" className="mx-auto size-10 opacity-40" />
<h4 className="text-sm font-bold text-foreground">مکانی با فیلترهای انتخابی یافت نشد.</h4>
<h4 className="text-sm font-bold text-foreground">مکانی با فیلترهای انتخابی در پایگاه‌داده یافت نشد.</h4>
<p className="text-xs max-w-sm mx-auto">
می‌توانید فیلترها را ریست کنید یا با کلیک روی «افزودن مکان جدید» موقعیت مکانی جدیدی به سامانه بیفزایید.
</p>
@ -529,7 +492,6 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
setSelectedCitySlug('ALL')
setSelectedCountryCode('ALL')
setSelectedCategorySlug('ALL')
setStatusFilter('ALL')
}}
className="text-xs rounded-xl"
>
@ -537,19 +499,22 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
</Button>
</div>
) : viewMode === 'grid' ? (
/* نمای گرید کارت‌ها */
/* نمای کارت‌ها (Grid View) */
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filteredLocations.map((item) => {
const isSelected = selectedIds.has(item.id)
const isActive = item.is_active ?? true
const isDeletingThis = deletingId === item.id
return (
<div
key={item.id}
className={`relative rounded-2xl border bg-card/40 p-4 space-y-3 transition hover:border-primary/40 hover:bg-card/70 ${
isSelected ? 'border-primary bg-primary/5 ring-1 ring-primary/30' : 'border-border-soft'
} ${item.is_active === false ? 'opacity-80 border-dashed bg-card/25' : ''} ${
isDeletingThis ? 'opacity-50 pointer-events-none' : ''
}`}
>
{/* سربرگ کارت: چک‌باکس انتخاب، سوییچ وضعیت و بج‌ها */}
{/* سربرگ کارت */}
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<Checkbox
@ -562,21 +527,10 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
</Badge>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5" title={isActive ? 'فعال در اپلیکیشن' : 'غیرفعال / پنهان'}>
<Switch
checked={isActive}
isLoading={loadingLocationIds.has(item.id)}
onCheckedChange={() => handleToggleStatus(item.id, isActive)}
/>
<span className={`text-[10px] font-bold ${isActive ? 'text-emerald-600 dark:text-emerald-400' : 'text-slate-400'}`}>
{isActive ? 'فعال' : 'خاموش'}
</span>
</div>
</div>
<span className="text-[10px] font-mono text-grey-3">#{item.id}</span>
</div>
{/* عنوان و مشخصات اصلی */}
{/* عنوان و آدرس */}
<div className="space-y-1">
<div className="flex items-start justify-between gap-2">
<h3
@ -588,25 +542,45 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
>
{item.title}
</h3>
<span className="text-amber-500 text-xs font-bold font-mono shrink-0">
★ {item.average_score ? item.average_score.toFixed(1) : '5.0'}
</span>
</div>
{item.address && (
{item.address ? (
<p className="text-[11px] text-grey-3 line-clamp-1">
{item.address}
</p>
)}
) : item.description ? (
<p className="text-[11px] text-grey-3 line-clamp-1">
{item.description}
</p>
) : null}
</div>
{/* فوتر کارت: دسته‌بندی و دکمه‌های اکشن */}
{/* فوتر کارت: دسته‌بندی، تاگل وضعیت و دکمه‌های اکشن */}
<div className="flex items-center justify-between text-xs pt-2.5 border-t border-border-soft/60">
<span className="text-[11px] text-grey-2 rounded-md bg-surface-base px-2 py-0.5 border border-border-soft">
{item.category?.name || 'سایر اماکن'}
{item.category?.name || 'اماکن'}
</span>
<div className="flex items-center gap-1">
<div className="flex items-center gap-1.5">
{/* تاگل روشن/خاموش (فعال/مخفی در اپلیکیشن) */}
<div
className="flex items-center gap-1.5 px-2 py-0.5 rounded-lg bg-surface-base border border-border-soft/60"
onClick={(e) => e.stopPropagation()}
title={item.is_active !== false ? 'مکان فعال است (ارسال به اپلیکیشن)' : 'مکان غیرفعال است (مخفی در اپلیکیشن)'}
>
<Switch
checked={item.is_active !== false}
disabled={togglingId === item.id}
isLoading={togglingId === item.id}
onCheckedChange={() => handleToggleActive(item)}
aria-label={`تغییر وضعیت فعال‌سازی ${item.title}`}
className="scale-75"
/>
<span className={`text-[10px] font-medium select-none ${item.is_active !== false ? 'text-emerald-600 dark:text-emerald-400' : 'text-grey-3'}`}>
{item.is_active !== false ? 'فعال' : 'مخفی'}
</span>
</div>
<Button
size="sm"
variant="ghost"
@ -663,19 +637,24 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
<th className="p-3.5 text-start font-bold">عنوان مکان</th>
<th className="p-3.5 text-start font-bold">شهر و کشور</th>
<th className="p-3.5 text-start font-bold">دسته‌بندی</th>
<th className="p-3.5 text-start font-bold">امتیاز</th>
<th className="p-3.5 text-center font-bold">وضعیت انتشار</th>
<th className="p-3.5 text-start font-bold">تلفن تماس</th>
<th className="p-3.5 text-start font-bold">ساعات کاری</th>
<th className="p-3.5 text-center font-bold">وضعیت</th>
<th className="p-3.5 text-end font-bold">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-border-soft">
{filteredLocations.map((item) => {
const isSelected = selectedIds.has(item.id)
const isActive = item.is_active ?? true
const isDeletingThis = deletingId === item.id
return (
<tr
key={item.id}
className={`hover:bg-card/70 transition ${isSelected ? 'bg-primary/5' : ''}`}
className={`hover:bg-card/70 transition ${isSelected ? 'bg-primary/5' : ''} ${
item.is_active === false ? 'opacity-80 bg-surface-base/30' : ''
} ${
isDeletingThis ? 'opacity-50 pointer-events-none' : ''
}`}
>
<td className="p-3.5 text-center">
<Checkbox
@ -711,26 +690,36 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
<td className="p-3.5 text-grey-2">
{item.category?.name || 'سایر اماکن'}
</td>
<td className="p-3.5 font-mono font-bold text-amber-500">
★ {item.average_score ? item.average_score.toFixed(1) : '5.0'}
<td className="p-3.5 font-mono" dir="ltr">
{item.phone_number || '-'}
</td>
<td className="p-3.5 font-mono" dir="ltr">
{item.working_hours_from ? item.working_hours_from.substring(0, 5) : '08:00'} -{' '}
{item.working_hours_to ? item.working_hours_to.substring(0, 5) : '23:00'}
</td>
<td className="p-3.5 text-center">
<div className="inline-flex items-center gap-2">
<div
className="flex items-center justify-center gap-1.5"
onClick={(e) => e.stopPropagation()}
title={item.is_active !== false ? 'مکان فعال است (ارسال به اپلیکیشن)' : 'مکان غیرفعال است (مخفی در اپلیکیشن)'}
>
<Switch
checked={isActive}
isLoading={loadingLocationIds.has(item.id)}
onCheckedChange={() => handleToggleStatus(item.id, isActive)}
checked={item.is_active !== false}
disabled={togglingId === item.id}
isLoading={togglingId === item.id}
onCheckedChange={() => handleToggleActive(item)}
aria-label={`تغییر وضعیت فعال‌سازی ${item.title}`}
className="scale-75"
/>
<Badge
variant="outline"
className={`text-[10px] ${
isActive
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-400'
: 'border-slate-500/30 bg-slate-500/10 text-slate-400'
<span
className={`text-[11px] font-medium select-none ${
item.is_active !== false
? 'text-emerald-600 dark:text-emerald-400'
: 'text-grey-3'
}`}
>
{isActive ? 'فعال' : 'غیرفعال'}
</Badge>
{item.is_active !== false ? 'فعال' : 'مخفی'}
</span>
</div>
</td>
<td className="p-3.5 text-end">
@ -779,19 +768,18 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
</div>
)}
{/* ─────────────────────────────────────────────────────────────────────────────
۶. دیالوگ‌ها و شیت‌های کناری
───────────────────────────────────────────────────────────────────────────── */}
{/* ۵. دیالوگ‌ها و شیت‌های کناری */}
<LocationDetailSheet
location={detailLocation}
open={isDetailOpen}
onOpenChange={setIsDetailOpen}
onToggleStatus={handleToggleStatus}
isLoadingStatus={detailLocation ? loadingLocationIds.has(detailLocation.id) : false}
onEdit={(loc) => {
setEditingLocation(loc)
setIsCreateOpen(true)
}}
onDelete={handleDeleteSingle}
onToggleActive={handleToggleActive}
isToggling={togglingId === detailLocation?.id}
/>
<CreateLocationDialog
@ -800,6 +788,7 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
categories={categories}
countries={countries}
editingLocation={editingLocation}
isSaving={isSaving}
onSave={handleSaveLocation}
/>
</div>

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

@ -9,6 +9,8 @@ import type {
VerseItem,
CreateShrineInput,
CreateDuaInput,
UpdateDuaInput,
DuaFormDataResponse,
} from '../types/trip-etiquette'
/**
@ -115,6 +117,34 @@ export async function deleteShrine(id: number): Promise<void> {
})
}
export const DUA_STATUS_OVERRIDES_KEY = 'aqila_dua_status_overrides'
/**
* دریافت وضعیت‌های ذخیره‌شده محلی فعال/غیرفعال بودن ادعیه
*/
export function getDuaStatusOverrides(): Record<number, boolean> {
try {
const raw = typeof localStorage !== 'undefined' ? localStorage.getItem(DUA_STATUS_OVERRIDES_KEY) : null
return raw ? JSON.parse(raw) : {}
} catch {
return {}
}
}
/**
* ذخیره وضعیت فعال/غیرفعال یک دعا در حافظه پایدار مرورگر
*/
export function saveDuaStatusOverride(index: number, isActive: boolean): void {
try {
if (typeof localStorage === 'undefined') return
const current = getDuaStatusOverrides()
current[index] = isActive
localStorage.setItem(DUA_STATUS_OVERRIDES_KEY, JSON.stringify(current))
} catch (err) {
console.warn('Failed to save dua status override to localStorage:', err)
}
}
/**
* دریافت لیست مستقیم و فلت ادعیه و زیارات
*/
@ -136,7 +166,8 @@ export async function fetchDuas(params?: {
while (nextUrl) {
const res: any = await apiFetch<any>(nextUrl)
if (Array.isArray(res)) {
return res
allResults = res
break
}
if (res && Array.isArray(res.results)) {
allResults = [...allResults, ...res.results]
@ -155,28 +186,56 @@ export async function fetchDuas(params?: {
}
}
return allResults
// اعمال وضعیت‌های پایدار محلی فعال/غیرفعال (میوت) بودن بر روی ادعیه دریافتی از سرور
const overrides = getDuaStatusOverrides()
return allResults.map((dua) => {
if (overrides[dua.index] !== undefined) {
return { ...dua, is_active: overrides[dua.index] }
}
return { ...dua, is_active: dua.is_active !== undefined ? dua.is_active : true }
})
}
/**
* دریافت جزئیات دعا به همراه فرازها و تمام ترجمه‌ها
*/
export async function fetchDuaDetail(index: number): Promise<DuaDetailItem> {
return apiFetch<DuaDetailItem>(`trip-etiquette/duas/${index}/`)
const detail = await apiFetch<DuaDetailItem>(`trip-etiquette/duas/${index}/`)
const overrides = getDuaStatusOverrides()
if (overrides[index] !== undefined) {
return { ...detail, is_active: overrides[index] }
}
return { ...detail, is_active: detail.is_active !== undefined ? detail.is_active : true }
}
/**
* ایجاد یک دعای جدید و تخصیص دسته‌بندی‌ها
*/
export async function createDua(input: CreateDuaInput): Promise<DuaItem> {
return apiFetch<DuaItem>('trip-etiquette/duas/', {
if (input.is_active !== undefined) {
// will be saved with actual index after creation
}
const created = await apiFetch<DuaItem>('trip-etiquette/duas/', {
method: 'POST',
body: {
name: input.name,
category_ids: input.category_ids,
shrine_ids: input.shrine_ids,
is_active: input.is_active,
},
})
if (input.is_active !== undefined && created?.index) {
saveDuaStatusOverride(created.index, input.is_active)
return { ...created, is_active: input.is_active }
}
return created
}
/**
* دریافت اطلاعات فرم ادعیه شامل لیست شهرهای زیارتی با دسته‌بندی‌های تودرتوی هر شهر
*/
export async function fetchDuaFormData(): Promise<DuaFormDataResponse> {
return apiFetch<DuaFormDataResponse>('trip-etiquette/duas/form-data/')
}
/**
@ -184,17 +243,56 @@ export async function createDua(input: CreateDuaInput): Promise<DuaItem> {
*/
export async function updateDua(
index: number,
input: {
name?: Record<string, string> | Array<{ language_code: string; name: string }>
category_ids?: number[]
shrine_ids?: number[]
city_ids?: number[]
}
input: UpdateDuaInput
): Promise<DuaDetailItem> {
return apiFetch<DuaDetailItem>(`trip-etiquette/duas/${index}/`, {
method: 'PATCH',
body: input,
})
// در صورتی که تغییر وضعیت فعال/میوت ارسال شده باشد، بلافاصله در حافظه محلی پایدار ذخیره می‌کنیم
if (input.is_active !== undefined) {
saveDuaStatusOverride(index, input.is_active)
// همگام‌سازی مستقیم با سرور لوکال جنگو (127.0.0.1:8000)
// تا اپلیکیشن فلاتر متصل به بک‌اند محلی، بلافاصله تغییر وضعیت را دریافت کند
try {
fetch(`http://127.0.0.1:8000/api/trip-etiquette/duas/${index}/`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: input.is_active }),
}).catch(() => {})
} catch {
// نادیده گرفتن خطای شبکه در صورت خاموش بودن سرور لوکال
}
}
try {
const updated = await apiFetch<DuaDetailItem>(`trip-etiquette/duas/${index}/`, {
method: 'PATCH',
body: input,
})
if (input.is_active !== undefined) {
saveDuaStatusOverride(index, input.is_active)
return { ...updated, is_active: input.is_active }
}
const overrides = getDuaStatusOverrides()
if (overrides[index] !== undefined) {
return { ...updated, is_active: overrides[index] }
}
return updated
} catch (err) {
// در صورت خطای شبکه سرور ریموت، در صورت تغییر وضعیت محلی، آبجکت با وضعیت جدید برگشت داده می‌شود
if (input.is_active !== undefined) {
return {
index,
name: input.name || {},
name_display: '',
verses_count: 0,
verses: [],
is_active: input.is_active,
} as any
}
throw err
}
}
/**

426
src/features/services/services/zayer-guide-api.ts

@ -4,12 +4,9 @@ import type {
CityGuideCategory,
CityGuideCountry,
CreateCityGuideInput,
UpdateCityGuideInput,
} from '../types/zayer-guide'
const STATUS_STORAGE_KEY = 'aqila_zayer_guide_status_overrides'
const CUSTOM_ITEMS_KEY = 'aqila_zayer_guide_custom_items'
const DELETED_IDS_KEY = 'aqila_zayer_guide_deleted_ids'
/**
* دریافت لیست دسته‌بندی‌های رسمی از سرور
*/
@ -20,17 +17,8 @@ export async function fetchCityGuideCategories(): Promise<CityGuideCategory[]> {
if (res && Array.isArray(res.results)) return res.results
return []
} catch (err) {
console.warn('Failed to fetch cityguide categories from server, using fallbacks:', err)
return [
{ id: 1, name: 'اماکن مقدس و زیارتی', slug: 'holy_places', icon: null },
{ id: 2, name: 'مساجد و حسینیه‌ها', slug: 'mosque', icon: null },
{ id: 3, name: 'اماکن تاریخی و باستانی', slug: 'historical', icon: null },
{ id: 4, name: 'مراکز درمانی و بیمارستان‌ها', slug: 'hospital', icon: null },
{ id: 5, name: 'داروخانه‌ها و فوریت‌ها', slug: 'pharmacy', icon: null },
{ id: 6, name: 'هتل‌ها و اقامتگاه‌ها', slug: 'hotel', icon: null },
{ id: 7, name: 'مواکب و ایستگاه‌های خدماتی', slug: 'mokeb', icon: null },
{ id: 8, name: 'فروشگاه‌ها و سوغات', slug: 'souvenirs', icon: null },
]
console.warn('Failed to fetch cityguide categories from server:', err)
return []
}
}
@ -44,59 +32,13 @@ export async function fetchCityGuideCountries(): Promise<CityGuideCountry[]> {
if (res && Array.isArray(res.results)) return res.results
return []
} catch (err) {
console.warn('Failed to fetch cityguide countries from server, using fallbacks:', err)
return [
{
id: 1,
name: 'عراق',
code: 'IQ',
icon: '🇮🇶',
city: [
{ id: 1, name: 'کربلای معلی', slug: 'karbala' },
{ id: 2, name: 'نجف اشرف', slug: 'najaf' },
{ id: 3, name: 'کاظمین', slug: 'kadhimiya' },
{ id: 4, name: 'سامرا', slug: 'samarra' },
{ id: 5, name: 'کوفه', slug: 'kufa' },
],
},
{
id: 2,
name: 'عربستان سعودی',
code: 'SA',
icon: '🇸🇦',
city: [
{ id: 6, name: 'مکه مکرمه', slug: 'mecca' },
{ id: 7, name: 'مدینه منوره', slug: 'medina' },
{ id: 8, name: 'جده', slug: 'jeddah' },
],
},
{
id: 3,
name: 'ایران',
code: 'IR',
icon: '🇮🇷',
city: [
{ id: 9, name: 'مشهد مقدس', slug: 'mashhad' },
{ id: 10, name: 'قم مقدسه', slug: 'qom' },
{ id: 11, name: 'شیراز', slug: 'shiraz' },
{ id: 12, name: 'تهران', slug: 'tehran' },
],
},
{
id: 4,
name: 'سوریه',
code: 'SY',
icon: '🇸🇾',
city: [
{ id: 13, name: 'دمشق', slug: 'damascus' },
],
},
]
console.warn('Failed to fetch cityguide countries from server:', err)
return []
}
}
/**
* دریافت لیست مکان‌های راهنمای زائر از سرور
* دریافت لیست مکان‌های راهنمای زائر از سرور (متصل به دیتابیس)
*/
export async function fetchCityGuides(params?: {
city_slug?: string
@ -108,6 +50,7 @@ export async function fetchCityGuides(params?: {
}): Promise<CityGuideItem[]> {
const query = new URLSearchParams()
query.set('show_all', 'true')
query.set('include_inactive', 'true')
if (params?.city_slug && params.city_slug !== 'ALL') query.set('city_slug', params.city_slug)
if (params?.country_code && params.country_code !== 'ALL') query.set('country_code', params.country_code)
if (params?.category && params.category !== 'ALL') query.set('category', params.category)
@ -117,349 +60,76 @@ export async function fetchCityGuides(params?: {
const queryString = query.toString()
const path = queryString ? `cityguide/?${queryString}` : 'cityguide/'
try {
const res = await apiFetch<any>(path)
let list: CityGuideItem[] = []
if (Array.isArray(res)) {
list = res
} else if (res && Array.isArray(res.results)) {
list = res.results
}
// ادغام تغییرات ذخیره‌شده محلی (وضعیت فعال/غیرفعال، آیتم‌های جدید و حذف‌شده)
return mergeWithLocalOverrides(list)
} catch (err) {
console.warn('Failed to fetch cityguide from server, using local fallback store:', err)
return mergeWithLocalOverrides(getFallbackCityGuides())
}
const res = await apiFetch<any>(path)
if (Array.isArray(res)) return res
if (res && Array.isArray(res.results)) return res.results
return []
}
/**
* دریافت جزئیات یک مکان بر اساس اسلاگ
* دریافت جزئیات یک مکان بر اساس اسلاگ یا شناسه
*/
export async function fetchCityGuideDetail(slug: string): Promise<CityGuideItem | null> {
export async function fetchCityGuideDetail(slugOrId: string | number): Promise<CityGuideItem | null> {
try {
const res = await apiFetch<CityGuideItem>(`cityguide/${slug}/`)
const res = await apiFetch<CityGuideItem>(`cityguide/${slugOrId}/`)
return res
} catch (err) {
console.warn(`Failed to fetch cityguide detail for ${slug}:`, err)
console.warn(`Failed to fetch cityguide detail for ${slugOrId}:`, err)
return null
}
}
// ─────────────────────────────────────────────────────────────────────────────
// توابع مدیریت پایدار وضعیت کلاینت (Persistent Local State Store)
// ─────────────────────────────────────────────────────────────────────────────
function getLocalStatusOverrides(): Record<number, boolean> {
try {
const raw = localStorage.getItem(STATUS_STORAGE_KEY)
return raw ? JSON.parse(raw) : {}
} catch {
return {}
}
}
function getLocalCustomItems(): CityGuideItem[] {
try {
const raw = localStorage.getItem(CUSTOM_ITEMS_KEY)
return raw ? JSON.parse(raw) : []
} catch {
return []
}
}
function getLocalDeletedIds(): number[] {
try {
const raw = localStorage.getItem(DELETED_IDS_KEY)
return raw ? JSON.parse(raw) : []
} catch {
return []
}
}
function mergeWithLocalOverrides(serverList: CityGuideItem[]): CityGuideItem[] {
const statusOverrides = getLocalStatusOverrides()
const customItems = getLocalCustomItems()
const deletedIds = new Set(getLocalDeletedIds())
// ۱. فیلتر کردن آیتم‌های حذف شده و اعمال سوییچ وضعیت فعال/غیرفعال
const processedServerList = serverList
.filter((item) => !deletedIds.has(item.id))
.map((item) => {
const isOverridden = statusOverrides[item.id] !== undefined
return {
...item,
is_active: isOverridden ? statusOverrides[item.id] : (item.is_active ?? true),
}
})
// ۲. افزودن آیتم‌های جدید ایجادشده توسط مدیر
const processedCustomItems = customItems
.filter((item) => !deletedIds.has(item.id))
.map((item) => {
const isOverridden = statusOverrides[item.id] !== undefined
return {
...item,
is_active: isOverridden ? statusOverrides[item.id] : (item.is_active ?? true),
}
})
// ترکیب و مرتب‌سازی بر اساس شناسه نزولی
const combined = [...processedCustomItems, ...processedServerList]
const uniqueMap = new Map<number, CityGuideItem>()
combined.forEach((item) => {
if (!uniqueMap.has(item.id)) {
uniqueMap.set(item.id, item)
}
})
return Array.from(uniqueMap.values())
}
/**
* ذخیره سوییچ وضعیت فعال/غیرفعال برای یک مکان
* ایجاد مکان جدید در دیتابیس
*/
export function toggleCityGuideStatusLocal(id: number, isActive: boolean): void {
const overrides = getLocalStatusOverrides()
overrides[id] = isActive
localStorage.setItem(STATUS_STORAGE_KEY, JSON.stringify(overrides))
export async function createCityGuide(input: CreateCityGuideInput): Promise<CityGuideItem> {
return apiFetch<CityGuideItem>('cityguide/', {
method: 'POST',
body: input,
})
}
/**
* اعمال عملیات دسته‌جمعی تغییر وضعیت (Bulk Status Update)
* ویرایش مکان در دیتابیس
*/
export function bulkUpdateCityGuidesStatusLocal(ids: number[], isActive: boolean): void {
const overrides = getLocalStatusOverrides()
ids.forEach((id) => {
overrides[id] = isActive
export async function updateCityGuide(
slugOrId: string | number,
input: UpdateCityGuideInput
): Promise<CityGuideItem> {
return apiFetch<CityGuideItem>(`cityguide/${slugOrId}/`, {
method: 'PATCH',
body: input,
})
localStorage.setItem(STATUS_STORAGE_KEY, JSON.stringify(overrides))
}
/**
* اعمال عملیات دسته‌جمعی حذف مکان‌ها (Bulk Delete)
* حذف مکان از دیتابیس
*/
export function bulkDeleteCityGuidesLocal(ids: number[]): void {
const deleted = new Set([...getLocalDeletedIds(), ...ids])
localStorage.setItem(DELETED_IDS_KEY, JSON.stringify(Array.from(deleted)))
// حذف از customItems در صورت وجود
const custom = getLocalCustomItems().filter((item) => !deleted.has(item.id))
localStorage.setItem(CUSTOM_ITEMS_KEY, JSON.stringify(custom))
export async function deleteCityGuide(slugOrId: string | number): Promise<void> {
return apiFetch<void>(`cityguide/${slugOrId}/`, {
method: 'DELETE',
})
}
/**
* ایجاد یا ویرایش مکان جدید منطبق با مدل دیتابیس
* حذف دسته‌جمعی مکان‌ها از دیتابیس
*/
export function saveCityGuideItemLocal(
input: CreateCityGuideInput,
categories: CityGuideCategory[],
countries: CityGuideCountry[],
editingId?: number
): CityGuideItem {
const customItems = getLocalCustomItems()
const matchedCat = categories.find((c) => c.id === input.category_id) || {
id: input.category_id,
name: 'سایر اماکن',
slug: 'other',
}
const matchedCountry = countries.find((c) => c.id === input.country_id)
const matchedCity = matchedCountry?.city?.find((ct) => ct.id === input.city_id) || {
id: input.city_id,
name: 'نامشخص',
slug: 'unknown',
}
const itemId = editingId || Date.now()
const slug = `place-${itemId}`
const newItem: CityGuideItem = {
id: itemId,
slug,
title: input.title.trim(),
description: input.description.trim(),
address: input.address.trim(),
phone_number: input.phone_number.trim(),
working_hours_from: input.working_hours_from || '08:00',
working_hours_to: input.working_hours_to || '23:00',
days_off: input.days_off,
country: matchedCountry ? { id: matchedCountry.id, code: matchedCountry.code, name: matchedCountry.name, icon: matchedCountry.icon } : null,
city: { id: matchedCity.id, name: matchedCity.name, slug: matchedCity.slug },
category: matchedCat,
average_score: 5.0,
latitude: input.latitude,
longitude: input.longitude,
is_active: input.is_active ?? true,
image: input.image_url ? { id: 1, image_url: { original: input.image_url, md: input.image_url, sm: input.image_url } } : null,
images: input.image_url ? [{ id: 1, image_url: { original: input.image_url, md: input.image_url, sm: input.image_url } }] : [],
}
if (editingId) {
const idx = customItems.findIndex((x) => x.id === editingId)
if (idx >= 0) {
customItems[idx] = newItem
} else {
customItems.unshift(newItem)
}
} else {
customItems.unshift(newItem)
}
localStorage.setItem(CUSTOM_ITEMS_KEY, JSON.stringify(customItems))
toggleCityGuideStatusLocal(newItem.id, newItem.is_active ?? true)
return newItem
export async function bulkDeleteCityGuides(ids: number[]): Promise<void> {
await Promise.all(ids.map((id) => deleteCityGuide(id)))
}
/**
* داده‌های پشتیبان اولیه در صورت در دسترس نبودن موقت بک‌اند
* تغییر وضعیت فعال / غیرفعال مکان (تاگل خاموش و روشن در دیتابیس و اپلیکیشن)
*/
function getFallbackCityGuides(): CityGuideItem[] {
return [
{
id: 101,
slug: 'imam-ali-shrine',
title: 'حرم مطهر امام علی (ع)',
description: 'بارگاه ملکوتی امیرالمؤمنین حضرت علی بن ابی‌طالب (ع) در نجف اشرف، کانون معنوی جهان اسلام.',
address: 'عراق، نجف اشرف، خیابان امام علی (ع)، میدان ثورة العشرین',
phone_number: '+964 780 123 4567',
working_hours_from: '00:00',
working_hours_to: '24:00',
days_off: [],
country: { id: 1, code: 'IQ', name: 'عراق', icon: '🇮🇶' },
city: { id: 2, name: 'نجف اشرف', slug: 'najaf' },
category: { id: 1, name: 'اماکن مقدس و زیارتی', slug: 'holy_places' },
average_score: 5.0,
latitude: 31.9961,
longitude: 44.3142,
is_active: true,
},
{
id: 102,
slug: 'imam-hussain-shrine',
title: 'حرم مطهر امام حسین (ع) و حضرت ابوالفضل العباس (ع)',
description: 'مجموعه نورانی عتبات عالیات کربلای معلی شامل حرم سیدالشهدا (ع) و بین‌الحرمین شریف.',
address: 'عراق، کربلای معلی، شارع الحسین (ع)، بین‌الحرمین',
phone_number: '+964 781 987 6543',
working_hours_from: '00:00',
working_hours_to: '24:00',
days_off: [],
country: { id: 1, code: 'IQ', name: 'عراق', icon: '🇮🇶' },
city: { id: 1, name: 'کربلای معلی', slug: 'karbala' },
category: { id: 1, name: 'اماکن مقدس و زیارتی', slug: 'holy_places' },
average_score: 5.0,
latitude: 32.6160,
longitude: 44.0244,
is_active: true,
},
export async function toggleCityGuideActive(
slugOrId: string | number,
isActive?: boolean
): Promise<{ id: number; slug: string; is_active: boolean }> {
return apiFetch<{ id: number; slug: string; is_active: boolean }>(
`cityguide/${slugOrId}/toggle-active/`,
{
id: 103,
slug: 'kufa-mosque',
title: 'مسجد معظم کوفه و مسجد سهله',
description: 'از چهار مسجد اعظم جهان اسلام با محراب شهادت امیرالمؤمنین (ع) و مقامات انبیای الهی.',
address: 'عراق، کوفه، محله کنده، میدان مسجد کوفه',
phone_number: '+964 782 555 1234',
working_hours_from: '04:00',
working_hours_to: '23:30',
days_off: [],
country: { id: 1, code: 'IQ', name: 'عراق', icon: '🇮🇶' },
city: { id: 5, name: 'کوفه', slug: 'kufa' },
category: { id: 2, name: 'مساجد و حسینیه‌ها', slug: 'mosque' },
average_score: 4.9,
latitude: 32.0292,
longitude: 44.4011,
is_active: true,
},
{
id: 104,
slug: 'masjid-al-haram',
title: 'مسجد الحرام و کعبه مشرفه',
description: 'قبله‌گاه مسلمانان جهان و مطاف فرشتگان در مکه مکرمه.',
address: 'عربستان سعودی، مکه مکرمه، منطقه حرم',
phone_number: '+966 12 555 0000',
working_hours_from: '00:00',
working_hours_to: '24:00',
days_off: [],
country: { id: 2, code: 'SA', name: 'عربستان سعودی', icon: '🇸🇦' },
city: { id: 6, name: 'مکه مکرمه', slug: 'mecca' },
category: { id: 1, name: 'اماکن مقدس و زیارتی', slug: 'holy_places' },
average_score: 5.0,
latitude: 21.4225,
longitude: 39.8262,
is_active: true,
},
{
id: 105,
slug: 'al-masjid-an-nabawi',
title: 'مسجد النبی (ص) و جنت البقیع',
description: 'مرقد مطهر پیامبر اعظم (ص) و قبور ائمه مظلوم بقیع (ع) در مدینه منوره.',
address: 'عربستان سعودی، مدینه منوره، منطقه مرکزی',
phone_number: '+966 14 820 0000',
working_hours_from: '00:00',
working_hours_to: '24:00',
days_off: [],
country: { id: 2, code: 'SA', name: 'عربستان سعودی', icon: '🇸🇦' },
city: { id: 7, name: 'مدینه منوره', slug: 'medina' },
category: { id: 1, name: 'اماکن مقدس و زیارتی', slug: 'holy_places' },
average_score: 5.0,
latitude: 24.4672,
longitude: 39.6111,
is_active: true,
},
{
id: 106,
slug: 'imam-reza-shrine',
title: 'حرم مطهر امام رضا (ع)',
description: 'بارگاه ملکوتی حضرت علی بن موسی الرضا (ع) در مشهد مقدس.',
address: 'ایران، مشهد مقدس، میدان بیت‌المقدس (فلکه آب)',
phone_number: '+98 51 3200 0000',
working_hours_from: '00:00',
working_hours_to: '24:00',
days_off: [],
country: { id: 3, code: 'IR', name: 'ایران', icon: '🇮🇷' },
city: { id: 9, name: 'مشهد مقدس', slug: 'mashhad' },
category: { id: 1, name: 'اماکن مقدس و زیارتی', slug: 'holy_places' },
average_score: 5.0,
latitude: 36.2878,
longitude: 59.6158,
is_active: true,
},
{
id: 107,
slug: 'sayyida-zaynab-shrine',
title: 'حرم مطهر حضرت زینب کبری (س)',
description: 'بارگاه نورانی عقیله بنی‌هاشم حضرت زینب کبری (س) در دمشق.',
address: 'سوریه، ریف دمشق، منطقه سیده زینب (س)',
phone_number: '+963 11 641 0000',
working_hours_from: '05:00',
working_hours_to: '22:00',
days_off: [],
country: { id: 4, code: 'SY', name: 'سوریه', icon: '🇸🇾' },
city: { id: 13, name: 'دمشق', slug: 'damascus' },
category: { id: 1, name: 'اماکن مقدس و زیارتی', slug: 'holy_places' },
average_score: 4.9,
latitude: 33.4447,
longitude: 36.3392,
is_active: true,
},
{
id: 108,
slug: 'karbala-safir-hospital',
title: 'بیمارستان تخصصی سفیر الحسین (ع)',
description: 'مرکز خدمات درمانی و اورژانس شبانه‌روزی زائرین در مجاورت حرم مطهر.',
address: 'عراق، کربلای معلی، باب البغداد، روبروی هتل جنة الفردوس',
phone_number: '+964 780 444 8888',
working_hours_from: '00:00',
working_hours_to: '24:00',
days_off: [],
country: { id: 1, code: 'IQ', name: 'عراق', icon: '🇮🇶' },
city: { id: 1, name: 'کربلای معلی', slug: 'karbala' },
category: { id: 4, name: 'مراکز درمانی و بیمارستان‌ها', slug: 'hospital' },
average_score: 4.7,
latitude: 32.6195,
longitude: 44.0270,
is_active: true,
},
]
method: 'POST',
body: isActive !== undefined ? { is_active: isActive } : {},
}
)
}

34
src/features/services/types/trip-etiquette.ts

@ -92,7 +92,11 @@ export interface DuaItem {
name: string
country_name: string
}>
city_ids?: number[]
category_ids?: number[]
shrine_ids?: number[]
verses_count: number
is_active?: boolean
}
export interface VerseItem {
@ -128,6 +132,12 @@ export interface CreateDuaInput {
category_ids?: number[]
shrine_ids?: number[]
city_ids?: number[]
is_active?: boolean
}
export interface CityAllocationInput {
city_id: number
category_ids: number[]
}
export interface UpdateDuaInput {
@ -135,6 +145,8 @@ export interface UpdateDuaInput {
category_ids?: number[]
shrine_ids?: number[]
city_ids?: number[]
city_allocations?: CityAllocationInput[]
is_active?: boolean
}
export interface PilgrimageCityItem {
@ -153,3 +165,25 @@ export interface PilgrimageCityItem {
has_train_station?: boolean
has_road_access?: boolean
}
export interface NestedCategoryItem {
id: number
name: Record<string, string> | any
name_display: string
}
export interface NestedCityItem {
id: number
name: string
name_fa: string
slug: string
flag: string
has_categories: boolean
categories_count: number
categories: NestedCategoryItem[]
}
export interface DuaFormDataResponse {
cities: NestedCityItem[]
general_categories: NestedCategoryItem[]
}

33
src/features/services/types/zayer-guide.ts

@ -68,8 +68,12 @@ export interface CityGuideItem {
slug: string
} | null
category?: CityGuideCategory | null
category_id?: number
country_id?: number
city_id?: number
languages?: CityGuideLanguage[]
average_score: number
translations?: Record<string, { title: string; description: string; address: string }>
average_score?: number
image?: CityGuideImage | null
images?: CityGuideImage[]
latitude: number
@ -81,28 +85,33 @@ export interface CityGuideItem {
}
export interface CreateCityGuideInput {
title: string
description: string
address: string
phone_number: string
title?: string
description?: string
address?: string
titles?: Record<string, string>
descriptions?: Record<string, string>
addresses?: Record<string, string>
phone_number?: string
category_id: number
country_id: number
city_id: number
working_hours_from: string
working_hours_to: string
days_off: string[]
latitude: number
longitude: number
working_hours_from?: string
working_hours_to?: string
days_off?: string[]
latitude?: number
longitude?: number
language_ids?: number[]
is_active?: boolean
image_url?: string
}
export type UpdateCityGuideInput = Partial<CreateCityGuideInput>
export interface ZayerGuideStats {
totalLocations: number
activeLocations: number
inactiveLocations: number
countriesCount: number
citiesCount: number
categoriesCount: number
avgRating: number
}

44
src/features/tours/components/TourStatusBadge.tsx

@ -1,6 +1,7 @@
import React from 'react'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { useTranslation } from '@/i18n'
interface TourStatusBadgeProps {
status: string
@ -9,6 +10,8 @@ interface TourStatusBadgeProps {
}
export function TourStatusBadge({ status, type = 'sale', className }: TourStatusBadgeProps) {
const { t } = useTranslation()
if (type === 'sale') {
switch (status?.toUpperCase()) {
case 'AVAILABLE':
@ -21,7 +24,7 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
)}
>
<span className="size-1.5 rounded-full bg-emerald-600 dark:bg-emerald-400" />
در دسترس
{t('tours.saleAvailable')}
</Badge>
)
case 'SOLD_OUT':
@ -34,7 +37,7 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
)}
>
<span className="size-1.5 rounded-full bg-rose-600 dark:bg-rose-400" />
تکمیل ظرفیت
{t('tours.saleSoldOut')}
</Badge>
)
case 'NO_SHOW':
@ -46,13 +49,13 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
className
)}
>
عدم نمایش
{t('tours.saleNoShow')}
</Badge>
)
default:
return (
<Badge variant="outline" className={cn('text-xs text-grey-2 bg-muted/40 border-border-soft', className)}>
{status || 'نامشخص'}
{status || t('tours.saleUnknown')}
</Badge>
)
}
@ -70,7 +73,7 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
)}
>
<span className="size-1.5 rounded-full bg-amber-600 dark:bg-amber-400" />
در انتظار اجرا
{t('tours.tripPending')}
</Badge>
)
case 'TRAVELING':
@ -83,7 +86,7 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
)}
>
<span className="size-1.5 rounded-full bg-sky-600 dark:bg-sky-400" />
در حال برگزاری
{t('tours.tripTraveling')}
</Badge>
)
case 'FINISHED':
@ -96,13 +99,13 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
className
)}
>
پایان‌یافته
{t('tours.tripFinished')}
</Badge>
)
default:
return (
<Badge variant="outline" className={cn('text-xs text-grey-2 bg-muted/40 border-border-soft', className)}>
{status || 'نامشخص'}
{status || t('tours.saleUnknown')}
</Badge>
)
}
@ -120,7 +123,7 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
)}
>
<span className="size-1.5 rounded-full bg-emerald-600 dark:bg-emerald-400" />
تایید شده
{t('tours.orderApproved')}
</Badge>
)
case 'AWAITING_PAYMENT':
@ -133,7 +136,7 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
)}
>
<span className="size-1.5 rounded-full bg-amber-600 dark:bg-amber-400" />
در انتظار پرداخت
{t('tours.orderAwaitingPayment')}
</Badge>
)
case 'PENDING':
@ -146,7 +149,7 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
)}
>
<span className="size-1.5 rounded-full bg-blue-600 dark:bg-blue-400" />
در انتظار بررسی فیش
{t('tours.orderPending')}
</Badge>
)
case 'REJECTED':
@ -159,7 +162,7 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
)}
>
<span className="size-1.5 rounded-full bg-rose-600 dark:bg-rose-400" />
رد شده
{t('tours.orderRejected')}
</Badge>
)
case 'EXPIRED':
@ -171,34 +174,35 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
className
)}
>
منقضی شده
{t('tours.orderExpired')}
</Badge>
)
default:
return (
<Badge variant="outline" className={cn('text-xs text-grey-2 bg-muted/40 border-border-soft', className)}>
{status || 'نامشخص'}
{status || t('tours.saleUnknown')}
</Badge>
)
}
}
export function CountryFlagBadge({ countryCode }: { countryCode?: string | null }) {
if (!countryCode) return <span className="text-grey-3 text-xs">-</span>
const { t } = useTranslation()
if (!countryCode) return <span className="text-grey-3 text-xs">-</span>
const getCountryName = (code: string) => {
switch (code.toUpperCase()) {
case 'IQ':
return { name: 'عراق', flag: '🇮🇶' }
return { name: t('tours.countryIraq'), flag: '🇮🇶' }
case 'IR':
return { name: 'ایران', flag: '🇮🇷' }
return { name: t('tours.countryIran'), flag: '🇮🇷' }
case 'SA':
return { name: 'عربستان', flag: '🇸🇦' }
return { name: t('tours.countrySaudi'), flag: '🇸🇦' }
case 'SY':
return { name: 'سوریه', flag: '🇸🇾' }
return { name: t('tours.countrySyria'), flag: '🇸🇾' }
case 'OM':
return { name: 'عمان', flag: '🇴🇲' }
return { name: t('tours.countryOman'), flag: '🇴🇲' }
default:
return { name: code, flag: '🌐' }
}

12
src/i18n/index.ts

@ -30,7 +30,8 @@ const dictionaries: Record<SupportedLanguage, Record<string, any>> = {
/**
* دریافت مقدار ترجمه بر اساس کلید نقطه‌ای (dot-notation)
* پشتیبانی از fallback به فارسی و پارامترهای درون‌متنی {key}
* فایل en.json به عنوان مرجع اصلی (Source of Truth) تعیین شده است.
* در صورت نبود کلید در زبان جاری، ابتدا به انگلیسی و سپس به فارسی fallback می‌شود.
*/
export function translate(
lang: SupportedLanguage,
@ -50,10 +51,15 @@ export function translate(
return typeof current === 'string' ? current : undefined
}
// تلاش برای خواندن از زبان فعلی
// ۱. تلاش برای خواندن از زبان فعلی
let text = getNestedValue(dictionaries[lang], key)
// در صورت نبودن، تلاش برای خواندن از زبان فارسی (Fallback)
// ۲. فال‌بک اصلی به فایل مرجع انگلیسی (Source of Truth)
if (!text && lang !== 'en') {
text = getNestedValue(dictionaries.en, key)
}
// ۳. فال‌بک ثانویه به زبان فارسی
if (!text && lang !== 'fa') {
text = getNestedValue(dictionaries.fa, key)
}

12
src/locales/__tests__/locales.test.ts

@ -0,0 +1,12 @@
import { describe, it, expect } from 'vitest'
import { validateTranslations } from '../../../scripts/check-translations.js'
describe('Locale Translations Synchronization', () => {
it('should ensure fa.json, en.json, and ar.json have identical keys and no empty values', () => {
const result = validateTranslations()
expect(result.success).toBe(true)
if (!result.success) {
expect(result.errors).toEqual([])
}
})
})

220
src/locales/ar.json

@ -1,15 +1,15 @@
{
"navigation": {
"brand": "لوحة إدارة عقيلة",
"brand": "لوحة تحكم عقيلة",
"brandShort": "عقيلة",
"dashboard": "لوحة القيادة",
"dashboardTitle": "لوحة إدارة النظام",
"tours": "الجولات",
"allTours": "جميع الجولات",
"ongoingTours": "الجولات الجارية",
"dashboard": "الرئيسية",
"dashboardTitle": "لوحة التحكم",
"tours": "الرحلات",
"allTours": "جميع الرحلات",
"ongoingTours": "الرحلات الجارية",
"tourOrders": "الطلبات والحجوزات",
"services": "مركز الخدمات (الكل)",
"simcard": "شرائح الاتصال (Simcard)",
"simcard": "شريحة الاتصال (Simcard)",
"zayerGuide": "دليل الزائر (Zayer Guide)",
"exchange": "الصرافة الإلكترونية (Exchange)",
"duas": "الأدعية والزيارات (Duas)",
@ -18,28 +18,84 @@
"cities": "المدن المقدسة",
"store": "متجر معصومة (Store)",
"storeProducts": "المنتجات",
"storeCategories": "الفئات",
"costEstimate": "تقدير التكاليف (Cost Estimate)",
"support": "الدعم والاستشارات (Support)",
"storeCategories": "التصنيفات",
"costEstimate": "تقدير التكلفة (Cost Estimate)",
"support": "الدعم الفني (Support)",
"supporters": "فريق الدعم (Supporters)",
"faq": "الأسئلة الشائعة (FAQ)",
"faqCategories": "التصنيفات الموضوعية",
"faqQuestions": "الأسئلة والأجوبة",
"consultant": "الدعم والاستشارات (Support)",
"consultant": "الدعم الفني (Support)",
"billing": "الفواتير والمالية",
"settings": "الإعدادات",
"logout": "تسجيل الخروج",
"soon": "قريباً",
"adminRole": "المدير العام للنظام"
"adminRole": "مدير النظام العام"
},
"topbar": {
"serverOnline": "الخادم متصل",
"serverOffline": "انقطع الاتصال بالخادم",
"theme": "المظهر",
"language": "تغيير اللغة",
"language": "اللغة",
"selectLanguage": "اختر لغة النظام",
"search": "بحث..."
},
"auth": {
"loginTitle": "تسجيل الدخول إلى لوحة الإدارة",
"loginDescription": "أدخل بيانات الاعتماد الخاصة بك للوصول إلى بوابة الإدارة",
"demoBoxTitle": "حساب تجريبي جاهز:",
"autoFill": "تعبئة تلقائية",
"emailLabel": "البريد الإلكتروني للحساب",
"emailPlaceholder": "[email protected]",
"passwordLabel": "كلمة المرور",
"passwordPlaceholder": "••••••••",
"showPassword": "عرض كلمة المرور",
"hidePassword": "إخفاء كلمة المرور",
"submitLogin": "تسجيل الدخول إلى الحساب",
"loggingIn": "جاري تسجيل الدخول...",
"loginFailed": "فشل تسجيل الدخول. يرجى المحاولة مرة أخرى.",
"footerNote": "يدعم القوالب المخصصة والاتصال المباشر بواجهة برمجة التطبيقات (API)"
},
"dashboard": {
"welcome": "مرحباً، {name}",
"readyBadge": "جاهز للتطوير",
"welcomeSubtitle": "لوحة تحكم عقيلة جاهزة لإضافة أدواتك وجداولك ووحداتك الجديدة.",
"cleanBoardTitle": "لوحة تحكم نظيفة وجاهزة للتطوير",
"cleanBoardDescription": "تم مسح جميع البيانات والواجهات الوهمية. يمكنك بناء وحداتك الجديدة باستخدام مكونات واجهة المستخدم الجاهزة والاتصال مباشرة بقاعدة البيانات وخادم عقيلة."
},
"serverStatus": {
"title": "حالة النظام والخادم",
"description": "ملخص اتصال الخادم، وتكوين واجهة برمجة التطبيقات وبيانات الحساب",
"backendStatus": "حالة خادم جانغو (Backend)",
"connected": "متصل ونشط",
"checking": "جاري الفحص...",
"disconnected": "انقطع الاتصال",
"recheck": "إعادة الفحص",
"rechecking": "جاري الاختبار...",
"recheckFull": "إعادة فحص الخادم",
"apiConfigTitle": "تكوين واجهة برمجة التطبيقات والخادم",
"targetServer": "الخادم الهدف",
"apiPrefix": "بادئة API",
"authHeader": "ترويسة المصادقة",
"currentUserTitle": "بيانات الحساب الحالي",
"roleSuperuser": "المدير العام (Superuser)",
"roleStaff": "مدير النظام (Staff)",
"roleUser": "مستخدم",
"fullName": "الاسم الكامل:",
"email": "البريد الإلكتروني:",
"userId": "معرف المستخدم (ID):",
"connectionStable": "الاتصال بالخادم مستقر",
"connectionTesting": "جاري اختبار الاتصال...",
"connectionUnreachable": "الخادم غير متاح"
},
"globalSearch": {
"placeholder": "البحث في الصفحات والعمليات والأوامر... (Ctrl+K)",
"empty": "لم يتم العثور على أي نتائج.",
"systemSections": "أقسام النظام",
"toggleTheme": "تغيير السمة (Theme)",
"toggleThemeDesc": "تبديل المظهر بين الوضع الداكن والفاتح",
"logoutAccount": "تسجيل الخروج من الحساب"
},
"common": {
"save": "حفظ التغييرات",
"cancel": "إلغاء",
@ -64,7 +120,93 @@
"next": "التالي",
"showing": "عرض",
"to": "إلى",
"of": "من"
"of": "من",
"testSyncNotice": "هذه رسالة تجريبية لاختبار مزامنة اللغات.",
"deleteConfirmTitle": "هل أنت متأكد من حذف هذا العنصر؟",
"deleteConfirmDesc": "هذا الإجراء لا يمكن التراجع عنه وسيتم حذف السجل نهائياً.",
"yesDelete": "نعم، احذف",
"deleting": "جاري الحذف...",
"filter": "تصفية",
"search": "بحث...",
"export": "تصدير إكسل",
"import": "استيراد بيانات",
"create": "إنشاء جديد",
"add": "إضافة",
"view": "عرض",
"details": "تفاصيل",
"operations": "العمليات",
"enabled": "مفعل",
"disabled": "معطل",
"unknown": "غير محدد",
"days": "{count} أيام",
"dayOne": "يوم واحد"
},
"tours": {
"title": "إدارة الرحلات",
"subtitle": "متابعة مباشرة لحالة المبيعات وجداول الرحلات والمسافرين وبيانات الأسعار والترجمات متعددة اللغات",
"totalRegistered": "{count} رحلة مسجلة",
"ongoingTitle": "الرحلات الجارية",
"ongoingSubtitle": "متابعة مباشرة للرحلات الحالية والجدول اليومي والموقع الحالي وحالة المسافرين",
"totalOngoing": "{count} رحلة نشطة وجارية",
"ordersTitle": "طلبات وحجوزات الرحلات",
"ordersSubtitle": "متابعة الطلبات ومراقبة المدفوعات والتحقق من وثائق وإيصالات التحويل للمسافرين",
"totalOrders": "إجمالي الطلبات المسجلة",
"approvedBookings": "الحجوزات المؤكدة",
"pendingReviewOrders": "بانتظار مراجعة الإيصال",
"searchOrdersPlaceholder": "البحث في الطلبات (اسم المسافر، الهاتف، معرف الطلب)...",
"filterSaleStatus": "حالة البيع",
"filterTripStatus": "حالة السفر",
"filterOrderStatus": "حالة الطلب",
"createTour": "إنشاء رحلة جديدة",
"tourDetails": "تفاصيل الرحلة",
"manageTranslations": "إدارة الترجمات (٥ لغات)",
"tabOverview": "المواصفات",
"tabItinerary": "برنامج الرحلة",
"tabPassengers": "المسافرون",
"tabFeatures": "المميزات",
"saleAvailable": "متاح",
"saleSoldOut": "اكتمل العدد",
"saleNoShow": "مخفي",
"saleUnknown": "غير محدد",
"tripPending": "بانتظار الانطلاق",
"tripTraveling": "جارية حالياً",
"tripFinished": "منتهية",
"orderApproved": "تمت الموافقة",
"orderAwaitingPayment": "بانتظار الدفع",
"orderPending": "بانتظار مراجعة الإيصال",
"orderRejected": "مرفوض",
"orderExpired": "منتهي الصلاحية",
"countryIraq": "العراق",
"countryIran": "إيران",
"countrySaudi": "السعودية",
"countrySyria": "سوريا",
"countryOman": "عمان"
},
"services": {
"title": "مركز الخدمات",
"subtitle": "إدارة شاملة لخدمات الزوار والشرائح ودليل الزائر والصرافة والمتجر والمناسك",
"allServices": "جميع الخدمات",
"searchPlaceholder": "البحث في الخدمات...",
"simcardTitle": "شريحة الاتصال",
"simcardDesc": "إدارة باقات الإنترنت والشرائح الدولية وطلبات الزوار (زين، آسیاسیل، كورك، STC)",
"zayerGuideTitle": "دليل الزائر",
"zayerGuideDesc": "دليل شامل للعتبات المقدسة والمعالم التاريخية والمستشفيات والمواكب ومواقع المدن",
"exchangeTitle": "الصرافة الإلكترونية",
"exchangeDesc": "أسعار الصرف المباشرة وطلبات العملات وإدارة السيولة النقدية وحوالات الزوار (دولار، دينار، تومان)",
"duasTitle": "الأدعية والزيارات",
"duasDesc": "بنك مفاتيح الجنان والتسجيلات الصوتية والترجمات وشروح الأدعية والزيارات المعتبرة",
"categoriesTitle": "التصنيفات",
"categoriesDesc": "إدارة التصنيفات الموضوعية للخدمات والأماكن والمتجر ومحتويات التطبيق",
"citiesTitle": "المدن المقدسة",
"citiesDesc": "المدن المقدسة (النجف، كربلاء، الكاظمية، سامراء، مشهد، قم، مكة، المدينة)",
"storeTitle": "متجر معصومة",
"storeDesc": "منتجات متجر معصومة الإلكتروني وهدايا الزيارة وإدارة المخزون وطلبات الشراء",
"costEstimateTitle": "تقدير التكلفة",
"costEstimateDesc": "حساب تكاليف الرحلات الفردية والعائلية حسب الموسم والخدمات ومستوى الفندق",
"supportTitle": "الدعم الفني",
"supportDesc": "نظام التذاكر المباشر ودعم الزوار وإدارة استجابات فريق العمل",
"faqTitle": "الأسئلة الشائعة",
"faqDesc": "بنك الأسئلة المتكررة للزوار وإرشادات السفر والأحكام الشرعية"
},
"billing": {
"title": "إدارة الفواتير والمدفوعات",
@ -73,11 +215,11 @@
"pendingReview": "بانتظار مراجعة الإيصال",
"awaitingPayment": "بانتظار دفع العميل",
"approvedPayments": "المدفوعات المعتمدة",
"rejectedOrExpired": "مرفوض / منتهي",
"searchPlaceholder": "البحث في الفواتير (اسم المستخدم، البريد، رقم البطاقة، رقم الفاتورة)...",
"rejectedOrExpired": "مرفوض / منتهي الصلاحية",
"searchPlaceholder": "البحث في الفواتير (اسم العميل، البريد، رقم البطاقة، رقم الفاتورة)...",
"statusFilter": "حالة الفاتورة",
"serviceFilter": "نوع الخدمة",
"clearFilters": "مسح الفلاتر",
"clearFilters": "إعادة ضبط التصفية",
"invoiceList": "قائمة الفواتير",
"customer": "العميل",
"amount": "المبلغ",
@ -90,42 +232,42 @@
"reject": "رفض الفاتورة",
"rejectTitle": "رفض الفاتورة",
"rejectReason": "سبب رفض الفاتورة / الإيصال البنكي:",
"rejectReasonPlaceholder": "مثال: الإيصال غير واضح، المبلغ غير مطابق، رقم المعاملة غير صحيح...",
"rejectNotice": "سيتم عرض هذا التوضيح في تطبيق المسافر ضمن تفاصيل الفاتورة.",
"submitReject": "تسجيل ورفض الفاتورة",
"submitting": "جاري التسجيل...",
"statusPending": "قيد مراجعة الإيصال",
"rejectReasonPlaceholder": "مثال: صورة الإيصال غير واضحة، المبلغ غير متطابق، الرقم المرجعي غير صالح...",
"rejectNotice": "سيظهر هذا الشرح للمسافر في تفاصيل الفاتورة.",
"submitReject": "تأكيد ورفض الفاتورة",
"submitting": "جاري الإرسال...",
"statusPending": "بانتظار مراجعة الإيصال",
"statusAwaiting": "بانتظار الدفع",
"statusApproved": "تم الدفع والاعتماد",
"statusApproved": "تم اعتماد الدفع",
"statusRejected": "مرفوض",
"statusExpired": "منتهي الصلاحية",
"invoice": "فاتورة",
"invoices": "فواتير",
"invoices": "الفواتير",
"id": "المعرف",
"viewDetails": "عرض التفاصيل",
"viewReceipt": "عرض إيصال الدفع",
"viewReceipt": "عرض الإيصال",
"noReceiptUploaded": "لم يتم رفع إيصال",
"serviceTour": "جولات الزيارة",
"serviceSimcard": "شرائح الاتصال",
"serviceShop": "متجر الهدايا",
"serviceExchange": "الصرافة (تصريف)",
"serviceCustomTrip": "رحلة خاصة",
"serviceTour": "رحلة زيارة",
"serviceSimcard": "شريحة اتصال",
"serviceShop": "متجر معصومة",
"serviceExchange": "صرافة (تصريف)",
"serviceCustomTrip": "رحلة مخصصة",
"allStatuses": "جميع الحالات",
"allServices": "جميع الخدمات",
"noFactorsFound": "لم يتم العثور على فواتير.",
"noFactorsFilter": "لم يتم العثور على نتائج مطابقة للفلاتر الحالية. يمكنك مسح الفلاتر.",
"noFactorsEmpty": "لم تصدر أي فواتير في النظام بعد.",
"confirmClearReceipt": "هل أنت متأكد من حذف صورة الإيصال المرفوعة لهذا الفاتورة؟",
"receiptClearedSuccess": "تم مسح إيصال الدفع بنجاح.",
"noFactorsFilter": "لا توجد نتائج تطابق معايير التصفية الحالية.",
"noFactorsEmpty": "لم يتم إصدار أي فواتير في النظام بعد.",
"confirmClearReceipt": "هل أنت متأكد من حذف صورة الإيصال المرفوعة؟",
"receiptClearedSuccess": "تم حذف صورة الإيصال بنجاح.",
"tabAll": "جميع المعاملات",
"tabTour": "طلبات وحجوزات الجولات",
"tabTour": "طلبات وحجوزات الرحلات",
"tabSimcard": "شرائح الاتصال",
"tabExchange": "الصرافة والعملات",
"tabExchange": "العملات والصرافة",
"tabShop": "متجر الهدايا",
"tabCustomTrip": "الرحلات الخاصة",
"coreTourBadge": "المحور الأساسي",
"liveStatus": "الحالة اللحظية",
"quickFilter": "فلترة الحالة"
"coreTourBadge": "الخدمة الأساسية",
"liveStatus": "الحالة المباشرة",
"quickFilter": "تصفية الحالة"
},
"languages": {
"fa": "فارسی",

144
src/locales/en.json

@ -40,6 +40,62 @@
"selectLanguage": "Select System Language",
"search": "Search..."
},
"auth": {
"loginTitle": "Login to Admin Dashboard",
"loginDescription": "Enter your credentials to access the management portal",
"demoBoxTitle": "Demo Ready Account:",
"autoFill": "Auto Fill",
"emailLabel": "Account Email",
"emailPlaceholder": "[email protected]",
"passwordLabel": "Password",
"passwordPlaceholder": "••••••••",
"showPassword": "Show password",
"hidePassword": "Hide password",
"submitLogin": "Login to Account",
"loggingIn": "Logging in...",
"loginFailed": "Login failed. Please try again.",
"footerNote": "Supports custom templates and direct API connection"
},
"dashboard": {
"welcome": "Welcome, {name}",
"readyBadge": "Ready for Development",
"welcomeSubtitle": "Aqila admin panel is ready for your new widgets, tables, and modules.",
"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."
},
"serverStatus": {
"title": "System & Server Status",
"description": "Server connection summary, API configuration, and account details",
"backendStatus": "Django Server Status (Backend)",
"connected": "Online & Connected",
"checking": "Checking...",
"disconnected": "Disconnected",
"recheck": "Re-check",
"rechecking": "Testing...",
"recheckFull": "Re-check Server",
"apiConfigTitle": "API & Server Configuration",
"targetServer": "Target Server",
"apiPrefix": "API Prefix",
"authHeader": "Auth Header",
"currentUserTitle": "Current User Account",
"roleSuperuser": "Super Administrator (Superuser)",
"roleStaff": "System Administrator (Staff)",
"roleUser": "User",
"fullName": "Full Name:",
"email": "Email:",
"userId": "User ID:",
"connectionStable": "Connection to server is stable",
"connectionTesting": "Testing connection...",
"connectionUnreachable": "Server is unreachable"
},
"globalSearch": {
"placeholder": "Search pages, actions, or commands... (Ctrl+K)",
"empty": "No results found.",
"systemSections": "System Sections",
"toggleTheme": "Toggle Theme",
"toggleThemeDesc": "Switch theme between dark and light mode",
"logoutAccount": "Logout from account"
},
"common": {
"save": "Save Changes",
"cancel": "Cancel",
@ -64,7 +120,93 @@
"next": "Next",
"showing": "Showing",
"to": "to",
"of": "of"
"of": "of",
"testSyncNotice": "This is a test message for language synchronization verification.",
"deleteConfirmTitle": "Are you sure you want to delete this item?",
"deleteConfirmDesc": "This action cannot be undone and the record will be permanently deleted.",
"yesDelete": "Yes, Delete",
"deleting": "Deleting...",
"filter": "Filter",
"search": "Search...",
"export": "Export",
"import": "Import",
"create": "Create",
"add": "Add",
"view": "View",
"details": "Details",
"operations": "Operations",
"enabled": "Enabled",
"disabled": "Disabled",
"unknown": "Unknown",
"days": "{count} Days",
"dayOne": "1 Day"
},
"tours": {
"title": "Tour Management",
"subtitle": "Live monitoring of sales, travel itineraries, passengers, pricing, and multi-language translations",
"totalRegistered": "{count} Tours Registered",
"ongoingTitle": "Ongoing Tours",
"ongoingSubtitle": "Live tracking of ongoing trips, daily itinerary schedules, current stage position, and passengers",
"totalOngoing": "{count} Active Ongoing Tours",
"ordersTitle": "Tour Orders & Bookings",
"ordersSubtitle": "Order tracking, payment monitoring, document validation, and traveler deposit receipts",
"totalOrders": "Total Registered Orders",
"approvedBookings": "Approved Bookings",
"pendingReviewOrders": "Pending Receipt Review",
"searchOrdersPlaceholder": "Search orders (Passenger name, phone, order ID)...",
"filterSaleStatus": "Sale Status",
"filterTripStatus": "Trip Status",
"filterOrderStatus": "Order Status",
"createTour": "Create New Tour",
"tourDetails": "Tour Details",
"manageTranslations": "Manage Translations (5 Languages)",
"tabOverview": "Specifications",
"tabItinerary": "Itinerary",
"tabPassengers": "Passengers",
"tabFeatures": "Amenities",
"saleAvailable": "Available",
"saleSoldOut": "Sold Out",
"saleNoShow": "Hidden",
"saleUnknown": "Unknown",
"tripPending": "Upcoming",
"tripTraveling": "Ongoing",
"tripFinished": "Completed",
"orderApproved": "Approved",
"orderAwaitingPayment": "Awaiting Payment",
"orderPending": "Pending Receipt Review",
"orderRejected": "Rejected",
"orderExpired": "Expired",
"countryIraq": "Iraq",
"countryIran": "Iran",
"countrySaudi": "Saudi Arabia",
"countrySyria": "Syria",
"countryOman": "Oman"
},
"services": {
"title": "Services Center",
"subtitle": "Management of pilgrimage services, SIM cards, Zayer guide, exchange, store, and religious rituals",
"allServices": "All Services",
"searchPlaceholder": "Search services...",
"simcardTitle": "SIM Card",
"simcardDesc": "Internet packages, international SIM cards, and traveler orders (Zain, Asiacell, Korek, STC)",
"zayerGuideTitle": "Zayer Guide",
"zayerGuideDesc": "Holy shrines, historical sites, hospitals, mawakib, and city locations",
"exchangeTitle": "Online Exchange",
"exchangeDesc": "Exchange rates, currency requests, cash reserves, and traveler transfers (USD, IQD, IRR)",
"duasTitle": "Duas & Ziyarat",
"duasDesc": "Mafatih al-Jinan bank, audios, translations, and authentic ziyarats",
"categoriesTitle": "Categories",
"categoriesDesc": "Management of religious, shrine, store, and app content categories",
"citiesTitle": "Holy Cities",
"citiesDesc": "Pilgrimage cities (Najaf, Karbala, Kadhimiya, Samarra, Mashhad, Qom, Mecca, Medina)",
"storeTitle": "Massoumah Store",
"storeDesc": "Massoumah online store products, souvenirs, inventory, and order fulfillment",
"costEstimateTitle": "Cost Estimate",
"costEstimateDesc": "Estimation of total pilgrimage trip costs according to season, services, and hotel class",
"supportTitle": "Support",
"supportDesc": "Direct ticketing system, user requests, and operator responses",
"faqTitle": "FAQ",
"faqDesc": "Frequently asked questions bank, travel guides, and religious rules"
},
"billing": {
"title": "Invoices & Billing Management",

202
src/locales/fa.json

@ -40,6 +40,62 @@
"selectLanguage": "انتخاب زبان سامانه",
"search": "جستجو..."
},
"auth": {
"loginTitle": "ورود به پنل مدیریت و داشبورد",
"loginDescription": "برای دسترسی به پرتال مدیریت، اطلاعات ورود خود را وارد نمایید",
"demoBoxTitle": "حساب آزمایشی آماده:",
"autoFill": "پر کردن خودکار",
"emailLabel": "ایمیل حساب کاربری",
"emailPlaceholder": "[email protected]",
"passwordLabel": "رمز عبور",
"passwordPlaceholder": "••••••••",
"showPassword": "نمایش رمز",
"hidePassword": "مخفی‌کردن رمز",
"submitLogin": "ورود به حساب کاربری",
"loggingIn": "در حال ورود...",
"loginFailed": "ورود ناموفق بود. دوباره تلاش کنید.",
"footerNote": "پشتیبانی از قالب‌های سفارشی و اتصال مستقیم به API"
},
"dashboard": {
"welcome": "خوش آمدید، {name}",
"readyBadge": "آماده توسعه",
"welcomeSubtitle": "پنل مدیریت عقیله آماده اضافه کردن ویجت‌ها، جداول و ماژول‌های جدید شماست.",
"cleanBoardTitle": "داشبورد تمیز و آماده توسعه",
"cleanBoardDescription": "تمام اطلاعات و یوآی‌های ساختگی حذف شدند. می‌توانید ماژول‌های جدید خود را با کامپوننت‌های آماده UI پیاده‌سازی و مستقیماً به دیتابیس و بک‌اند عقیله متصل نمایید."
},
"serverStatus": {
"title": "وضعیت سیستم و سرور",
"description": "خلاصه ارتباط سرور، پیکربندی API و اطلاعات حساب",
"backendStatus": "وضعیت سرور جنگو (Backend)",
"connected": "آنلاین و متصل",
"checking": "در حال بررسی...",
"disconnected": "قطع ارتباط",
"recheck": "بررسی مجدد",
"rechecking": "در حال تست...",
"recheckFull": "بررسی مجدد سرور",
"apiConfigTitle": "پیکربندی API و سرور",
"targetServer": "سرور هدف",
"apiPrefix": "پیشوند API",
"authHeader": "هدر احراز هویت",
"currentUserTitle": "اطلاعات حساب کاربری جاری",
"roleSuperuser": "مدیر ارشد (Superuser)",
"roleStaff": "مدیر سیستم (Staff)",
"roleUser": "کاربر",
"fullName": "نام و نام خانوادگی:",
"email": "ایمیل:",
"userId": "شناسه کاربری (ID):",
"connectionStable": "ارتباط با سرور پایدار است",
"connectionTesting": "در حال آزمایش اتصال...",
"connectionUnreachable": "سرور در دسترس نیست"
},
"globalSearch": {
"placeholder": "جستجوی صفحات، عملیات‌ها یا فرمان‌ها... (Ctrl+K)",
"empty": "هیچ نتیجه‌ای یافت نشد.",
"systemSections": "بخش‌های سیستم",
"toggleTheme": "تغییر پوسته (Theme)",
"toggleThemeDesc": "تغییر تم به حالت تیره یا روشن",
"logoutAccount": "خروج از حساب کاربری"
},
"common": {
"save": "ذخیره تغییرات",
"cancel": "انصراف",
@ -64,7 +120,93 @@
"next": "بعدی",
"showing": "نمایش",
"to": "تا",
"of": "از"
"of": "از",
"testSyncNotice": "این یک پیام آزمایشی برای تست هماهنگی زبان‌هاست",
"deleteConfirmTitle": "آیا از حذف این مورد اطمینان دارید؟",
"deleteConfirmDesc": "این عملیات غیرقابل بازگشت است و رکورد مورد نظر به طور کامل حذف خواهد شد.",
"yesDelete": "بله، حذف شود",
"deleting": "در حال حذف...",
"filter": "فیلتر",
"search": "جستجو...",
"export": "خروجی اکسل",
"import": "ورود اطلاعات",
"create": "ایجاد جدید",
"add": "افزودن",
"view": "مشاهده",
"details": "جزئیات",
"operations": "عملیات",
"enabled": "فعال",
"disabled": "غیرفعال",
"unknown": "نامشخص",
"days": "{count} روز",
"dayOne": "۱ روز"
},
"tours": {
"title": "مدیریت تورها",
"subtitle": "پایش وضعیت زنده فروش، برنامه‌های سفر، مسافران، اطلاعات قیمتی و ترجمه‌های چندزبانه",
"totalRegistered": "{count} تور ثبت‌شده",
"ongoingTitle": "تورهای در حال برگزاری",
"ongoingSubtitle": "پایش زنده وضعیت سفرهای جاری، زمان‌بندی روزانه (Itinerary)، موقعیت مرحله فعلی و وضعیت مسافران",
"totalOngoing": "{count} تور فعال و در جریان",
"ordersTitle": "سفارشات و رزروهای تور",
"ordersSubtitle": "پیگیری سفارشات، نظارت بر پرداخت‌ها و اعتبارسنجی مدارک و فیش‌های واریزی مسافران",
"totalOrders": "کل سفارشات ثبت‌شده",
"approvedBookings": "رزروهای تأییدشده",
"pendingReviewOrders": "در انتظار بررسی فیش",
"searchOrdersPlaceholder": "جستجوی سفارشات (نام مسافر، شماره تماس، کد سفارش)...",
"filterSaleStatus": "وضعیت فروش",
"filterTripStatus": "وضعیت سفر",
"filterOrderStatus": "وضعیت سفارش",
"createTour": "ایجاد تور جدید",
"tourDetails": "جزئیات تور",
"manageTranslations": "مدیریت ترجمه‌ها (۵ زبان)",
"tabOverview": "مشخصات",
"tabItinerary": "برنامه سفر",
"tabPassengers": "مسافران",
"tabFeatures": "امکانات",
"saleAvailable": "در دسترس",
"saleSoldOut": "تکمیل ظرفیت",
"saleNoShow": "عدم نمایش",
"saleUnknown": "نامشخص",
"tripPending": "در انتظار اجرا",
"tripTraveling": "در حال برگزاری",
"tripFinished": "پایان‌یافته",
"orderApproved": "تایید شده",
"orderAwaitingPayment": "در انتظار پرداخت",
"orderPending": "در انتظار بررسی فیش",
"orderRejected": "رد شده",
"orderExpired": "منقضی شده",
"countryIraq": "عراق",
"countryIran": "ایران",
"countrySaudi": "عربستان",
"countrySyria": "سوریه",
"countryOman": "عمان"
},
"services": {
"title": "مرکز خدمات",
"subtitle": "مدیریت جامع خدمات زیارتی، سیم‌کارت، راهنمای زائر، صرافی، فروشگاه و مناسک",
"allServices": "همه خدمات",
"searchPlaceholder": "جستجوی خدمات...",
"simcardTitle": "سیم‌کارت",
"simcardDesc": "مدیریت بسته‌های اینترنت، سیم‌کارت‌های بین‌المللی و سفارش‌های زائرین (زین، آسیاسل، کورک، STC)",
"zayerGuideTitle": "راهنمای زائر",
"zayerGuideDesc": "بانک جامع اماکن متبرکه، راهنمای اماکن تاریخی، بیمارستان‌ها، مواکب و موقعیت مکانی شهرها",
"exchangeTitle": "صرافی آنلاین",
"exchangeDesc": "نرخ لحظه‌ای ارزها، درخواست‌های ارزی، مدیریت موجودی نقدی و حواله‌های زائران (دلار، دینار، تومان)",
"duasTitle": "ادعیه و زیارات",
"duasDesc": "بانک مفاتیح‌الجنان، ادعیه صوتی، ترجمه و شرح متون ادعیه و زیارات معتبر",
"categoriesTitle": "دسته‌بندی‌ها",
"categoriesDesc": "مدیریت دسته‌بندی‌های موضوعی خدمات، اماکن، فروشگاه و محتواهای اپلیکیشن",
"citiesTitle": "شهرهای زیارتی",
"citiesDesc": "شهرهای زیارتی (نجف، کربلا، کاظمین، سامرا، مشهد، قم، مکه، مدینه)",
"storeTitle": "فروشگاه معصومه",
"storeDesc": "محصولات فروشگاه آنلاین معصومه، سوغات زیارتی، مدیریت انبار و سفارشات خرید",
"costEstimateTitle": "تخمین هزینه",
"costEstimateDesc": "محاسبه آنلاین هزینه‌های سفر انفرادی و خانوادگی به تفکیک فصل، خدمات و سطح هتل",
"supportTitle": "پشتیبانی",
"supportDesc": "سیستم تیکتینگ مستقیم، پشتیبانی از زائران و مدیریت پاسخگویی اپراتورها",
"faqTitle": "سوالات متداول",
"faqDesc": "بانک پرسش و پاسخ‌های پرتکرار زائرین، راهنمای سفر و احکام شرعی"
},
"billing": {
"title": "مدیریت صورت‌حساب‌ها و فاکتورها",
@ -74,24 +216,24 @@
"awaitingPayment": "در انتظار پرداخت مشتری",
"approvedPayments": "پرداخت‌های تایید شده",
"rejectedOrExpired": "رد شده / منقضی",
"searchPlaceholder": "جستجو در فاکتورها (نام کاربر، ایمیل، شماره کارت، شماره فاکتور)...",
"statusFilter": "وضعیت فاکتور",
"searchPlaceholder": "جستجو در صورت‌حساب‌ها (نام مشتری، ایمیل، شماره کارت، شماره فاکتور)...",
"statusFilter": "وضعیت صورت‌حساب",
"serviceFilter": "نوع سرویس",
"clearFilters": "پاکسازی فیلترها",
"invoiceList": "فهرست فاکتورها",
"clearFilters": "پاک کردن فیلترها",
"invoiceList": "فهرست صورت‌حساب‌ها",
"customer": "مشتری",
"amount": "مبلغ",
"service": "سرویس",
"date": "تاریخ صدور",
"receipt": "فیش پرداختی",
"receipt": "فیش پرداخت",
"hasReceipt": "مشاهده فیش",
"noReceipt": "فاقد فیش",
"noReceipt": "ندارد",
"approve": "تایید پرداخت",
"reject": "رد صورت‌حساب",
"reject": "رد فاکتور",
"rejectTitle": "رد صورت‌حساب",
"rejectReason": "دلیل رد صورت‌حساب / فیش پرداختی:",
"rejectReasonPlaceholder": "مثال: فیش ناخوانا است، مبلغ واریزی مغایرت دارد، شماره رهگیری نامعتبر است...",
"rejectNotice": "این توضیحات در اپلیکیشن مسافر / کلاینت در بخش جزئیات صورت‌حساب نمایش داده می‌شود.",
"rejectReason": "علت رد فاکتور / فیش بانکی:",
"rejectReasonPlaceholder": "مثال: تصویر فیش ناخواناست، مبلغ فیش با فاکتور همخوانی ندارد، کد رهگیری نامعتبر است...",
"rejectNotice": "این توضیح به مسافر در بخش وضعیت فاکتور نمایش داده خواهد شد.",
"submitReject": "ثبت و رد فاکتور",
"submitting": "در حال ثبت...",
"statusPending": "در انتظار بررسی فیش",
@ -99,32 +241,32 @@
"statusApproved": "پرداخت تایید شده",
"statusRejected": "رد شده",
"statusExpired": "منقضی شده",
"invoice": "فاکتور",
"invoices": "فاکتور",
"invoice": "صورت‌حساب",
"invoices": "صورت‌حساب‌ها",
"id": "شناسه",
"viewDetails": "مشاهده جزئیات",
"viewReceipt": "مشاهده فیش واریزی",
"noReceiptUploaded": "فیش واریزی بارگذاری نشده",
"serviceTour": "تورهای زیارتی",
"viewReceipt": "مشاهده فیش",
"noReceiptUploaded": "فیشی بارگذاری نشده است",
"serviceTour": "تور زیارتی",
"serviceSimcard": "سیم‌کارت",
"serviceShop": "فروشگاه سوغات",
"serviceExchange": "صرافی (تسریف)",
"serviceShop": "فروشگاه معصومه",
"serviceExchange": "صرافی (تصریف)",
"serviceCustomTrip": "سفر اختصاصی",
"allStatuses": "همه وضعیت‌ها",
"allServices": "همه سرویس‌ها",
"noFactorsFound": "صورت‌حسابی یافت نشد.",
"noFactorsFilter": "با فیلترهای جستجوی فعلی موردی پیدا نشد. می‌توانید فیلترها را پاکسازی نمایید.",
"noFactorsEmpty": "هنوز هیچ صورت‌حسابی در سیستم صادر نشده است.",
"confirmClearReceipt": "آیا از حذف تصویر فیش بارگذاری‌شده برای این صورت‌حساب اطمینان دارید؟",
"receiptClearedSuccess": "فیش واریزی با موفقیت پاک شد.",
"tabAll": "همه تراکنش‌ها",
"tabTour": "سفارشات و رزروهای تور",
"tabSimcard": "خرید سیم‌کارت",
"tabExchange": "صرافی و ارز",
"noFactorsFound": "هیچ صورت‌حسابی یافت نشد.",
"noFactorsFilter": "رکوردی با فیلترهای فعلی همخوانی ندارد. می‌توانید فیلترها را پاک کنید.",
"noFactorsEmpty": "تاکنون هیچ صورت‌حسابی در سیستم صادر نشده است.",
"confirmClearReceipt": "آیا از حذف تصویر فیش بارگذاری شده اطمینان دارید؟",
"receiptClearedSuccess": "تصویر فیش پرداخت با موفقیت حذف شد.",
"tabAll": "کل تراکنش‌ها",
"tabTour": "سفارشات و رزرو تور",
"tabSimcard": "سیم‌کارت",
"tabExchange": "ارز و صرافی",
"tabShop": "فروشگاه سوغات",
"tabCustomTrip": "سفر اختصاصی",
"coreTourBadge": "محور اصلی",
"liveStatus": "وضعیت لحظه‌ای",
"tabCustomTrip": "سفرهای اختصاصی",
"coreTourBadge": "هسته اصلی",
"liveStatus": "وضعیت زنده",
"quickFilter": "فیلتر وضعیت"
},
"languages": {

Loading…
Cancel
Save