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
-
939src/features/services/components/CategoriesServiceView.tsx
-
401src/features/services/components/CitiesServiceView.tsx
-
1913src/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
939
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> |
|||
) |
|||
} |
|||
1913
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