Browse Source

feat: add core panel components, navigation layout, and services management views

master
Muhammad A. Ghorbani 5 hours ago
parent
commit
025bdd730c
  1. 50
      guide/aqila_panel_ui_design_system_standard.md
  2. 16
      src/components/layout/AppSidebar.tsx
  3. 8
      src/config/navigation.ts
  4. 10
      src/features/billing/components/BillingDetailModal.tsx
  5. 79
      src/features/billing/components/RejectFactorDialog.tsx
  6. 76
      src/features/services/ServicesPage.tsx
  7. 859
      src/features/services/components/CategoriesServiceView.tsx
  8. 946
      src/features/services/components/DuasServiceView.tsx
  9. 1271
      src/features/services/components/FaqServiceView.tsx
  10. 614
      src/features/services/components/ShrinesServiceView.tsx
  11. 111
      src/features/services/services/faq-api.ts
  12. 198
      src/features/services/services/trip-etiquette-api.ts
  13. 68
      src/features/services/types/faq.ts
  14. 105
      src/features/services/types/trip-etiquette.ts
  15. 1
      src/features/tours/components/TourStatusBadge.tsx
  16. 3
      src/icons.tsx

50
guide/aqila_panel_ui_design_system_standard.md

@ -204,25 +204,63 @@
## ۸. استاندارد لودینگ و اسکلتون (Shimmer / Loading Skeleton Standard)
> [!IMPORTANT]
> **الگوی واحد لودینگ:**
> مطابق مصوبه سیستم، لودینگ تمامی صفحات و جداول پنل باید منحصراً از کامپوننت بازاستفاده‌پذیر **`Shimmer`** (برگرفته از صفحه صورت‌حساب‌ها) استفاده کنند و از قراردادن اسپینرهای ناهماهنگ خودداری شود.
### مشخصات استاندارد Shimmer:
> **الگوی واحد و سراسری لودینگ (Unified Shimmer Standard):**
> لودینگ تمامی صفحات، جداول و کارت‌ها در سامانه عقیله (هم در پنل وب و هم در اپلیکیشن فلاتر) باید منحصراً از اسکلتون یکپارچه شیمر با پالت خنثی (Neutral Slate) و ریتم پالس متناسب تبعیت کنند. هرگونه اسپینر ناهماهنگ، کانتینرهای بدون انیمیشن یا استفاده از رنگ‌های متفرقه در لودینگ اکیداً ممنوع است.
### ۸.۱. پالت رنگی و زمان‌بندی استاندارد شیمر (Shimmer Color Tokens & Timing)
برای جلوگیری از چندپارگی بصری و پرهیز از لودینگ‌های کدر یا مدادرنگی، رنگ‌های شیمر در تمام کلاینت‌ها قفل شده است:
- **تم روشن (Light Mode):**
- رنگ پایه (Base): `#E2E8F0` (توکن `outline` / `muted-200`)
- رنگ درخشش (Highlight): `#F8FAFC` (توکن `muted-50`)
- **تم تاریک (Dark Mode):**
- رنگ پایه (Base): `#22252C` (توکن `card-base`)
- رنگ درخشش (Highlight): `#333742` (توکن `card-highlight`)
- **دوره تناوب انیمیشن (Cycle Period):** دقیقاً `1500ms` (۱.۵ ثانیه) برای ایجاد پالس موجی لطیف، طبیعی و یکنواخت.
### ۸.۲. مشخصات پیاده‌سازی در وب و پنل مدیریت (`aqila_panel`)
- **کامپوننت مرجع:** `@/components/shared/Shimmer`
- **کانتینر:** `p-8 space-y-3` (یا درون `Card` با بوردر استاندارد).
- **ردیف‌ها:** `h-12 bg-muted/30 animate-pulse rounded-lg`.
- **رفتار در تم لایت و دارک:** پالس ملایم با کنتراست طبیعی متناسب با توکن `--muted`.
```tsx
// نحوه فراخوانی استاندارد در صفحات
// نحوه فراخوانی استاندارد در صفحات پنل
import { Shimmer } from '@/components/shared/Shimmer'
{isLoading ? (
<Shimmer withCard cardHeaderTitle="عنوان لیست" rows={5} />
<Shimmer withCard cardHeaderTitle="صورت‌حساب‌ها" rows={5} />
) : (
<TableContent />
)}
```
### ۸.۳. مشخصات پیاده‌سازی در اپلیکیشن فلاتر (`aqila_flutter`)
- **مرجع واحد هماهنگ‌کننده موج (Single Source of Truth):** ویجت **`AppShimmer`** در مسیر:
`lib/core/ui_kit/atoms/app_shimmer.dart`
- **ویجت‌های اتمیک درون اسکلتون:**
1. **`ShimmerBox`:** جهت ترسیم کارت‌ها، بنرها، تصاویر و دکمه‌ها (`AppRadii.radiusSm` تا `AppRadii.radiusLg`).
2. **`ShimmerLine`:** جهت ترسیم سطرهای متنی و عناوین با ارتفاع پیش‌فرض `14px` و انحنای `4px`.
3. **`ShimmerCircle`:** جهت ترسیم آواتارها، آیکون‌ها و بج‌های دایره‌ای.
- **وضعیت ویجت `ShimmerWidget`:**
ویجت `ShimmerWidget` در مسیر `lib/core/widgets/shimmer_widget.dart` صرفاً یک **آداپتور سازگاری گذشته‌نگر (Legacy Adapter)** است تا کدهای قدیمی متوقف نشوند. در کلیه فیچرها، صفحات و پول‌ریکوئست‌های جدید باید **منحصراً از `AppShimmer`** استفاده شود.
```dart
// نحوه فراخوانی استاندارد در اپلیکیشن فلاتر
import 'package:tourism/core/ui_kit/atoms/app_shimmer.dart';
return const AppShimmer(
child: Column(
children: [
ShimmerBox(width: double.infinity, height: 160, borderRadius: 16),
SizedBox(height: 12),
ShimmerLine(width: 140, height: 16),
SizedBox(height: 8),
ShimmerLine(width: 220, height: 12),
],
),
);
```
---
## ۹. دسترسی‌پذیری و تم‌های تاریک و روشن (Accessibility, Light & Dark Mode)

16
src/components/layout/AppSidebar.tsx

