Browse Source
feat: add core panel components, navigation layout, and services management views
master
feat: add core panel components, navigation layout, and services management views
master
16 changed files with 4386 additions and 69 deletions
-
50guide/aqila_panel_ui_design_system_standard.md
-
24src/components/layout/AppSidebar.tsx
-
8src/config/navigation.ts
-
10src/features/billing/components/BillingDetailModal.tsx
-
111src/features/billing/components/RejectFactorDialog.tsx
-
76src/features/services/ServicesPage.tsx
-
859src/features/services/components/CategoriesServiceView.tsx
-
946src/features/services/components/DuasServiceView.tsx
-
1271src/features/services/components/FaqServiceView.tsx
-
614src/features/services/components/ShrinesServiceView.tsx
-
111src/features/services/services/faq-api.ts
-
198src/features/services/services/trip-etiquette-api.ts
-
68src/features/services/types/faq.ts
-
105src/features/services/types/trip-etiquette.ts
-
1src/features/tours/components/TourStatusBadge.tsx
-
3src/icons.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} • {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> |
|||
) |
|||
} |
|||
@ -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
File diff suppressed because it is too large
View File
@ -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> |
|||
) |
|||
} |
|||
@ -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', |
|||
}) |
|||
} |
|||
@ -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, |
|||
}) |
|||
} |
|||
@ -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> |
|||
@ -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[] |
|||
} |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue