Browse Source

feat: implement services feature, internationalization support, login page, and core UI components

master
Muhammad A. Ghorbani 2 weeks ago
parent
commit
bcedc4ea04
  1. 856
      scripts/generate-locales.js
  2. 52
      src/components/layout/ServerStatusDialog.tsx
  3. 26
      src/components/shared/DeleteConfirmDialog.tsx
  4. 26
      src/features/auth/LoginPage.tsx
  5. 15
      src/features/dashboard/DashboardHome.tsx
  6. 20
      src/features/global-search/GlobalSearchDialog.tsx
  7. 40
      src/features/services/ServicesPage.tsx
  8. 13
      src/features/services/components/CategoriesServiceView.tsx
  9. 237
      src/features/services/components/CitiesServiceView.tsx
  10. 468
      src/features/services/components/CreateLocationDialog.tsx
  11. 932
      src/features/services/components/DuasServiceView.tsx
  12. 144
      src/features/services/components/LocationDetailSheet.tsx
  13. 407
      src/features/services/components/ZayerGuideServiceView.tsx
  14. 126
      src/features/services/services/trip-etiquette-api.ts
  15. 426
      src/features/services/services/zayer-guide-api.ts
  16. 34
      src/features/services/types/trip-etiquette.ts
  17. 33
      src/features/services/types/zayer-guide.ts
  18. 44
      src/features/tours/components/TourStatusBadge.tsx
  19. 12
      src/i18n/index.ts
  20. 219
      src/locales/ar.json
  21. 143
      src/locales/en.json
  22. 201
      src/locales/fa.json

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: 'you@example.com',
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: 'you@example.com',
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: 'you@example.com',
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!')

52
src/components/layout/ServerStatusDialog.tsx

@ -14,6 +14,7 @@ import { Ic } from '@/icons'
import { useServerHealthStore } from '@/stores/server-health' import { useServerHealthStore } from '@/stores/server-health'
import { useAuthStore } from '@/stores/auth' import { useAuthStore } from '@/stores/auth'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useTranslation } from '@/i18n'
interface ServerStatusDialogProps { interface ServerStatusDialogProps {
open: boolean open: boolean
@ -23,6 +24,7 @@ interface ServerStatusDialogProps {
export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusDialogProps) { export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusDialogProps) {
const health = useServerHealthStore() const health = useServerHealthStore()
const user = useAuthStore((s) => s.user) const user = useAuthStore((s) => s.user)
const { t } = useTranslation()
const [isRefreshing, setIsRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false)
const handleRefresh = async () => { const handleRefresh = async () => {
@ -49,10 +51,10 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
</div> </div>
<div> <div>
<DialogTitle className="text-base font-bold text-foreground"> <DialogTitle className="text-base font-bold text-foreground">
وضعیت سیستم و سرور
{t('serverStatus.title')}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-xs text-grey-3 mt-0.5"> <DialogDescription className="text-xs text-grey-3 mt-0.5">
خلاصه ارتباط سرور، پیکربندی API و اطلاعات حساب
{t('serverStatus.description')}
</DialogDescription> </DialogDescription>
</div> </div>
</div> </div>
@ -72,7 +74,7 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
: 'bg-rose-500 shadow-[0_0_8px_#f43f5e]' : 'bg-rose-500 shadow-[0_0_8px_#f43f5e]'
}`} }`}
/> />
وضعیت سرور جنگو (Backend)
{t('serverStatus.backendStatus')}
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge <Badge
@ -86,10 +88,10 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
} }
> >
{health.status === 'connected' {health.status === 'connected'
? 'آنلاین و متصل'
? t('serverStatus.connected')
: health.status === 'checking' : health.status === 'checking'
? 'در حال بررسی...'
: 'قطع ارتباط'}
? t('serverStatus.checking')
: t('serverStatus.disconnected')}
</Badge> </Badge>
<Button <Button
type="button" type="button"
@ -98,7 +100,7 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
onClick={handleRefresh} onClick={handleRefresh}
disabled={isRefreshing} disabled={isRefreshing}
className="size-7 rounded-lg text-grey-3 hover:text-foreground hover:bg-muted/80 cursor-pointer" className="size-7 rounded-lg text-grey-3 hover:text-foreground hover:bg-muted/80 cursor-pointer"
title="بررسی مجدد ارتباط"
title={t('serverStatus.recheck')}
> >
<Ic name="refresh" className={cn('size-3.5', isRefreshing && 'animate-spin')} /> <Ic name="refresh" className={cn('size-3.5', isRefreshing && 'animate-spin')} />
</Button> </Button>
@ -119,20 +121,20 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
<div className="flex items-center justify-between pb-1 border-b border-border-soft"> <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"> <span className="text-xs font-semibold text-foreground flex items-center gap-1.5">
<Ic name="code" className="size-3.5 text-grey-3" /> <Ic name="code" className="size-3.5 text-grey-3" />
پیکربندی API و سرور
{t('serverStatus.apiConfigTitle')}
</span> </span>
</div> </div>
<div className="space-y-1.5 font-mono text-[11px]" dir="ltr"> <div className="space-y-1.5 font-mono text-[11px]" dir="ltr">
<div className="flex justify-between items-center text-grey-2"> <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> <span className="text-foreground">http://127.0.0.1:8000</span>
</div> </div>
<div className="flex justify-between items-center text-grey-2"> <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> <span className="text-foreground">/api</span>
</div> </div>
<div className="flex justify-between items-center text-grey-2"> <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> <span className="text-primary font-semibold">Token (DRF)</span>
</div> </div>
</div> </div>
@ -143,29 +145,29 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
<div className="flex items-center justify-between pb-1 border-b border-border-soft"> <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"> <span className="text-xs font-semibold text-foreground flex items-center gap-1.5">
<Ic name="users" className="size-3.5 text-grey-3" /> <Ic name="users" className="size-3.5 text-grey-3" />
اطلاعات حساب کاربری جاری
{t('serverStatus.currentUserTitle')}
</span> </span>
<span className="text-[11px] text-primary font-medium"> <span className="text-[11px] text-primary font-medium">
{user?.role === 'superadmin' {user?.role === 'superadmin'
? 'مدیر ارشد (Superuser)'
? t('serverStatus.roleSuperuser')
: user?.role === 'admin' : user?.role === 'admin'
? 'مدیر سیستم (Staff)'
: 'کاربر'}
? t('serverStatus.roleStaff')
: t('serverStatus.roleUser')}
</span> </span>
</div> </div>
<div className="space-y-1.5 text-xs"> <div className="space-y-1.5 text-xs">
<div className="flex justify-between items-center"> <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>
<div className="flex justify-between items-center"> <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"> <span dir="ltr" className="font-mono text-[11px] text-grey-2">
{user?.email || '-'} {user?.email || '-'}
</span> </span>
</div> </div>
<div className="flex justify-between items-center"> <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"> <span dir="ltr" className="font-mono text-[11px] text-grey-3">
#{user?.id ?? 1} #{user?.id ?? 1}
</span> </span>
@ -187,17 +189,17 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
/> />
<span> <span>
{health.status === 'connected' {health.status === 'connected'
? 'ارتباط با سرور پایدار است'
? t('serverStatus.connectionStable')
: health.status === 'checking' : health.status === 'checking'
? 'در حال آزمایش اتصال...'
: 'سرور در دسترس نیست'}
? t('serverStatus.connectionTesting')
: t('serverStatus.connectionUnreachable')}
</span> </span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<DialogClose asChild> <DialogClose asChild>
<Button type="button" variant="outline" size="sm" className="rounded-xl h-9 px-4 text-xs cursor-pointer"> <Button type="button" variant="outline" size="sm" className="rounded-xl h-9 px-4 text-xs cursor-pointer">
بستن
{t('common.close')}
</Button> </Button>
</DialogClose> </DialogClose>
<Button <Button
@ -206,11 +208,11 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD
size="sm" size="sm"
onClick={handleRefresh} onClick={handleRefresh}
isLoading={isRefreshing} isLoading={isRefreshing}
loadingText="در حال بررسی..."
loadingText={t('serverStatus.rechecking')}
className="rounded-xl h-9 px-4 text-xs gap-1.5 cursor-pointer" className="rounded-xl h-9 px-4 text-xs gap-1.5 cursor-pointer"
> >
<Ic name="refresh" className="size-3.5" /> <Ic name="refresh" className="size-3.5" />
<span>بررسی مجدد</span>
<span>{t('serverStatus.recheck')}</span>
</Button> </Button>
</div> </div>
</DialogFooter> </DialogFooter>

26
src/components/shared/DeleteConfirmDialog.tsx

@ -9,6 +9,7 @@ import {
AlertDialogTitle, AlertDialogTitle,
} from '@/components/ui/alert-dialog' } from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { useTranslation } from '@/i18n'
interface DeleteConfirmDialogProps { interface DeleteConfirmDialogProps {
open: boolean open: boolean
@ -24,38 +25,45 @@ interface DeleteConfirmDialogProps {
export default function DeleteConfirmDialog({ export default function DeleteConfirmDialog({
open, open,
onOpenChange, onOpenChange,
title = 'آیا از حذف این مورد اطمینان دارید؟',
description = 'این عملیات غیرقابل بازگشت است و رکورد مورد نظر به طور کامل حذف خواهد شد.',
confirmText = 'بله، حذف شود',
cancelText = 'انصراف',
title,
description,
confirmText,
cancelText,
isLoading = false, isLoading = false,
onConfirm, onConfirm,
}: DeleteConfirmDialogProps) { }: 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 ( return (
<AlertDialog open={open} onOpenChange={onOpenChange}> <AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent className="sm:max-w-[425px]"> <AlertDialogContent className="sm:max-w-[425px]">
<AlertDialogHeader className="text-start"> <AlertDialogHeader className="text-start">
<AlertDialogTitle className="text-base font-bold text-foreground"> <AlertDialogTitle className="text-base font-bold text-foreground">
{title}
{finalTitle}
</AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription className="text-xs leading-relaxed text-grey-3"> <AlertDialogDescription className="text-xs leading-relaxed text-grey-3">
{description}
{finalDescription}
</AlertDialogDescription> </AlertDialogDescription>
</AlertDialogHeader> </AlertDialogHeader>
<AlertDialogFooter className="mt-4 gap-2 sm:justify-start"> <AlertDialogFooter className="mt-4 gap-2 sm:justify-start">
<Button <Button
variant="destructive" variant="destructive"
isLoading={isLoading} isLoading={isLoading}
loadingText="در حال حذف..."
loadingText={t('common.deleting')}
onClick={() => { onClick={() => {
onConfirm() onConfirm()
}} }}
> >
{confirmText}
{finalConfirmText}
</Button> </Button>
<AlertDialogCancel asChild> <AlertDialogCancel asChild>
<Button variant="outline" disabled={isLoading}> <Button variant="outline" disabled={isLoading}>
{cancelText}
{finalCancelText}
</Button> </Button>
</AlertDialogCancel> </AlertDialogCancel>
</AlertDialogFooter> </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 { ApiError } from '@/services/http'
import { siteConfig } from '@/config/site' import { siteConfig } from '@/config/site'
import { AqilaLogo } from '@/components/AqilaLogo' import { AqilaLogo } from '@/components/AqilaLogo'
import { useTranslation } from '@/i18n'
function Field({ label, children }: { label: string; children: React.ReactNode }) { function Field({ label, children }: { label: string; children: React.ReactNode }) {
return ( return (
@ -22,6 +23,7 @@ export default function LoginPage() {
const location = useLocation() const location = useLocation()
const { isAuthenticated } = useAuth() const { isAuthenticated } = useAuth()
const loginMutation = useLoginMutation() const loginMutation = useLoginMutation()
const { t } = useTranslation()
const [email, setEmail] = useState('') const [email, setEmail] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
@ -50,7 +52,7 @@ export default function LoginPage() {
loginMutation.error instanceof ApiError loginMutation.error instanceof ApiError
? loginMutation.error.message ? loginMutation.error.message
: loginMutation.error : loginMutation.error
? 'ورود ناموفق بود. دوباره تلاش کنید.'
? t('auth.loginFailed')
: null : null
return ( return (
@ -67,7 +69,7 @@ export default function LoginPage() {
</div> </div>
<div> <div>
<h1 className="text-xl font-black text-foreground">{siteConfig.name}</h1> <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>
</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 className="flex items-center justify-between rounded-xl border border-primary/20 bg-primary/10 p-3 text-xs">
<div> <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> <span dir="ltr" className="text-[11px] text-grey-2 font-mono">admin@gmail.com / admin</span>
</div> </div>
<Button <Button
@ -89,31 +91,31 @@ export default function LoginPage() {
onClick={fillDemo} onClick={fillDemo}
className="h-7 text-[11px]" className="h-7 text-[11px]"
> >
پر کردن خودکار
{t('auth.autoFill')}
</Button> </Button>
</div> </div>
<Field label="ایمیل حساب کاربری">
<Field label={t('auth.emailLabel')}>
<Input <Input
type="email" type="email"
dir="ltr" dir="ltr"
className="text-left" className="text-left"
autoComplete="username" autoComplete="username"
placeholder="you@example.com"
placeholder={t('auth.emailPlaceholder')}
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
autoFocus autoFocus
/> />
</Field> </Field>
<Field label="رمز عبور">
<Field label={t('auth.passwordLabel')}>
<div className="relative"> <div className="relative">
<Input <Input
type={showPassword ? 'text' : 'password'} type={showPassword ? 'text' : 'password'}
dir="ltr" dir="ltr"
className="text-left pe-11" className="text-left pe-11"
autoComplete="current-password" autoComplete="current-password"
placeholder="••••••••"
placeholder={t('auth.passwordPlaceholder')}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
/> />
@ -121,7 +123,7 @@ export default function LoginPage() {
type="button" type="button"
onClick={() => setShowPassword((v) => !v)} 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" 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} tabIndex={-1}
> >
<Ic name={showPassword ? 'eyeOff' : 'eye'} /> <Ic name={showPassword ? 'eyeOff' : 'eye'} />
@ -142,14 +144,14 @@ export default function LoginPage() {
className="w-full font-bold" className="w-full font-bold"
disabled={!valid} disabled={!valid}
isLoading={loginMutation.isPending} isLoading={loginMutation.isPending}
loadingText="در حال ورود…"
loadingText={t('auth.loggingIn')}
> >
ورود به حساب کاربری
{t('auth.submitLogin')}
</Button> </Button>
</form> </form>
<p className="mt-4 text-center text-xs text-grey-4"> <p className="mt-4 text-center text-xs text-grey-4">
پشتیبانی از قالب‌های سفارشی و اتصال مستقیم به API
{t('auth.footerNote')}
</p> </p>
</div> </div>
</div> </div>

15
src/features/dashboard/DashboardHome.tsx

@ -14,17 +14,14 @@ export default function DashboardHome() {
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<h1 className="text-xl font-black text-foreground"> <h1 className="text-xl font-black text-foreground">
خوش آمدید، {user?.name || 'مدیر سیستم'}
{t('dashboard.welcome', { name: user?.name || t('navigation.adminRole') })}
</h1> </h1>
<Badge variant="outline" className="border-primary/30 bg-primary/10 text-primary"> <Badge variant="outline" className="border-primary/30 bg-primary/10 text-primary">
آماده توسعه
{t('dashboard.readyBadge')}
</Badge> </Badge>
</div> </div>
<p className="text-xs text-grey-3"> <p className="text-xs text-grey-3">
پنل مدیریت عقیله آماده اضافه کردن ویجت‌ها، جداول و ماژول‌های جدید شماست.
</p>
<p className="text-xs text-primary font-medium mt-1">
{t('common.testSyncNotice')}
{t('dashboard.welcomeSubtitle')}
</p> </p>
</div> </div>
</div> </div>
@ -34,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"> <div className="mx-auto flex size-12 items-center justify-center rounded-2xl bg-primary/10 text-primary">
<Ic name="sparkles" className="size-6" /> <Ic name="sparkles" className="size-6" />
</div> </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"> <p className="mx-auto mt-2 max-w-md text-xs leading-relaxed text-grey-3">
تمام اطلاعات و یوآی‌های ساختگی حذف شدند. می‌توانید ماژول‌های جدید خود را با کامپوننت‌های آماده UI پیاده‌سازی و مستقیماً به دیتابیس و بک‌اند عقیله متصل نمایید.
{t('dashboard.cleanBoardDescription')}
</p> </p>
</div> </div>
</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 { useNavigate } from 'react-router-dom'
import { Ic } from '@/icons' import { Ic } from '@/icons'
import { import {
@ -11,13 +11,17 @@ import {
CommandSeparator, CommandSeparator,
} from '@/components/ui/command' } from '@/components/ui/command'
import { useGlobalSearchStore } from '@/stores/global-search' import { useGlobalSearchStore } from '@/stores/global-search'
import { navigationConfig } from '@/config/navigation'
import { getNavigationConfig } from '@/config/navigation'
import { useLogout } from '@/features/auth/use-auth' import { useLogout } from '@/features/auth/use-auth'
import { useTranslation } from '@/i18n'
export default function GlobalSearchDialog() { export default function GlobalSearchDialog() {
const { open, setOpen } = useGlobalSearchStore() const { open, setOpen } = useGlobalSearchStore()
const navigate = useNavigate() const navigate = useNavigate()
const logout = useLogout() const logout = useLogout()
const { t } = useTranslation()
const navConfig = useMemo(() => getNavigationConfig(t), [t])
// میانبر صفحه‌کلید Cmd+K یا Ctrl+K // میانبر صفحه‌کلید Cmd+K یا Ctrl+K
useEffect(() => { useEffect(() => {
@ -38,15 +42,15 @@ export default function GlobalSearchDialog() {
return ( return (
<CommandDialog open={open} onOpenChange={setOpen}> <CommandDialog open={open} onOpenChange={setOpen}>
<CommandInput placeholder="جستجوی صفحات، عملیات‌ها یا فرمان‌ها... (Ctrl+K)" />
<CommandInput placeholder={t('globalSearch.placeholder')} />
<CommandList className="max-h-[350px] overflow-y-auto p-2"> <CommandList className="max-h-[350px] overflow-y-auto p-2">
<CommandEmpty className="py-6 text-center text-xs text-grey-3"> <CommandEmpty className="py-6 text-center text-xs text-grey-3">
هیچ نتیجه‌ای یافت نشد.
{t('globalSearch.empty')}
</CommandEmpty> </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) => { {group.items.flatMap((item) => {
const entries: React.ReactNode[] = [] const entries: React.ReactNode[] = []
if (item.to) { if (item.to) {
@ -112,13 +116,13 @@ export default function GlobalSearchDialog() {
<CommandSeparator className="my-2" /> <CommandSeparator className="my-2" />
{/* اکشن‌های سریع */} {/* اکشن‌های سریع */}
<CommandGroup heading="عملیات سریع">
<CommandGroup heading={t('common.actions')}>
<CommandItem <CommandItem
onSelect={() => runAction(() => logout())} onSelect={() => runAction(() => logout())}
className="flex cursor-pointer items-center gap-2.5 rounded-lg px-3 py-2 text-xs text-rose-400" 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" /> <Ic name="logout" className="size-4 text-rose-400" />
<span>خروج از حساب کاربری</span>
<span>{t('globalSearch.logoutAccount')}</span>
</CommandItem> </CommandItem>
</CommandGroup> </CommandGroup>
</CommandList> </CommandList>

40
src/features/services/ServicesPage.tsx

@ -214,28 +214,30 @@ export default function ServicesPage() {
return ( return (
<div className="space-y-6 pt-2 pb-14"> <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> </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> </div>
<p className="text-xs text-grey-3">
{currentService.description}
</p>
</div> </div>
</div>
)}
{/* رندرینگ محتوای اختصاصی سرویس مستقل */} {/* رندرینگ محتوای اختصاصی سرویس مستقل */}
<ServiceDetailRenderer service={currentService} subKey={subKey} /> <ServiceDetailRenderer service={currentService} subKey={subKey} />

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

@ -21,6 +21,7 @@ import {
deleteCategory, deleteCategory,
fetchDuas, fetchDuas,
} from '../services/trip-etiquette-api' } from '../services/trip-etiquette-api'
import { toast } from '@/stores/toast'
export const PILGRIMAGE_CITIES = [ export const PILGRIMAGE_CITIES = [
{ id: 5, name: 'Karbala', nameFa: 'کربلای معلی', flag: '🇮🇶' }, { id: 5, name: 'Karbala', nameFa: 'کربلای معلی', flag: '🇮🇶' },
{ id: 6, name: 'Najaf', 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))) setCategories((prev) => prev.map((item) => (item.id === updated.id ? updated : item)))
setAssigningCategory(null) setAssigningCategory(null)
toast.success('تخصیص ادعیه به دسته‌بندی با موفقیت ذخیره شد.')
} catch (err: any) { } catch (err: any) {
alert(err?.message || 'خطا در تخصیص ادعیه.')
toast.error(err?.message || 'خطا در تخصیص ادعیه.')
} finally { } finally {
setIsSavingQuickAssign(false) setIsSavingQuickAssign(false)
} }
@ -281,7 +283,7 @@ export function CategoriesServiceView() {
const handleSaveCategory = async (e: React.FormEvent) => { const handleSaveCategory = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!formNameAr.trim()) { if (!formNameAr.trim()) {
alert('لطفاً عنوان دسته‌بندی (عربی) را وارد کنید. زبان عربی پیش‌فرض و الزامی است.')
toast.warning('لطفاً عنوان دسته‌بندی (عربی) را وارد کنید. زبان عربی پیش‌فرض و الزامی است.')
return return
} }
@ -305,13 +307,15 @@ export function CategoriesServiceView() {
if (editingCategory) { if (editingCategory) {
const updated = await updateCategory(editingCategory.id, payload) const updated = await updateCategory(editingCategory.id, payload)
setCategories((prev) => prev.map((item) => (item.id === updated.id ? updated : item))) setCategories((prev) => prev.map((item) => (item.id === updated.id ? updated : item)))
toast.success('دسته‌بندی با موفقیت به‌روزرسانی شد.')
} else { } else {
const created = await createCategory(payload as CreateCategoryInput) const created = await createCategory(payload as CreateCategoryInput)
setCategories((prev) => [created, ...prev]) setCategories((prev) => [created, ...prev])
toast.success('دسته‌بندی جدید با موفقیت ایجاد شد.')
} }
setIsDialogOpen(false) setIsDialogOpen(false)
} catch (err: any) { } catch (err: any) {
alert(err?.message || 'خطا در ذخیره‌سازی دسته‌بندی.')
toast.error(err?.message || 'خطا در ذخیره‌سازی دسته‌بندی.')
} finally { } finally {
setIsSaving(false) setIsSaving(false)
} }
@ -326,8 +330,9 @@ export function CategoriesServiceView() {
try { try {
await deleteCategory(c.id) await deleteCategory(c.id)
setCategories((prev) => prev.filter((item) => item.id !== c.id)) setCategories((prev) => prev.filter((item) => item.id !== c.id))
toast.success('دسته‌بندی با موفقیت حذف شد.')
} catch (err: any) { } 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 React, { useState, useEffect, useMemo } from 'react'
import { Link } from 'react-router-dom'
import { Ic } from '@/icons' import { Ic } from '@/icons'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { ShimmerBox, ShimmerLine } from '@/components/shared/shimmer_frontend' import { ShimmerBox, ShimmerLine } from '@/components/shared/shimmer_frontend'
import { toFa } from '@/lib/utils' import { toFa } from '@/lib/utils'
import type { import type {
PilgrimageCityItem, PilgrimageCityItem,
CategoryItem, CategoryItem,
DuaItem,
} from '../types/trip-etiquette' } from '../types/trip-etiquette'
import { import {
fetchTripEtiquetteCities, fetchTripEtiquetteCities,
fetchCategories, fetchCategories,
fetchDuas,
} from '../services/trip-etiquette-api' } from '../services/trip-etiquette-api'
// نام‌های روان و استاندارد فارسی برای شهرهای زیارتی // نام‌های روان و استاندارد فارسی برای شهرهای زیارتی
@ -48,6 +48,7 @@ const COUNTRY_NAMES_FA: Record<string, string> = {
export function CitiesServiceView() { export function CitiesServiceView() {
const [cities, setCities] = useState<PilgrimageCityItem[]>([]) const [cities, setCities] = useState<PilgrimageCityItem[]>([])
const [categories, setCategories] = useState<CategoryItem[]>([]) const [categories, setCategories] = useState<CategoryItem[]>([])
const [duas, setDuas] = useState<DuaItem[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false) const [isRefreshing, setIsRefreshing] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null) const [errorMessage, setErrorMessage] = useState<string | null>(null)
@ -62,12 +63,14 @@ export function CitiesServiceView() {
setErrorMessage(null) setErrorMessage(null)
try { try {
const [citiesRes, categoriesRes] = await Promise.all([
const [citiesRes, categoriesRes, duasRes] = await Promise.all([
fetchTripEtiquetteCities(), fetchTripEtiquetteCities(),
fetchCategories(), fetchCategories(),
fetchDuas(),
]) ])
setCities(citiesRes) setCities(citiesRes)
setCategories(categoriesRes) setCategories(categoriesRes)
setDuas(duasRes)
} catch (err: any) { } catch (err: any) {
setErrorMessage(err?.message || 'خطا در بارگذاری فهرست شهرهای زیارتی.') setErrorMessage(err?.message || 'خطا در بارگذاری فهرست شهرهای زیارتی.')
} finally { } finally {
@ -125,44 +128,27 @@ export function CitiesServiceView() {
return ( return (
<div className="space-y-6"> <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> </div>
<h1 className="text-lg sm:text-xl font-black text-foreground">
شهرهای زیارتی
</h1>
</div> </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> </div>
{/* نوار فیلتر و جستجو */} {/* نوار فیلتر و جستجو */}
@ -232,50 +218,24 @@ export function CitiesServiceView() {
</div> </div>
)} )}
{/* وضعیت لودینگ اسکلتون منطبق با ساختار واقعی کارت‌های شهر */}
{/* وضعیت لودینگ اسکلتون منطبق با ساختار کارت‌های ساده‌شده شهر */}
{isLoading ? ( {isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
<div <div
key={i} 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>
{/* دکمه‌های فوتر */}
<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>
</div> </div>
))} ))}
@ -286,111 +246,64 @@ export function CitiesServiceView() {
<p className="text-sm font-bold text-foreground">هیچ شهری با این مشخصات یافت نشد</p> <p className="text-sm font-bold text-foreground">هیچ شهری با این مشخصات یافت نشد</p>
</div> </div>
) : ( ) : (
/* شبکه کارت‌های شهرهای زیارتی */
/* شبکه کارت‌های شهرهای زیارتی (ساده‌شده، فقط خواندنی و بدون واکنش کلیک) */
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredCities.map((city) => { {filteredCities.map((city) => {
const faName = CITY_FA_NAMES[city.slug.toLowerCase()] || city.name const faName = CITY_FA_NAMES[city.slug.toLowerCase()] || city.name
const flag = COUNTRY_FLAGS[city.country_code || ''] || '🌍' const flag = COUNTRY_FLAGS[city.country_code || ''] || '🌍'
const countryFa = COUNTRY_NAMES_FA[city.country_name || ''] || city.country_name || '' 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 ( return (
<div <div
key={city.id} 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>
{/* دسته‌بندی‌های متصل به این شهر */}
<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>
{/* دسترسی‌های حمل‌ونقل و موقعیت */}
<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> </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> </div>
) )
})} })}

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

@ -10,7 +10,6 @@ import {
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Switch } from '@/components/ui/switch'
import { Ic } from '@/icons' import { Ic } from '@/icons'
import type { import type {
CityGuideItem, CityGuideItem,
@ -25,7 +24,8 @@ interface CreateLocationDialogProps {
categories: CityGuideCategory[] categories: CityGuideCategory[]
countries: CityGuideCountry[] countries: CityGuideCountry[]
editingLocation?: CityGuideItem | null editingLocation?: CityGuideItem | null
onSave: (data: CreateCityGuideInput, editingId?: number) => void
isSaving?: boolean
onSave: (data: CreateCityGuideInput, editingId?: number) => void | Promise<void>
} }
const WEEK_DAYS = [ const WEEK_DAYS = [
@ -44,11 +44,28 @@ export function CreateLocationDialog({
categories, categories,
countries, countries,
editingLocation, editingLocation,
isSaving = false,
onSave, onSave,
}: CreateLocationDialogProps) { }: 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 [phoneNumber, setPhoneNumber] = useState('')
const [categoryId, setCategoryId] = useState<number>(categories[0]?.id || 1) const [categoryId, setCategoryId] = useState<number>(categories[0]?.id || 1)
const [countryId, setCountryId] = useState<number>(countries[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 [daysOff, setDaysOff] = useState<string[]>([])
const [latitude, setLatitude] = useState('32.6160') const [latitude, setLatitude] = useState('32.6160')
const [longitude, setLongitude] = useState('44.0244') const [longitude, setLongitude] = useState('44.0244')
const [isActive, setIsActive] = useState(true)
const [imageUrl, setImageUrl] = useState('') const [imageUrl, setImageUrl] = useState('')
const [validationError, setValidationError] = useState<string | null>(null) const [validationError, setValidationError] = useState<string | null>(null)
// لیست شهرهای کشور انتخاب‌شده
// لیست کلیه شهرهای تابعه کشور انتخابی
const currentCountry = countries.find((c) => c.id === countryId) || countries[0] const currentCountry = countries.find((c) => c.id === countryId) || countries[0]
const availableCities = currentCountry?.city || [] const availableCities = currentCountry?.city || []
useEffect(() => { useEffect(() => {
if (editingLocation) { 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 || '') 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) const parsedDays = Array.isArray(editingLocation.days_off)
? editingLocation.days_off ? editingLocation.days_off
@ -85,9 +112,8 @@ export function CreateLocationDialog({
: [] : []
setDaysOff(parsedDays) 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 = const img =
(typeof editingLocation.image?.image_url === 'object' (typeof editingLocation.image?.image_url === 'object'
@ -96,9 +122,15 @@ export function CreateLocationDialog({
setImageUrl(typeof img === 'string' ? img : '') setImageUrl(typeof img === 'string' ? img : '')
} else { } else {
// مقادیر پیش‌فرض برای ایجاد مکان جدید // مقادیر پیش‌فرض برای ایجاد مکان جدید
setTitle('')
setDescription('')
setAddress('')
setTitleFa('')
setTitleAr('')
setTitleEn('')
setDescFa('')
setDescAr('')
setDescEn('')
setAddrFa('')
setAddrAr('')
setAddrEn('')
setPhoneNumber('') setPhoneNumber('')
setCategoryId(categories[0]?.id || 1) setCategoryId(categories[0]?.id || 1)
const firstCountry = countries[0] const firstCountry = countries[0]
@ -113,30 +145,23 @@ export function CreateLocationDialog({
setDaysOff([]) setDaysOff([])
setLatitude('32.6160') setLatitude('32.6160')
setLongitude('44.0244') setLongitude('44.0244')
setIsActive(true)
setImageUrl('') setImageUrl('')
} }
setValidationError(null) setValidationError(null)
}, [editingLocation, open, categories, countries]) }, [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) => { const toggleDayOff = (dayCode: string) => {
setDaysOff((prev) => setDaysOff((prev) =>
prev.includes(dayCode) ? prev.filter((d) => d !== dayCode) : [...prev, dayCode] prev.includes(dayCode) ? prev.filter((d) => d !== dayCode) : [...prev, dayCode]
) )
} }
const handleSubmit = (e: React.FormEvent) => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!title.trim()) {
// حداقل عنوان فارسی یا اصلی باید پر باشد
const mainTitle = titleFa.trim() || titleAr.trim() || titleEn.trim()
if (!mainTitle) {
setValidationError('لطفاً عنوان مکان را وارد کنید.') setValidationError('لطفاً عنوان مکان را وارد کنید.')
return return
} }
@ -148,10 +173,28 @@ export function CreateLocationDialog({
return 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 = { 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(), phone_number: phoneNumber.trim(),
category_id: categoryId, category_id: categoryId,
country_id: countryId, country_id: countryId,
@ -161,11 +204,10 @@ export function CreateLocationDialog({
days_off: daysOff, days_off: daysOff,
latitude: latNum, latitude: latNum,
longitude: lngNum, longitude: lngNum,
is_active: isActive,
image_url: imageUrl.trim() || undefined, image_url: imageUrl.trim() || undefined,
} }
onSave(inputData, editingLocation?.id)
await onSave(inputData, editingLocation?.id)
onOpenChange(false) onOpenChange(false)
} }
@ -182,7 +224,7 @@ export function CreateLocationDialog({
{editingLocation ? `ویرایش مکان: ${editingLocation.title}` : 'افزودن مکان جدید به راهنمای زائر'} {editingLocation ? `ویرایش مکان: ${editingLocation.title}` : 'افزودن مکان جدید به راهنمای زائر'}
</DialogTitle> </DialogTitle>
<DialogDescription className="text-xs text-grey-3 mt-0.5"> <DialogDescription className="text-xs text-grey-3 mt-0.5">
فیلدها منطبق بر ساختار رسمی مدل پایگاه‌داده و وب‌سرویس CityGuide
ثبت مستقیم و پایدار در دیتابیس سامانه و همگام با اپلیکیشن زائرین
</DialogDescription> </DialogDescription>
</div> </div>
</div> </div>
@ -196,34 +238,155 @@ export function CreateLocationDialog({
</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">
عنوان مکان / مرکز <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>
<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>
</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>
{/* ۲. دسته‌بندی و شهر/کشور مقصد */}
<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"> <div className="space-y-1.5">
<label className="text-xs font-bold text-foreground"> <label className="text-xs font-bold text-foreground">
دسته‌بندی <span className="text-rose-500">*</span> دسته‌بندی <span className="text-rose-500">*</span>
@ -241,125 +404,96 @@ export function CreateLocationDialog({
</select> </select>
</div> </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"> <label className="text-xs font-bold text-foreground">
شهر و کشور مقصد <span className="text-rose-500">*</span> شهر و کشور مقصد <span className="text-rose-500">*</span>
</label> </label>
<select <select
value={`${countryId}_${cityId}`} value={`${countryId}_${cityId}`}
onChange={(e) => { 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" 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> </option>
)}
))}
</optgroup> </optgroup>
))} ))}
</select> </select>
</div> </div>
</div> </div>
{/* ۳. آدرس و شماره تماس */}
{/* ساعات کاری و تلفن تماس */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> <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 <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>
<div className="space-y-1.5"> <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 <Input
value={phoneNumber} value={phoneNumber}
onChange={(e) => setPhoneNumber(e.target.value)} 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" className="text-xs bg-surface-base font-mono"
dir="ltr"
/> />
</div> </div>
</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>
<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>
</div> </div>
{/* ۶. مختصات جغرافیایی (Latitude / Longitude) */}
{/* مختصات جغرافیایی */}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="text-xs font-bold text-foreground"> <label className="text-xs font-bold text-foreground">
@ -368,9 +502,9 @@ export function CreateLocationDialog({
<Input <Input
value={latitude} value={latitude}
onChange={(e) => setLatitude(e.target.value)} onChange={(e) => setLatitude(e.target.value)}
placeholder="مثلاً: 32.6160"
dir="ltr"
placeholder="32.6160"
className="text-xs bg-surface-base font-mono" className="text-xs bg-surface-base font-mono"
dir="ltr"
required required
/> />
</div> </div>
@ -382,41 +516,37 @@ export function CreateLocationDialog({
<Input <Input
value={longitude} value={longitude}
onChange={(e) => setLongitude(e.target.value)} onChange={(e) => setLongitude(e.target.value)}
placeholder="مثلاً: 44.0244"
dir="ltr"
placeholder="44.0244"
className="text-xs bg-surface-base font-mono" className="text-xs bg-surface-base font-mono"
dir="ltr"
required required
/> />
</div> </div>
</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> </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> <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> </Button>
</DialogClose> </DialogClose>
<Button <Button
type="button" type="button"
size="sm"
onClick={handleSubmit} 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> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </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 { import {
Sheet, Sheet,
SheetContent, SheetContent,
@ -16,9 +16,10 @@ interface LocationDetailSheetProps {
location: CityGuideItem | null location: CityGuideItem | null
open: boolean open: boolean
onOpenChange: (open: boolean) => void onOpenChange: (open: boolean) => void
onToggleStatus: (id: number, currentStatus: boolean) => void
isLoadingStatus?: boolean
onEdit: (location: CityGuideItem) => void onEdit: (location: CityGuideItem) => void
onDelete?: (id: number, title: string) => void
onToggleActive?: (location: CityGuideItem) => void
isToggling?: boolean
} }
const DAY_LABELS: Record<string, string> = { const DAY_LABELS: Record<string, string> = {
@ -35,10 +36,13 @@ export function LocationDetailSheet({
location, location,
open, open,
onOpenChange, onOpenChange,
onToggleStatus,
isLoadingStatus,
onEdit, onEdit,
onDelete,
onToggleActive,
isToggling,
}: LocationDetailSheetProps) { }: LocationDetailSheetProps) {
const [activeLang, setActiveLang] = useState<'fa' | 'ar' | 'en'>('fa')
if (!location) return null if (!location) return null
const daysOffList = Array.isArray(location.days_off) 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?.original || location.image?.image_url?.lg || location.image?.image_url?.md || location.image?.image_url?.sm
: location.image?.image_url) || null : 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 ( return (
<Sheet open={open} onOpenChange={onOpenChange}> <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"> <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="space-y-1 text-start">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<SheetTitle className="text-lg font-black text-foreground"> <SheetTitle className="text-lg font-black text-foreground">
{location.title}
{displayTitle}
</SheetTitle> </SheetTitle>
</div> </div>
<SheetDescription className="text-xs text-grey-3 font-mono" dir="ltr"> <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"> <div className="flex items-center gap-2 shrink-0">
<Badge <Badge
variant="outline" 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> </Badge>
</div> </div>
</div> </div>
@ -98,37 +105,61 @@ export function LocationDetailSheet({
</div> </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"> <div className="flex items-center gap-3">
<Switch <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="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"> <div className="text-[11px] text-grey-3">
{location.is_active ? 'این مکان هم‌اکنون برای زائرین قابل مشاهده است' : 'این مکان از دید زائرین مخفی شده است'}
{location.is_active !== false ? 'این مکان به زائران در اپلیکیشن نمایش داده می‌شود.' : 'این مکان از دید کاربران اپلیکیشن کاملاً پنهان است.'}
</div> </div>
</div> </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>
{/* کارت‌های شاخص (دسته‌بندی، شهر، امتیاز) */}
{/* کارت‌های شاخص (دسته‌بندی، شهر، کشور) */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3"> <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"> <div className="rounded-xl border border-border-soft bg-card/30 p-3.5 text-center">
<span className="text-[11px] text-grey-3">دسته‌بندی</span> <span className="text-[11px] text-grey-3">دسته‌بندی</span>
@ -138,28 +169,50 @@ export function LocationDetailSheet({
</div> </div>
<div className="rounded-xl border border-border-soft bg-card/30 p-3.5 text-center"> <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> </div>
<div className="rounded-xl border border-border-soft bg-card/30 p-3.5 text-center col-span-2 sm:col-span-1"> <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> </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"> <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> <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> </p>
</div> </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"> <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="text-grey-3 shrink-0">آدرس دقیق:</span>
<span className="font-medium text-foreground text-start"> <span className="font-medium text-foreground text-start">
{location.address || 'آدرس ثبت نشده است.'}
{displayAddress || 'آدرس ثبت نشده است.'}
</span> </span>
</div> </div>
@ -198,7 +251,8 @@ export function LocationDetailSheet({
<span className="text-emerald-600 dark:text-emerald-400">۲۴ ساعته (شبانه‌روزی)</span> <span className="text-emerald-600 dark:text-emerald-400">۲۴ ساعته (شبانه‌روزی)</span>
) : ( ) : (
<span className="font-mono" dir="ltr"> <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>
)} )}
</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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import { Checkbox } from '@/components/ui/checkbox' import { Checkbox } from '@/components/ui/checkbox'
import { Switch } from '@/components/ui/switch'
import { Shimmer } from '@/components/shared/shimmer_frontend' import { Shimmer } from '@/components/shared/shimmer_frontend'
import { toFa } from '@/lib/utils' import { toFa } from '@/lib/utils'
import { toast } from '@/stores/toast' import { toast } from '@/stores/toast'
@ -20,10 +20,11 @@ import {
fetchCityGuides, fetchCityGuides,
fetchCityGuideCategories, fetchCityGuideCategories,
fetchCityGuideCountries, fetchCityGuideCountries,
toggleCityGuideStatusLocal,
bulkUpdateCityGuidesStatusLocal,
bulkDeleteCityGuidesLocal,
saveCityGuideItemLocal,
createCityGuide,
updateCityGuide,
deleteCityGuide,
bulkDeleteCityGuides,
toggleCityGuideActive,
} from '../services/zayer-guide-api' } from '../services/zayer-guide-api'
import { LocationDetailSheet } from './LocationDetailSheet' import { LocationDetailSheet } from './LocationDetailSheet'
import { CreateLocationDialog } from './CreateLocationDialog' import { CreateLocationDialog } from './CreateLocationDialog'
@ -38,15 +39,52 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
const [countries, setCountries] = useState<CityGuideCountry[]>([]) const [countries, setCountries] = useState<CityGuideCountry[]>([])
const [isLoading, setIsLoading] = useState(true) const [isLoading, setIsLoading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false) 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 [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 [searchTerm, setSearchTerm] = useState('')
const [selectedCitySlug, setSelectedCitySlug] = useState('ALL') const [selectedCitySlug, setSelectedCitySlug] = useState('ALL')
const [selectedCountryCode, setSelectedCountryCode] = useState('ALL') const [selectedCountryCode, setSelectedCountryCode] = useState('ALL')
const [selectedCategorySlug, setSelectedCategorySlug] = useState('ALL') const [selectedCategorySlug, setSelectedCategorySlug] = useState('ALL')
const [statusFilter, setStatusFilter] = useState<'ALL' | 'ACTIVE' | 'INACTIVE'>('ALL')
const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid') const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid')
// عملیات دسته‌جمعی (Bulk Actions) // عملیات دسته‌جمعی (Bulk Actions)
@ -58,7 +96,7 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
const [editingLocation, setEditingLocation] = useState<CityGuideItem | null>(null) const [editingLocation, setEditingLocation] = useState<CityGuideItem | null>(null)
const [isCreateOpen, setIsCreateOpen] = useState(false) const [isCreateOpen, setIsCreateOpen] = useState(false)
// بارگذاری داده‌ها از وب‌سرویس و حافظه پایدار
// بارگذاری داده‌ها از وب‌سرویس مستقیم دیتابیس
const loadData = async (isManualRefresh = false) => { const loadData = async (isManualRefresh = false) => {
if (isManualRefresh) setIsRefreshing(true) if (isManualRefresh) setIsRefreshing(true)
else setIsLoading(true) else setIsLoading(true)
@ -106,36 +144,31 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
return list return list
}, [countries]) }, [countries])
// فیلتر و جستجوی کلاینت
// فیلتر و جستجوی کلاینت روی داده‌های زنده دیتابیس
const filteredLocations = useMemo(() => { const filteredLocations = useMemo(() => {
return locations.filter((loc) => { 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') { if (selectedCitySlug !== 'ALL') {
const citySlug = loc.city?.slug?.toLowerCase() const citySlug = loc.city?.slug?.toLowerCase()
if (citySlug !== selectedCitySlug.toLowerCase()) return false if (citySlug !== selectedCitySlug.toLowerCase()) return false
} }
// ۳. فیلتر کشور
// ۲. فیلتر کشور
if (selectedCountryCode !== 'ALL') { if (selectedCountryCode !== 'ALL') {
const cCode = loc.country?.code?.toUpperCase() const cCode = loc.country?.code?.toUpperCase()
if (cCode !== selectedCountryCode.toUpperCase()) return false if (cCode !== selectedCountryCode.toUpperCase()) return false
} }
// ۴. فیلتر دسته‌بندی
// ۳. فیلتر دسته‌بندی
if (selectedCategorySlug !== 'ALL') { if (selectedCategorySlug !== 'ALL') {
const catSlug = loc.category?.slug const catSlug = loc.category?.slug
if (catSlug !== selectedCategorySlug) return false if (catSlug !== selectedCategorySlug) return false
} }
// ۵. جستجوی متنی
// ۴. جستجوی متنی
if (searchTerm.trim()) { if (searchTerm.trim()) {
const q = searchTerm.toLowerCase().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 descMatch = (loc.description || '').toLowerCase().includes(q)
const addrMatch = (loc.address || '').toLowerCase().includes(q) const addrMatch = (loc.address || '').toLowerCase().includes(q)
const phoneMatch = (loc.phone_number || '').includes(q) const phoneMatch = (loc.phone_number || '').includes(q)
@ -144,57 +177,21 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
return true return true
}) })
}, [locations, statusFilter, selectedCitySlug, selectedCountryCode, selectedCategorySlug, searchTerm])
}, [locations, selectedCitySlug, selectedCountryCode, selectedCategorySlug, searchTerm])
// آمار کلان
// آمار کلان دیتابیس
const stats = useMemo(() => { const stats = useMemo(() => {
const total = locations.length 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 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 const categoriesCount = new Set(locations.map((l) => l.category?.slug).filter(Boolean)).size
return { return {
total, total,
active,
inactive,
citiesCount, citiesCount,
categoriesCount, categoriesCount,
} }
}, [locations]) }, [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) // عملیات انتخاب چندگانه (Multi-Select)
const handleSelectAll = (checked: boolean) => { const handleSelectAll = (checked: boolean) => {
if (checked) { 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) const ids = Array.from(selectedIds)
if (ids.length === 0) return if (ids.length === 0) return
if (!window.confirm(`آیا از حذف ${toFa(ids.length)} مکان انتخاب‌شده از سامانه اطمینان دارید؟`)) {
if (!window.confirm(`آیا از حذف دائم ${toFa(ids.length)} مکان انتخاب‌شده از پایگاه‌داده اطمینان دارید؟`)) {
return 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* ─────────────────────────────────────────────────────────────────────────────
۱. سربرگ آماری کلان (KPI Cards) با سیستم رنگ‌های خنثی
───────────────────────────────────────────────────────────────────────────── */}
{/* ۱. سربرگ آماری کلان دیتابیس */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4"> <div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<Card className="border-border-soft bg-card/40 shadow-xs"> <Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4"> <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"> <div className="mt-2 text-2xl font-black text-foreground font-mono">
{toFa(stats.total)} {toFa(stats.total)}
</div> </div>
<div className="mt-1 text-[11px] text-grey-3">بانک اطلاعاتی جامع زائر</div>
<div className="mt-1 text-[11px] text-grey-3">داده‌های رسمی و همگام با اپ</div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-border-soft bg-card/40 shadow-xs"> <Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4"> <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>
<div className="mt-1 text-[11px] text-grey-3">عراق، عربستان، ایران، سوریه</div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-border-soft bg-card/40 shadow-xs"> <Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4"> <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>
<div className="mt-1 text-[11px] text-grey-3">عراق، عربستان، ایران، سوریه</div>
<div className="mt-1 text-[11px] text-grey-3">مساجد، درمانی، تاریخی و...</div>
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-border-soft bg-card/40 shadow-xs"> <Card className="border-border-soft bg-card/40 shadow-xs">
<CardContent className="p-4"> <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"> <div className="mt-2 text-2xl font-black text-foreground font-mono">
{toFa(categories.length || stats.categoriesCount || 8)}
{toFa(countries.length || 4)}
</div> </div>
<div className="mt-1 text-[11px] text-grey-3">مساجد، درمانی، تاریخی و...</div>
<div className="mt-1 text-[11px] text-grey-3">عراق، عربستان، ایران، سوریه</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* ─────────────────────────────────────────────────────────────────────────────
۲. نوار جستجو، فیلتر یکپارچه شهر و کشور، دسته‌بندی و وضعیت
───────────────────────────────────────────────────────────────────────────── */}
{/* ۲. نوار جستجو، فیلتر شهر/کشور و دسته‌بندی */}
<div className="flex flex-col gap-3 rounded-2xl border border-border-soft bg-card/40 p-4 sm:flex-row sm:items-center sm:justify-between"> <div className="flex flex-col gap-3 rounded-2xl border border-border-soft bg-card/40 p-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 flex-wrap flex-1 min-w-0"> <div className="flex items-center gap-2 flex-wrap flex-1 min-w-0">
{/* اینپوت جستجو */} {/* اینپوت جستجو */}
@ -381,17 +379,6 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
</option> </option>
))} ))}
</select> </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> </div>
{/* دکمه‌های سوئیچ نما، تازه‌سازی و افزودن مکان */} {/* دکمه‌های سوئیچ نما، تازه‌سازی و افزودن مکان */}
@ -441,9 +428,7 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
</div> </div>
</div> </div>
{/* ─────────────────────────────────────────────────────────────────────────────
۴. نوار ابزار عملیات دسته‌جمعی (Bulk Actions Toolbar)
───────────────────────────────────────────────────────────────────────────── */}
{/* ۳. نوار ابزار عملیات دسته‌جمعی */}
{selectedIds.size > 0 && ( {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 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"> <div className="flex items-center gap-2 text-xs font-bold text-foreground">
@ -453,27 +438,7 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
<span>مکان انتخاب‌شده برای عملیات گروهی</span> <span>مکان انتخاب‌شده برای عملیات گروهی</span>
</div> </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 <Button
size="sm" size="sm"
variant="outline" 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" 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" /> <Ic name="trash" className="size-3" />
<span>حذف دسته‌جمعی</span>
<span>حذف دسته‌جمعی از دیتابیس</span>
</Button> </Button>
<Button <Button
@ -496,9 +461,7 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
</div> </div>
)} )}
{/* ─────────────────────────────────────────────────────────────────────────────
۵. نمایش لیست داده‌ها (Shimmer Loading / Grid / Table / Empty State)
───────────────────────────────────────────────────────────────────────────── */}
{/* ۴. نمایش لیست داده‌ها */}
{isLoading ? ( {isLoading ? (
<Shimmer rows={6} /> <Shimmer rows={6} />
) : errorMessage ? ( ) : errorMessage ? (
@ -517,7 +480,7 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
) : filteredLocations.length === 0 ? ( ) : filteredLocations.length === 0 ? (
<div className="rounded-2xl border border-dashed border-border-soft p-12 text-center text-grey-3 space-y-3"> <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" /> <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 className="text-xs max-w-sm mx-auto">
می‌توانید فیلترها را ریست کنید یا با کلیک روی «افزودن مکان جدید» موقعیت مکانی جدیدی به سامانه بیفزایید. می‌توانید فیلترها را ریست کنید یا با کلیک روی «افزودن مکان جدید» موقعیت مکانی جدیدی به سامانه بیفزایید.
</p> </p>
@ -529,7 +492,6 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
setSelectedCitySlug('ALL') setSelectedCitySlug('ALL')
setSelectedCountryCode('ALL') setSelectedCountryCode('ALL')
setSelectedCategorySlug('ALL') setSelectedCategorySlug('ALL')
setStatusFilter('ALL')
}} }}
className="text-xs rounded-xl" className="text-xs rounded-xl"
> >
@ -537,19 +499,22 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
</Button> </Button>
</div> </div>
) : viewMode === 'grid' ? ( ) : viewMode === 'grid' ? (
/* نمای گرید کارت‌ها */
/* نمای کارت‌ها (Grid View) */
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filteredLocations.map((item) => { {filteredLocations.map((item) => {
const isSelected = selectedIds.has(item.id) const isSelected = selectedIds.has(item.id)
const isActive = item.is_active ?? true
const isDeletingThis = deletingId === item.id
return ( return (
<div <div
key={item.id} 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 ${ 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' 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 justify-between gap-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Checkbox <Checkbox
@ -562,21 +527,10 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
</Badge> </Badge>
</div> </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>
{/* عنوان و مشخصات اصلی */}
{/* عنوان و آدرس */}
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-start justify-between gap-2"> <div className="flex items-start justify-between gap-2">
<h3 <h3
@ -588,25 +542,45 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
> >
{item.title} {item.title}
</h3> </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> </div>
{item.address && (
{item.address ? (
<p className="text-[11px] text-grey-3 line-clamp-1"> <p className="text-[11px] text-grey-3 line-clamp-1">
{item.address} {item.address}
</p> </p>
)}
) : item.description ? (
<p className="text-[11px] text-grey-3 line-clamp-1">
{item.description}
</p>
) : null}
</div> </div>
{/* فوتر کارت: دسته‌بندی و دکمه‌های اکشن */}
{/* فوتر کارت: دسته‌بندی، تاگل وضعیت و دکمه‌های اکشن */}
<div className="flex items-center justify-between text-xs pt-2.5 border-t border-border-soft/60"> <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"> <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> </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 <Button
size="sm" size="sm"
variant="ghost" 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-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> <th className="p-3.5 text-end font-bold">عملیات</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border-soft"> <tbody className="divide-y divide-border-soft">
{filteredLocations.map((item) => { {filteredLocations.map((item) => {
const isSelected = selectedIds.has(item.id) const isSelected = selectedIds.has(item.id)
const isActive = item.is_active ?? true
const isDeletingThis = deletingId === item.id
return ( return (
<tr <tr
key={item.id} 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"> <td className="p-3.5 text-center">
<Checkbox <Checkbox
@ -711,26 +690,36 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
<td className="p-3.5 text-grey-2"> <td className="p-3.5 text-grey-2">
{item.category?.name || 'سایر اماکن'} {item.category?.name || 'سایر اماکن'}
</td> </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>
<td className="p-3.5 text-center"> <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 <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> </div>
</td> </td>
<td className="p-3.5 text-end"> <td className="p-3.5 text-end">
@ -779,19 +768,18 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
</div> </div>
)} )}
{/* ─────────────────────────────────────────────────────────────────────────────
۶. دیالوگ‌ها و شیت‌های کناری
───────────────────────────────────────────────────────────────────────────── */}
{/* ۵. دیالوگ‌ها و شیت‌های کناری */}
<LocationDetailSheet <LocationDetailSheet
location={detailLocation} location={detailLocation}
open={isDetailOpen} open={isDetailOpen}
onOpenChange={setIsDetailOpen} onOpenChange={setIsDetailOpen}
onToggleStatus={handleToggleStatus}
isLoadingStatus={detailLocation ? loadingLocationIds.has(detailLocation.id) : false}
onEdit={(loc) => { onEdit={(loc) => {
setEditingLocation(loc) setEditingLocation(loc)
setIsCreateOpen(true) setIsCreateOpen(true)
}} }}
onDelete={handleDeleteSingle}
onToggleActive={handleToggleActive}
isToggling={togglingId === detailLocation?.id}
/> />
<CreateLocationDialog <CreateLocationDialog
@ -800,6 +788,7 @@ export function ZayerGuideServiceView({ service }: ZayerGuideServiceViewProps) {
categories={categories} categories={categories}
countries={countries} countries={countries}
editingLocation={editingLocation} editingLocation={editingLocation}
isSaving={isSaving}
onSave={handleSaveLocation} onSave={handleSaveLocation}
/> />
</div> </div>

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

@ -9,6 +9,8 @@ import type {
VerseItem, VerseItem,
CreateShrineInput, CreateShrineInput,
CreateDuaInput, CreateDuaInput,
UpdateDuaInput,
DuaFormDataResponse,
} from '../types/trip-etiquette' } 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) { while (nextUrl) {
const res: any = await apiFetch<any>(nextUrl) const res: any = await apiFetch<any>(nextUrl)
if (Array.isArray(res)) { if (Array.isArray(res)) {
return res
allResults = res
break
} }
if (res && Array.isArray(res.results)) { if (res && Array.isArray(res.results)) {
allResults = [...allResults, ...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> { 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> { 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', method: 'POST',
body: { body: {
name: input.name, name: input.name,
category_ids: input.category_ids, category_ids: input.category_ids,
shrine_ids: input.shrine_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( export async function updateDua(
index: number, 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> { ): 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, CityGuideCategory,
CityGuideCountry, CityGuideCountry,
CreateCityGuideInput, CreateCityGuideInput,
UpdateCityGuideInput,
} from '../types/zayer-guide' } 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 if (res && Array.isArray(res.results)) return res.results
return [] return []
} catch (err) { } 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 if (res && Array.isArray(res.results)) return res.results
return [] return []
} catch (err) { } 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?: { export async function fetchCityGuides(params?: {
city_slug?: string city_slug?: string
@ -108,6 +50,7 @@ export async function fetchCityGuides(params?: {
}): Promise<CityGuideItem[]> { }): Promise<CityGuideItem[]> {
const query = new URLSearchParams() const query = new URLSearchParams()
query.set('show_all', 'true') 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?.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?.country_code && params.country_code !== 'ALL') query.set('country_code', params.country_code)
if (params?.category && params.category !== 'ALL') query.set('category', params.category) 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 queryString = query.toString()
const path = queryString ? `cityguide/?${queryString}` : 'cityguide/' 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 { try {
const res = await apiFetch<CityGuideItem>(`cityguide/${slug}/`)
const res = await apiFetch<CityGuideItem>(`cityguide/${slugOrId}/`)
return res return res
} catch (err) { } catch (err) {
console.warn(`Failed to fetch cityguide detail for ${slug}:`, err)
console.warn(`Failed to fetch cityguide detail for ${slugOrId}:`, err)
return null 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 name: string
country_name: string country_name: string
}> }>
city_ids?: number[]
category_ids?: number[]
shrine_ids?: number[]
verses_count: number verses_count: number
is_active?: boolean
} }
export interface VerseItem { export interface VerseItem {
@ -128,6 +132,12 @@ export interface CreateDuaInput {
category_ids?: number[] category_ids?: number[]
shrine_ids?: number[] shrine_ids?: number[]
city_ids?: number[] city_ids?: number[]
is_active?: boolean
}
export interface CityAllocationInput {
city_id: number
category_ids: number[]
} }
export interface UpdateDuaInput { export interface UpdateDuaInput {
@ -135,6 +145,8 @@ export interface UpdateDuaInput {
category_ids?: number[] category_ids?: number[]
shrine_ids?: number[] shrine_ids?: number[]
city_ids?: number[] city_ids?: number[]
city_allocations?: CityAllocationInput[]
is_active?: boolean
} }
export interface PilgrimageCityItem { export interface PilgrimageCityItem {
@ -153,3 +165,25 @@ export interface PilgrimageCityItem {
has_train_station?: boolean has_train_station?: boolean
has_road_access?: 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 slug: string
} | null } | null
category?: CityGuideCategory | null category?: CityGuideCategory | null
category_id?: number
country_id?: number
city_id?: number
languages?: CityGuideLanguage[] languages?: CityGuideLanguage[]
average_score: number
translations?: Record<string, { title: string; description: string; address: string }>
average_score?: number
image?: CityGuideImage | null image?: CityGuideImage | null
images?: CityGuideImage[] images?: CityGuideImage[]
latitude: number latitude: number
@ -81,28 +85,33 @@ export interface CityGuideItem {
} }
export interface CreateCityGuideInput { 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 category_id: number
country_id: number country_id: number
city_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 is_active?: boolean
image_url?: string image_url?: string
} }
export type UpdateCityGuideInput = Partial<CreateCityGuideInput>
export interface ZayerGuideStats { export interface ZayerGuideStats {
totalLocations: number totalLocations: number
activeLocations: number
inactiveLocations: number
countriesCount: number countriesCount: number
citiesCount: number citiesCount: number
categoriesCount: number categoriesCount: number
avgRating: number avgRating: number
} }

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

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

12
src/i18n/index.ts

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

219
src/locales/ar.json

@ -1,15 +1,15 @@
{ {
"navigation": { "navigation": {
"brand": "لوحة إدارة عقيلة",
"brand": "لوحة تحكم عقيلة",
"brandShort": "عقيلة", "brandShort": "عقيلة",
"dashboard": "لوحة القيادة",
"dashboardTitle": "لوحة إدارة النظام",
"tours": "الجولات",
"allTours": "جميع الجولات",
"ongoingTours": "الجولات الجارية",
"dashboard": "الرئيسية",
"dashboardTitle": "لوحة التحكم",
"tours": "الرحلات",
"allTours": "جميع الرحلات",
"ongoingTours": "الرحلات الجارية",
"tourOrders": "الطلبات والحجوزات", "tourOrders": "الطلبات والحجوزات",
"services": "مركز الخدمات (الكل)", "services": "مركز الخدمات (الكل)",
"simcard": "شرائح الاتصال (Simcard)",
"simcard": "شريحة الاتصال (Simcard)",
"zayerGuide": "دليل الزائر (Zayer Guide)", "zayerGuide": "دليل الزائر (Zayer Guide)",
"exchange": "الصرافة الإلكترونية (Exchange)", "exchange": "الصرافة الإلكترونية (Exchange)",
"duas": "الأدعية والزيارات (Duas)", "duas": "الأدعية والزيارات (Duas)",
@ -18,28 +18,84 @@
"cities": "المدن المقدسة", "cities": "المدن المقدسة",
"store": "متجر معصومة (Store)", "store": "متجر معصومة (Store)",
"storeProducts": "المنتجات", "storeProducts": "المنتجات",
"storeCategories": "الفئات",
"costEstimate": "تقدير التكاليف (Cost Estimate)",
"support": "الدعم والاستشارات (Support)",
"storeCategories": "التصنيفات",
"costEstimate": "تقدير التكلفة (Cost Estimate)",
"support": "الدعم الفني (Support)",
"supporters": "فريق الدعم (Supporters)", "supporters": "فريق الدعم (Supporters)",
"faq": "الأسئلة الشائعة (FAQ)", "faq": "الأسئلة الشائعة (FAQ)",
"faqCategories": "التصنيفات الموضوعية", "faqCategories": "التصنيفات الموضوعية",
"faqQuestions": "الأسئلة والأجوبة", "faqQuestions": "الأسئلة والأجوبة",
"consultant": "الدعم والاستشارات (Support)",
"consultant": "الدعم الفني (Support)",
"billing": "الفواتير والمالية", "billing": "الفواتير والمالية",
"settings": "الإعدادات", "settings": "الإعدادات",
"logout": "تسجيل الخروج", "logout": "تسجيل الخروج",
"soon": "قريباً", "soon": "قريباً",
"adminRole": "المدير العام للنظام"
"adminRole": "مدير النظام العام"
}, },
"topbar": { "topbar": {
"serverOnline": "الخادم متصل", "serverOnline": "الخادم متصل",
"serverOffline": "انقطع الاتصال بالخادم", "serverOffline": "انقطع الاتصال بالخادم",
"theme": "المظهر", "theme": "المظهر",
"language": "تغيير اللغة",
"language": "اللغة",
"selectLanguage": "اختر لغة النظام", "selectLanguage": "اختر لغة النظام",
"search": "بحث..." "search": "بحث..."
}, },
"auth": {
"loginTitle": "تسجيل الدخول إلى لوحة الإدارة",
"loginDescription": "أدخل بيانات الاعتماد الخاصة بك للوصول إلى بوابة الإدارة",
"demoBoxTitle": "حساب تجريبي جاهز:",
"autoFill": "تعبئة تلقائية",
"emailLabel": "البريد الإلكتروني للحساب",
"emailPlaceholder": "you@example.com",
"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": { "common": {
"save": "حفظ التغييرات", "save": "حفظ التغييرات",
"cancel": "إلغاء", "cancel": "إلغاء",
@ -65,7 +121,92 @@
"showing": "عرض", "showing": "عرض",
"to": "إلى", "to": "إلى",
"of": "من", "of": "من",
"testSyncNotice": "هذه رسالة تجريبية لاختبار مزامنة اللغات."
"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": { "billing": {
"title": "إدارة الفواتير والمدفوعات", "title": "إدارة الفواتير والمدفوعات",
@ -74,11 +215,11 @@
"pendingReview": "بانتظار مراجعة الإيصال", "pendingReview": "بانتظار مراجعة الإيصال",
"awaitingPayment": "بانتظار دفع العميل", "awaitingPayment": "بانتظار دفع العميل",
"approvedPayments": "المدفوعات المعتمدة", "approvedPayments": "المدفوعات المعتمدة",
"rejectedOrExpired": "مرفوض / منتهي",
"searchPlaceholder": "البحث في الفواتير (اسم المستخدم، البريد، رقم البطاقة، رقم الفاتورة)...",
"rejectedOrExpired": "مرفوض / منتهي الصلاحية",
"searchPlaceholder": "البحث في الفواتير (اسم العميل، البريد، رقم البطاقة، رقم الفاتورة)...",
"statusFilter": "حالة الفاتورة", "statusFilter": "حالة الفاتورة",
"serviceFilter": "نوع الخدمة", "serviceFilter": "نوع الخدمة",
"clearFilters": "مسح الفلاتر",
"clearFilters": "إعادة ضبط التصفية",
"invoiceList": "قائمة الفواتير", "invoiceList": "قائمة الفواتير",
"customer": "العميل", "customer": "العميل",
"amount": "المبلغ", "amount": "المبلغ",
@ -91,42 +232,42 @@
"reject": "رفض الفاتورة", "reject": "رفض الفاتورة",
"rejectTitle": "رفض الفاتورة", "rejectTitle": "رفض الفاتورة",
"rejectReason": "سبب رفض الفاتورة / الإيصال البنكي:", "rejectReason": "سبب رفض الفاتورة / الإيصال البنكي:",
"rejectReasonPlaceholder": "مثال: الإيصال غير واضح، المبلغ غير مطابق، رقم المعاملة غير صحيح...",
"rejectNotice": "سيتم عرض هذا التوضيح في تطبيق المسافر ضمن تفاصيل الفاتورة.",
"submitReject": "تسجيل ورفض الفاتورة",
"submitting": "جاري التسجيل...",
"statusPending": "قيد مراجعة الإيصال",
"rejectReasonPlaceholder": "مثال: صورة الإيصال غير واضحة، المبلغ غير متطابق، الرقم المرجعي غير صالح...",
"rejectNotice": "سيظهر هذا الشرح للمسافر في تفاصيل الفاتورة.",
"submitReject": "تأكيد ورفض الفاتورة",
"submitting": "جاري الإرسال...",
"statusPending": "بانتظار مراجعة الإيصال",
"statusAwaiting": "بانتظار الدفع", "statusAwaiting": "بانتظار الدفع",
"statusApproved": "تم الدفع والاعتماد",
"statusApproved": "تم اعتماد الدفع",
"statusRejected": "مرفوض", "statusRejected": "مرفوض",
"statusExpired": "منتهي الصلاحية", "statusExpired": "منتهي الصلاحية",
"invoice": "فاتورة", "invoice": "فاتورة",
"invoices": "فواتير",
"invoices": "الفواتير",
"id": "المعرف", "id": "المعرف",
"viewDetails": "عرض التفاصيل", "viewDetails": "عرض التفاصيل",
"viewReceipt": "عرض إيصال الدفع",
"viewReceipt": "عرض الإيصال",
"noReceiptUploaded": "لم يتم رفع إيصال", "noReceiptUploaded": "لم يتم رفع إيصال",
"serviceTour": "جولات الزيارة",
"serviceSimcard": "شرائح الاتصال",
"serviceShop": "متجر الهدايا",
"serviceExchange": "الصرافة (تصريف)",
"serviceCustomTrip": "رحلة خاصة",
"serviceTour": "رحلة زيارة",
"serviceSimcard": "شريحة اتصال",
"serviceShop": "متجر معصومة",
"serviceExchange": "صرافة (تصريف)",
"serviceCustomTrip": "رحلة مخصصة",
"allStatuses": "جميع الحالات", "allStatuses": "جميع الحالات",
"allServices": "جميع الخدمات", "allServices": "جميع الخدمات",
"noFactorsFound": "لم يتم العثور على فواتير.", "noFactorsFound": "لم يتم العثور على فواتير.",
"noFactorsFilter": "لم يتم العثور على نتائج مطابقة للفلاتر الحالية. يمكنك مسح الفلاتر.",
"noFactorsEmpty": "لم تصدر أي فواتير في النظام بعد.",
"confirmClearReceipt": "هل أنت متأكد من حذف صورة الإيصال المرفوعة لهذا الفاتورة؟",
"receiptClearedSuccess": "تم مسح إيصال الدفع بنجاح.",
"noFactorsFilter": "لا توجد نتائج تطابق معايير التصفية الحالية.",
"noFactorsEmpty": "لم يتم إصدار أي فواتير في النظام بعد.",
"confirmClearReceipt": "هل أنت متأكد من حذف صورة الإيصال المرفوعة؟",
"receiptClearedSuccess": "تم حذف صورة الإيصال بنجاح.",
"tabAll": "جميع المعاملات", "tabAll": "جميع المعاملات",
"tabTour": "طلبات وحجوزات الجولات",
"tabTour": "طلبات وحجوزات الرحلات",
"tabSimcard": "شرائح الاتصال", "tabSimcard": "شرائح الاتصال",
"tabExchange": "الصرافة والعملات",
"tabExchange": "العملات والصرافة",
"tabShop": "متجر الهدايا", "tabShop": "متجر الهدايا",
"tabCustomTrip": "الرحلات الخاصة", "tabCustomTrip": "الرحلات الخاصة",
"coreTourBadge": "المحور الأساسي",
"liveStatus": "الحالة اللحظية",
"quickFilter": "فلترة الحالة"
"coreTourBadge": "الخدمة الأساسية",
"liveStatus": "الحالة المباشرة",
"quickFilter": "تصفية الحالة"
}, },
"languages": { "languages": {
"fa": "فارسی", "fa": "فارسی",

143
src/locales/en.json

@ -40,6 +40,62 @@
"selectLanguage": "Select System Language", "selectLanguage": "Select System Language",
"search": "Search..." "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": "you@example.com",
"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": { "common": {
"save": "Save Changes", "save": "Save Changes",
"cancel": "Cancel", "cancel": "Cancel",
@ -65,7 +121,92 @@
"showing": "Showing", "showing": "Showing",
"to": "to", "to": "to",
"of": "of", "of": "of",
"testSyncNotice": "This is a test message for language synchronization verification."
"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": { "billing": {
"title": "Invoices & Billing Management", "title": "Invoices & Billing Management",

201
src/locales/fa.json

@ -40,6 +40,62 @@
"selectLanguage": "انتخاب زبان سامانه", "selectLanguage": "انتخاب زبان سامانه",
"search": "جستجو..." "search": "جستجو..."
}, },
"auth": {
"loginTitle": "ورود به پنل مدیریت و داشبورد",
"loginDescription": "برای دسترسی به پرتال مدیریت، اطلاعات ورود خود را وارد نمایید",
"demoBoxTitle": "حساب آزمایشی آماده:",
"autoFill": "پر کردن خودکار",
"emailLabel": "ایمیل حساب کاربری",
"emailPlaceholder": "you@example.com",
"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": { "common": {
"save": "ذخیره تغییرات", "save": "ذخیره تغییرات",
"cancel": "انصراف", "cancel": "انصراف",
@ -65,7 +121,92 @@
"showing": "نمایش", "showing": "نمایش",
"to": "تا", "to": "تا",
"of": "از", "of": "از",
"testSyncNotice": "این یک پیام آزمایشی برای تست هماهنگی زبان‌هاست"
"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": { "billing": {
"title": "مدیریت صورت‌حساب‌ها و فاکتورها", "title": "مدیریت صورت‌حساب‌ها و فاکتورها",
@ -75,24 +216,24 @@
"awaitingPayment": "در انتظار پرداخت مشتری", "awaitingPayment": "در انتظار پرداخت مشتری",
"approvedPayments": "پرداخت‌های تایید شده", "approvedPayments": "پرداخت‌های تایید شده",
"rejectedOrExpired": "رد شده / منقضی", "rejectedOrExpired": "رد شده / منقضی",
"searchPlaceholder": "جستجو در فاکتورها (نام کاربر، ایمیل، شماره کارت، شماره فاکتور)...",
"statusFilter": "وضعیت فاکتور",
"searchPlaceholder": "جستجو در صورت‌حساب‌ها (نام مشتری، ایمیل، شماره کارت، شماره فاکتور)...",
"statusFilter": "وضعیت صورت‌حساب",
"serviceFilter": "نوع سرویس", "serviceFilter": "نوع سرویس",
"clearFilters": "پاکسازی فیلترها",
"invoiceList": "فهرست فاکتورها",
"clearFilters": "پاک کردن فیلترها",
"invoiceList": "فهرست صورت‌حساب‌ها",
"customer": "مشتری", "customer": "مشتری",
"amount": "مبلغ", "amount": "مبلغ",
"service": "سرویس", "service": "سرویس",
"date": "تاریخ صدور", "date": "تاریخ صدور",
"receipt": "فیش پرداختی",
"receipt": "فیش پرداخت",
"hasReceipt": "مشاهده فیش", "hasReceipt": "مشاهده فیش",
"noReceipt": "فاقد فیش",
"noReceipt": "ندارد",
"approve": "تایید پرداخت", "approve": "تایید پرداخت",
"reject": "رد صورت‌حساب",
"reject": "رد فاکتور",
"rejectTitle": "رد صورت‌حساب", "rejectTitle": "رد صورت‌حساب",
"rejectReason": "دلیل رد صورت‌حساب / فیش پرداختی:",
"rejectReasonPlaceholder": "مثال: فیش ناخوانا است، مبلغ واریزی مغایرت دارد، شماره رهگیری نامعتبر است...",
"rejectNotice": "این توضیحات در اپلیکیشن مسافر / کلاینت در بخش جزئیات صورت‌حساب نمایش داده می‌شود.",
"rejectReason": "علت رد فاکتور / فیش بانکی:",
"rejectReasonPlaceholder": "مثال: تصویر فیش ناخواناست، مبلغ فیش با فاکتور همخوانی ندارد، کد رهگیری نامعتبر است...",
"rejectNotice": "این توضیح به مسافر در بخش وضعیت فاکتور نمایش داده خواهد شد.",
"submitReject": "ثبت و رد فاکتور", "submitReject": "ثبت و رد فاکتور",
"submitting": "در حال ثبت...", "submitting": "در حال ثبت...",
"statusPending": "در انتظار بررسی فیش", "statusPending": "در انتظار بررسی فیش",
@ -100,32 +241,32 @@
"statusApproved": "پرداخت تایید شده", "statusApproved": "پرداخت تایید شده",
"statusRejected": "رد شده", "statusRejected": "رد شده",
"statusExpired": "منقضی شده", "statusExpired": "منقضی شده",
"invoice": "فاکتور",
"invoices": "فاکتور",
"invoice": "صورت‌حساب",
"invoices": "صورت‌حساب‌ها",
"id": "شناسه", "id": "شناسه",
"viewDetails": "مشاهده جزئیات", "viewDetails": "مشاهده جزئیات",
"viewReceipt": "مشاهده فیش واریزی",
"noReceiptUploaded": "فیش واریزی بارگذاری نشده",
"serviceTour": "تورهای زیارتی",
"viewReceipt": "مشاهده فیش",
"noReceiptUploaded": "فیشی بارگذاری نشده است",
"serviceTour": "تور زیارتی",
"serviceSimcard": "سیم‌کارت", "serviceSimcard": "سیم‌کارت",
"serviceShop": "فروشگاه سوغات",
"serviceExchange": "صرافی (تسریف)",
"serviceShop": "فروشگاه معصومه",
"serviceExchange": "صرافی (تصریف)",
"serviceCustomTrip": "سفر اختصاصی", "serviceCustomTrip": "سفر اختصاصی",
"allStatuses": "همه وضعیت‌ها", "allStatuses": "همه وضعیت‌ها",
"allServices": "همه سرویس‌ها", "allServices": "همه سرویس‌ها",
"noFactorsFound": "صورت‌حسابی یافت نشد.",
"noFactorsFilter": "با فیلترهای جستجوی فعلی موردی پیدا نشد. می‌توانید فیلترها را پاکسازی نمایید.",
"noFactorsEmpty": "هنوز هیچ صورت‌حسابی در سیستم صادر نشده است.",
"confirmClearReceipt": "آیا از حذف تصویر فیش بارگذاری‌شده برای این صورت‌حساب اطمینان دارید؟",
"receiptClearedSuccess": "فیش واریزی با موفقیت پاک شد.",
"tabAll": "همه تراکنش‌ها",
"tabTour": "سفارشات و رزروهای تور",
"tabSimcard": "خرید سیم‌کارت",
"tabExchange": "صرافی و ارز",
"noFactorsFound": "هیچ صورت‌حسابی یافت نشد.",
"noFactorsFilter": "رکوردی با فیلترهای فعلی همخوانی ندارد. می‌توانید فیلترها را پاک کنید.",
"noFactorsEmpty": "تاکنون هیچ صورت‌حسابی در سیستم صادر نشده است.",
"confirmClearReceipt": "آیا از حذف تصویر فیش بارگذاری شده اطمینان دارید؟",
"receiptClearedSuccess": "تصویر فیش پرداخت با موفقیت حذف شد.",
"tabAll": "کل تراکنش‌ها",
"tabTour": "سفارشات و رزرو تور",
"tabSimcard": "سیم‌کارت",
"tabExchange": "ارز و صرافی",
"tabShop": "فروشگاه سوغات", "tabShop": "فروشگاه سوغات",
"tabCustomTrip": "سفر اختصاصی",
"coreTourBadge": "محور اصلی",
"liveStatus": "وضعیت لحظه‌ای",
"tabCustomTrip": "سفرهای اختصاصی",
"coreTourBadge": "هسته اصلی",
"liveStatus": "وضعیت زنده",
"quickFilter": "فیلتر وضعیت" "quickFilter": "فیلتر وضعیت"
}, },
"languages": { "languages": {

Loading…
Cancel
Save