@ -42,22 +42,24 @@ function NavItem({ item }: { item: NavItemConfig }) {
item.children?.some((c) =>
c.to === '/tours' || c.to === '/services' ? pathname === c.to : pathname.startsWith(c.to)
)
const [isOpen, setIsOpen] = React.useState<boolean>(Boolean(isChildActive))
const isAlwaysOpen = Boolean(item.alwaysOpen)
const [isOpen, setIsOpen] = React.useState<boolean>(isAlwaysOpen || Boolean(isChildActive))
React.useEffect(() => {
if (isChildActive) {
if (isAlwaysOpen || isChildActive) {
setIsOpen(true)
}
}, [isChildActive])
}, [isAlwaysOpen, isChildActive])
if (hasChildren && item.children) {
return (
<SidebarMenuItem className="flex flex-col">
<SidebarMenuButton
onClick={() => setIsOpen((prev) => !prev)}
onClick={isAlwaysOpen ? undefined : () => setIsOpen((prev) => !prev)}
className={cn(
'w-full justify-between transition-colors',
isChildActive && 'text-primary font-semibold'
isChildActive && 'text-primary font-semibold',
isAlwaysOpen && 'cursor-default select-none hover:bg-transparent'
)}
tooltip={item.title}
>
@ -67,14 +69,16 @@ function NavItem({ item }: { item: NavItemConfig }) {
</div>
<div className="flex items-center gap-1.5 ms-auto shrink-0">
<Tail badge={item.badge} soon={item.soon} />
{!isAlwaysOpen && (
<Ic
name={isOpen ? 'chevUp' : 'chevDown'}
className="size-3.5 text-grey-3 transition-transform shrink-0"
/>
)}
</div>
</SidebarMenuButton>
{isOpen && (
{(isAlwaysOpen || isOpen) && (
<div className="mr-5 mt-1 flex flex-col gap-1 border-r border-border/40 pr-2">
{item.children.map((sub) => {
const isSubActive =

8
src/config/navigation.ts

@ -13,6 +13,7 @@ export interface NavItemConfig {
icon: IconName
badge?: string
soon?: boolean
alwaysOpen?: boolean
children?: NavSubItemConfig[]
}
@ -38,15 +39,18 @@ export const navigationConfig: NavGroupConfig[] = [
{
title: 'خدمات سامانه',
icon: 'sparkles',
alwaysOpen: true,
children: [
{ title: 'مرکز خدمات (همه)', to: '/services', icon: 'grid' },
{ title: 'سیم‌کارت (Simcard)', to: '/services/simcard', icon: 'simcard' },
{ title: 'راهنمای زائر (Zayer Guide)', to: '/services/zayer-guide', icon: 'guide' },
{ title: 'صرافی آنلاین (Exchange)', to: '/services/exchange', icon: 'exchange' },
{ title: 'آداب سفر (Etiquette)', to: '/services/trip-etiquette', icon: 'etiquette' },
{ title: 'مشاوره (Consultant)', to: '/services/consultant', icon: 'headset' },
{ title: 'ادعیه و زیارات (Duas)', to: '/services/duas', icon: 'book' },
{ title: 'اماکن زیارتی (Shrines)', to: '/services/shrines', icon: 'shrine' },
{ title: 'فروشگاه معصومه (Store)', to: '/services/store', icon: 'store' },
{ title: 'تخمین هزینه (Cost Estimate)', to: '/services/cost-estimate', icon: 'calculator' },
{ title: 'سوالات متداول (FAQ)', to: '/services/faq', icon: 'helpCircle' },
{ title: 'پشتیبانی (Support)', to: '/services/consultant', icon: 'headset' },
],
},
{ title: 'صورت‌حساب‌ها و مالی', to: '/billing', icon: 'receipt' },

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

@ -88,6 +88,16 @@ export function BillingDetailModal({
</div>
)}
{factor.status === 'expired' && (
<div className="bg-slate-500/10 border border-slate-500/20 rounded-xl p-4 flex items-start gap-3 text-slate-700 dark:text-slate-300">
<Ic name="clock" className="size-5 shrink-0 mt-0.5 text-slate-500" />
<div>
<h4 className="font-bold text-xs mb-1">دلیل انقضا:</h4>
<p className="text-xs leading-relaxed">{factor.rejected_description || 'انقضای مهلت پرداخت (سپری شدن ۷۲ ساعت)'}</p>
</div>
</div>
)}
{factor.expiration_date && (
<div className="bg-muted/40 border border-border/40 rounded-xl p-3 flex items-center justify-between text-xs">
<span className="text-muted-foreground flex items-center gap-1.5">

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

@ -45,21 +45,55 @@ export function RejectFactorDialog({
return (
<Dialog open={open} onOpenChange={(val) => !val && onClose()}>
<DialogContent className="max-w-md border border-border/60 bg-background/95 backdrop-blur-md">
<DialogHeader>
<DialogTitle className="text-base font-bold flex items-center gap-2 text-rose-600 dark:text-rose-400">
<DialogContent
className="max-w-md p-0 overflow-hidden rounded-2xl border border-border/60 bg-card shadow-2xl"
dir="rtl"
>
{/* هدر دیالوگ با پس‌زمینه و حاشیه متناسب */}
<DialogHeader className="p-5 border-b border-border/40 bg-muted/20 flex flex-row items-center justify-between">
<div className="flex items-center gap-3">
<div className="size-10 rounded-xl bg-rose-500/15 text-rose-600 dark:text-rose-400 flex items-center justify-center shrink-0">
<Ic name="xCircle" className="size-5" />
<span>رد صورتحساب #{factor.id}</span>
</div>
<div>
<DialogTitle className="text-base font-black text-rose-600 dark:text-rose-400 flex items-center gap-1.5">
<span>رد صورتحساب</span>
<span dir="ltr" className="font-mono">#{factor.id}</span>
</DialogTitle>
<DialogDescription className="text-xs text-muted-foreground">
مشتری: {factor.user_info?.fullname || factor.user_info?.email || 'نامشخص'} مبلغ:{' '}
{Number(factor.amount).toLocaleString()} $
<DialogDescription className="text-xs text-muted-foreground mt-0.5 flex items-center gap-2">
<span>
مشتری:{' '}
<strong className="font-medium text-foreground">
{factor.user_info?.fullname || factor.user_info?.email || 'نامشخص'}
</strong>
</span>
<span className="text-border-soft"></span>
<span>
مبلغ:{' '}
<strong dir="ltr" className="font-mono font-bold text-foreground">
${Number(factor.amount).toLocaleString()}
</strong>
</span>
</DialogDescription>
</div>
</div>
<button
type="button"
onClick={onClose}
className="size-8 rounded-xl flex items-center justify-center text-muted-foreground hover:text-foreground hover:bg-muted/60 transition-colors"
title="بستن"
>
<Ic name="x" className="size-4" />
</button>
</DialogHeader>
<div className="space-y-3 py-2">
<label className="text-xs font-semibold text-foreground">
دلیل رد صورتحساب / فیش پرداختی:
{/* بدنه فرم با پدینگ استاندارد */}
<div className="p-5 space-y-4">
<div className="space-y-2">
<label className="text-xs font-bold text-foreground flex items-center gap-1.5">
<Ic name="alert" className="size-3.5 text-rose-500 shrink-0" />
<span>دلیل رد صورتحساب / فیش پرداختی:</span>
</label>
<Textarea
rows={4}
@ -69,17 +103,30 @@ export function RejectFactorDialog({
if (error) setError(null)
}}
placeholder="مثال: فیش ناخوانا است، مبلغ واریزی مغایرت دارد، شماره رهگیری نامعتبر است..."
className="text-xs resize-none"
className="text-xs leading-relaxed resize-none rounded-xl border-border/60 bg-muted/10 focus:border-rose-500/60 focus:ring-1 focus:ring-rose-500/30 p-3"
autoFocus
/>
{error && <p className="text-xs text-destructive font-medium">{error}</p>}
<p className="text-[11px] text-muted-foreground">
{error && (
<div className="flex items-center gap-1.5 text-xs text-rose-600 dark:text-rose-400 font-medium">
<Ic name="xCircle" className="size-3.5 shrink-0" />
<span>{error}</span>
</div>
)}
<p className="text-[11px] text-muted-foreground leading-relaxed">
این توضیحات در اپلیکیشن مسافر / کلاینت در بخش جزئیات صورتحساب نمایش داده میشود.
</p>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button variant="outline" size="sm" onClick={onClose} disabled={isSubmitting}>
{/* فوتر دکمه‌ها با فاصله‌گذاری مناسب و بدون بیرون‌زدگی */}
<DialogFooter className="p-4 px-5 border-t border-border/40 bg-muted/20 flex flex-row items-center justify-end gap-2.5">
<Button
variant="outline"
size="sm"
onClick={onClose}
disabled={isSubmitting}
className="rounded-xl h-9 text-xs px-4 border-border/60 hover:bg-muted/50"
>
انصراف
</Button>
<Button
@ -87,7 +134,7 @@ export function RejectFactorDialog({
size="sm"
onClick={handleConfirm}
disabled={isSubmitting}
className="gap-1.5"
className="rounded-xl h-9 text-xs px-4 gap-1.5 bg-rose-600 hover:bg-rose-700 text-white font-semibold shadow-xs"
>
{isSubmitting ? (
<span>در حال ثبت...</span>

76
src/features/services/ServicesPage.tsx

@ -7,8 +7,11 @@ import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Shimmer } from '@/components/shared/Shimmer'
import { toFa } from '@/lib/utils'
import { apiFetch } from '@/services/http'
import { ZayerGuideServiceView } from './components/ZayerGuideServiceView'
import { DuasServiceView } from './components/DuasServiceView'
import { CategoriesServiceView } from './components/CategoriesServiceView'
import { ShrinesServiceView } from './components/ShrinesServiceView'
import { FaqServiceView } from './components/FaqServiceView'
// تعریف ساختار داده‌های سرویس‌ها
export interface ServiceMeta {
@ -70,32 +73,32 @@ export const SERVICES_LIST: ServiceMeta[] = [
badge: 'مالی و ارزی',
},
{
key: 'trip-etiquette',
title: 'آداب سفر و زیارت',
titleEn: 'Trip Etiquette',
icon: 'etiquette',
key: 'duas',
title: 'ادعیه و زیارات',
titleEn: 'Duas & Ziyarat',
icon: 'book',
color: 'text-primary',
bgColor: 'bg-primary/10',
borderColor: 'border-primary/20',
description: 'مناسک، ادعیه و زیارات وارده، آداب حضور در عتبات عالیات و راهنمای گام‌به‌گام زیارت',
endpoint: '/trip-etiquette/cities/',
statsLabel: 'مناسک و زیارات',
defaultCount: 26,
description: 'فهرست مستقیم ادعیه، تخصیص چندگانه به حرم‌ها، مدیریت متن عربی با اعراب و ترجمه‌های ۵‌زبانه',
endpoint: '/trip-etiquette/duas/',
statsLabel: 'دعا و زیارت ثبت‌شده',
defaultCount: 4,
badge: 'فرهنگی و معنوی',
},
{
key: 'consultant',
title: 'مشاوره و پشتیبانی',
titleEn: 'Consultant',
icon: 'headset',
key: 'categories',
title: 'دسته‌بندی‌های ادعیه',
titleEn: 'Dua Categories',
icon: 'folder',
color: 'text-primary',
bgColor: 'bg-primary/10',
borderColor: 'border-primary/20',
description: 'شبکه مشاورین تخصصی پزشکی سفر، احکام، امور حقوقی، گمشدگان و پشتیبانی ۲۴ ساعته زائرین',
endpoint: '/consultants/',
statsLabel: 'مشاور فعال',
defaultCount: 12,
badge: 'پشتیبانی زنده',
description: 'تعریف و مدیریت پوشه‌ها و دسته‌بندی‌های ادعیه (مانند اعمال، آداب، زیارات) به تفکیک شهرها و تخصیص مستقیم ادعیه',
endpoint: '/trip-etiquette/categories/',
statsLabel: 'دسته‌بندی فعال',
defaultCount: 6,
badge: 'شهرها و پوشه‌ها',
},
{
key: 'store',
@ -125,6 +128,34 @@ export const SERVICES_LIST: ServiceMeta[] = [
defaultCount: 14,
badge: 'محاسبات و برآورد',
},
{
key: 'faq',
title: 'سوالات متداول',
titleEn: 'FAQ',
icon: 'helpCircle',
color: 'text-primary',
bgColor: 'bg-primary/10',
borderColor: 'border-primary/20',
description: 'مدیریت جامع دسته‌بندی‌های موضوعی، پرسش و پاسخ‌ها، پشتیبانی کامل ۵ زبانه و فعال/غیرفعال‌سازی نمایش در اپلیکیشن',
endpoint: '/home/faq/categories/',
statsLabel: 'دسته‌بندی فعال',
defaultCount: 6,
badge: 'پشتیبانی و راهنما',
},
{
key: 'consultant',
title: 'پشتیبانی',
titleEn: 'Support',
icon: 'headset',
color: 'text-primary',
bgColor: 'bg-primary/10',
borderColor: 'border-primary/20',
description: 'شبکه کارشناسان تخصصی پزشکی سفر، احکام، امور حقوقی، گمشدگان و پشتیبانی ۲۴ ساعته زائرین',
endpoint: '/consultants/',
statsLabel: 'کارشناس فعال',
defaultCount: 12,
badge: 'پشتیبانی زنده',
},
]
export default function ServicesPage() {
@ -402,10 +433,17 @@ function ServiceDetailRenderer({ service }: { service: ServiceMeta }) {
return <ZayerGuideServiceView service={service} />
case 'exchange':
return <ExchangeServiceView service={service} />
case 'duas':
return <DuasServiceView />
case 'categories':
case 'shrines':
return <CategoriesServiceView />
case 'trip-etiquette':
return <TripEtiquetteServiceView service={service} />
return <DuasServiceView />
case 'consultant':
return <ConsultantServiceView service={service} />
case 'faq':
return <FaqServiceView />
case 'store':
return <StoreServiceView service={service} />
case 'cost-estimate':

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

@ -0,0 +1,859 @@
import React, { useState, useEffect, useMemo } from 'react'
import { Ic } from '@/icons'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Shimmer } from '@/components/shared/Shimmer'
import { toFa } from '@/lib/utils'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import type { CategoryItem, DuaItem, CreateCategoryInput, UpdateCategoryInput } from '../types/trip-etiquette'
import {
fetchCategories,
createCategory,
updateCategory,
deleteCategory,
fetchDuas,
} from '../services/trip-etiquette-api'
import { fetchCityGuideCountries } from '../services/zayer-guide-api'
import type { CityGuideCountry } from '../types/zayer-guide'
export function CategoriesServiceView() {
const [categories, setCategories] = useState<CategoryItem[]>([])
const [allDuas, setAllDuas] = useState<DuaItem[]>([])
const [countries, setCountries] = useState<CityGuideCountry[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
// فیلترها و مرتب‌سازی
const [searchTerm, setSearchTerm] = useState('')
const [selectedCountryCode, setSelectedCountryCode] = useState('ALL')
const [selectedCityId, setSelectedCityId] = useState('ALL')
const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid')
// وضعیت دیالوگ ایجاد و ویرایش
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [editingCategory, setEditingCategory] = useState<CategoryItem | null>(null)
const [isSaving, setIsSaving] = useState(false)
// فرم ایجاد/ویرایش
const [formNameFa, setFormNameFa] = useState('')
const [formNameAr, setFormNameAr] = useState('')
const [formNameEn, setFormNameEn] = useState('')
const [formCountryCode, setFormCountryCode] = useState('')
const [formCityId, setFormCityId] = useState<number | ''>('')
const [formSelectedDuaIds, setFormSelectedDuaIds] = useState<number[]>([])
const [duaFilterSearch, setDuaFilterSearch] = useState('')
// وضعیت دیالوگ تخصیص سریع ادعیه
const [assigningCategory, setAssigningCategory] = useState<CategoryItem | null>(null)
const [quickAssignDuaIds, setQuickAssignDuaIds] = useState<number[]>([])
const [isSavingQuickAssign, setIsSavingQuickAssign] = useState(false)
// بارگذاری داده‌های اولیه
const loadData = async (isManual = false) => {
if (isManual) setIsRefreshing(true)
else setIsLoading(true)
setErrorMessage(null)
try {
const [categoriesRes, duasRes, countriesRes] = await Promise.all([
fetchCategories(),
fetchDuas(),
fetchCityGuideCountries(),
])
setCategories(categoriesRes)
setAllDuas(duasRes)
setCountries(countriesRes)
} catch (err: any) {
setErrorMessage(err?.message || 'خطا در برقراری ارتباط با وب‌سرویس دسته‌بندی‌های ادعیه.')
} finally {
setIsLoading(false)
setIsRefreshing(false)
}
}
useEffect(() => {
loadData()
}, [])
// لیست کلیه شهرهای در دسترس از کشورها
const allCities = useMemo(() => {
const list: { id: number; name: string; countryCode: string; countryName: string }[] = []
countries.forEach((country) => {
if (country.city) {
country.city.forEach((c) => {
list.push({
id: c.id,
name: c.name,
countryCode: country.code,
countryName: country.name,
})
})
}
})
return list
}, [countries])
// شهرهای قابل انتخاب بر اساس کشور انتخابی در فیلتر
const filterAvailableCities = useMemo(() => {
if (selectedCountryCode === 'ALL') return allCities
return allCities.filter((c) => c.countryCode.toLowerCase() === selectedCountryCode.toLowerCase())
}, [allCities, selectedCountryCode])
// شهرهای قابل انتخاب در فرم ایجاد/ویرایش
const formAvailableCities = useMemo(() => {
if (!formCountryCode) return allCities
return allCities.filter((c) => c.countryCode.toLowerCase() === formCountryCode.toLowerCase())
}, [allCities, formCountryCode])
// فیلتر کردن لیست دسته‌بندی‌ها
const filteredCategories = useMemo(() => {
return categories.filter((c) => {
// فیلتر کشور
if (selectedCountryCode !== 'ALL') {
const countryCode = c.country_info?.code || ''
if (countryCode.toLowerCase() !== selectedCountryCode.toLowerCase()) return false
}
// فیلتر شهر
if (selectedCityId !== 'ALL') {
if (String(c.city) !== String(selectedCityId)) return false
}
// فیلتر جستجو در عنوان
if (searchTerm.trim()) {
const term = searchTerm.toLowerCase().trim()
const nameFa = (c.name?.fa || c.name_display || '').toLowerCase()
const nameAr = (c.name?.ar || '').toLowerCase()
const nameEn = (c.name?.en || '').toLowerCase()
const cityName = (c.city_info?.name || '').toLowerCase()
if (
!nameFa.includes(term) &&
!nameAr.includes(term) &&
!nameEn.includes(term) &&
!cityName.includes(term)
) {
return false
}
}
return true
})
}, [categories, selectedCountryCode, selectedCityId, searchTerm])
// آمار کلی
const totalCategoriesCount = categories.length
const coveredCitiesCount = useMemo(() => {
const cityIds = new Set(categories.map((c) => c.city).filter(Boolean))
return cityIds.size
}, [categories])
const totalAssignedDuas = useMemo(() => {
return categories.reduce((sum, c) => sum + (c.duas_count || 0), 0)
}, [categories])
// باز کردن فرم ایجاد دسته‌بندی جدید
const handleOpenCreateDialog = () => {
setEditingCategory(null)
setFormNameFa('')
setFormNameAr('')
setFormNameEn('')
setFormCountryCode(selectedCountryCode !== 'ALL' ? selectedCountryCode : '')
setFormCityId(selectedCityId !== 'ALL' ? Number(selectedCityId) : '')
setFormSelectedDuaIds([])
setDuaFilterSearch('')
setIsDialogOpen(true)
}
// باز کردن فرم ویرایش دسته‌بندی
const handleOpenEditDialog = (c: CategoryItem) => {
setEditingCategory(c)
const raw = c.name || {}
setFormNameFa(typeof raw === 'object' ? raw.fa || c.name_display : c.name_display)
setFormNameAr(typeof raw === 'object' ? raw.ar || '' : '')
setFormNameEn(typeof raw === 'object' ? raw.en || '' : '')
setFormCountryCode(c.country_info?.code || '')
setFormCityId(c.city || '')
setFormSelectedDuaIds(c.assigned_dua_ids || [])
setDuaFilterSearch('')
setIsDialogOpen(true)
}
// تغییر وضعیت انتخاب یک دعا در فرم
const toggleDuaSelection = (duaIndex: number) => {
setFormSelectedDuaIds((prev) =>
prev.includes(duaIndex) ? prev.filter((id) => id !== duaIndex) : [...prev, duaIndex]
)
}
// باز کردن پنجره تخصیص سریع ادعیه
const handleOpenQuickAssign = (cat: CategoryItem) => {
setAssigningCategory(cat)
setQuickAssignDuaIds(cat.assigned_dua_ids || [])
setDuaFilterSearch('')
}
const toggleQuickAssignDua = (duaIndex: number) => {
setQuickAssignDuaIds((prev) =>
prev.includes(duaIndex) ? prev.filter((id) => id !== duaIndex) : [...prev, duaIndex]
)
}
const handleSaveQuickAssign = async () => {
if (!assigningCategory) return
setIsSavingQuickAssign(true)
try {
const updated = await updateCategory(assigningCategory.id, {
dua_ids: quickAssignDuaIds,
})
setCategories((prev) => prev.map((item) => (item.id === updated.id ? updated : item)))
setAssigningCategory(null)
} catch (err: any) {
alert(err?.message || 'خطا در تخصیص ادعیه.')
} finally {
setIsSavingQuickAssign(false)
}
}
// ذخیره فرم ایجاد/ویرایش دسته‌بندی
const handleSaveCategory = async (e: React.FormEvent) => {
e.preventDefault()
if (!formNameFa.trim()) {
alert('لطفاً عنوان دسته‌بندی (فارسی) را وارد کنید.')
return
}
if (!formCityId) {
alert('لطفاً شهر مربوطه را انتخاب کنید.')
return
}
setIsSaving(true)
try {
const nameObj = {
fa: formNameFa.trim(),
ar: formNameAr.trim() || formNameFa.trim(),
en: formNameEn.trim() || formNameFa.trim(),
}
if (editingCategory) {
const payload: UpdateCategoryInput = {
name: nameObj,
city: Number(formCityId),
dua_ids: formSelectedDuaIds,
}
const updated = await updateCategory(editingCategory.id, payload)
setCategories((prev) => prev.map((item) => (item.id === updated.id ? updated : item)))
} else {
const payload: CreateCategoryInput = {
name: nameObj,
city: Number(formCityId),
dua_ids: formSelectedDuaIds,
}
const created = await createCategory(payload)
setCategories((prev) => [created, ...prev])
}
setIsDialogOpen(false)
} catch (err: any) {
alert(err?.message || 'خطا در ذخیره‌سازی دسته‌بندی.')
} finally {
setIsSaving(false)
}
}
// حذف دسته‌بندی
const handleDeleteCategory = async (c: CategoryItem) => {
if (!window.confirm(`آیا از حذف دسته‌بندی «${c.name_display}» در شهر ${c.city_info?.name || ''} اطمینان دارید؟`)) {
return
}
try {
await deleteCategory(c.id)
setCategories((prev) => prev.filter((item) => item.id !== c.id))
} catch (err: any) {
alert(err?.message || 'خطا در حذف دسته‌بندی.')
}
}
// ادعیه فیلترشده در بخش انتخاب فرم
const formFilteredDuas = useMemo(() => {
if (!duaFilterSearch.trim()) return allDuas
const term = duaFilterSearch.toLowerCase().trim()
return allDuas.filter((d) => d.name_display.toLowerCase().includes(term))
}, [allDuas, duaFilterSearch])
return (
<div className="space-y-6" dir="rtl">
{/* پیام خطا در صورت بروز مشکل در اتصال */}
{errorMessage && (
<div className="rounded-2xl border border-destructive/20 bg-destructive/10 p-4 text-xs font-bold text-destructive flex items-center justify-between">
<div className="flex items-center gap-2">
<Ic name="alertCircle" className="size-4 shrink-0" />
<span>{errorMessage}</span>
</div>
<Button
size="sm"
variant="outline"
onClick={() => loadData(true)}
className="h-7 rounded-lg text-xs"
>
تلاش مجدد
</Button>
</div>
)}
{/* ۱. کارت‌های آمار و شاخص‌های کلیدی */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">کل دستهبندیهای فعال</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">
{isLoading ? <Shimmer className="h-8 w-16" /> : toFa(totalCategoriesCount)}
</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">شهرهای تحت پوشش</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">
{isLoading ? <Shimmer className="h-8 w-12" /> : toFa(coveredCitiesCount)}
</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">پیوندهای ادعیه به پوشهها</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">
{isLoading ? <Shimmer className="h-8 w-14" /> : toFa(totalAssignedDuas)}
</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">سلسلهمراتب ناوبری</span>
<div className="mt-2 text-2xl font-black text-emerald-600 dark:text-emerald-400 font-mono">
مستقیم
</div>
</div>
</div>
{/* ۲. نوار ابزار، فیلترها و دکمه افزودن */}
<Card className="border-border-soft shadow-xs bg-card/40">
<CardContent className="p-4 flex flex-col md:flex-row items-stretch md:items-center justify-between gap-3">
{/* فیلترها و سرچ */}
<div className="flex flex-wrap items-center gap-2.5 flex-1">
{/* سرچ عنوان */}
<div className="relative w-full sm:w-64">
<Ic name="search" className="absolute right-3 top-1/2 -translate-y-1/2 size-4 text-grey-3" />
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="جستجوی دسته‌بندی، شهر..."
className="h-9 pr-9 text-xs bg-card border-border-soft rounded-xl"
/>
</div>
{/* فیلتر کشور */}
<select
value={selectedCountryCode}
onChange={(e) => {
setSelectedCountryCode(e.target.value)
setSelectedCityId('ALL')
}}
className="h-9 px-3 text-xs bg-card border border-border-soft rounded-xl text-foreground focus:outline-hidden"
>
<option value="ALL">همه کشورها</option>
{countries.map((c) => (
<option key={c.code} value={c.code}>
{c.name}
</option>
))}
</select>
{/* فیلتر شهر */}
<select
value={selectedCityId}
onChange={(e) => setSelectedCityId(e.target.value)}
className="h-9 px-3 text-xs bg-card border border-border-soft rounded-xl text-foreground focus:outline-hidden"
>
<option value="ALL">همه شهرها</option>
{filterAvailableCities.map((city) => (
<option key={city.id} value={city.id}>
{city.name} ({city.countryName})
</option>
))}
</select>
{/* کلید تغییر حالت نمایش */}
<div className="flex items-center gap-1 border border-border-soft rounded-xl p-0.5 bg-card">
<Button
variant={viewMode === 'grid' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setViewMode('grid')}
className="h-7 px-2 rounded-lg text-xs"
title="نمایش شبکه‌ای"
>
<Ic name="layoutGrid" className="size-3.5" />
</Button>
<Button
variant={viewMode === 'table' ? 'secondary' : 'ghost'}
size="sm"
onClick={() => setViewMode('table')}
className="h-7 px-2 rounded-lg text-xs"
title="نمایش جدولی"
>
<Ic name="table" className="size-3.5" />
</Button>
</div>
</div>
{/* دکمه‌های عملیاتی */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => loadData(true)}
disabled={isRefreshing}
className="h-9 px-3 rounded-xl border-border-soft text-xs gap-1.5"
>
<Ic name="refresh" className={`size-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
<span>تازهسازی</span>
</Button>
<Button
size="sm"
onClick={handleOpenCreateDialog}
className="h-9 px-3.5 rounded-xl text-xs font-bold gap-1.5 bg-primary text-primary-foreground hover:bg-primary/90"
>
<Ic name="plus" className="size-4" />
<span>افزودن دستهبندی جدید</span>
</Button>
</div>
</CardContent>
</Card>
{/* ۳. لیست دسته‌بندی‌ها */}
{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{[1, 2, 3, 4, 5, 6].map((i) => (
<Card key={i} className="border-border-soft bg-card/40 p-4 space-y-3">
<Shimmer className="h-5 w-1/2 rounded-lg" />
<Shimmer className="h-4 w-3/4 rounded-lg" />
<Shimmer className="h-8 w-full rounded-xl" />
</Card>
))}
</div>
) : filteredCategories.length === 0 ? (
<Card className="border-border-soft bg-card/40 p-12 text-center">
<div className="mx-auto size-12 rounded-2xl bg-muted/40 border border-border-soft flex items-center justify-center text-grey-3 mb-3">
<Ic name="folder" className="size-6" />
</div>
<h3 className="text-sm font-bold text-foreground">دستهبندی یافت نشد</h3>
<p className="text-xs text-grey-3 mt-1">
هیچ دستهبندی با فیلترهای انتخابی مطابقت ندارد. میتوانید دستهبندی جدیدی اضافه کنید.
</p>
<Button
size="sm"
onClick={handleOpenCreateDialog}
className="mt-4 h-8 rounded-xl text-xs font-bold bg-primary text-primary-foreground"
>
افزودن دستهبندی
</Button>
</Card>
) : viewMode === 'grid' ? (
/* نمای شبکه‌ای (Cards) */
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredCategories.map((cat) => (
<Card
key={cat.id}
className="group relative flex flex-col justify-between overflow-hidden border-border-soft bg-card/40 shadow-xs transition duration-200 hover:border-primary/40 hover:bg-card/80"
>
<CardContent className="p-5 space-y-4">
{/* هدر کارت: آیکون پوشه، شناسه و شهر */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className="flex size-10 items-center justify-center rounded-xl bg-primary/10 text-primary border border-primary/20">
<Ic name="folder" className="size-5" />
</div>
<div>
<h3 className="text-sm font-black text-foreground group-hover:text-primary transition">
{cat.name_display}
</h3>
<span className="text-[11px] font-mono text-grey-3">
#{cat.id} &bull; {cat.name?.en || ''}
</span>
</div>
</div>
<Badge
variant="outline"
className="border-border-soft bg-card/80 text-[11px] font-medium text-foreground"
>
{cat.city_info?.name || 'شهر نامشخص'}
</Badge>
</div>
{/* کشور و آمار ادعیه */}
<div className="space-y-1.5 pt-2 border-t border-border-soft">
<div className="flex items-center justify-between text-xs">
<span className="text-grey-3">کشور منتسب:</span>
<span className="font-bold text-foreground">
{cat.country_info?.name || 'نامشخص'}
</span>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-grey-3">ادعیه منتسب در اپلیکیشن:</span>
<span className="font-bold text-primary font-mono">
{toFa(cat.duas_count)} دعا
</span>
</div>
</div>
{/* فوتر کارت و دکمه‌های عملیاتی */}
<div className="pt-3 border-t border-border-soft flex items-center justify-between gap-2">
<Button
variant="outline"
size="sm"
onClick={() => handleOpenQuickAssign(cat)}
className="h-8 rounded-xl text-xs border-border-soft gap-1.5 flex-1 hover:bg-primary/10 hover:text-primary hover:border-primary/30"
>
<Ic name="bookmark" className="size-3.5" />
<span>مدیریت ادعیه ({toFa(cat.duas_count)})</span>
</Button>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenEditDialog(cat)}
className="size-8 p-0 rounded-lg text-grey-3 hover:text-foreground hover:bg-card"
title="ویرایش دسته‌بندی"
>
<Ic name="pencil" className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteCategory(cat)}
className="size-8 p-0 rounded-lg text-destructive/70 hover:text-destructive hover:bg-destructive/10"
title="حذف دسته‌بندی"
>
<Ic name="trash" className="size-3.5" />
</Button>
</div>
</div>
</CardContent>
</Card>
))}
</div>
) : (
/* نمای جدولی (Table) */
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40 shadow-xs">
<table className="w-full text-start text-xs">
<thead className="border-b border-border-soft bg-card/60 text-grey-3">
<tr>
<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-end font-bold">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-border-soft">
{filteredCategories.map((c) => (
<tr key={c.id} className="hover:bg-card/70 transition">
<td className="p-3.5 font-mono font-bold text-grey-3">#{c.id}</td>
<td className="p-3.5">
<div className="font-bold text-foreground">{c.name_display}</div>
<div className="text-[10px] text-grey-3 font-mono">{c.name?.en || ''}</div>
</td>
<td className="p-3.5 font-bold text-foreground">
<Badge variant="outline" className="border-border-soft bg-card/80 text-[10px]">
{c.city_info?.name || 'نامشخص'}
</Badge>
</td>
<td className="p-3.5 text-grey-3">{c.country_info?.name || 'نامشخص'}</td>
<td className="p-3.5 font-mono font-bold text-primary">{toFa(c.duas_count)}</td>
<td className="p-3.5 text-end">
<div className="flex items-center justify-end gap-1.5">
<Button
variant="outline"
size="sm"
onClick={() => handleOpenQuickAssign(c)}
className="rounded-lg border-border-soft text-[11px] h-7 px-2"
>
تخصیص ادعیه
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenEditDialog(c)}
className="size-7 p-0 rounded-lg text-grey-3 hover:text-foreground"
>
<Ic name="pencil" className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteCategory(c)}
className="size-7 p-0 rounded-lg text-destructive/70 hover:text-destructive hover:bg-destructive/10"
>
<Ic name="trash" className="size-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* ۴. دیالوگ ایجاد و ویرایش دسته‌بندی */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="sm:max-w-lg rounded-2xl bg-card border-border-soft max-h-[90vh] overflow-y-auto p-6" dir="rtl">
<DialogHeader>
<DialogTitle className="text-sm font-black text-foreground">
{editingCategory ? 'ویرایش دسته‌بندی ادعیه' : 'افزودن دسته‌بندی جدید برای شهر'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSaveCategory} className="space-y-4 py-2">
{/* انتخاب کشور و شهر */}
<div className="grid grid-cols-2 gap-2.5">
<div>
<label className="block text-[11px] text-grey-3 mb-1">کشور</label>
<select
value={formCountryCode}
onChange={(e) => {
setFormCountryCode(e.target.value)
setFormCityId('')
}}
className="w-full h-9 px-3 text-xs bg-card/60 border border-border-soft rounded-xl text-foreground focus:outline-hidden"
>
<option value="">انتخاب کشور</option>
{countries.map((c) => (
<option key={c.code} value={c.code}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-[11px] font-bold text-foreground mb-1">
شهر مربوطه <span className="text-destructive">*</span>
</label>
<select
value={formCityId}
onChange={(e) => setFormCityId(e.target.value ? Number(e.target.value) : '')}
required
className="w-full h-9 px-3 text-xs bg-card/60 border border-border-soft rounded-xl text-foreground focus:outline-hidden"
>
<option value="">انتخاب شهر</option>
{formAvailableCities.map((city) => (
<option key={city.id} value={city.id}>
{city.name}
</option>
))}
</select>
</div>
</div>
{/* عنوان فارسی */}
<div>
<label className="block text-xs font-bold text-foreground mb-1">
عنوان دستهبندی (فارسی) <span className="text-destructive">*</span>
</label>
<Input
type="text"
placeholder="مثلاً: اعمال و زیارات یا آداب زیارت"
value={formNameFa}
onChange={(e) => setFormNameFa(e.target.value)}
required
className="text-xs bg-card/60 border-border-soft rounded-xl"
/>
</div>
{/* عنوان عربی و انگلیسی */}
<div className="grid grid-cols-2 gap-2.5">
<div>
<label className="block text-[11px] text-grey-3 mb-1">عنوان (عربی)</label>
<Input
type="text"
placeholder="الأعمال والزيارات"
value={formNameAr}
onChange={(e) => setFormNameAr(e.target.value)}
className="text-xs bg-card/60 border-border-soft rounded-xl"
/>
</div>
<div>
<label className="block text-[11px] text-grey-3 mb-1">عنوان (انگلیسی)</label>
<Input
type="text"
placeholder="Aamal and Ziyarat"
value={formNameEn}
onChange={(e) => setFormNameEn(e.target.value)}
className="text-xs bg-card/60 border-border-soft rounded-xl font-mono"
dir="ltr"
/>
</div>
</div>
{/* تخصیص اولیه ادعیه به این دسته‌بندی */}
<div className="space-y-2 pt-2 border-t border-border-soft">
<div className="flex items-center justify-between">
<label className="block text-xs font-bold text-foreground">
تخصیص ادعیه به این دستهبندی (اختیاری)
</label>
<span className="text-[11px] font-mono text-primary font-bold">
{toFa(formSelectedDuaIds.length)} دعا انتخاب شده
</span>
</div>
<p className="text-[11px] text-grey-3">
ادعیه انتخابشده پس از ذخیره، بلافاصله در این پوشه در اپلیکیشن نمایش داده میشوند:
</p>
{/* جستجوی دعا در لیست */}
<div className="relative">
<Ic name="search" className="absolute right-2.5 top-1/2 -translate-y-1/2 size-3.5 text-grey-3" />
<Input
value={duaFilterSearch}
onChange={(e) => setDuaFilterSearch(e.target.value)}
placeholder="جستجو در ادعیه..."
className="h-8 pr-8 text-xs bg-card/60 border-border-soft rounded-xl"
/>
</div>
{/* لیست چک‌باکس‌های ادعیه */}
<div className="max-h-48 overflow-y-auto space-y-1 p-2 rounded-xl border border-border-soft bg-card/40">
{formFilteredDuas.map((dua) => {
const isSelected = formSelectedDuaIds.includes(dua.index)
return (
<div
key={dua.index}
onClick={() => toggleDuaSelection(dua.index)}
className={`flex items-center justify-between p-2 rounded-lg cursor-pointer transition text-xs ${
isSelected
? 'bg-primary/15 text-primary border border-primary/30 font-bold'
: 'hover:bg-card/80 text-foreground'
}`}
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
className="rounded border-border-soft text-primary focus:ring-primary size-3.5 pointer-events-none"
/>
<span>{dua.name_display}</span>
</div>
<span className="text-[10px] text-grey-3 font-mono">
{toFa(dua.verses_count)} فراز
</span>
</div>
)
})}
</div>
</div>
<DialogFooter className="pt-3 gap-2">
<Button
type="button"
variant="outline"
onClick={() => setIsDialogOpen(false)}
className="rounded-xl border-border-soft text-xs"
>
انصراف
</Button>
<Button
type="submit"
disabled={isSaving}
className="rounded-xl text-xs font-bold bg-primary text-primary-foreground hover:bg-primary/90"
>
{isSaving ? 'در حال ذخیره‌سازی...' : editingCategory ? 'ثبت تغییرات' : 'ایجاد دسته‌بندی'}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* ۵. دیالوگ تخصیص سریع ادعیه به دسته‌بندی */}
<Dialog open={Boolean(assigningCategory)} onOpenChange={(open) => !open && setAssigningCategory(null)}>
<DialogContent className="sm:max-w-md rounded-2xl bg-card border-border-soft max-h-[90vh] overflow-y-auto p-6" dir="rtl">
<DialogHeader>
<DialogTitle className="text-sm font-black text-foreground">
تخصیص ادعیه به «{assigningCategory?.name_display}» ({assigningCategory?.city_info?.name})
</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<p className="text-xs text-grey-3">
چکباکس دعاهایی که میخواهید درون این پوشه نمایش داده شوند را فعال کنید:
</p>
<div className="relative">
<Ic name="search" className="absolute right-2.5 top-1/2 -translate-y-1/2 size-3.5 text-grey-3" />
<Input
value={duaFilterSearch}
onChange={(e) => setDuaFilterSearch(e.target.value)}
placeholder="جستجو در ادعیه..."
className="h-8 pr-8 text-xs bg-card/60 border-border-soft rounded-xl"
/>
</div>
<div className="max-h-64 overflow-y-auto space-y-1.5 p-2 rounded-xl border border-border-soft bg-card/40">
{formFilteredDuas.map((dua) => {
const isSelected = quickAssignDuaIds.includes(dua.index)
return (
<div
key={dua.index}
onClick={() => toggleQuickAssignDua(dua.index)}
className={`flex items-center justify-between p-2 rounded-lg cursor-pointer transition text-xs ${
isSelected
? 'bg-primary/15 text-primary border border-primary/30 font-bold'
: 'hover:bg-card/80 text-foreground'
}`}
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
className="rounded border-border-soft text-primary focus:ring-primary size-3.5 pointer-events-none"
/>
<span>{dua.name_display}</span>
</div>
<span className="text-[10px] text-grey-3 font-mono">
{toFa(dua.verses_count)} فراز
</span>
</div>
)
})}
</div>
</div>
<DialogFooter className="pt-2 gap-2">
<Button
type="button"
variant="outline"
onClick={() => setAssigningCategory(null)}
className="rounded-xl border-border-soft text-xs"
>
انصراف
</Button>
<Button
type="button"
onClick={handleSaveQuickAssign}
disabled={isSavingQuickAssign}
className="rounded-xl text-xs font-bold bg-primary text-primary-foreground hover:bg-primary/90"
>
{isSavingQuickAssign ? 'در حال ذخیره‌سازی...' : 'ذخیره تخصیص ادعیه'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

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

@ -0,0 +1,946 @@
import React, { useState, useEffect, useMemo } from 'react'
import { Ic } from '@/icons'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Textarea } from '@/components/ui/textarea'
import { Shimmer } from '@/components/shared/Shimmer'
import { toFa } from '@/lib/utils'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet'
import type { DuaItem, DuaDetailItem, CategoryItem, ShrineItem, VerseItem } from '../types/trip-etiquette'
import {
fetchDuas,
fetchDuaDetail,
createDua,
updateDua,
deleteDua,
fetchCategories,
fetchShrines,
updateVerse,
} from '../services/trip-etiquette-api'
import { fetchCityGuideCountries } from '../services/zayer-guide-api'
import type { CityGuideCountry } from '../types/zayer-guide'
export function DuasServiceView() {
const [duas, setDuas] = useState<DuaItem[]>([])
const [categories, setCategories] = useState<CategoryItem[]>([])
const [shrines, setShrines] = useState<ShrineItem[]>([])
const [countries, setCountries] = useState<CityGuideCountry[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
// فیلترها و جستجو
const [searchTerm, setSearchTerm] = useState('')
const [selectedCategoryId, setSelectedCategoryId] = useState('ALL')
const [selectedCityId, setSelectedCityId] = useState('ALL')
const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid')
// دیالوگ افزودن/ویرایش دعا
const [isDuaDialogOpen, setIsDuaDialogOpen] = useState(false)
const [editingDua, setEditingDua] = useState<DuaItem | null>(null)
const [formDuaNameFa, setFormDuaNameFa] = useState('')
const [formDuaNameAr, setFormDuaNameAr] = useState('')
const [formDuaNameEn, setFormDuaNameEn] = useState('')
const [formSelectedCategoryIds, setFormSelectedCategoryIds] = useState<number[]>([])
const [categorySearchTerm, setCategorySearchTerm] = useState('')
const [isSavingDua, setIsSavingDua] = useState(false)
// شیت مدیریت فرازها و ترجمه‌ها
const [selectedDuaForVerses, setSelectedDuaForVerses] = useState<DuaItem | null>(null)
const [duaDetail, setDuaDetail] = useState<DuaDetailItem | null>(null)
const [isLoadingDetail, setIsLoadingDetail] = useState(false)
const [verseSearch, setVerseSearch] = useState('')
const [activeTranslationLang, setActiveTranslationLang] = useState<'fa' | 'en' | 'ru' | 'id'>('fa')
const [editingVerseIndex, setEditingVerseIndex] = useState<number | null>(null)
const [verseFormText, setVerseFormText] = useState('')
const [verseFormTranslations, setVerseFormTranslations] = useState<{ fa: string; en: string; ru: string; id: string }>({
fa: '',
en: '',
ru: '',
id: '',
})
const [isSavingVerse, setIsSavingVerse] = useState(false)
// بارگذاری لیست داده‌ها
const loadData = async (isManual = false) => {
if (isManual) setIsRefreshing(true)
else setIsLoading(true)
setErrorMessage(null)
try {
const [duasRes, categoriesRes, shrinesRes, countriesRes] = await Promise.all([
fetchDuas(),
fetchCategories(),
fetchShrines(),
fetchCityGuideCountries(),
])
setDuas(duasRes)
setCategories(categoriesRes)
setShrines(shrinesRes)
setCountries(countriesRes)
} catch (err: any) {
setErrorMessage(err?.message || 'خطا در برقراری ارتباط با وب‌سرویس ادعیه و زیارات.')
} finally {
setIsLoading(false)
setIsRefreshing(false)
}
}
useEffect(() => {
loadData()
}, [])
// لیست یکپارچه شهرها
const allCities = useMemo(() => {
const list: { id: number; name: string }[] = []
countries.forEach((co) => {
if (co.city) {
co.city.forEach((c) => {
list.push({ id: c.id, name: c.name })
})
}
})
return list
}, [countries])
// فیلتر کلاینت ادعیه
const filteredDuas = useMemo(() => {
return duas.filter((d) => {
// فیلتر بر اساس دسته‌بندی
if (selectedCategoryId !== 'ALL') {
const hasCategory = (d.categories || []).some((c) => String(c.id) === String(selectedCategoryId))
if (!hasCategory) return false
}
// فیلتر بر اساس شهر
if (selectedCityId !== 'ALL') {
const hasCityInCat = (d.categories || []).some((c) => String(c.city_id) === String(selectedCityId))
const targetShrinesInCity = shrines.filter((s) => String(s.city) === String(selectedCityId)).map((s) => s.id)
const hasCityInShrine = (d.shrines || []).some((s) => targetShrinesInCity.includes(s.id))
if (!hasCityInCat && !hasCityInShrine) return false
}
// جستجو
if (searchTerm.trim()) {
const term = searchTerm.toLowerCase()
const matchName = d.name_display.toLowerCase().includes(term)
const matchCat = (d.categories || []).some((c) => c.name.toLowerCase().includes(term) || c.city_name.toLowerCase().includes(term))
const matchShrine = (d.shrines || []).some((s) => s.name.toLowerCase().includes(term))
if (!matchName && !matchCat && !matchShrine) return false
}
return true
})
}, [duas, selectedCategoryId, selectedCityId, searchTerm, shrines])
// دسته‌بندی‌های فیلترشده برای دراپ‌داون بالای صفحه
const filterAvailableCategories = useMemo(() => {
if (selectedCityId === 'ALL') return categories
return categories.filter((c) => String(c.city) === String(selectedCityId))
}, [categories, selectedCityId])
// باز کردن فرم ایجاد دعا
const handleOpenCreateDua = () => {
setEditingDua(null)
setFormDuaNameFa('')
setFormDuaNameAr('')
setFormDuaNameEn('')
setFormSelectedCategoryIds([])
setCategorySearchTerm('')
setIsDuaDialogOpen(true)
}
// باز کردن فرم ویرایش دعا
const handleOpenEditDua = (d: DuaItem) => {
setEditingDua(d)
const raw = d.name || {}
setFormDuaNameFa(typeof raw === 'object' ? raw.fa || d.name_display : d.name_display)
setFormDuaNameAr(typeof raw === 'object' ? raw.ar || '' : '')
setFormDuaNameEn(typeof raw === 'object' ? raw.en || '' : '')
setFormSelectedCategoryIds((d.categories || []).map((c) => c.id))
setCategorySearchTerm('')
setIsDuaDialogOpen(true)
}
// سوئیچ انتخاب یک دسته‌بندی در فرم مولتی‌سلکت
const toggleCategorySelection = (catId: number) => {
setFormSelectedCategoryIds((prev) =>
prev.includes(catId) ? prev.filter((id) => id !== catId) : [...prev, catId]
)
}
// دسته‌بندی‌های فیلترشده داخل فرم ایجاد/ویرایش دعا
const formFilteredCategories = useMemo(() => {
if (!categorySearchTerm.trim()) return categories
const term = categorySearchTerm.toLowerCase().trim()
return categories.filter(
(c) =>
c.name_display.toLowerCase().includes(term) ||
(c.city_info?.name || '').toLowerCase().includes(term)
)
}, [categories, categorySearchTerm])
// ذخیره فرم ایجاد/ویرایش دعا
const handleSaveDua = async (e: React.FormEvent) => {
e.preventDefault()
if (!formDuaNameFa.trim()) {
alert('لطفاً عنوان دعا را وارد کنید.')
return
}
setIsSavingDua(true)
try {
const nameObj = {
fa: formDuaNameFa.trim(),
ar: formDuaNameAr.trim() || formDuaNameFa.trim(),
en: formDuaNameEn.trim() || formDuaNameFa.trim(),
}
if (editingDua) {
const updated = await updateDua(editingDua.index, {
name: nameObj,
category_ids: formSelectedCategoryIds,
})
setDuas((prev) => prev.map((item) => (item.index === updated.index ? updated : item)))
} else {
const created = await createDua({
name: nameObj,
category_ids: formSelectedCategoryIds,
})
setDuas((prev) => [created, ...prev])
}
setIsDuaDialogOpen(false)
} catch (err: any) {
alert(err?.message || 'خطا در ذخیره‌سازی دعا.')
} finally {
setIsSavingDua(false)
}
}
// حذف دعا
const handleDeleteDua = async (d: DuaItem) => {
if (!window.confirm(`آیا از حذف دعای «${d.name_display}» اطمینان دارید؟`)) return
try {
await deleteDua(d.index)
setDuas((prev) => prev.filter((item) => item.index !== d.index))
} catch (err: any) {
alert(err?.message || 'خطا در حذف دعا.')
}
}
// باز کردن شیت مدیریت فرازها و ترجمه‌ها
const handleOpenVersesSheet = async (d: DuaItem) => {
setSelectedDuaForVerses(d)
setIsLoadingDetail(true)
setEditingVerseIndex(null)
try {
const detail = await fetchDuaDetail(d.index)
setDuaDetail(detail)
} catch (err: any) {
alert(err?.message || 'خطا در دریافت متن و ترجمه‌های دعا.')
} finally {
setIsLoadingDetail(false)
}
}
// شروع ویرایش یک فراز
const handleStartEditVerse = (v: VerseItem) => {
setEditingVerseIndex(v.index)
setVerseFormText(v.text)
setVerseFormTranslations({
fa: v.translations?.fa || '',
en: v.translations?.en || '',
ru: v.translations?.ru || '',
id: v.translations?.id || '',
})
}
// لغو ویرایش فراز
const handleCancelEditVerse = () => {
setEditingVerseIndex(null)
}
// ذخیره ویرایش یک فراز
const handleSaveVerse = async (vIndex: number) => {
setIsSavingVerse(true)
try {
const updated = await updateVerse(vIndex, {
text: verseFormText,
translations: verseFormTranslations,
})
if (duaDetail) {
setDuaDetail({
...duaDetail,
verses: duaDetail.verses.map((v) =>
v.index === vIndex ? { ...v, text: updated.text, translations: updated.translations as any } : v
),
})
}
setEditingVerseIndex(null)
} catch (err: any) {
alert(err?.message || 'خطا در به‌روزرسانی متن و ترجمه.')
} finally {
setIsSavingVerse(false)
}
}
// فرازهای فیلترشده در شیت
const filteredVerses = useMemo(() => {
if (!duaDetail?.verses) return []
if (!verseSearch.trim()) return duaDetail.verses
const term = verseSearch.toLowerCase()
return duaDetail.verses.filter((v) => {
const matchAr = v.text.toLowerCase().includes(term)
const matchFa = v.translations?.fa?.toLowerCase().includes(term)
const matchEn = v.translations?.en?.toLowerCase().includes(term)
return matchAr || matchFa || matchEn
})
}, [duaDetail, verseSearch])
return (
<div className="space-y-6" dir="rtl">
{/* آمار خلاصه ادعیه (طراحی خنثی طبق استاندارد) */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<div className="rounded-2xl border border-primary/20 bg-primary/5 p-4">
<span className="text-xs text-primary font-medium">مجموع ادعیه و زیارات ثبتشده</span>
<div className="mt-2 text-2xl font-black text-primary font-mono">{toFa(duas.length)}</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">فرازها و بندهای قرائت</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">
{toFa(duas.reduce((acc, curr) => acc + (curr.verses_count || 0), 0))}
</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">زبانهای ترجمه فعال</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">۵ زبان</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">پوشهبندی و دستهبندی</span>
<div className="mt-2 text-2xl font-black text-emerald-600 dark:text-emerald-400 font-mono">
مستقیم
</div>
</div>
</div>
{/* نوار ابزار، جستجو، فیلترها و دکمه افزودن */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2.5">
{/* ورودی جستجو */}
<div className="relative w-64">
<Input
type="text"
placeholder="جستجوی دعا یا زیارت..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pe-8 text-xs bg-card/40 border-border-soft rounded-xl"
/>
<Ic name="search" className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-grey-3" />
</div>
{/* فیلتر بر اساس شهر */}
<select
value={selectedCityId}
onChange={(e) => {
setSelectedCityId(e.target.value)
setSelectedCategoryId('ALL')
}}
className="h-9 px-3 text-xs bg-card/40 border border-border-soft rounded-xl text-foreground focus:outline-none focus:border-primary"
>
<option value="ALL">همه شهرها</option>
{allCities.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
{/* فیلتر بر اساس دسته‌بندی */}
<select
value={selectedCategoryId}
onChange={(e) => setSelectedCategoryId(e.target.value)}
className="h-9 px-3 text-xs bg-card/40 border border-border-soft rounded-xl text-foreground focus:outline-none focus:border-primary max-w-[200px]"
>
<option value="ALL">همه دستهبندیها</option>
{filterAvailableCategories.map((cat) => (
<option key={cat.id} value={cat.id}>
{cat.name_display} ({cat.city_info?.name || 'شهر'})
</option>
))}
</select>
{/* سوئیچ نمایش */}
<div className="flex items-center border border-border-soft rounded-xl p-0.5 bg-card/30">
<button
onClick={() => setViewMode('grid')}
className={`p-1.5 rounded-lg transition ${viewMode === 'grid' ? 'bg-card text-primary shadow-xs' : 'text-grey-3 hover:text-foreground'}`}
title="نمای کارتی"
>
<Ic name="grid" className="size-4" />
</button>
<button
onClick={() => setViewMode('table')}
className={`p-1.5 rounded-lg transition ${viewMode === 'table' ? 'bg-card text-primary shadow-xs' : 'text-grey-3 hover:text-foreground'}`}
title="نمای جدولی"
>
<Ic name="list" className="size-4" />
</button>
</div>
</div>
{/* دکمه‌ها */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => loadData(true)}
disabled={isRefreshing}
className="rounded-xl border-border-soft text-xs gap-1"
>
<Ic name="refresh" className={`size-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
<span>تازهسازی</span>
</Button>
<Button
size="sm"
onClick={handleOpenCreateDua}
className="rounded-xl bg-primary text-primary-foreground text-xs font-bold gap-1.5 shadow-sm"
>
<Ic name="plus" className="size-4" />
<span>افزودن دعای جدید</span>
</Button>
</div>
</div>
{/* خطا */}
{errorMessage && (
<div className="p-4 rounded-xl border border-destructive/20 bg-destructive/5 text-destructive text-xs flex items-center justify-between">
<span>{errorMessage}</span>
<Button variant="ghost" size="sm" onClick={() => loadData(true)}>تلاش مجدد</Button>
</div>
)}
{/* وضعیت لودینگ */}
{isLoading ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[1, 2, 3].map((i) => (
<div key={i} className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-3">
<Shimmer className="h-6 w-3/4 rounded-md" />
<Shimmer className="h-5 w-1/2 rounded-md" />
<Shimmer className="h-4 w-full rounded-md" />
</div>
))}
</div>
) : filteredDuas.length === 0 ? (
<div className="py-16 text-center rounded-2xl border border-dashed border-border-soft bg-card/20 space-y-3">
<div className="size-12 rounded-full bg-card/80 border border-border-soft mx-auto flex items-center justify-center text-grey-3">
<Ic name="book" className="size-6" />
</div>
<p className="text-sm font-bold text-foreground">هیچ دعا یا زیارتی یافت نشد</p>
<p className="text-xs text-grey-3">فیلترها را تغییر داده یا از دکمه «افزودن دعای جدید» استفاده کنید.</p>
</div>
) : viewMode === 'grid' ? (
/* نمای کارتی مستقیم و فلت ادعیه */
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filteredDuas.map((d) => (
<div
key={d.index}
className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-3 hover:border-primary/30 transition shadow-xs flex flex-col justify-between"
>
<div className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-xs font-mono font-bold text-grey-3">کد: #{d.index}</span>
<span className="text-[11px] font-mono text-primary font-bold">
{toFa(d.verses_count)} فراز
</span>
</div>
<h3 className="text-base font-black text-foreground">{d.name_display}</h3>
{/* نشان‌های دسته‌بندی‌های منتسب */}
<div className="space-y-1.5 pt-1">
<span className="text-[10px] text-grey-3 font-medium">دستهبندیها و شهرها:</span>
<div className="flex flex-wrap gap-1.5">
{d.categories && d.categories.length > 0 ? (
d.categories.map((cat) => (
<Badge
key={cat.id}
variant="secondary"
className="bg-card/90 text-foreground border border-border-soft text-[10px] px-2 py-0.5 rounded-lg"
>
📁 {cat.name} ({cat.city_name})
</Badge>
))
) : d.shrines && d.shrines.length > 0 ? (
d.shrines.map((shrine) => (
<Badge
key={shrine.id}
variant="secondary"
className="bg-card/90 text-foreground border border-border-soft text-[10px] px-2 py-0.5 rounded-lg"
>
📍 {shrine.name}
</Badge>
))
) : (
<span className="text-[11px] text-grey-3 italic">دستهبندی منتسب نشده</span>
)}
</div>
</div>
</div>
{/* اکشن‌ها */}
<div className="pt-3 border-t border-border-soft flex items-center justify-between">
<Button
variant="outline"
size="sm"
onClick={() => handleOpenVersesSheet(d)}
className="rounded-xl border-border-soft text-xs gap-1.5 hover:border-primary/30"
>
<Ic name="file" className="size-3.5 text-primary" />
<span>متن و ترجمهها</span>
</Button>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenEditDua(d)}
className="size-8 p-0 rounded-lg text-grey-3 hover:text-foreground"
title="ویرایش و تخصیص دسته‌بندی‌ها"
>
<Ic name="pencil" className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteDua(d)}
className="size-8 p-0 rounded-lg text-destructive/70 hover:text-destructive hover:bg-destructive/10"
title="حذف دعا"
>
<Ic name="trash" className="size-3.5" />
</Button>
</div>
</div>
</div>
))}
</div>
) : (
/* نمای جدولی */
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40 shadow-xs">
<table className="w-full text-start text-xs">
<thead className="border-b border-border-soft bg-card/60 text-grey-3">
<tr>
<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-end font-bold">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-border-soft">
{filteredDuas.map((d) => (
<tr key={d.index} className="hover:bg-card/70 transition">
<td className="p-3.5 font-mono font-bold text-grey-3">#{d.index}</td>
<td className="p-3.5 font-bold text-foreground">{d.name_display}</td>
<td className="p-3.5">
<div className="flex flex-wrap gap-1 max-w-md">
{d.categories && d.categories.length > 0
? d.categories.map((c) => (
<Badge
key={c.id}
variant="secondary"
className="bg-card/80 text-foreground border border-border-soft text-[10px]"
>
📁 {c.name} ({c.city_name})
</Badge>
))
: d.shrines?.map((s) => (
<Badge
key={s.id}
variant="secondary"
className="bg-card/80 text-foreground border border-border-soft text-[10px]"
>
📍 {s.name}
</Badge>
))}
</div>
</td>
<td className="p-3.5 font-mono font-bold text-primary">{toFa(d.verses_count)}</td>
<td className="p-3.5 text-end">
<div className="flex items-center justify-end gap-1.5">
<Button
variant="outline"
size="sm"
onClick={() => handleOpenVersesSheet(d)}
className="rounded-lg border-border-soft text-[11px] h-7 px-2"
>
متن و ۵ زبان
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenEditDua(d)}
className="size-7 p-0 rounded-lg text-grey-3 hover:text-foreground"
>
<Ic name="pencil" className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteDua(d)}
className="size-7 p-0 rounded-lg text-destructive/70 hover:text-destructive hover:bg-destructive/10"
>
<Ic name="trash" className="size-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* دیالوگ ایجاد و ویرایش دعا + تخصیص چندگانه حرم‌ها */}
<Dialog open={isDuaDialogOpen} onOpenChange={setIsDuaDialogOpen}>
<DialogContent className="sm:max-w-lg rounded-2xl bg-card border-border-soft max-h-[90vh] overflow-y-auto p-6" dir="rtl">
<DialogHeader>
<DialogTitle className="text-sm font-black text-foreground">
{editingDua ? 'ویرایش دعا و تخصیص اماکن زیارتی' : 'ثبت دعای جدید و تخصیص چندگانه حرم‌ها'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSaveDua} className="space-y-4 py-2">
{/* عنوان به فارسی */}
<div>
<label className="block text-xs font-bold text-foreground mb-1.5">
عنوان دعا (فارسی) <span className="text-destructive">*</span>
</label>
<Input
type="text"
placeholder="مثلاً: زیارت امین الله"
value={formDuaNameFa}
onChange={(e) => setFormDuaNameFa(e.target.value)}
required
className="text-xs bg-card/60 border-border-soft rounded-xl"
/>
</div>
{/* عنوان به عربی و انگلیسی */}
<div className="grid grid-cols-2 gap-2.5">
<div>
<label className="block text-[11px] text-grey-3 mb-1">عنوان (عربی)</label>
<Input
type="text"
placeholder="زيارة أمين الله"
value={formDuaNameAr}
onChange={(e) => setFormDuaNameAr(e.target.value)}
className="text-xs bg-card/60 border-border-soft rounded-xl"
/>
</div>
<div>
<label className="block text-[11px] text-grey-3 mb-1">عنوان (انگلیسی)</label>
<Input
type="text"
placeholder="Ziyarat Aminullah"
value={formDuaNameEn}
onChange={(e) => setFormDuaNameEn(e.target.value)}
className="text-xs bg-card/60 border-border-soft rounded-xl font-mono"
dir="ltr"
/>
</div>
</div>
{/* بخش تخصیص چندگانه (Multi-select) دسته‌بندی‌ها */}
<div className="space-y-2 pt-2 border-t border-border-soft">
<div className="flex items-center justify-between">
<label className="block text-xs font-bold text-foreground">
تخصیص به دستهبندیهای ادعیه (چند انتخابی)
</label>
<span className="text-[11px] font-mono text-primary font-bold">
{toFa(formSelectedCategoryIds.length)} دستهبندی انتخاب شده
</span>
</div>
<p className="text-[11px] text-grey-3">
با انتخاب هر دستهبندی، این دعا درون پوشه مربوطه در شهر انتخابی در اپلیکیشن نمایش داده میشود:
</p>
{/* جستجو در دسته‌بندی‌ها */}
<div className="relative">
<Ic name="search" className="absolute right-2.5 top-1/2 -translate-y-1/2 size-3.5 text-grey-3" />
<Input
value={categorySearchTerm}
onChange={(e) => setCategorySearchTerm(e.target.value)}
placeholder="جستجو در دسته‌بندی‌ها یا شهرها..."
className="h-8 pr-8 text-xs bg-card/60 border-border-soft rounded-xl"
/>
</div>
{/* لیست چک‌باکس‌های داینامیک دسته‌بندی‌ها */}
<div className="max-h-48 overflow-y-auto space-y-1.5 p-2 rounded-xl border border-border-soft bg-card/40">
{formFilteredCategories.map((cat) => {
const isSelected = formSelectedCategoryIds.includes(cat.id)
return (
<div
key={cat.id}
onClick={() => toggleCategorySelection(cat.id)}
className={`flex items-center justify-between p-2 rounded-lg cursor-pointer transition text-xs ${
isSelected
? 'bg-primary/15 text-primary border border-primary/30 font-bold'
: 'hover:bg-card/80 text-foreground'
}`}
>
<div className="flex items-center gap-2">
<input
type="checkbox"
checked={isSelected}
onChange={() => {}}
className="rounded border-border-soft text-primary focus:ring-primary size-3.5 pointer-events-none"
/>
<span>{cat.name_display}</span>
</div>
<span className="text-[10px] text-grey-3">
{cat.city_info?.name || 'شهر نامشخص'}
</span>
</div>
)
})}
</div>
</div>
<DialogFooter className="gap-2 sm:justify-start pt-2">
<Button
type="submit"
disabled={isSavingDua}
className="rounded-xl bg-primary text-primary-foreground text-xs font-bold"
>
{isSavingDua ? 'در حال ذخیره...' : editingDua ? 'به‌روزرسانی دعا و تخصیص‌ها' : 'ثبت دعا'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => setIsDuaDialogOpen(false)}
className="rounded-xl border-border-soft text-xs"
>
انصراف
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
{/* شیت مدیریت فرازها و ترجمه‌های ۵‌زبانه */}
<Sheet open={!!selectedDuaForVerses} onOpenChange={(open) => !open && setSelectedDuaForVerses(null)}>
<SheetContent className="sm:max-w-2xl w-full p-0 flex flex-col bg-card border-border-soft" dir="rtl">
<SheetHeader className="p-5 border-b border-border-soft">
<div className="flex items-center justify-between">
<div>
<SheetTitle className="text-sm font-black text-foreground">
مدیریت متن و ترجمههای: {selectedDuaForVerses?.name_display}
</SheetTitle>
<p className="text-[11px] text-grey-3 mt-1">
شامل متن کامل عربی (با اعراب) و ترجمههای ۴ زبان (فارسی، انگلیسی، روسی، اندونزیایی)
</p>
</div>
</div>
{/* نوار جستجوی فراز و تب زبان فعال */}
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 pt-2">
<Input
type="text"
placeholder="جستجو در بین فرازها..."
value={verseSearch}
onChange={(e) => setVerseSearch(e.target.value)}
className="w-56 text-xs bg-card/60 border-border-soft rounded-xl"
/>
{/* تب‌های انتخاب زبان ترجمه */}
<div className="flex items-center gap-1 border border-border-soft rounded-xl p-0.5 bg-card/30 text-xs">
{(['fa', 'en', 'ru', 'id'] as const).map((lang) => (
<button
key={lang}
onClick={() => setActiveTranslationLang(lang)}
className={`px-2.5 py-1 rounded-lg transition font-mono ${
activeTranslationLang === lang
? 'bg-primary text-primary-foreground font-bold'
: 'text-grey-3 hover:text-foreground'
}`}
>
{lang === 'fa' && 'فارسی'}
{lang === 'en' && 'English'}
{lang === 'ru' && 'Русский'}
{lang === 'id' && 'Indonesia'}
</button>
))}
</div>
</div>
</SheetHeader>
{/* بدنه شیت: لیست فرازها */}
<div className="flex-1 overflow-y-auto p-5 space-y-4">
{isLoadingDetail ? (
<div className="space-y-3">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="p-4 rounded-xl border border-border-soft bg-card/30 space-y-2">
<Shimmer className="h-5 w-16 rounded-md" />
<Shimmer className="h-10 w-full rounded-md" />
<Shimmer className="h-8 w-4/5 rounded-md" />
</div>
))}
</div>
) : filteredVerses.length === 0 ? (
<div className="py-12 text-center text-xs text-grey-3">
فرازی برای این دعا یافت نشد.
</div>
) : (
filteredVerses.map((v, index) => {
const isEditingThis = editingVerseIndex === v.index
return (
<div
key={v.index}
className="p-4 rounded-2xl border border-border-soft bg-card/30 space-y-3 hover:border-primary/20 transition"
>
<div className="flex items-center justify-between">
<span className="text-[11px] font-mono font-bold text-primary">
فراز {toFa(index + 1)} (کد: #{v.index})
</span>
{!isEditingThis && (
<Button
variant="ghost"
size="sm"
onClick={() => handleStartEditVerse(v)}
className="h-7 px-2 rounded-lg text-xs text-grey-3 hover:text-foreground gap-1"
>
<Ic name="pencil" className="size-3" />
<span>ویرایش فراز</span>
</Button>
)}
</div>
{isEditingThis ? (
/* فرم ویرایش درون‌خطی فراز و ترجمه‌ها */
<div className="space-y-3 pt-2">
<div>
<label className="block text-[11px] font-bold text-foreground mb-1">
متن عربی (اصلی با اعراب)
</label>
<Textarea
value={verseFormText}
onChange={(e) => setVerseFormText(e.target.value)}
rows={3}
className="text-sm bg-card/80 border-border-soft rounded-xl font-bold leading-relaxed"
dir="rtl"
/>
</div>
{/* فیلدهای ترجمه ۴ زبانه */}
<div className="grid grid-cols-1 gap-2.5 sm:grid-cols-2 pt-1">
<div>
<label className="block text-[10px] text-grey-3 mb-1">ترجمه فارسی</label>
<Textarea
value={verseFormTranslations.fa}
onChange={(e) =>
setVerseFormTranslations({ ...verseFormTranslations, fa: e.target.value })
}
rows={2}
className="text-xs bg-card/80 border-border-soft rounded-xl"
dir="rtl"
/>
</div>
<div>
<label className="block text-[10px] text-grey-3 mb-1">ترجمه انگلیسی (EN)</label>
<Textarea
value={verseFormTranslations.en}
onChange={(e) =>
setVerseFormTranslations({ ...verseFormTranslations, en: e.target.value })
}
rows={2}
className="text-xs bg-card/80 border-border-soft rounded-xl font-mono"
dir="ltr"
/>
</div>
<div>
<label className="block text-[10px] text-grey-3 mb-1">ترجمه روسی (RU)</label>
<Textarea
value={verseFormTranslations.ru}
onChange={(e) =>
setVerseFormTranslations({ ...verseFormTranslations, ru: e.target.value })
}
rows={2}
className="text-xs bg-card/80 border-border-soft rounded-xl font-mono"
dir="ltr"
/>
</div>
<div>
<label className="block text-[10px] text-grey-3 mb-1">ترجمه اندونزیایی (ID)</label>
<Textarea
value={verseFormTranslations.id}
onChange={(e) =>
setVerseFormTranslations({ ...verseFormTranslations, id: e.target.value })
}
rows={2}
className="text-xs bg-card/80 border-border-soft rounded-xl font-mono"
dir="ltr"
/>
</div>
</div>
<div className="flex items-center gap-2 pt-2">
<Button
size="sm"
disabled={isSavingVerse}
onClick={() => handleSaveVerse(v.index)}
className="rounded-xl bg-primary text-primary-foreground text-xs font-bold"
>
{isSavingVerse ? 'در حال ذخیره...' : 'ذخیره تغییرات فراز'}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleCancelEditVerse}
className="rounded-xl border-border-soft text-xs"
>
انصراف
</Button>
</div>
</div>
) : (
/* نمایش متن اصلی و ترجمه فعال */
<div className="space-y-2">
{/* متن عربی با استایل برجسته */}
<div className="p-3 rounded-xl bg-card/60 border border-border-soft/60 text-sm font-bold text-foreground leading-loose" dir="rtl">
{v.text}
</div>
{/* ترجمه به زبان انتخاب شده */}
<div className="p-2.5 rounded-xl bg-card/30 border border-border-soft/40 text-xs text-grey-2 leading-relaxed" dir={activeTranslationLang === 'fa' ? 'rtl' : 'ltr'}>
<span className="text-[10px] font-bold text-primary me-1.5 uppercase font-mono">
[{activeTranslationLang}]:
</span>
{v.translations?.[activeTranslationLang] || (
<span className="text-grey-3 italic">بدون ترجمه</span>
)}
</div>
</div>
)}
</div>
)
})
)}
</div>
</SheetContent>
</Sheet>
</div>
)
}

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

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

@ -0,0 +1,614 @@
import React, { useState, useEffect, useMemo } from 'react'
import { Ic } from '@/icons'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { Shimmer } from '@/components/shared/Shimmer'
import { toFa } from '@/lib/utils'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog'
import type { ShrineItem, CreateShrineInput } from '../types/trip-etiquette'
import {
fetchShrines,
createShrine,
updateShrine,
deleteShrine,
} from '../services/trip-etiquette-api'
import { fetchCityGuideCountries } from '../services/zayer-guide-api'
import type { CityGuideCountry } from '../types/zayer-guide'
export function ShrinesServiceView() {
const [shrines, setShrines] = useState<ShrineItem[]>([])
const [countries, setCountries] = useState<CityGuideCountry[]>([])
const [isLoading, setIsLoading] = useState(true)
const [isRefreshing, setIsRefreshing] = useState(false)
const [errorMessage, setErrorMessage] = useState<string | null>(null)
// فیلترها و مرتب‌سازی
const [searchTerm, setSearchTerm] = useState('')
const [selectedCountryCode, setSelectedCountryCode] = useState('ALL')
const [selectedCityId, setSelectedCityId] = useState('ALL')
const [viewMode, setViewMode] = useState<'grid' | 'table'>('grid')
// وضعیت دیالوگ ایجاد و ویرایش
const [isDialogOpen, setIsDialogOpen] = useState(false)
const [editingShrine, setEditingShrine] = useState<ShrineItem | null>(null)
const [isSaving, setIsSaving] = useState(false)
// فرم ایجاد/ویرایش
const [formNameFa, setFormNameFa] = useState('')
const [formNameAr, setFormNameAr] = useState('')
const [formNameEn, setFormNameEn] = useState('')
const [formCountryCode, setFormCountryCode] = useState('')
const [formCityId, setFormCityId] = useState<number | ''>('')
const [formLat, setFormLat] = useState('')
const [formLng, setFormLng] = useState('')
// بارگذاری داده‌های اولیه
const loadData = async (isManual = false) => {
if (isManual) setIsRefreshing(true)
else setIsLoading(true)
setErrorMessage(null)
try {
const [shrinesRes, countriesRes] = await Promise.all([
fetchShrines(),
fetchCityGuideCountries(),
])
setShrines(shrinesRes)
setCountries(countriesRes)
} catch (err: any) {
setErrorMessage(err?.message || 'خطا در برقراری ارتباط با وب‌سرویس اماکن زیارتی.')
} finally {
setIsLoading(false)
setIsRefreshing(false)
}
}
useEffect(() => {
loadData()
}, [])
// لیست کلیه شهرهای در دسترس از کشورها
const allCities = useMemo(() => {
const list: { id: number; name: string; countryCode: string; countryName: string }[] = []
countries.forEach((country) => {
if (country.city) {
country.city.forEach((c) => {
list.push({
id: c.id,
name: c.name,
countryCode: country.code,
countryName: country.name,
})
})
}
})
return list
}, [countries])
// شهرهای قابل انتخاب بر اساس کشور انتخابی در فیلتر
const filterAvailableCities = useMemo(() => {
if (selectedCountryCode === 'ALL') return allCities
return allCities.filter((c) => c.countryCode.toLowerCase() === selectedCountryCode.toLowerCase())
}, [allCities, selectedCountryCode])
// شهرهای قابل انتخاب در فرم ایجاد/ویرایش
const formAvailableCities = useMemo(() => {
if (!formCountryCode) return allCities
return allCities.filter((c) => c.countryCode.toLowerCase() === formCountryCode.toLowerCase())
}, [allCities, formCountryCode])
// فیلتر کلاینت
const filteredShrines = useMemo(() => {
return shrines.filter((item) => {
// فیلتر کشور
if (selectedCountryCode !== 'ALL') {
const itemCode = item.country_info?.code?.toLowerCase()
if (itemCode !== selectedCountryCode.toLowerCase()) return false
}
// فیلتر شهر
if (selectedCityId !== 'ALL') {
if (String(item.city) !== String(selectedCityId)) return false
}
// جستجو
if (searchTerm.trim()) {
const term = searchTerm.toLowerCase()
const matchName = item.name_display.toLowerCase().includes(term)
const matchCity = item.city_info?.name?.toLowerCase().includes(term)
if (!matchName && !matchCity) return false
}
return true
})
}, [shrines, selectedCountryCode, selectedCityId, searchTerm])
// باز کردن فرم ایجاد
const handleOpenCreate = () => {
setEditingShrine(null)
setFormNameFa('')
setFormNameAr('')
setFormNameEn('')
const defaultCountry = countries[0]?.code || 'IQ'
setFormCountryCode(defaultCountry)
const initialCity = allCities.find((c) => c.countryCode === defaultCountry)?.id || ''
setFormCityId(initialCity)
setFormLat('')
setFormLng('')
setIsDialogOpen(true)
}
// باز کردن فرم ویرایش
const handleOpenEdit = (item: ShrineItem) => {
setEditingShrine(item)
const rawName = item.name || {}
setFormNameFa(typeof rawName === 'object' ? rawName.fa || item.name_display : item.name_display)
setFormNameAr(typeof rawName === 'object' ? rawName.ar || '' : '')
setFormNameEn(typeof rawName === 'object' ? rawName.en || '' : '')
setFormCountryCode(item.country_info?.code || '')
setFormCityId(item.city || '')
setFormLat(item.latitude ? String(item.latitude) : '')
setFormLng(item.longitude ? String(item.longitude) : '')
setIsDialogOpen(true)
}
// ذخیره فرم
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
if (!formNameFa.trim() || !formCityId) {
alert('لطفاً عنوان فارسی و شهر را مشخص فرمایید.')
return
}
setIsSaving(true)
try {
const nameObj = {
fa: formNameFa.trim(),
ar: formNameAr.trim() || formNameFa.trim(),
en: formNameEn.trim() || formNameFa.trim(),
}
if (editingShrine) {
const updated = await updateShrine(editingShrine.id, {
name: nameObj,
city: Number(formCityId),
latitude: formLat ? parseFloat(formLat) : null,
longitude: formLng ? parseFloat(formLng) : null,
})
setShrines((prev) => prev.map((s) => (s.id === updated.id ? updated : s)))
} else {
const created = await createShrine({
name: nameObj,
city: Number(formCityId),
latitude: formLat ? parseFloat(formLat) : null,
longitude: formLng ? parseFloat(formLng) : null,
})
setShrines((prev) => [created, ...prev])
}
setIsDialogOpen(false)
} catch (err: any) {
alert(err?.message || 'خطا در ذخیره‌سازی مکان زیارتی.')
} finally {
setIsSaving(false)
}
}
// حذف مکان زیارتی
const handleDelete = async (item: ShrineItem) => {
if (!window.confirm(`آیا از حذف مکان زیارتی «${item.name_display}» اطمینان دارید؟`)) return
try {
await deleteShrine(item.id)
setShrines((prev) => prev.filter((s) => s.id !== item.id))
} catch (err: any) {
alert(err?.message || 'خطا در حذف مکان.')
}
}
return (
<div className="space-y-6" dir="rtl">
{/* ردیف آمار خلاصه (طراحی خنثی مطابق سند) */}
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-4">
<div className="rounded-2xl border border-primary/20 bg-primary/5 p-4">
<span className="text-xs text-primary font-medium">کل اماکن متبرکه و حرمها</span>
<div className="mt-2 text-2xl font-black text-primary font-mono">{toFa(shrines.length)}</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">شهرهای تحت پوشش</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">
{toFa(new Set(shrines.map((s) => s.city)).size)}
</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">کشورهای زیارتی</span>
<div className="mt-2 text-2xl font-black text-foreground font-mono">
{toFa(countries.length)}
</div>
</div>
<div className="rounded-2xl border border-border-soft bg-card/40 p-4">
<span className="text-xs text-grey-3 font-medium">وضعیت وبسرویس</span>
<div className="mt-2 text-2xl font-black text-emerald-600 dark:text-emerald-400 font-mono">
داینامیک
</div>
</div>
</div>
{/* نوار ابزار، جستجو، فیلترها و دکمه ایجاد */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-wrap items-center gap-2.5">
{/* ورودی جستجو */}
<div className="relative w-64">
<Input
type="text"
placeholder="جستجوی حرم یا مکان زیارتی..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pe-8 text-xs bg-card/40 border-border-soft rounded-xl"
/>
<Ic name="search" className="absolute left-2.5 top-1/2 -translate-y-1/2 size-4 text-grey-3" />
</div>
{/* فیلتر کشور */}
<select
value={selectedCountryCode}
onChange={(e) => {
setSelectedCountryCode(e.target.value)
setSelectedCityId('ALL')
}}
className="h-9 px-3 text-xs bg-card/40 border border-border-soft rounded-xl text-foreground focus:outline-none focus:border-primary"
>
<option value="ALL">همه کشورها</option>
{countries.map((c) => (
<option key={c.code} value={c.code}>
{c.name} ({c.code})
</option>
))}
</select>
{/* فیلتر شهر */}
<select
value={selectedCityId}
onChange={(e) => setSelectedCityId(e.target.value)}
className="h-9 px-3 text-xs bg-card/40 border border-border-soft rounded-xl text-foreground focus:outline-none focus:border-primary"
>
<option value="ALL">همه شهرها</option>
{filterAvailableCities.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
{/* سوئیچ نحوه نمایش */}
<div className="flex items-center border border-border-soft rounded-xl p-0.5 bg-card/30">
<button
onClick={() => setViewMode('grid')}
className={`p-1.5 rounded-lg transition ${viewMode === 'grid' ? 'bg-card text-primary shadow-xs' : 'text-grey-3 hover:text-foreground'}`}
title="نمای کارتی"
>
<Ic name="grid" className="size-4" />
</button>
<button
onClick={() => setViewMode('table')}
className={`p-1.5 rounded-lg transition ${viewMode === 'table' ? 'bg-card text-primary shadow-xs' : 'text-grey-3 hover:text-foreground'}`}
title="نمای جدولی"
>
<Ic name="list" className="size-4" />
</button>
</div>
</div>
{/* دکمه افزودن و تازه‌سازی */}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => loadData(true)}
disabled={isRefreshing}
className="rounded-xl border-border-soft text-xs gap-1"
>
<Ic name="refresh" className={`size-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
<span>تازهسازی</span>
</Button>
<Button
size="sm"
onClick={handleOpenCreate}
className="rounded-xl bg-primary text-primary-foreground text-xs font-bold gap-1.5 shadow-sm"
>
<Ic name="plus" className="size-4" />
<span>افزودن مکان زیارتی</span>
</Button>
</div>
</div>
{/* پیام خطا */}
{errorMessage && (
<div className="p-4 rounded-xl border border-destructive/20 bg-destructive/5 text-destructive text-xs flex items-center justify-between">
<span>{errorMessage}</span>
<Button variant="ghost" size="sm" onClick={() => loadData(true)}>تلاش مجدد</Button>
</div>
)}
{/* لودینگ اسکلتون */}
{isLoading ? (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
{[1, 2, 3, 4, 5, 6].map((i) => (
<div key={i} className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-3">
<Shimmer className="h-5 w-24 rounded-md" />
<Shimmer className="h-6 w-4/5 rounded-md" />
<Shimmer className="h-4 w-1/2 rounded-md" />
</div>
))}
</div>
) : filteredShrines.length === 0 ? (
<div className="py-16 text-center rounded-2xl border border-dashed border-border-soft bg-card/20 space-y-3">
<div className="size-12 rounded-full bg-card/80 border border-border-soft mx-auto flex items-center justify-center text-grey-3">
<Ic name="shrine" className="size-6" />
</div>
<p className="text-sm font-bold text-foreground">هیچ مکان زیارتی مطابق با فیلترها یافت نشد</p>
<p className="text-xs text-grey-3">برای مشاهده مکانها فیلترها را تغییر داده یا مکان جدیدی ثبت کنید.</p>
</div>
) : viewMode === 'grid' ? (
/* نمای کارتی */
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filteredShrines.map((item) => (
<div
key={item.id}
className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-3 hover:border-primary/30 transition shadow-xs flex flex-col justify-between"
>
<div>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<Badge variant="outline" className="text-[10px] text-primary border-primary/20">
{item.city_info?.name || 'شهر نامشخص'}
</Badge>
{item.country_info && (
<span className="text-[10px] text-grey-3">({item.country_info.name})</span>
)}
</div>
<span className="text-[11px] font-mono text-grey-3 font-bold">#{item.id}</span>
</div>
<h3 className="mt-3 text-base font-black text-foreground">{item.name_display}</h3>
<div className="mt-3 flex items-center gap-3 text-xs text-grey-3">
<span className="flex items-center gap-1">
<Ic name="book" className="size-3.5 text-primary" />
<span>{toFa(item.duas_count)} دعا و زیارت متصل</span>
</span>
</div>
</div>
<div className="pt-3 border-t border-border-soft flex items-center justify-between">
<div className="flex items-center gap-1.5 text-[11px] text-grey-3 font-mono">
{item.latitude && item.longitude ? (
<span>📍 {item.latitude.toFixed(2)}, {item.longitude.toFixed(2)}</span>
) : (
<span>موقعیت مکانی ثبت نشده</span>
)}
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenEdit(item)}
className="size-8 p-0 rounded-lg text-grey-3 hover:text-foreground"
title="ویرایش مکان"
>
<Ic name="pencil" className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(item)}
className="size-8 p-0 rounded-lg text-destructive/70 hover:text-destructive hover:bg-destructive/10"
title="حذف مکان"
>
<Ic name="trash" className="size-3.5" />
</Button>
</div>
</div>
</div>
))}
</div>
) : (
/* نمای جدولی */
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40 shadow-xs">
<table className="w-full text-start text-xs">
<thead className="border-b border-border-soft bg-card/60 text-grey-3">
<tr>
<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-end font-bold">عملیات</th>
</tr>
</thead>
<tbody className="divide-y divide-border-soft">
{filteredShrines.map((item) => (
<tr key={item.id} className="hover:bg-card/70 transition">
<td className="p-3.5 font-mono font-bold text-grey-3">#{item.id}</td>
<td className="p-3.5 font-bold text-foreground">{item.name_display}</td>
<td className="p-3.5 text-grey-2">{item.city_info?.name || '-'}</td>
<td className="p-3.5 text-grey-3">{item.country_info?.name || '-'}</td>
<td className="p-3.5 font-mono font-bold text-primary">{toFa(item.duas_count)}</td>
<td className="p-3.5 font-mono text-grey-3">
{item.latitude && item.longitude ? `${item.latitude.toFixed(2)}, ${item.longitude.toFixed(2)}` : '-'}
</td>
<td className="p-3.5 text-end">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleOpenEdit(item)}
className="size-7 p-0 rounded-lg text-grey-3 hover:text-foreground"
>
<Ic name="pencil" className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(item)}
className="size-7 p-0 rounded-lg text-destructive/70 hover:text-destructive hover:bg-destructive/10"
>
<Ic name="trash" className="size-3.5" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* دیالوگ ایجاد و ویرایش مکان زیارتی */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="sm:max-w-md rounded-2xl bg-card border-border-soft p-6" dir="rtl">
<DialogHeader>
<DialogTitle className="text-sm font-black text-foreground">
{editingShrine ? 'ویرایش مکان زیارتی' : 'ثبت مکان زیارتی جدید (کاملاً داینامیک)'}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSave} className="space-y-4 py-2">
{/* نام به زبان فارسی */}
<div>
<label className="block text-xs font-bold text-foreground mb-1.5">
نام مکان (فارسی) <span className="text-destructive">*</span>
</label>
<Input
type="text"
placeholder="مانند: حرم مطهر امام حسین (ع)"
value={formNameFa}
onChange={(e) => setFormNameFa(e.target.value)}
required
className="text-xs bg-card/60 border-border-soft rounded-xl"
/>
</div>
{/* نام به عربی و انگلیسی */}
<div className="grid grid-cols-2 gap-2.5">
<div>
<label className="block text-[11px] text-grey-3 mb-1">نام (عربی)</label>
<Input
type="text"
placeholder="العتبة الحسينية المقدسة"
value={formNameAr}
onChange={(e) => setFormNameAr(e.target.value)}
className="text-xs bg-card/60 border-border-soft rounded-xl"
/>
</div>
<div>
<label className="block text-[11px] text-grey-3 mb-1">نام (انگلیسی)</label>
<Input
type="text"
placeholder="Holy Shrine of Imam Hussain"
value={formNameEn}
onChange={(e) => setFormNameEn(e.target.value)}
className="text-xs bg-card/60 border-border-soft rounded-xl font-mono"
dir="ltr"
/>
</div>
</div>
{/* کشور و شهر */}
<div className="grid grid-cols-2 gap-2.5">
<div>
<label className="block text-xs font-bold text-foreground mb-1.5">کشور</label>
<select
value={formCountryCode}
onChange={(e) => {
const cCode = e.target.value
setFormCountryCode(cCode)
const firstCity = allCities.find((c) => c.countryCode === cCode)?.id || ''
setFormCityId(firstCity)
}}
className="w-full h-9 px-3 text-xs bg-card/60 border border-border-soft rounded-xl text-foreground focus:outline-none focus:border-primary"
>
{countries.map((c) => (
<option key={c.code} value={c.code}>
{c.name} ({c.code})
</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-bold text-foreground mb-1.5">
شهر <span className="text-destructive">*</span>
</label>
<select
value={formCityId}
onChange={(e) => setFormCityId(Number(e.target.value))}
required
className="w-full h-9 px-3 text-xs bg-card/60 border border-border-soft rounded-xl text-foreground focus:outline-none focus:border-primary"
>
<option value="">انتخاب شهر...</option>
{formAvailableCities.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
</div>
{/* مختصات جغرافیایی */}
<div className="grid grid-cols-2 gap-2.5">
<div>
<label className="block text-[11px] text-grey-3 mb-1">عرض جغرافیایی (Latitude)</label>
<Input
type="number"
step="any"
placeholder="32.0"
value={formLat}
onChange={(e) => setFormLat(e.target.value)}
className="text-xs bg-card/60 border-border-soft rounded-xl font-mono"
dir="ltr"
/>
</div>
<div>
<label className="block text-[11px] text-grey-3 mb-1">طول جغرافیایی (Longitude)</label>
<Input
type="number"
step="any"
placeholder="44.0"
value={formLng}
onChange={(e) => setFormLng(e.target.value)}
className="text-xs bg-card/60 border-border-soft rounded-xl font-mono"
dir="ltr"
/>
</div>
</div>
<DialogFooter className="gap-2 sm:justify-start pt-2">
<Button
type="submit"
disabled={isSaving}
className="rounded-xl bg-primary text-primary-foreground text-xs font-bold"
>
{isSaving ? 'در حال ذخیره...' : editingShrine ? 'ذخیره تغییرات' : 'ثبت مکان زیارتی'}
</Button>
<Button
type="button"
variant="outline"
onClick={() => setIsDialogOpen(false)}
className="rounded-xl border-border-soft text-xs"
>
انصراف
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
)
}

111
src/features/services/services/faq-api.ts

@ -0,0 +1,111 @@
import { apiFetch } from '@/services/http'
import type {
FaqCategoryItem,
CreateFaqCategoryInput,
UpdateFaqCategoryInput,
FaqItem,
CreateFaqItemInput,
UpdateFaqItemInput,
} from '../types/faq'
/**
* دریافت فهرست دستهبندیهای FAQ برای پنل ادمین
*/
export async function fetchAdminFaqCategories(params?: { search?: string }): Promise<FaqCategoryItem[]> {
const q = new URLSearchParams()
if (params?.search) q.set('search', params.search)
const url = `home/faq/admin/categories/${q.toString() ? `?${q.toString()}` : ''}`
const res = await apiFetch<any>(url)
if (Array.isArray(res)) return res
if (res && Array.isArray(res.results)) return res.results
return []
}
/**
* ایجاد دستهبندی جدید FAQ
*/
export async function createFaqCategory(input: CreateFaqCategoryInput): Promise<FaqCategoryItem> {
return apiFetch<FaqCategoryItem>('home/faq/admin/categories/', {
method: 'POST',
body: input,
})
}
/**
* ویرایش دستهبندی FAQ
*/
export async function updateFaqCategory(id: number, input: UpdateFaqCategoryInput): Promise<FaqCategoryItem> {
return apiFetch<FaqCategoryItem>(`home/faq/admin/categories/${id}/`, {
method: 'PATCH',
body: input,
})
}
/**
* تغییر وضعیت فعال/غیرفعال دستهبندی
*/
export async function toggleFaqCategoryActive(id: number, is_active: boolean): Promise<FaqCategoryItem> {
return updateFaqCategory(id, { is_active })
}
/**
* حذف دستهبندی FAQ
*/
export async function deleteFaqCategory(id: number): Promise<void> {
return apiFetch<void>(`home/faq/admin/categories/${id}/`, {
method: 'DELETE',
})
}
/**
* دریافت فهرست سوالات FAQ برای پنل ادمین
*/
export async function fetchAdminFaqItems(params?: { category_id?: number | string }): Promise<FaqItem[]> {
const q = new URLSearchParams()
if (params?.category_id && params.category_id !== 'ALL') {
q.set('category_id', String(params.category_id))
}
const url = `home/faq/admin/items/${q.toString() ? `?${q.toString()}` : ''}`
const res = await apiFetch<any>(url)
if (Array.isArray(res)) return res
if (res && Array.isArray(res.results)) return res.results
return []
}
/**
* ایجاد سوال جدید FAQ
*/
export async function createFaqItem(input: CreateFaqItemInput): Promise<FaqItem> {
return apiFetch<FaqItem>('home/faq/admin/items/', {
method: 'POST',
body: input,
})
}
/**
* ویرایش سوال FAQ
*/
export async function updateFaqItem(id: number, input: UpdateFaqItemInput): Promise<FaqItem> {
return apiFetch<FaqItem>(`home/faq/admin/items/${id}/`, {
method: 'PATCH',
body: input,
})
}
/**
* تغییر وضعیت فعال/غیرفعال سوال
*/
export async function toggleFaqItemActive(id: number, is_active: boolean): Promise<FaqItem> {
return updateFaqItem(id, { is_active })
}
/**
* حذف سوال FAQ
*/
export async function deleteFaqItem(id: number): Promise<void> {
return apiFetch<void>(`home/faq/admin/items/${id}/`, {
method: 'DELETE',
})
}

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

@ -0,0 +1,198 @@
import { apiFetch } from '@/services/http'
import type {
CategoryItem,
CreateCategoryInput,
UpdateCategoryInput,
ShrineItem,
DuaItem,
DuaDetailItem,
VerseItem,
CreateShrineInput,
CreateDuaInput,
} from '../types/trip-etiquette'
/**
* دریافت لیست دستهبندیهای ادعیه شهرها
*/
export async function fetchCategories(params?: {
city_id?: number | string
country_code?: string
search?: string
}): Promise<CategoryItem[]> {
const q = new URLSearchParams()
if (params?.city_id && params.city_id !== 'ALL') q.set('city_id', String(params.city_id))
if (params?.country_code && params.country_code !== 'ALL') q.set('country_code', params.country_code)
if (params?.search) q.set('search', params.search)
const url = `trip-etiquette/categories/${q.toString() ? `?${q.toString()}` : ''}`
const res = await apiFetch<any>(url)
if (Array.isArray(res)) return res
if (res && Array.isArray(res.results)) return res.results
return []
}
/**
* ایجاد دستهبندی جدید برای یک شهر
*/
export async function createCategory(input: CreateCategoryInput): Promise<CategoryItem> {
return apiFetch<CategoryItem>('trip-etiquette/categories/', {
method: 'POST',
body: input,
})
}
/**
* ویرایش دستهبندی و همگامسازی ادعیه منتسب
*/
export async function updateCategory(id: number, input: UpdateCategoryInput): Promise<CategoryItem> {
return apiFetch<CategoryItem>(`trip-etiquette/categories/${id}/`, {
method: 'PATCH',
body: input,
})
}
/**
* حذف دستهبندی
*/
export async function deleteCategory(id: number): Promise<void> {
return apiFetch<void>(`trip-etiquette/categories/${id}/`, {
method: 'DELETE',
})
}
/**
* دریافت لیست اماکن زیارتی (حرمها)
*/
export async function fetchShrines(params?: {
city_id?: number | string
country_code?: string
search?: string
}): Promise<ShrineItem[]> {
const q = new URLSearchParams()
if (params?.city_id && params.city_id !== 'ALL') q.set('city_id', String(params.city_id))
if (params?.country_code && params.country_code !== 'ALL') q.set('country_code', params.country_code)
if (params?.search) q.set('search', params.search)
const url = `trip-etiquette/shrines/${q.toString() ? `?${q.toString()}` : ''}`
const res = await apiFetch<any>(url)
if (Array.isArray(res)) return res
if (res && Array.isArray(res.results)) return res.results
return []
}
/**
* ایجاد مکان زیارتی جدید
*/
export async function createShrine(input: CreateShrineInput): Promise<ShrineItem> {
const body = {
name: input.name,
city: input.city,
latitude: input.latitude || null,
longitude: input.longitude || null,
}
return apiFetch<ShrineItem>('trip-etiquette/shrines/', {
method: 'POST',
body,
})
}
/**
* ویرایش مکان زیارتی
*/
export async function updateShrine(id: number, input: Partial<CreateShrineInput>): Promise<ShrineItem> {
return apiFetch<ShrineItem>(`trip-etiquette/shrines/${id}/`, {
method: 'PATCH',
body: input,
})
}
/**
* حذف مکان زیارتی
*/
export async function deleteShrine(id: number): Promise<void> {
return apiFetch<void>(`trip-etiquette/shrines/${id}/`, {
method: 'DELETE',
})
}
/**
* دریافت لیست مستقیم و فلت ادعیه و زیارات
*/
export async function fetchDuas(params?: {
category_id?: number | string
shrine_id?: number | string
city_id?: number | string
search?: string
}): Promise<DuaItem[]> {
const q = new URLSearchParams()
if (params?.category_id && params.category_id !== 'ALL') q.set('category_id', String(params.category_id))
if (params?.shrine_id && params.shrine_id !== 'ALL') q.set('shrine_id', String(params.shrine_id))
if (params?.city_id && params.city_id !== 'ALL') q.set('city_id', String(params.city_id))
if (params?.search) q.set('search', params.search)
const url = `trip-etiquette/duas/${q.toString() ? `?${q.toString()}` : ''}`
const res = await apiFetch<any>(url)
if (Array.isArray(res)) return res
if (res && Array.isArray(res.results)) return res.results
return []
}
/**
* دریافت جزئیات دعا به همراه فرازها و تمام ترجمهها
*/
export async function fetchDuaDetail(index: number): Promise<DuaDetailItem> {
return apiFetch<DuaDetailItem>(`trip-etiquette/duas/${index}/`)
}
/**
* ایجاد یک دعای جدید و تخصیص دستهبندیها
*/
export async function createDua(input: CreateDuaInput): Promise<DuaItem> {
return apiFetch<DuaItem>('trip-etiquette/duas/', {
method: 'POST',
body: {
name: input.name,
category_ids: input.category_ids,
shrine_ids: input.shrine_ids,
},
})
}
/**
* ویرایش دعا و بهروزرسانی تخصیص دستهبندیها
*/
export async function updateDua(
index: number,
input: {
name?: Record<string, string>
category_ids?: number[]
shrine_ids?: number[]
}
): Promise<DuaItem> {
return apiFetch<DuaItem>(`trip-etiquette/duas/${index}/`, {
method: 'PATCH',
body: input,
})
}
/**
* حذف دعا
*/
export async function deleteDua(index: number): Promise<void> {
return apiFetch<void>(`trip-etiquette/duas/${index}/`, {
method: 'DELETE',
})
}
/**
* ویرایش متن اصلی یک فراز و ترجمههای چندزبانه آن
*/
export async function updateVerse(
index: number,
input: { text?: string; translations?: Record<string, string> }
): Promise<VerseItem> {
return apiFetch<VerseItem>(`trip-etiquette/verses/${index}/`, {
method: 'PATCH',
body: input,
})
}

68
src/features/services/types/faq.ts

@ -0,0 +1,68 @@
export type SupportedLanguage = 'fa' | 'ar' | 'en' | 'ru' | 'id'
export const SUPPORTED_LANGUAGES: { code: SupportedLanguage; label: string; nativeName: string; dir: 'rtl' | 'ltr' }[] = [
{ code: 'fa', label: 'فارسی', nativeName: 'فارسی', dir: 'rtl' },
{ code: 'ar', label: 'عربی', nativeName: 'العربية', dir: 'rtl' },
{ code: 'en', label: 'انگلیسی', nativeName: 'English', dir: 'ltr' },
{ code: 'ru', label: 'روسی', nativeName: 'Русский', dir: 'ltr' },
{ code: 'id', label: 'اندونزیایی', nativeName: 'Bahasa Indonesia', dir: 'ltr' },
]
export interface FaqCategoryTranslation {
title: string
description: string
}
export type FaqCategoryTranslations = Record<SupportedLanguage, FaqCategoryTranslation>
export interface FaqCategoryItem {
id: number
slug: string
icon_name: string
order: number
is_active: boolean
translations: Record<string, { title?: string; description?: string }>
title_fa: string
items_count: number
created_at: string
updated_at: string
}
export interface CreateFaqCategoryInput {
slug: string
icon_name: string
order: number
is_active: boolean
translations: Record<string, { title: string; description: string }>
}
export type UpdateFaqCategoryInput = Partial<CreateFaqCategoryInput>
export interface FaqItemTranslation {
question: string
answer: string
}
export type FaqItemTranslations = Record<SupportedLanguage, FaqItemTranslation>
export interface FaqItem {
id: number
category: number
category_slug?: string
category_title?: string
order: number
is_active: boolean
translations: Record<string, { question?: string; answer?: string }>
question_fa: string
created_at: string
updated_at: string
}
export interface CreateFaqItemInput {
category: number
order: number
is_active: boolean
translations: Record<string, { question: string; answer: string }>
}
export type UpdateFaqItemInput = Partial<CreateFaqItemInput>

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

@ -0,0 +1,105 @@
export interface CategoryItem {
id: number
name: Record<string, string> | any
name_display: string
city: number
city_info?: {
id: number
name: string
slug: string
} | null
country_info?: {
id: number
name: string
code: string
} | null
duas_count: number
assigned_dua_ids?: number[]
}
export interface CreateCategoryInput {
name: Record<string, string>
city: number
dua_ids?: number[]
}
export interface UpdateCategoryInput {
name?: Record<string, string>
city?: number
dua_ids?: number[]
}
export interface ShrineItem {
id: number
name: Record<string, string> | any
name_display: string
city: number
city_info?: {
id: number
name: string
slug: string
} | null
country_info?: {
id: number
name: string
code: string
} | null
duas_count: number
thumbnail?: string | null
thumbnail_url?: string | null
latitude?: number | null
longitude?: number | null
}
export interface DuaItem {
index: number
name: Record<string, string> | any
name_display: string
categories?: Array<{
id: number
name: string
city_id?: number
city_name: string
country_name: string
}>
shrines?: Array<{
id: number
name: string
city_name: string
country_name: string
}>
verses_count: number
}
export interface VerseItem {
index: number
sub_ritual: number
text: string
juz: number
translations: {
fa: string
en: string
ru: string
id: string
}
}
export interface DuaDetailItem extends DuaItem {
category_ids?: number[]
shrine_ids?: number[]
verses: VerseItem[]
}
export interface CreateShrineInput {
name: Record<string, string>
city: number
latitude?: number | null
longitude?: number | null
thumbnail?: File | null
}
export interface CreateDuaInput {
name: Record<string, string>
category_ids?: number[]
shrine_ids?: number[]
}

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

@ -87,6 +87,7 @@ export function TourStatusBadge({ status, type = 'sale', className }: TourStatus
</Badge>
)
case 'FINISHED':
case 'COMPLETED':
return (
<Badge
variant="outline"

3
src/icons.tsx

@ -89,6 +89,9 @@ const PATHS: Record<string, string> = {
headset: '<path d="M3 18v-6a9 9 0 0 1 18 0v6"/><path d="M21 19a2 2 0 0 1-2 2h-1a2 2 0 0 1-2-2v-3a2 2 0 0 1 2-2h3zM3 19a2 2 0 0 0 2 2h1a2 2 0 0 0 2-2v-3a2 2 0 0 0-2-2H3z"/><path d="M21 16v2a3 3 0 0 1-3 3h-3"/>',
store: '<path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4Z"/><path d="M3 6h18"/><path d="M16 10a4 4 0 0 1-8 0"/>',
calculator: '<rect width="16" height="20" x="4" y="2" rx="2"/><line x1="8" x2="16" y1="6" y2="6"/><circle cx="8" cy="11" r="1"/><circle cx="12" cy="11" r="1"/><circle cx="16" cy="11" r="1"/><circle cx="8" cy="15" r="1"/><circle cx="12" cy="15" r="1"/><circle cx="16" cy="15" r="1"/><circle cx="8" cy="18" r="1"/><circle cx="12" cy="18" r="1"/><circle cx="16" cy="18" r="1"/>',
book: '<path d="M4 19.5v-15A2.5 2.5 0 0 1 6.5 2H20v20H6.5a2.5 2.5 0 0 1-2.5-2.5Z"/><path d="M6 6h10"/><path d="M6 10h10"/>',
shrine: '<path d="M12 2a4 4 0 0 0-4 4c0 3 4 5 4 5s4-2 4-5a4 4 0 0 0-4-4Z"/><path d="M4 22h16"/><path d="M6 22V13a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v9"/><path d="M10 22v-5a2 2 0 0 1 4 0v5"/>',
helpCircle: '<circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
}
export type IconName = keyof typeof PATHS

Loading…
Cancel
Save