Browse Source

feat: add services feature with localized resources, navigation, and management views

master
Muhammad A. Ghorbani 2 weeks ago
parent
commit
ad4b770334
  1. 23
      src/components/ui/dialog.tsx
  2. 7
      src/config/navigation.ts
  3. 34
      src/features/services/ServicesPage.tsx
  4. 721
      src/features/services/components/CategoriesServiceView.tsx
  5. 401
      src/features/services/components/CitiesServiceView.tsx
  6. 1697
      src/features/services/components/DuasServiceView.tsx
  7. 24
      src/features/services/components/ShrinesServiceView.tsx
  8. 126
      src/features/services/services/trip-etiquette-api.ts
  9. 58
      src/features/services/types/trip-etiquette.ts
  10. 2
      src/icons.tsx
  11. 2
      src/locales/ar.json
  12. 2
      src/locales/en.json
  13. 2
      src/locales/fa.json

23
src/components/ui/dialog.tsx

@ -1,6 +1,7 @@
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { cn } from '@/lib/utils'
import { Ic } from '@/icons'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
@ -16,19 +17,24 @@ function DialogOverlay({ className, ...props }: React.ComponentProps<typeof Dial
)
}
export interface DialogContentProps extends React.ComponentProps<typeof DialogPrimitive.Content> {
showCloseButton?: boolean
}
function DialogContent({
className,
children,
showCloseButton = true,
onPointerDownOutside,
onInteractOutside,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
}: DialogContentProps) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
className={cn(
'fixed left-1/2 top-1/2 z-50 w-full max-w-[640px] -translate-x-1/2 -translate-y-1/2 rounded-xl border border-border-soft bg-card text-card-foreground shadow-[0_30px_80px_rgba(0,0,0,.35)] anim-pop outline-none',
'fixed left-1/2 top-1/2 z-50 w-full max-w-[640px] -translate-x-1/2 -translate-y-1/2 rounded-2xl border border-border-soft bg-card text-card-foreground shadow-[0_30px_80px_rgba(0,0,0,.35)] anim-pop outline-none flex flex-col',
className,
)}
onPointerDownOutside={(e) => {
@ -43,6 +49,15 @@ function DialogContent({
{...props}
>
{children}
{showCloseButton && (
<DialogPrimitive.Close
aria-label="بستن"
className="absolute end-4 top-4 z-20 flex size-7 items-center justify-center rounded-xl text-grey-3 transition-colors hover:bg-muted/80 hover:text-foreground focus:outline-hidden disabled:pointer-events-none cursor-pointer"
>
<Ic name="x" className="size-4" />
<span className="sr-only">بستن</span>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
)
@ -54,7 +69,7 @@ const DialogDescription = DialogPrimitive.Description
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('flex flex-col space-y-1.5 text-start pb-3 border-b border-border-soft', className)}
className={cn('flex flex-col space-y-1.5 text-start pb-3 pe-8 border-b border-border-soft shrink-0', className)}
{...props}
/>
)
@ -63,7 +78,7 @@ function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
function DialogFooter({ className, ...props }: React.ComponentProps<'div'>) {
return (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end gap-2 pt-4 border-t border-border-soft', className)}
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end gap-2 pt-4 border-t border-border-soft shrink-0', className)}
{...props}
/>
)

7
src/config/navigation.ts

@ -73,9 +73,14 @@ export function getNavigationConfig(t: (key: string) => string): NavGroupConfig[
to: '/services/categories',
icon: 'folder',
},
{
title: t('navigation.cities'),
titleKey: 'navigation.cities',
to: '/services/cities',
icon: 'map',
},
],
},
{ title: t('navigation.shrines'), titleKey: 'navigation.shrines', to: '/services/shrines', icon: 'shrine' },
{
title: t('navigation.store'),
titleKey: 'navigation.store',

34
src/features/services/ServicesPage.tsx

@ -10,7 +10,7 @@ import { toFa } from '@/lib/utils'
import { ZayerGuideServiceView } from './components/ZayerGuideServiceView'
import { DuasServiceView } from './components/DuasServiceView'
import { CategoriesServiceView } from './components/CategoriesServiceView'
import { ShrinesServiceView } from './components/ShrinesServiceView'
import { CitiesServiceView } from './components/CitiesServiceView'
import { FaqServiceView } from './components/FaqServiceView'
import { StoreServiceView } from './components/StoreServiceView'
@ -73,6 +73,20 @@ export const SERVICES_LIST: ServiceMeta[] = [
defaultCount: 19,
badge: 'مالی و ارزی',
},
{
key: 'cities',
title: 'شهرهای زیارتی',
titleEn: 'Holy Cities',
icon: 'map',
color: 'text-primary',
bgColor: 'bg-primary/10',
borderColor: 'border-primary/20',
description: 'فهرست استاتیک و رسمی شهرهای زیارتی اپلیکیشن عقیله به همراه شناسه‌های ثابت دیتابیس و دسته‌بندی‌های اعمال',
endpoint: '/trip-etiquette/cities/',
statsLabel: 'شهر زیارتی مرجع',
defaultCount: 10,
badge: 'استاتیک و مرجع',
},
{
key: 'duas',
title: 'ادعیه و زیارات',
@ -101,20 +115,6 @@ export const SERVICES_LIST: ServiceMeta[] = [
defaultCount: 6,
badge: 'شهرها و پوشه‌ها',
},
{
key: 'shrines',
title: 'اماکن زیارتی',
titleEn: 'Holy Shrines',
icon: 'shrine',
color: 'text-primary',
bgColor: 'bg-primary/10',
borderColor: 'border-primary/20',
description: 'بانک داینامیک حرم‌ها و اماکن متبرکه، ثبت کشور و شهر، مختصات جغرافیایی و پیوند ادعیه',
endpoint: '/trip-etiquette/shrines/',
statsLabel: 'مکان زیارتی ثبت‌شده',
defaultCount: 3,
badge: 'اماکن و حرم‌ها',
},
{
key: 'store',
title: 'فروشگاه معصومه',
@ -260,12 +260,14 @@ function ServiceDetailRenderer({
return <ZayerGuideServiceView service={service} />
case 'exchange':
return <ExchangeServiceView service={service} />
case 'cities':
return <CitiesServiceView />
case 'duas':
return <DuasServiceView />
case 'categories':
return <CategoriesServiceView />
case 'shrines':
return <ShrinesServiceView />
return <Navigate to="/services/duas" replace />
case 'trip-etiquette':
return <DuasServiceView />
case 'consultant':

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

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

@ -0,0 +1,401 @@
import React, { useState, useEffect, useMemo } from 'react'
import { Link } from 'react-router-dom'
import { Ic } from '@/icons'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge'
import { ShimmerBox, ShimmerLine } from '@/components/shared/shimmer_frontend'
import { toFa } from '@/lib/utils'
import type {
PilgrimageCityItem,
CategoryItem,
} from '../types/trip-etiquette'
import {
fetchTripEtiquetteCities,
fetchCategories,
} from '../services/trip-etiquette-api'
// نام‌های روان و استاندارد فارسی برای شهرهای زیارتی
const CITY_FA_NAMES: Record<string, string> = {
karbala: 'کربلای معلی',
najaf: 'نجف اشرف',
koufa: 'کوفه',
kufa: 'کوفه',
kazemein: 'کاظمین',
kadhimiya: 'کاظمین',
samara: 'سامرا',
samarra: 'سامرا',
make: 'مکه مکرمه',
mecca: 'مکه مکرمه',
madineh: 'مدینه منوره',
medina: 'مدینه منوره',
mashhad: 'مشهد مقدس',
qom: 'قم مقدسه',
}
const COUNTRY_FLAGS: Record<string, string> = {
IQ: '🇮🇶',
IR: '🇮🇷',
SA: '🇸🇦',
}
const COUNTRY_NAMES_FA: Record<string, string> = {
Iraq: 'عراق',
Iran: 'ایران',
'Saudi Arabia': 'عربستان سعودی',
}
export function CitiesServiceView() {
const [cities, setCities] = useState<PilgrimageCityItem[]>([])
const [categories, setCategories] = useState<CategoryItem[]>([])
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 loadData = async (isManual = false) => {
if (isManual) setIsRefreshing(true)
else setIsLoading(true)
setErrorMessage(null)
try {
const [citiesRes, categoriesRes] = await Promise.all([
fetchTripEtiquetteCities(),
fetchCategories(),
])
setCities(citiesRes)
setCategories(categoriesRes)
} catch (err: any) {
setErrorMessage(err?.message || 'خطا در بارگذاری فهرست شهرهای زیارتی.')
} finally {
setIsLoading(false)
setIsRefreshing(false)
}
}
useEffect(() => {
loadData()
}, [])
// کشورهای موجود در لیست شهرها
const availableCountries = useMemo(() => {
const map = new Map<string, { code: string; name: string; nameFa: string; flag: string }>()
cities.forEach((c) => {
const code = c.country_code || ''
const name = c.country_name || ''
if (code && !map.has(code)) {
map.set(code, {
code,
name,
nameFa: COUNTRY_NAMES_FA[name] || name,
flag: COUNTRY_FLAGS[code] || '🌍',
})
}
})
return Array.from(map.values())
}, [cities])
// شهرهای فیلترشده
const filteredCities = useMemo(() => {
return cities.filter((city) => {
// فیلتر کشور
if (selectedCountryCode !== 'ALL') {
if ((city.country_code || '').toLowerCase() !== selectedCountryCode.toLowerCase()) {
return false
}
}
// فیلتر جستجو بر اساس نام، شناسه یا اسلاگ
if (searchTerm.trim()) {
const term = searchTerm.toLowerCase().trim()
const faName = CITY_FA_NAMES[city.slug.toLowerCase()] || ''
const matchName = city.name.toLowerCase().includes(term)
const matchFa = faName.toLowerCase().includes(term)
const matchSlug = city.slug.toLowerCase().includes(term)
const matchId = String(city.id).includes(term)
return matchName || matchFa || matchSlug || matchId
}
return true
})
}, [cities, selectedCountryCode, searchTerm])
return (
<div className="space-y-6">
{/* سربرگ معرفی شناسه شهرها و وضعیت استاتیک */}
<div className="rounded-2xl border border-border-soft bg-card/40 p-4 sm:p-5 flex flex-col md:flex-row items-start md:items-center justify-between gap-4 shadow-xs">
<div className="space-y-1">
<div className="flex items-center gap-2.5">
<div className="size-9 rounded-xl bg-primary/10 border border-primary/20 flex items-center justify-center text-primary">
<Ic name="map" className="size-5" />
</div>
<div>
<h2 className="text-base sm:text-lg font-black text-foreground">
شهرهای زیارتی مرجع (شناسه‌های ثابت دیتابیس)
</h2>
<p className="text-xs text-grey-3">
شهرهای استاتیک اپلیکیشن عقیله؛ جهت تخصیص دسته‌بندی‌ها و ادعیه از شناسه‌های زیر استفاده می‌شود.
</p>
</div>
</div>
</div>
<div className="flex items-center flex-wrap gap-2">
<Badge
variant="outline"
className="border-primary/30 bg-primary/5 text-primary text-xs px-3 py-1 rounded-xl font-bold gap-1.5"
>
<Ic name="shield" className="size-3.5" />
<span>شهرهای پایه استاتیک (بدون امکان حذف یا تغییر)</span>
</Badge>
<Button
variant="outline"
size="sm"
onClick={() => loadData(true)}
disabled={isRefreshing}
className="rounded-xl border-border-soft text-xs gap-1.5 h-8 hover:bg-card/80"
>
<Ic name="refresh" className={`size-3.5 ${isRefreshing ? 'animate-spin' : ''}`} />
<span>تازه‌سازی</span>
</Button>
</div>
</div>
{/* نوار فیلتر و جستجو */}
<div className="rounded-2xl border border-border-soft bg-card/30 p-3.5 flex flex-col sm:flex-row items-center justify-between gap-3 shadow-xs">
<div className="flex items-center flex-wrap gap-2 w-full sm:w-auto">
{/* تب‌های کشورها */}
<button
onClick={() => setSelectedCountryCode('ALL')}
className={`text-xs px-3 py-1.5 rounded-xl font-bold transition ${
selectedCountryCode === 'ALL'
? 'bg-primary text-primary-foreground shadow-xs'
: 'bg-card/80 text-grey-3 hover:text-foreground border border-border-soft'
}`}
>
همه کشورها ({toFa(cities.length)})
</button>
{availableCountries.map((co) => {
const count = cities.filter((c) => c.country_code === co.code).length
return (
<button
key={co.code}
onClick={() => setSelectedCountryCode(co.code)}
className={`text-xs px-3 py-1.5 rounded-xl font-bold transition flex items-center gap-1.5 ${
selectedCountryCode === co.code
? 'bg-primary text-primary-foreground shadow-xs'
: 'bg-card/80 text-grey-3 hover:text-foreground border border-border-soft'
}`}
>
<span>{co.flag}</span>
<span>{co.nameFa}</span>
<span className="text-[10px] font-mono opacity-80">({toFa(count)})</span>
</button>
)
})}
</div>
{/* فیلد جستجوی سریع */}
<div className="relative w-full sm:w-64">
<Ic name="search" className="absolute start-3 top-1/2 -translate-y-1/2 size-3.5 text-grey-3" />
<Input
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="جستجوی نام یا شناسه شهر..."
className="ps-9 bg-card/80 border-border-soft rounded-xl text-xs h-9"
/>
{searchTerm && (
<button
onClick={() => setSearchTerm('')}
className="absolute end-2.5 top-1/2 -translate-y-1/2 text-grey-3 hover:text-foreground p-0.5"
>
<Ic name="x" className="size-3" />
</button>
)}
</div>
</div>
{/* پیام خطا در صورت وجود */}
{errorMessage && (
<div className="p-4 rounded-2xl border border-destructive/20 bg-destructive/5 text-destructive text-xs flex items-center justify-between">
<div className="flex items-center gap-2">
<Ic name="alert" className="size-4 shrink-0" />
<span>{errorMessage}</span>
</div>
<Button size="sm" variant="ghost" onClick={() => loadData(true)} className="h-7 text-xs">
تلاش مجدد
</Button>
</div>
)}
{/* وضعیت لودینگ اسکلتون منطبق با ساختار واقعی کارت‌های شهر */}
{isLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-4 shadow-xs flex flex-col justify-between"
>
<div className="space-y-3.5">
{/* شناسه و کشور */}
<div className="flex items-center justify-between">
<ShimmerBox width={120} height={24} rounded="lg" />
<ShimmerLine width={60} height={14} rounded="sm" />
</div>
{/* عنوان و اسلاگ */}
<div className="space-y-1.5">
<ShimmerLine width="55%" height={22} rounded="md" />
<ShimmerLine width="35%" height={12} rounded="sm" />
</div>
{/* دسته‌بندی‌ها */}
<div className="space-y-2 pt-2 border-t border-border-soft/60">
<div className="flex items-center justify-between">
<ShimmerLine width={100} height={12} rounded="sm" />
<ShimmerLine width={40} height={12} rounded="sm" />
</div>
<div className="flex gap-2">
<ShimmerBox width={75} height={22} rounded="md" />
<ShimmerBox width={95} height={22} rounded="md" />
</div>
</div>
{/* دسترسی‌های حمل‌ونقل و موقعیت */}
<div className="pt-2 border-t border-border-soft/60 flex items-center justify-between">
<ShimmerLine width={110} height={12} rounded="sm" />
<ShimmerLine width={70} height={12} rounded="sm" />
</div>
</div>
{/* دکمه‌های فوتر */}
<div className="pt-3 border-t border-border-soft flex items-center gap-2">
<ShimmerBox height={32} rounded="xl" className="flex-1" />
<ShimmerBox height={32} rounded="xl" className="flex-1" />
</div>
</div>
))}
</div>
) : filteredCities.length === 0 ? (
<div className="py-16 text-center rounded-2xl border border-dashed border-border-soft bg-card/20 space-y-2">
<Ic name="map" className="size-8 mx-auto text-grey-3" />
<p className="text-sm font-bold text-foreground">هیچ شهری با این مشخصات یافت نشد</p>
</div>
) : (
/* شبکه کارت‌های شهرهای زیارتی */
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredCities.map((city) => {
const faName = CITY_FA_NAMES[city.slug.toLowerCase()] || city.name
const flag = COUNTRY_FLAGS[city.country_code || ''] || '🌍'
const countryFa = COUNTRY_NAMES_FA[city.country_name || ''] || city.country_name || ''
// دسته‌بندی‌های متصل به این شهر در دیتابیس
const cityCategories = categories.filter(
(c) => c.city === city.id || c.city_info?.id === city.id
)
return (
<div
key={city.id}
className="rounded-2xl border border-border-soft bg-card/40 p-5 space-y-4 hover:border-primary/40 transition shadow-xs flex flex-col justify-between"
>
<div className="space-y-3.5">
{/* شناسه دیتابیس و کشور */}
<div className="flex items-center justify-between">
<Badge
variant="outline"
className="border-primary/40 bg-primary/10 text-primary font-mono text-xs px-2.5 py-0.5 rounded-lg font-bold"
>
شناسه دیتابیس: #{city.id}
</Badge>
<div className="flex items-center gap-1.5 text-xs text-grey-3 font-medium">
<span>{flag}</span>
<span>{countryFa}</span>
</div>
</div>
{/* نام شهر و اسلاگ */}
<div>
<h3 className="text-lg font-black text-foreground">{faName}</h3>
<div className="flex items-center gap-2 mt-1 text-xs text-grey-3 font-mono">
<span>{city.name}</span>
<span>•</span>
<span className="text-primary/80">slug: {city.slug}</span>
</div>
</div>
{/* دسته‌بندی‌های متصل به این شهر */}
<div className="space-y-1.5 pt-1 border-t border-border-soft/60">
<div className="flex items-center justify-between text-[11px] text-grey-3 font-bold">
<span>دسته‌بندی‌های اعمال و آداب:</span>
<span className="font-mono text-primary">{toFa(cityCategories.length)} دسته</span>
</div>
<div className="flex flex-wrap gap-1.5">
{cityCategories.length > 0 ? (
cityCategories.map((c) => (
<Badge
key={c.id}
variant="secondary"
className="bg-card/90 text-foreground border border-border-soft text-[10px] px-2 py-0.5 rounded-md"
>
📁 {c.name_display} <span className="font-mono text-grey-3 me-1">#{c.id}</span>
</Badge>
))
) : (
<span className="text-[11px] text-grey-3 italic">بدون دسته‌بندی مستقیم</span>
)}
</div>
</div>
{/* دسترسی‌های حمل‌ونقل و موقعیت */}
<div className="pt-2 border-t border-border-soft/60 flex items-center justify-between text-[10px] text-grey-3 font-mono">
<div className="flex items-center gap-2">
<span title={city.has_airport ? 'دارای فرودگاه' : 'فاقد فرودگاه'} className={city.has_airport ? 'text-primary' : 'opacity-40'}>
✈️ فرودگاه
</span>
<span title={city.has_train_station ? 'دارای راه‌آهن' : 'فاقد راه‌آهن'} className={city.has_train_station ? 'text-primary' : 'opacity-40'}>
🚆 قطار
</span>
<span title={city.has_road_access ? 'دسترسی جاده‌ای' : ''} className={city.has_road_access ? 'text-primary' : 'opacity-40'}>
🚗 جاده
</span>
</div>
{city.latitude && city.longitude && (
<span dir="ltr">
{city.latitude.toFixed(2)}, {city.longitude.toFixed(2)}
</span>
)}
</div>
</div>
{/* پیوندهای دسترسی سریع به ادعیه و دسته‌بندی‌ها */}
<div className="pt-3 border-t border-border-soft flex items-center gap-2">
<Link
to="/services/categories"
className="flex-1 inline-flex items-center justify-center rounded-xl border border-border-soft text-xs h-8 hover:border-primary/40 hover:text-primary gap-1 transition bg-card/60"
>
<Ic name="folder" className="size-3 text-primary" />
<span>دسته‌بندی‌ها ({toFa(cityCategories.length)})</span>
</Link>
<Link
to="/services/duas"
className="flex-1 inline-flex items-center justify-center rounded-xl border border-border-soft text-xs h-8 hover:border-primary/40 hover:text-primary gap-1 transition bg-card/60"
>
<Ic name="book" className="size-3 text-primary" />
<span>ادعیه و زیارات</span>
</Link>
</div>
</div>
)
})}
</div>
)}
</div>
)
}

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

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

@ -4,7 +4,7 @@ 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_frontend'
import { ShimmerBox, ShimmerLine } from '@/components/shared/shimmer_frontend'
import { toFa } from '@/lib/utils'
import {
Dialog,
@ -278,7 +278,7 @@ export function ShrinesServiceView() {
<option value="ALL">همه شهرها</option>
{filterAvailableCities.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
{c.name} (#{c.id}) - {c.countryName}
</option>
))}
</select>
@ -339,9 +339,9 @@ export function ShrinesServiceView() {
<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" />
<ShimmerBox width={96} height={20} rounded="md" />
<ShimmerLine width="80%" height={22} rounded="md" />
<ShimmerLine width="50%" height={14} rounded="sm" />
</div>
))}
</div>
@ -364,8 +364,9 @@ export function ShrinesServiceView() {
<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 variant="outline" className="text-[10px] text-primary border-primary/20 gap-1 font-mono">
<span>{item.city_info?.name || 'شهر نامشخص'}</span>
{item.city && <span className="font-bold">#{item.city}</span>}
</Badge>
{item.country_info && (
<span className="text-[10px] text-grey-3">({item.country_info.name})</span>
@ -436,7 +437,12 @@ export function ShrinesServiceView() {
<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-foreground font-medium">
<span className="flex items-center gap-1 font-mono text-[11px]">
<span>{item.city_info?.name || '-'}</span>
{item.city && <span className="text-primary font-bold">#{item.city}</span>}
</span>
</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">
@ -554,7 +560,7 @@ export function ShrinesServiceView() {
<option value="">انتخاب شهر...</option>
{formAvailableCities.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
{c.name} (شناسه: #{c.id})
</option>
))}
</select>

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

@ -130,11 +130,32 @@ export async function fetchDuas(params?: {
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 []
let allResults: DuaItem[] = []
let nextUrl: string | null = `trip-etiquette/duas/${q.toString() ? `?${q.toString()}` : ''}`
while (nextUrl) {
const res: any = await apiFetch<any>(nextUrl)
if (Array.isArray(res)) {
return res
}
if (res && Array.isArray(res.results)) {
allResults = [...allResults, ...res.results]
if (res.next) {
try {
const parsed = new URL(res.next, 'http://placeholder')
nextUrl = `trip-etiquette/duas/${parsed.search}`
} catch {
nextUrl = null
}
} else {
nextUrl = null
}
} else {
break
}
}
return allResults
}
/**
@ -164,12 +185,13 @@ export async function createDua(input: CreateDuaInput): Promise<DuaItem> {
export async function updateDua(
index: number,
input: {
name?: Record<string, string>
name?: Record<string, string> | Array<{ language_code: string; name: string }>
category_ids?: number[]
shrine_ids?: number[]
city_ids?: number[]
}
): Promise<DuaItem> {
return apiFetch<DuaItem>(`trip-etiquette/duas/${index}/`, {
): Promise<DuaDetailItem> {
return apiFetch<DuaDetailItem>(`trip-etiquette/duas/${index}/`, {
method: 'PATCH',
body: input,
})
@ -196,3 +218,91 @@ export async function updateVerse(
body: input,
})
}
// لیست پشتیبان ۹ شهر زیارتی مرجع اپلیکیشن عقیله با شناسه‌های دیتابیس (بدون سوریه)
const STATIC_PILGRIMAGE_CITIES: import('../types/trip-etiquette').PilgrimageCityItem[] = [
{ id: 5, name: 'Karbala', slug: 'karbala', country_id: 2, country_name: 'Iraq', country_code: 'IQ', has_road_access: true },
{ id: 6, name: 'Najaf', slug: 'najaf', country_id: 2, country_name: 'Iraq', country_code: 'IQ', has_airport: true, has_road_access: true },
{ id: 7, name: 'Koufa', slug: 'koufa', country_id: 2, country_name: 'Iraq', country_code: 'IQ', has_road_access: true },
{ id: 8, name: 'Kazemein', slug: 'kazemein', country_id: 2, country_name: 'Iraq', country_code: 'IQ', has_train_station: true, has_road_access: true },
{ id: 9, name: 'Samara', slug: 'samara', country_id: 2, country_name: 'Iraq', country_code: 'IQ', has_road_access: true },
{ id: 10, name: 'Make', slug: 'make', country_id: 4, country_name: 'Saudi Arabia', country_code: 'SA', has_train_station: true, has_road_access: true },
{ id: 11, name: 'Madineh', slug: 'madineh', country_id: 4, country_name: 'Saudi Arabia', country_code: 'SA', has_airport: true, has_train_station: true, has_road_access: true },
{ id: 16, name: 'Mashhad', slug: 'mashhad', country_id: 1, country_name: 'Iran', country_code: 'IR', has_airport: true, has_train_station: true, has_road_access: true },
{ id: 17, name: 'Qom', slug: 'qom', country_id: 1, country_name: 'Iran', country_code: 'IR', has_train_station: true, has_road_access: true },
]
/**
* دریافت لیست ۹ شهر زیارتی رسمی و استاتیک اپلیکیشن عقیله (عراق، ایران، عربستان)
*/
export async function fetchTripEtiquetteCities(params?: {
country?: string
}): Promise<import('../types/trip-etiquette').PilgrimageCityItem[]> {
try {
// اندپوینت مشترک شهرهای زیارتی با اپلیکیشن
const res = await apiFetch<any>('cityguide/countries/?service=trip_etiquette')
const countries = Array.isArray(res) ? res : (Array.isArray(res?.results) ? res.results : [])
if (countries.length > 0) {
const list: import('../types/trip-etiquette').PilgrimageCityItem[] = []
const duplicateSlugs = new Set(['kufa', 'kadhimiya', 'samarra', 'mecca', 'medina', 'damascus'])
countries.forEach((co: any) => {
if ((co.code || '').toUpperCase() === 'SY') return // حذف سوریه
if (Array.isArray(co.city)) {
co.city.forEach((c: any) => {
if (duplicateSlugs.has((c.slug || '').toLowerCase())) return
list.push({
id: c.id,
name: c.name,
slug: c.slug,
country_id: co.id,
country_name: co.name,
country_code: co.code,
total_shrine: c.total_shrine,
total_sub_ritual: c.total_sub_ritual,
thumbnail: c.thumbnail,
latitude: c.latitude,
longitude: c.longitude,
has_airport: c.has_airport,
has_train_station: c.has_train_station,
has_road_access: c.has_road_access,
})
})
}
})
if (list.length > 0) {
if (params?.country && params.country !== 'ALL') {
return list.filter((c) => (c.country_code || '').toLowerCase() === params.country?.toLowerCase())
}
return list.sort((a, b) => a.id - b.id)
}
}
} catch (err) {
console.warn('Failed to fetch from cityguide countries, trying trip-etiquette/cities/', err)
}
// تلاش پشتیبان از اندپوینت trip-etiquette/cities/
try {
const q = new URLSearchParams()
if (params?.country && params.country !== 'ALL') q.set('country', params.country)
const url = `trip-etiquette/cities/${q.toString() ? `?${q.toString()}` : ''}`
const res = await apiFetch<any>(url)
const list = Array.isArray(res) ? res : (Array.isArray(res?.results) ? res.results : [])
const filteredList = list.filter(
(c: any) => (c.slug || '').toLowerCase() !== 'damascus' && (c.country_code || '').toUpperCase() !== 'SY'
)
if (filteredList.length >= 9) return filteredList
} catch (err) {
console.warn('Fallback to static pilgrimage cities:', err)
}
// در صورت عدم پاسخ کامل از سرور، لیست قطعی ۹ شهر استاتیک اپلیکیشن بازگردانده می‌شود
if (params?.country && params.country !== 'ALL') {
return STATIC_PILGRIMAGE_CITIES.filter(
(c) => (c.country_code || '').toLowerCase() === params.country?.toLowerCase()
)
}
return STATIC_PILGRIMAGE_CITIES
}

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

@ -2,7 +2,18 @@ export interface CategoryItem {
id: number
name: Record<string, string> | any
name_display: string
city: number
city?: number | null
cities?: Array<{
id: number
name: string
slug?: string
country_name?: string
}>
shrine?: number | null
shrine_info?: {
id: number
name: string
} | null
city_info?: {
id: number
name: string
@ -19,13 +30,15 @@ export interface CategoryItem {
export interface CreateCategoryInput {
name: Record<string, string>
city: number
city?: number | null
city_ids?: number[]
dua_ids?: number[]
}
export interface UpdateCategoryInput {
name?: Record<string, string>
city?: number
city?: number | null
city_ids?: number[]
dua_ids?: number[]
}
@ -58,9 +71,15 @@ export interface DuaItem {
categories?: Array<{
id: number
name: string
shrine_id?: number | null
shrine_name?: string
city_id?: number
city_name: string
country_name: string
cities?: Array<{
id: number
name: string
}>
}>
shrines?: Array<{
id: number
@ -68,6 +87,11 @@ export interface DuaItem {
city_name: string
country_name: string
}>
cities?: Array<{
id: number
name: string
country_name: string
}>
verses_count: number
}
@ -87,6 +111,7 @@ export interface VerseItem {
export interface DuaDetailItem extends DuaItem {
category_ids?: number[]
shrine_ids?: number[]
city_ids?: number[]
verses: VerseItem[]
}
@ -99,7 +124,32 @@ export interface CreateShrineInput {
}
export interface CreateDuaInput {
name: Record<string, string>
name: Record<string, string> | Array<{ language_code: string; name: string }>
category_ids?: number[]
shrine_ids?: number[]
city_ids?: number[]
}
export interface UpdateDuaInput {
name?: Record<string, string> | Array<{ language_code: string; name: string }>
category_ids?: number[]
shrine_ids?: number[]
city_ids?: number[]
}
export interface PilgrimageCityItem {
id: number
name: string
slug: string
country_id?: number
country_name?: string
country_code?: string
total_shrine?: number
total_sub_ritual?: number
thumbnail?: string | null
latitude?: number | null
longitude?: number | null
has_airport?: boolean
has_train_station?: boolean
has_road_access?: boolean
}

2
src/icons.tsx

@ -96,6 +96,8 @@ const PATHS: Record<string, string> = {
star: '<polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/>',
message: '<path d="M7.9 20A9 9 0 1 0 4 16.1L2 22Z"/>',
save: '<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/>',
bookmark: '<path d="m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z"/>',
mapPin: '<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>',
}
export type IconName = keyof typeof PATHS

2
src/locales/ar.json

@ -15,7 +15,7 @@
"duas": "الأدعية والزيارات (Duas)",
"duasList": "الأدعية والزيارات",
"categories": "التصنيفات",
"shrines": "المزارات والعتبات (Shrines)",
"cities": "المدن المقدسة",
"store": "متجر معصومة (Store)",
"storeProducts": "المنتجات",
"storeCategories": "الفئات",

2
src/locales/en.json

@ -15,7 +15,7 @@
"duas": "Duas & Ziyarat",
"duasList": "Duas & Ziyarat",
"categories": "Categories",
"shrines": "Holy Shrines",
"cities": "Holy Cities",
"store": "Massoumah Store",
"storeProducts": "Products",
"storeCategories": "Categories",

2
src/locales/fa.json

@ -15,7 +15,7 @@
"duas": "ادعیه و زیارات (Duas)",
"duasList": "ادعیه و زیارات",
"categories": "دسته‌بندی‌ها",
"shrines": "اماکن زیارتی (Shrines)",
"cities": "شهرهای زیارتی",
"store": "فروشگاه معصومه (Store)",
"storeProducts": "محصولات",
"storeCategories": "دسته‌بندی‌ها",

Loading…
Cancel
Save