Browse Source
feat: add services feature with localized resources, navigation, and management views
master
feat: add services feature with localized resources, navigation, and management views
master
13 changed files with 2339 additions and 1194 deletions
-
23src/components/ui/dialog.tsx
-
7src/config/navigation.ts
-
34src/features/services/ServicesPage.tsx
-
721src/features/services/components/CategoriesServiceView.tsx
-
401src/features/services/components/CitiesServiceView.tsx
-
1697src/features/services/components/DuasServiceView.tsx
-
24src/features/services/components/ShrinesServiceView.tsx
-
126src/features/services/services/trip-etiquette-api.ts
-
58src/features/services/types/trip-etiquette.ts
-
2src/icons.tsx
-
2src/locales/ar.json
-
2src/locales/en.json
-
2src/locales/fa.json
721
src/features/services/components/CategoriesServiceView.tsx
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -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
File diff suppressed because it is too large
View File
Write
Preview
Loading…
Cancel
Save
Reference in new issue