-
7.agents/AGENTS.md
-
13.agents/rules/automatic_rule_propagation.md
-
19.agents/rules/translation_synchronization.md
-
7AGENTS.md
-
2index.html
-
BINpublic/aqila-logo-bronze.png
-
BINpublic/aqila-logo-white.png
-
BINpublic/aqila-logo.png
-
3public/aqila-logo.svg
-
67src/components/AqilaLogo.tsx
-
59src/components/layout/AppSidebar.tsx
-
4src/components/shared/UniversalDatePicker.tsx
-
7src/config/navigation.ts
-
8src/config/site.ts
-
35src/features/billing/components/BillingDetailModal.tsx
-
87src/features/custom-trip/CustomTripOrderDetailPage.tsx
-
19src/features/custom-trip/components/CustomTripOrderReviewDialog.tsx
-
151src/features/custom-trip/components/CustomTripOrdersTab.tsx
-
34src/features/services/ServicesPage.tsx
-
679src/features/services/components/BlogsServiceView.tsx
-
863src/features/services/components/SurahsServiceView.tsx
-
96src/features/services/services/blogs-api.ts
-
27src/features/services/services/trip-etiquette-api.ts
-
4src/features/settings/components/SocialMediaSettingsTab.tsx
-
2src/features/tours/OngoingToursPage.tsx
-
60src/features/tours/ToursPage.tsx
-
16src/features/tours/components/TourDetailDialog.tsx
-
22src/features/tours/components/TourDetailSheet.tsx
-
48src/features/tours/components/TourTranslationsDialog.tsx
-
4src/features/tours/types.ts
-
140src/lib/date-utils.ts
-
28src/lib/utils.ts
-
29src/locales/ar.json
-
27src/locales/en.json
-
43src/locales/fa.json
-
41src/stores/custom-trip-badge.ts
@ -0,0 +1,13 @@ |
|||||
|
# Automatic Cross-Project Rule Propagation Protocol (Admin Panel) |
||||
|
|
||||
|
Whenever any new rule, standard, or requirement is introduced (via `/learn`, user directives, or bug resolutions): |
||||
|
|
||||
|
1. **Proactive Relevance Assessment:** |
||||
|
- The agent MUST automatically determine whether the rule impacts the Admin Panel and/or other subprojects (Flutter, Frontend Web, Backend). |
||||
|
- Never wait for the user to manually ask to copy or add the rule to other folders. |
||||
|
|
||||
|
2. **Tailored Admin Panel Implementation:** |
||||
|
- Adapt the rule for Vite + React + TypeScript architecture, 3 languages (`src/locales/fa.json`, `ar.json`, `en.json`), automated Vitest locale checks (`locales.test.ts`), and administrative table/form layouts. |
||||
|
|
||||
|
3. **In-Source Persistence:** |
||||
|
- Save updates directly to `aqila_panel/AGENTS.md` and `aqila_panel/.agents/rules/` so the entire team receives them via Git. |
||||
@ -0,0 +1,19 @@ |
|||||
|
# Multi-Language Translation Synchronization Rule - Aqila Admin Panel |
||||
|
|
||||
|
Whenever adding, updating, or modifying any text, table header, button label, modal message, toast notification, or string in `aqila_panel`: |
||||
|
|
||||
|
1. **Mandatory 100% Language Parity across 3 Locales:** |
||||
|
- Any new translation key or modified string MUST be updated across **ALL THREE** locale files simultaneously in `src/locales/`: |
||||
|
- `src/locales/fa.json` (Persian) |
||||
|
- `src/locales/ar.json` (Arabic) |
||||
|
- `src/locales/en.json` (English) |
||||
|
- Zero missing, extra, or empty keys (`fa.json`, `ar.json`, and `en.json` must have perfectly symmetrical key trees). |
||||
|
- All changes must pass the automated translation test (`src/locales/__tests__/locales.test.ts` via `scripts/check-translations.js`). |
||||
|
|
||||
|
2. **Text Modification & Word Parity Consistency:** |
||||
|
- Whenever words are added, removed, or modified in any text (e.g. omitting file extensions, shortening labels, updating status messages), the exact same conceptual and grammatical change MUST be reflected across all 3 files. |
||||
|
- Do not leave raw English text or placeholder strings in Persian or Arabic files. |
||||
|
|
||||
|
3. **Zero Hardcoded Text in UI Components:** |
||||
|
- No hardcoded strings in React components, Ant Design or Tailwind elements, table columns, action buttons, or notification toasts. All copy must be fetched via `useTranslation`. |
||||
|
- Ensure RTL/LTR alignment and smart bidirectional input handling via `rtlUtils.ts`. |
||||
|
Before Width: 256 | Height: 335 | Size: 39 KiB After Width: 1043 | Height: 684 | Size: 744 KiB |
|
Before Width: 256 | Height: 335 | Size: 39 KiB After Width: 1043 | Height: 684 | Size: 744 KiB |
|
Before Width: 256 | Height: 335 | Size: 54 KiB After Width: 1043 | Height: 684 | Size: 744 KiB |
3
public/aqila-logo.svg
File diff suppressed because it is too large
View File
@ -0,0 +1,679 @@ |
|||||
|
import React, { useState, useMemo } from 'react' |
||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' |
||||
|
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 { toFa } from '@/lib/utils' |
||||
|
import { |
||||
|
fetchAdminBlogs, |
||||
|
createAdminBlog, |
||||
|
updateAdminBlog, |
||||
|
deleteAdminBlog, |
||||
|
BlogItem, |
||||
|
} from '../services/blogs-api' |
||||
|
|
||||
|
const LANGUAGE_OPTIONS = [ |
||||
|
{ code: 'ALL', label: 'همه زبانها', flag: '🌐' }, |
||||
|
{ code: 'fa', label: 'فارسی', flag: '🇮🇷' }, |
||||
|
{ code: 'ar', label: 'العربية', flag: '🇸🇦' }, |
||||
|
{ code: 'en', label: 'English', flag: '🇬🇧' }, |
||||
|
{ code: 'ru', label: 'Русский', flag: '🇷🇺' }, |
||||
|
{ code: 'id', label: 'Indonesia', flag: '🇮🇩' }, |
||||
|
] |
||||
|
|
||||
|
export function BlogsServiceView() { |
||||
|
const qc = useQueryClient() |
||||
|
const [selectedLang, setSelectedLang] = useState<string>('ALL') |
||||
|
const [statusFilter, setStatusFilter] = useState<string>('ALL') |
||||
|
const [searchQuery, setSearchQuery] = useState<string>('') |
||||
|
const [editingBlog, setEditingBlog] = useState<Partial<BlogItem> | null>(null) |
||||
|
const [isDrawerOpen, setIsDrawerOpen] = useState<boolean>(false) |
||||
|
const [previewMode, setPreviewMode] = useState<boolean>(false) |
||||
|
|
||||
|
// Query blogs
|
||||
|
const { data: blogs = [], isLoading, refetch } = useQuery({ |
||||
|
queryKey: ['admin-blogs', selectedLang, statusFilter], |
||||
|
queryFn: () => fetchAdminBlogs({ |
||||
|
language_code: selectedLang !== 'ALL' ? selectedLang : undefined, |
||||
|
status: statusFilter !== 'ALL' ? statusFilter : undefined, |
||||
|
}), |
||||
|
}) |
||||
|
|
||||
|
// Mutations
|
||||
|
const saveMutation = useMutation({ |
||||
|
mutationFn: (payload: Partial<BlogItem>) => { |
||||
|
if (payload.id) { |
||||
|
return updateAdminBlog(payload.id, payload) |
||||
|
} else { |
||||
|
return createAdminBlog(payload) |
||||
|
} |
||||
|
}, |
||||
|
onSuccess: () => { |
||||
|
qc.invalidateQueries({ queryKey: ['admin-blogs'] }) |
||||
|
setIsDrawerOpen(false) |
||||
|
setEditingBlog(null) |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
const deleteMutation = useMutation({ |
||||
|
mutationFn: (id: number) => deleteAdminBlog(id), |
||||
|
onSuccess: () => { |
||||
|
qc.invalidateQueries({ queryKey: ['admin-blogs'] }) |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
const toggleStatusMutation = useMutation({ |
||||
|
mutationFn: ({ id, status }: { id: number; status: boolean }) => |
||||
|
updateAdminBlog(id, { status }), |
||||
|
onSuccess: () => { |
||||
|
qc.invalidateQueries({ queryKey: ['admin-blogs'] }) |
||||
|
}, |
||||
|
}) |
||||
|
|
||||
|
// Filtered list
|
||||
|
const filteredBlogs = useMemo(() => { |
||||
|
let list = blogs |
||||
|
if (searchQuery.trim()) { |
||||
|
const q = searchQuery.toLowerCase().trim() |
||||
|
list = list.filter( |
||||
|
(b) => |
||||
|
b.title?.toLowerCase().includes(q) || |
||||
|
b.slug?.toLowerCase().includes(q) || |
||||
|
b.summary?.toLowerCase().includes(q) |
||||
|
) |
||||
|
} |
||||
|
return list |
||||
|
}, [blogs, searchQuery]) |
||||
|
|
||||
|
// Stats
|
||||
|
const totalCount = blogs.length |
||||
|
const activeCount = blogs.filter((b) => b.status).length |
||||
|
const draftCount = blogs.filter((b) => !b.status).length |
||||
|
|
||||
|
const handleOpenAdd = () => { |
||||
|
setEditingBlog({ |
||||
|
title: '', |
||||
|
slug: '', |
||||
|
summary: '', |
||||
|
content: '', |
||||
|
status: true, |
||||
|
is_top_list: false, |
||||
|
language_code: selectedLang !== 'ALL' ? selectedLang : 'fa', |
||||
|
cover: '', |
||||
|
tag_names: ['زیارت', 'عقیله'], |
||||
|
}) |
||||
|
setPreviewMode(false) |
||||
|
setIsDrawerOpen(true) |
||||
|
} |
||||
|
|
||||
|
const handleOpenEdit = (blog: BlogItem) => { |
||||
|
setEditingBlog({ |
||||
|
...blog, |
||||
|
cover_input: blog.cover || '', |
||||
|
tag_names: blog.tags?.map((t) => t.name) || [], |
||||
|
language_code: blog.language_code_display || 'fa', |
||||
|
}) |
||||
|
setPreviewMode(false) |
||||
|
setIsDrawerOpen(true) |
||||
|
} |
||||
|
|
||||
|
return ( |
||||
|
<div className="space-y-6"> |
||||
|
{/* 1. معرفی و آمار کلان مقالات وبلاگ */} |
||||
|
<div className="relative overflow-hidden rounded-2xl border border-border-soft bg-gradient-to-br from-card/80 via-card/50 to-primary/5 p-6 backdrop-blur shadow-xs"> |
||||
|
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> |
||||
|
<div className="space-y-1.5"> |
||||
|
<div className="flex items-center gap-2.5"> |
||||
|
<span className="flex size-10 items-center justify-center rounded-xl bg-primary/10 text-primary shadow-xs"> |
||||
|
<Ic name="file" className="size-5" /> |
||||
|
</span> |
||||
|
<div> |
||||
|
<h1 className="text-xl font-black text-foreground">وبلاگ، مقالات و دانشنامه زیارتی</h1> |
||||
|
<p className="text-xs font-mono text-grey-2">Aqila Pilgrimage Blog & Articles Management</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
<p className="max-w-3xl text-xs text-grey-3 leading-relaxed"> |
||||
|
بانک مقالات سئومحور و راهنمای اماکن مقدسه در ۵ زبان (فارسی، عربی، انگلیسی، روسی و اندونزیایی). همگام با مدل داده فرانتاند و اندپوینتهای عمومی وبسایت عقیله. |
||||
|
</p> |
||||
|
</div> |
||||
|
<div className="flex items-center gap-2.5"> |
||||
|
<Button |
||||
|
onClick={handleOpenAdd} |
||||
|
className="gap-2 bg-primary text-primary-foreground hover:bg-primary/90 text-xs font-bold h-10 px-4 rounded-xl shadow-md" |
||||
|
> |
||||
|
<Ic name="plus" className="size-4" /> |
||||
|
افزودن مقاله جدید |
||||
|
</Button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* کارتهای آماری */} |
||||
|
<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-4"> |
||||
|
<Card className="border-border-soft bg-card/60 backdrop-blur"> |
||||
|
<CardContent className="p-4 flex items-center justify-between"> |
||||
|
<div> |
||||
|
<p className="text-[11px] text-grey-3">کل مقالات ثبتشده</p> |
||||
|
<p className="text-xl font-black text-foreground mt-1 tabular-nums">{toFa(totalCount)}</p> |
||||
|
</div> |
||||
|
<span className="p-2 rounded-xl bg-primary/10 text-primary"> |
||||
|
<Ic name="file" className="size-4" /> |
||||
|
</span> |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
|
||||
|
<Card className="border-border-soft bg-card/60 backdrop-blur"> |
||||
|
<CardContent className="p-4 flex items-center justify-between"> |
||||
|
<div> |
||||
|
<p className="text-[11px] text-grey-3">مقالات منتشرشده (فعال)</p> |
||||
|
<p className="text-xl font-black text-emerald-400 mt-1 tabular-nums">{toFa(activeCount)}</p> |
||||
|
</div> |
||||
|
<span className="p-2 rounded-xl bg-emerald-500/10 text-emerald-400"> |
||||
|
<Ic name="check" className="size-4" /> |
||||
|
</span> |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
|
||||
|
<Card className="border-border-soft bg-card/60 backdrop-blur"> |
||||
|
<CardContent className="p-4 flex items-center justify-between"> |
||||
|
<div> |
||||
|
<p className="text-[11px] text-grey-3">پیشنویس / غیرفعال</p> |
||||
|
<p className="text-xl font-black text-amber-400 mt-1 tabular-nums">{toFa(draftCount)}</p> |
||||
|
</div> |
||||
|
<span className="p-2 rounded-xl bg-amber-500/10 text-amber-400"> |
||||
|
<Ic name="clock" className="size-4" /> |
||||
|
</span> |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
|
||||
|
<Card className="border-border-soft bg-card/60 backdrop-blur"> |
||||
|
<CardContent className="p-4 flex items-center justify-between"> |
||||
|
<div> |
||||
|
<p className="text-[11px] text-grey-3">زبانهای تحت پوشش</p> |
||||
|
<p className="text-xl font-black text-sky-400 mt-1">۵ زبان</p> |
||||
|
</div> |
||||
|
<span className="p-2 rounded-xl bg-sky-500/10 text-sky-400"> |
||||
|
<Ic name="globe" className="size-4" /> |
||||
|
</span> |
||||
|
</CardContent> |
||||
|
</Card> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* 2. تبهای فیلتر زبان و نوار جستجو */} |
||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between"> |
||||
|
{/* زبانها */} |
||||
|
<div className="flex items-center gap-1.5 overflow-x-auto pb-1 scrollbar-none"> |
||||
|
{LANGUAGE_OPTIONS.map((lang) => ( |
||||
|
<button |
||||
|
key={lang.code} |
||||
|
onClick={() => setSelectedLang(lang.code)} |
||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition-all shrink-0 ${ |
||||
|
selectedLang === lang.code |
||||
|
? 'bg-primary text-primary-foreground shadow-sm' |
||||
|
: 'bg-card/60 text-grey-2 hover:bg-card/90 border border-border-soft' |
||||
|
}`}
|
||||
|
> |
||||
|
<span>{lang.flag}</span> |
||||
|
<span>{lang.label}</span> |
||||
|
</button> |
||||
|
))} |
||||
|
</div> |
||||
|
|
||||
|
{/* جستجو و وضعیت */} |
||||
|
<div className="flex items-center gap-2.5"> |
||||
|
<div className="relative w-full sm:w-64"> |
||||
|
<Ic name="search" className="absolute start-3 top-1/2 -translate-y-1/2 size-3.5 text-grey-3" /> |
||||
|
<Input |
||||
|
value={searchQuery} |
||||
|
onChange={(e) => setSearchQuery(e.target.value)} |
||||
|
placeholder="جستجو در عنوان یا متن مقاله..." |
||||
|
className="h-9 ps-9 text-xs bg-surface-base rounded-xl border-border-soft" |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<select |
||||
|
value={statusFilter} |
||||
|
onChange={(e) => setStatusFilter(e.target.value)} |
||||
|
aria-label="فیلتر وضعیت انتشار مقاله" |
||||
|
className="h-9 px-3 text-xs bg-surface-base rounded-xl border border-border-soft text-foreground focus:outline-none" |
||||
|
> |
||||
|
<option value="ALL">همه وضعیتها</option> |
||||
|
<option value="true">فقط فعال</option> |
||||
|
<option value="false">پیشنویس</option> |
||||
|
</select> |
||||
|
|
||||
|
<Button |
||||
|
variant="outline" |
||||
|
size="sm" |
||||
|
onClick={() => refetch()} |
||||
|
className="h-9 gap-1 text-xs border-border-soft" |
||||
|
> |
||||
|
<Ic name="refresh" className={`size-3.5 ${isLoading ? 'animate-spin' : ''}`} /> |
||||
|
بروزرسانی |
||||
|
</Button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* 3. لیست مقالات در قالب جدول استاندارد */} |
||||
|
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40 shadow-xs"> |
||||
|
<div className="flex items-center justify-between border-b border-border-soft bg-card/60 px-5 py-3.5"> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<span className="text-xs font-bold text-foreground">فهرست مقالات دانشنامه عقیله</span> |
||||
|
<Badge variant="secondary" className="bg-primary/10 text-primary border-0 text-[11px] font-mono"> |
||||
|
{toFa(filteredBlogs.length)} مورد |
||||
|
</Badge> |
||||
|
</div> |
||||
|
<span className="text-[11px] text-grey-3">اتصال زنده به /api/blogs/admin/</span> |
||||
|
</div> |
||||
|
|
||||
|
{isLoading ? ( |
||||
|
<div className="p-8 text-center text-xs text-grey-3">در حال دریافت مقالات از پایگاهداده...</div> |
||||
|
) : filteredBlogs.length === 0 ? ( |
||||
|
<div className="p-12 text-center space-y-2"> |
||||
|
<Ic name="file" className="size-8 mx-auto text-grey-3 opacity-40" /> |
||||
|
<p className="text-xs font-bold text-foreground">هیچ مقالهای یافت نشد</p> |
||||
|
<p className="text-[11px] text-grey-3">با تغییر فیلترها یا افزودن مقاله جدید لیست را کامل کنید.</p> |
||||
|
</div> |
||||
|
) : ( |
||||
|
<div className="overflow-x-auto"> |
||||
|
<table className="w-full text-start text-xs"> |
||||
|
<thead className="border-b border-border-soft bg-card/80 text-grey-3 font-bold"> |
||||
|
<tr> |
||||
|
<th className="p-3.5 text-start w-16">شناسه</th> |
||||
|
<th className="p-3.5 text-start">تصویر و عنوان سئو</th> |
||||
|
<th className="p-3.5 text-start w-28">زبان</th> |
||||
|
<th className="p-3.5 text-start w-48">برچسبها</th> |
||||
|
<th className="p-3.5 text-center w-28">وضعیت انتشار</th> |
||||
|
<th className="p-3.5 text-center w-36">تاریخ ثبت</th> |
||||
|
<th className="p-3.5 text-end w-28">عملیات</th> |
||||
|
</tr> |
||||
|
</thead> |
||||
|
<tbody className="divide-y divide-border-soft"> |
||||
|
{filteredBlogs.map((b, idx) => { |
||||
|
const langOpt = LANGUAGE_OPTIONS.find((l) => l.code === (b.language_code_display || 'fa')) |
||||
|
return ( |
||||
|
<tr |
||||
|
key={b.id} |
||||
|
style={{ |
||||
|
contentVisibility: idx >= 6 ? 'auto' : 'visible', |
||||
|
containIntrinsicSize: idx >= 6 ? 'auto none auto 80px' : undefined, |
||||
|
}} |
||||
|
className="hover:bg-card/70 transition-colors" |
||||
|
> |
||||
|
<td className="p-3.5 font-mono text-grey-3">#{toFa(b.id)}</td> |
||||
|
<td className="p-3.5"> |
||||
|
<div className="flex items-center gap-3"> |
||||
|
{b.cover ? ( |
||||
|
<img |
||||
|
src={b.cover} |
||||
|
alt={b.title} |
||||
|
className="size-12 rounded-xl object-cover border border-border-soft shrink-0 bg-muted/20" |
||||
|
onError={(e) => { |
||||
|
(e.currentTarget as HTMLElement).style.display = 'none' |
||||
|
}} |
||||
|
/> |
||||
|
) : ( |
||||
|
<div className="size-12 rounded-xl border border-dashed border-border-soft flex items-center justify-center shrink-0 text-grey-3"> |
||||
|
<Ic name="image" className="size-5" /> |
||||
|
</div> |
||||
|
)} |
||||
|
<div className="space-y-1 min-w-0"> |
||||
|
<p className="font-bold text-foreground text-sm line-clamp-1 hover:text-primary transition-colors cursor-pointer" onClick={() => handleOpenEdit(b)}> |
||||
|
{b.title} |
||||
|
</p> |
||||
|
<p className="font-mono text-[11px] text-grey-3 truncate max-w-md" dir="ltr"> |
||||
|
/blog/{b.slug} |
||||
|
</p> |
||||
|
{b.summary && ( |
||||
|
<p className="text-[11px] text-grey-2 line-clamp-1 max-w-xl"> |
||||
|
{b.summary} |
||||
|
</p> |
||||
|
)} |
||||
|
</div> |
||||
|
</div> |
||||
|
</td> |
||||
|
<td className="p-3.5"> |
||||
|
<Badge variant="outline" className="gap-1 border-border-soft bg-surface-base"> |
||||
|
<span>{langOpt?.flag || '🌐'}</span> |
||||
|
<span className="font-mono text-[10px] uppercase font-bold">{b.language_code_display || 'FA'}</span> |
||||
|
</Badge> |
||||
|
</td> |
||||
|
<td className="p-3.5"> |
||||
|
<div className="flex flex-wrap gap-1 max-w-xs"> |
||||
|
{b.tags && b.tags.length > 0 ? ( |
||||
|
b.tags.slice(0, 3).map((t) => ( |
||||
|
<span key={t.id || t.name} className="px-2 py-0.5 rounded-md bg-muted/30 text-[10px] text-grey-2 border border-border-soft"> |
||||
|
#{t.name} |
||||
|
</span> |
||||
|
)) |
||||
|
) : ( |
||||
|
<span className="text-[10px] text-grey-3">-</span> |
||||
|
)} |
||||
|
{b.tags && b.tags.length > 3 && ( |
||||
|
<span className="text-[10px] text-grey-3 font-mono">+{b.tags.length - 3}</span> |
||||
|
)} |
||||
|
</div> |
||||
|
</td> |
||||
|
<td className="p-3.5 text-center"> |
||||
|
<button |
||||
|
onClick={() => toggleStatusMutation.mutate({ id: b.id, status: !b.status })} |
||||
|
className={`px-2.5 py-1 rounded-full text-[11px] font-bold transition-all ${ |
||||
|
b.status |
||||
|
? 'bg-emerald-500/10 text-emerald-400 border border-emerald-500/20 hover:bg-emerald-500/20' |
||||
|
: 'bg-zinc-800 text-zinc-400 border border-zinc-700 hover:bg-zinc-700' |
||||
|
}`}
|
||||
|
> |
||||
|
{b.status ? 'منتشرشده' : 'پیشنویس'} |
||||
|
</button> |
||||
|
</td> |
||||
|
<td className="p-3.5 text-center font-mono text-grey-3 text-[11px]"> |
||||
|
{b.created_at ? new Date(b.created_at).toLocaleDateString('fa-IR') : '-'} |
||||
|
</td> |
||||
|
<td className="p-3.5 text-end"> |
||||
|
<div className="flex items-center justify-end gap-1.5"> |
||||
|
<Button |
||||
|
variant="ghost" |
||||
|
size="sm" |
||||
|
onClick={() => handleOpenEdit(b)} |
||||
|
className="size-8 p-0 text-grey-2 hover:text-primary hover:bg-primary/10 rounded-lg" |
||||
|
title="ویرایش مقاله" |
||||
|
> |
||||
|
<Ic name="pencil" className="size-4" /> |
||||
|
</Button> |
||||
|
<Button |
||||
|
variant="ghost" |
||||
|
size="sm" |
||||
|
onClick={() => { |
||||
|
if (confirm(`آیا از حذف مقاله «${b.title}» اطمینان دارید؟`)) { |
||||
|
deleteMutation.mutate(b.id) |
||||
|
} |
||||
|
}} |
||||
|
className="size-8 p-0 text-grey-2 hover:text-red-400 hover:bg-red-500/10 rounded-lg" |
||||
|
title="حذف مقاله" |
||||
|
> |
||||
|
<Ic name="trash" className="size-4" /> |
||||
|
</Button> |
||||
|
</div> |
||||
|
</td> |
||||
|
</tr> |
||||
|
) |
||||
|
})} |
||||
|
</tbody> |
||||
|
</table> |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
|
||||
|
{/* 4. دراور افزودن / ویرایش مقاله سئومحور */} |
||||
|
{isDrawerOpen && editingBlog && ( |
||||
|
<div className="fixed inset-0 z-50 flex items-center justify-end bg-black/60 backdrop-blur-xs transition-opacity animate-in fade-in"> |
||||
|
<div className="relative flex h-full w-full max-w-3xl flex-col bg-card border-s border-border-soft shadow-2xl overflow-hidden animate-in slide-in-from-right duration-200"> |
||||
|
{/* Header */} |
||||
|
<div className="flex items-center justify-between border-b border-border-soft bg-card/80 px-6 py-4"> |
||||
|
<div className="flex items-center gap-2.5"> |
||||
|
<span className="p-2 rounded-xl bg-primary/10 text-primary"> |
||||
|
<Ic name="file" className="size-5" /> |
||||
|
</span> |
||||
|
<div> |
||||
|
<h2 className="text-base font-bold text-foreground"> |
||||
|
{editingBlog.id ? 'ویرایش مقاله سئو' : 'افزودن مقاله جدید'} |
||||
|
</h2> |
||||
|
<p className="text-[11px] font-mono text-grey-3"> |
||||
|
{editingBlog.id ? `ID: #${editingBlog.id}` : 'پست جدید در پایگاهداده مقالات'} |
||||
|
</p> |
||||
|
</div> |
||||
|
</div> |
||||
|
<Button |
||||
|
variant="ghost" |
||||
|
size="sm" |
||||
|
onClick={() => setIsDrawerOpen(false)} |
||||
|
className="size-8 p-0 rounded-lg text-grey-3 hover:text-foreground" |
||||
|
> |
||||
|
<Ic name="close" className="size-4" /> |
||||
|
</Button> |
||||
|
</div> |
||||
|
|
||||
|
{/* Body */} |
||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-5"> |
||||
|
{/* تبهای زبان */} |
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-bold text-grey-2">زبان مقاله (Language):</label> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
{LANGUAGE_OPTIONS.filter((l) => l.code !== 'ALL').map((lang) => ( |
||||
|
<button |
||||
|
key={lang.code} |
||||
|
type="button" |
||||
|
onClick={() => setEditingBlog({ ...editingBlog, language_code: lang.code })} |
||||
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-xl text-xs font-bold transition-all border ${ |
||||
|
editingBlog.language_code === lang.code |
||||
|
? 'bg-primary/15 border-primary text-primary' |
||||
|
: 'border-border-soft bg-surface-base text-grey-2 hover:bg-muted/30' |
||||
|
}`}
|
||||
|
> |
||||
|
<span>{lang.flag}</span> |
||||
|
<span>{lang.label}</span> |
||||
|
</button> |
||||
|
))} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* عنوان سئو */} |
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-bold text-grey-2">عنوان سئو مقاله (H1 Title):</label> |
||||
|
<Input |
||||
|
value={editingBlog.title || ''} |
||||
|
onChange={(e) => setEditingBlog({ ...editingBlog, title: e.target.value })} |
||||
|
placeholder="مثال: راهنمای کامل اماکن زیارتی و تاریخی نجف اشرف..." |
||||
|
className="bg-surface-base border-border-soft rounded-xl text-sm" |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
{/* نامک اسلاگ */} |
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-bold text-grey-2">نامک URL یکتا (Slug):</label> |
||||
|
<Input |
||||
|
value={editingBlog.slug || ''} |
||||
|
onChange={(e) => setEditingBlog({ ...editingBlog, slug: e.target.value })} |
||||
|
placeholder="najaf-holy-sites-guide" |
||||
|
dir="ltr" |
||||
|
className="bg-surface-base border-border-soft rounded-xl text-xs font-mono" |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
{/* آدرس تصویر شاخص */} |
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-bold text-grey-2">آدرس تصویر شاخص (Cover Image URL):</label> |
||||
|
<div className="flex gap-3"> |
||||
|
<Input |
||||
|
value={editingBlog.cover_input || ''} |
||||
|
onChange={(e) => setEditingBlog({ ...editingBlog, cover_input: e.target.value })} |
||||
|
placeholder="https://... یا /media/blog/cover/..." |
||||
|
dir="ltr" |
||||
|
className="bg-surface-base border-border-soft rounded-xl text-xs font-mono flex-1" |
||||
|
/> |
||||
|
{editingBlog.cover_input && ( |
||||
|
<img |
||||
|
src={editingBlog.cover_input} |
||||
|
alt="پیشنمایش کاور" |
||||
|
className="size-10 rounded-lg object-cover border border-border-soft shrink-0 bg-muted/20" |
||||
|
/> |
||||
|
)} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* خلاصه سئو */} |
||||
|
<div className="space-y-1.5"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<label className="text-xs font-bold text-grey-2">خلاصه متای سئو (Meta Description):</label> |
||||
|
<span className="text-[10px] font-mono text-grey-3"> |
||||
|
{editingBlog.summary?.length || 0} / ۱۶۰ کاراکتر |
||||
|
</span> |
||||
|
</div> |
||||
|
<textarea |
||||
|
value={editingBlog.summary || ''} |
||||
|
onChange={(e) => setEditingBlog({ ...editingBlog, summary: e.target.value })} |
||||
|
rows={2} |
||||
|
placeholder="خلاصه جذاب برای نمایش در نتایج گوگل و کارت مقالات..." |
||||
|
className="w-full rounded-xl border border-border-soft bg-surface-base p-3 text-xs text-foreground focus:outline-none focus:border-primary/50" |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
{/* برچسبها */} |
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-bold text-grey-2">برچسبها (با کاما جدا کنید):</label> |
||||
|
<Input |
||||
|
value={editingBlog.tag_names?.join('، ') || ''} |
||||
|
onChange={(e) => |
||||
|
setEditingBlog({ |
||||
|
...editingBlog, |
||||
|
tag_names: e.target.value.split(/[،,]+/).map((s) => s.trim()).filter(Boolean), |
||||
|
}) |
||||
|
} |
||||
|
placeholder="نجف، حرم امام علی، وادی السلام، زیارت" |
||||
|
className="bg-surface-base border-border-soft rounded-xl text-xs" |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
{/* وضعیت و ویژگی مقاله ویژه */} |
||||
|
<div className="flex items-center gap-6 rounded-xl border border-border-soft bg-surface-base p-3.5"> |
||||
|
<label className="flex items-center gap-2 cursor-pointer text-xs font-bold text-foreground"> |
||||
|
<input |
||||
|
type="checkbox" |
||||
|
checked={editingBlog.status ?? true} |
||||
|
onChange={(e) => setEditingBlog({ ...editingBlog, status: e.target.checked })} |
||||
|
className="size-4 rounded accent-primary cursor-pointer" |
||||
|
/> |
||||
|
<span>انتشار عمومی در وبسایت</span> |
||||
|
</label> |
||||
|
|
||||
|
<label className="flex items-center gap-2 cursor-pointer text-xs font-bold text-foreground"> |
||||
|
<input |
||||
|
type="checkbox" |
||||
|
checked={editingBlog.is_top_list ?? false} |
||||
|
onChange={(e) => setEditingBlog({ ...editingBlog, is_top_list: e.target.checked })} |
||||
|
className="size-4 rounded accent-primary cursor-pointer" |
||||
|
/> |
||||
|
<span>نمایش در بخش مقالات ویژه (Top List)</span> |
||||
|
</label> |
||||
|
</div> |
||||
|
|
||||
|
{/* ویرایشگر محتوای اصلی مقاله */} |
||||
|
<div className="space-y-2"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<label className="text-xs font-bold text-grey-2">محتوای مقاله (Semantic HTML):</label> |
||||
|
<div className="flex items-center gap-1.5"> |
||||
|
<Button |
||||
|
type="button" |
||||
|
variant="outline" |
||||
|
size="sm" |
||||
|
onClick={() => setPreviewMode(!previewMode)} |
||||
|
className="h-7 text-[11px] gap-1 border-border-soft" |
||||
|
> |
||||
|
<Ic name="eye" className="size-3" /> |
||||
|
{previewMode ? 'حالت کد HTML' : 'پیشنمایش خروجی'} |
||||
|
</Button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{!previewMode ? ( |
||||
|
<div className="space-y-1.5"> |
||||
|
{/* Quick HTML tags helper */} |
||||
|
<div className="flex flex-wrap gap-1 p-2 rounded-lg bg-muted/20 border border-border-soft text-[11px]"> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => |
||||
|
setEditingBlog({ |
||||
|
...editingBlog, |
||||
|
content: (editingBlog.content || '') + '\n<h2>عنوان بخش جدید</h2>\n<p>متن بخش...</p>', |
||||
|
}) |
||||
|
} |
||||
|
className="px-2 py-0.5 rounded bg-card hover:bg-card/80 border border-border-soft text-grey-2" |
||||
|
> |
||||
|
+ تیتر H2 |
||||
|
</button> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => |
||||
|
setEditingBlog({ |
||||
|
...editingBlog, |
||||
|
content: (editingBlog.content || '') + '\n<h3>زیرتیتر</h3>\n<p>متن توضیحات...</p>', |
||||
|
}) |
||||
|
} |
||||
|
className="px-2 py-0.5 rounded bg-card hover:bg-card/80 border border-border-soft text-grey-2" |
||||
|
> |
||||
|
+ زیرتیتر H3 |
||||
|
</button> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => |
||||
|
setEditingBlog({ |
||||
|
...editingBlog, |
||||
|
content: (editingBlog.content || '') + '\n<ul>\n <li>مورد اول</li>\n <li>مورد دوم</li>\n</ul>', |
||||
|
}) |
||||
|
} |
||||
|
className="px-2 py-0.5 rounded bg-card hover:bg-card/80 border border-border-soft text-grey-2" |
||||
|
> |
||||
|
+ لیست آیتمها |
||||
|
</button> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => |
||||
|
setEditingBlog({ |
||||
|
...editingBlog, |
||||
|
content: |
||||
|
(editingBlog.content || '') + |
||||
|
'\n<div class="callout-box bg-primary/10 border-r-4 border-primary p-4 rounded-xl my-4">\n <p>نکته زیارتی مهم برای زائران محترم...</p>\n</div>', |
||||
|
}) |
||||
|
} |
||||
|
className="px-2 py-0.5 rounded bg-card hover:bg-card/80 border border-border-soft text-primary font-bold" |
||||
|
> |
||||
|
+ جعبه نکته کاربردی |
||||
|
</button> |
||||
|
</div> |
||||
|
|
||||
|
<textarea |
||||
|
value={editingBlog.content || ''} |
||||
|
onChange={(e) => setEditingBlog({ ...editingBlog, content: e.target.value })} |
||||
|
rows={14} |
||||
|
dir="auto" |
||||
|
placeholder="متن کامل مقاله به همراه تگهای استاندارد سئو..." |
||||
|
className="w-full rounded-xl border border-border-soft bg-surface-base p-4 text-xs font-mono text-foreground focus:outline-none focus:border-primary/50 leading-relaxed" |
||||
|
/> |
||||
|
</div> |
||||
|
) : ( |
||||
|
<div |
||||
|
className="max-h-96 overflow-y-auto rounded-xl border border-border-soft bg-card/60 p-5 prose dark:prose-invert prose-sm text-xs leading-relaxed" |
||||
|
dangerouslySetInnerHTML={{ __html: editingBlog.content || '<p class="text-grey-3">محتوایی وارد نشده است.</p>' }} |
||||
|
/> |
||||
|
)} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* Footer */} |
||||
|
<div className="flex items-center justify-end gap-3 border-t border-border-soft bg-card/80 px-6 py-4"> |
||||
|
<Button |
||||
|
variant="outline" |
||||
|
size="sm" |
||||
|
onClick={() => setIsDrawerOpen(false)} |
||||
|
className="text-xs border-border-soft" |
||||
|
> |
||||
|
انصراف |
||||
|
</Button> |
||||
|
<Button |
||||
|
size="sm" |
||||
|
onClick={() => saveMutation.mutate(editingBlog)} |
||||
|
disabled={saveMutation.isPending || !editingBlog.title?.trim()} |
||||
|
className="gap-1.5 bg-primary text-primary-foreground hover:bg-primary/90 text-xs font-bold px-5" |
||||
|
> |
||||
|
{saveMutation.isPending && <Ic name="refresh" className="size-3 animate-spin" />} |
||||
|
{editingBlog.id ? 'ذخیره تغییرات' : 'ثبت مقاله در دیتابیس'} |
||||
|
</Button> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,863 @@ |
|||||
|
import React, { useState, useEffect, useMemo } from 'react' |
||||
|
import { Ic } from '@/icons' |
||||
|
import { Button } from '@/components/ui/button' |
||||
|
import { Input } from '@/components/ui/input' |
||||
|
import { Badge } from '@/components/ui/badge' |
||||
|
import { |
||||
|
Table, |
||||
|
TableBody, |
||||
|
TableCell, |
||||
|
TableHead, |
||||
|
TableHeader, |
||||
|
TableRow, |
||||
|
} from '@/components/ui/table' |
||||
|
import { |
||||
|
Dialog, |
||||
|
DialogContent, |
||||
|
DialogHeader, |
||||
|
DialogTitle, |
||||
|
DialogBody, |
||||
|
DialogFooter, |
||||
|
} from '@/components/ui/dialog' |
||||
|
import { ShimmerBox, ShimmerLine } from '@/components/shared/shimmer_frontend' |
||||
|
import { toFa } from '@/lib/utils' |
||||
|
import type { DuaItem, DuaDetailItem } from '../types/trip-etiquette' |
||||
|
import { |
||||
|
fetchSurahs, |
||||
|
fetchDuaDetail, |
||||
|
updateDua, |
||||
|
updateVerse, |
||||
|
} from '../services/trip-etiquette-api' |
||||
|
import { toast } from '@/stores/toast' |
||||
|
|
||||
|
interface SurahStaticMeta { |
||||
|
surahNumber: number |
||||
|
revelationType: 'مکی' | 'مدنی' |
||||
|
juzLabel: string |
||||
|
virtueNote: string |
||||
|
} |
||||
|
|
||||
|
const SURAH_META_BY_NUMBER: Record<number, SurahStaticMeta> = { |
||||
|
1: { surahNumber: 1, revelationType: 'مکی', juzLabel: 'جزء ۱', virtueNote: 'فاتحة الکتاب و امالقرآن' }, |
||||
|
18: { surahNumber: 18, revelationType: 'مکی', juzLabel: 'جزء ۱۵ و ۱۶', virtueNote: 'تلاوت در شب و روز جمعه' }, |
||||
|
29: { surahNumber: 29, revelationType: 'مکی', juzLabel: 'جزء ۲۰ و ۲۱', virtueNote: 'از سورههای شبهای قدر (شب ۲۳ رمضان)' }, |
||||
|
30: { surahNumber: 30, revelationType: 'مکی', juzLabel: 'جزء ۲۱', virtueNote: 'از سورههای شبهای قدر (شب ۲۳ رمضان)' }, |
||||
|
36: { surahNumber: 36, revelationType: 'مکی', juzLabel: 'جزء ۲۲ و ۲۳', virtueNote: 'قلب قرآن کریم و زیارت اهل قبور' }, |
||||
|
44: { surahNumber: 44, revelationType: 'مکی', juzLabel: 'جزء ۲۵', virtueNote: 'از سورههای شبهای قدر و شبهای جمعه' }, |
||||
|
55: { surahNumber: 55, revelationType: 'مدنی', juzLabel: 'جزء ۲۷', virtueNote: 'عروس قرآن کریم' }, |
||||
|
56: { surahNumber: 56, revelationType: 'مکی', juzLabel: 'جزء ۲۷', virtueNote: 'سوره وسعت رزق در هر شب' }, |
||||
|
62: { surahNumber: 62, revelationType: 'مدنی', juzLabel: 'جزء ۲۸', virtueNote: 'تلاوت در نماز و روز جمعه' }, |
||||
|
67: { surahNumber: 67, revelationType: 'مکی', juzLabel: 'جزء ۲۹', virtueNote: 'سوره منجیه و هدیه به اموات' }, |
||||
|
76: { surahNumber: 76, revelationType: 'مدنی', juzLabel: 'جزء ۲۹', virtueNote: 'سوره هل أتی در شأن اهلبیت (ع)' }, |
||||
|
78: { surahNumber: 78, revelationType: 'مکی', juzLabel: 'جزء ۳۰', virtueNote: 'سوره عمّ یتساءلون (توفیق زیارت خانه خدا)' }, |
||||
|
87: { surahNumber: 87, revelationType: 'مکی', juzLabel: 'جزء ۳۰', virtueNote: 'سوره سبح اسم ربک الاعلی در نمازهای مستحبی' }, |
||||
|
91: { surahNumber: 91, revelationType: 'مکی', juzLabel: 'جزء ۳۰', virtueNote: 'تلاوت در نمازهای مستحبی زیارت' }, |
||||
|
97: { surahNumber: 97, revelationType: 'مکی', juzLabel: 'جزء ۳۰', virtueNote: 'سوره انا انزلناه در اعمال حرمهای مطهر' }, |
||||
|
99: { surahNumber: 99, revelationType: 'مدنی', juzLabel: 'جزء ۳۰', virtueNote: 'تلاوت در نوافل و نمازهای زیارت' }, |
||||
|
100: { surahNumber: 100, revelationType: 'مکی', juzLabel: 'جزء ۳۰', virtueNote: 'ثواب معادل ختم قرآن در روایات' }, |
||||
|
102: { surahNumber: 102, revelationType: 'مکی', juzLabel: 'جزء ۳۰', virtueNote: 'تلاوت پیش از خواب و در مشاهد مشرفه' }, |
||||
|
109: { surahNumber: 109, revelationType: 'مکی', juzLabel: 'جزء ۳۰', virtueNote: 'از سورههای چهارقل (برائت از شرک)' }, |
||||
|
110: { surahNumber: 110, revelationType: 'مدنی', juzLabel: 'جزء ۳۰', virtueNote: 'سوره استغفار و نصرت الهی' }, |
||||
|
112: { surahNumber: 112, revelationType: 'مکی', juzLabel: 'جزء ۳۰', virtueNote: 'سوره توحید (معادل یکسوم قرآن)' }, |
||||
|
113: { surahNumber: 113, revelationType: 'مکی', juzLabel: 'جزء ۳۰', virtueNote: 'از سورههای معوذتین و چهارقل' }, |
||||
|
114: { surahNumber: 114, revelationType: 'مکی', juzLabel: 'جزء ۳۰', virtueNote: 'از سورههای معوذتین و چهارقل' }, |
||||
|
} |
||||
|
|
||||
|
function extractLangName(nameField: any, lang: string): string { |
||||
|
if (!nameField) return '' |
||||
|
if (typeof nameField === 'object' && !Array.isArray(nameField)) { |
||||
|
return nameField[lang] || '' |
||||
|
} |
||||
|
if (Array.isArray(nameField)) { |
||||
|
const found = nameField.find((x: any) => x?.language_code === lang) |
||||
|
return found?.name || '' |
||||
|
} |
||||
|
return '' |
||||
|
} |
||||
|
|
||||
|
export function SurahsServiceView() { |
||||
|
const [surahs, setSurahs] = useState<DuaItem[]>([]) |
||||
|
const [isLoading, setIsLoading] = useState(true) |
||||
|
const [isRefreshing, setIsRefreshing] = useState(false) |
||||
|
const [errorMessage, setErrorMessage] = useState<string | null>(null) |
||||
|
|
||||
|
// Filters
|
||||
|
const [searchTerm, setSearchTerm] = useState('') |
||||
|
|
||||
|
// Modal State
|
||||
|
const [selectedSurah, setSelectedSurah] = useState<DuaItem | null>(null) |
||||
|
const [modalTab, setModalTab] = useState<'verses' | 'info'>('verses') |
||||
|
const [surahDetail, setSurahDetail] = useState<DuaDetailItem | null>(null) |
||||
|
const [isLoadingDetail, setIsLoadingDetail] = useState(false) |
||||
|
|
||||
|
// Tab 1 (5-lang Names)
|
||||
|
const [formNameFa, setFormNameFa] = useState('') |
||||
|
const [formNameAr, setFormNameAr] = useState('') |
||||
|
const [formNameEn, setFormNameEn] = useState('') |
||||
|
const [formNameRu, setFormNameRu] = useState('') |
||||
|
const [formNameId, setFormNameId] = useState('') |
||||
|
const [formIsActive, setFormIsActive] = useState(true) |
||||
|
const [isSavingInfo, setIsSavingInfo] = useState(false) |
||||
|
const [togglingIndex, setTogglingIndex] = useState<number | null>(null) |
||||
|
|
||||
|
// Tab 2 (Verses & 4-lang translations)
|
||||
|
const [verseSearch, setVerseSearch] = useState('') |
||||
|
const [activeTransLang, setActiveTransLang] = useState<'fa' | 'en' | 'ru' | 'id'>('fa') |
||||
|
const [verseDrafts, setVerseDrafts] = useState< |
||||
|
Record<number, { text: string; translations: Record<string, string> }> |
||||
|
>({}) |
||||
|
const [isSavingAllVerses, setIsSavingAllVerses] = useState(false) |
||||
|
|
||||
|
const loadSurahs = async (manual = false) => { |
||||
|
if (manual) setIsRefreshing(true) |
||||
|
else setIsLoading(true) |
||||
|
setErrorMessage(null) |
||||
|
try { |
||||
|
const data = await fetchSurahs() |
||||
|
setSurahs(data) |
||||
|
} catch (err: any) { |
||||
|
setErrorMessage(err?.message || 'خطا در دریافت فهرست سورههای قرآن کریم از سرور.') |
||||
|
} finally { |
||||
|
setIsLoading(false) |
||||
|
setIsRefreshing(false) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
useEffect(() => { |
||||
|
loadSurahs() |
||||
|
}, []) |
||||
|
|
||||
|
const filteredSurahs = useMemo(() => { |
||||
|
return surahs.filter((s) => { |
||||
|
const sNum = s.index >= 6000 ? s.index - 6000 : s.index |
||||
|
if (searchTerm.trim()) { |
||||
|
const q = searchTerm.trim().toLowerCase() |
||||
|
const fa = extractLangName(s.name, 'fa').toLowerCase() |
||||
|
const ar = extractLangName(s.name, 'ar').toLowerCase() |
||||
|
const en = extractLangName(s.name, 'en').toLowerCase() |
||||
|
const ru = extractLangName(s.name, 'ru').toLowerCase() |
||||
|
const id = extractLangName(s.name, 'id').toLowerCase() |
||||
|
const disp = (s.name_display || '').toLowerCase() |
||||
|
if ( |
||||
|
!fa.includes(q) && |
||||
|
!ar.includes(q) && |
||||
|
!en.includes(q) && |
||||
|
!ru.includes(q) && |
||||
|
!id.includes(q) && |
||||
|
!disp.includes(q) && |
||||
|
!String(sNum).includes(q) && |
||||
|
!String(s.index).includes(q) |
||||
|
) { |
||||
|
return false |
||||
|
} |
||||
|
} |
||||
|
return true |
||||
|
}) |
||||
|
}, [surahs, searchTerm]) |
||||
|
|
||||
|
const totalVersesCount = useMemo( |
||||
|
() => surahs.reduce((sum, s) => sum + (s.verses_count || 0), 0), |
||||
|
[surahs] |
||||
|
) |
||||
|
const activeSurahsCount = useMemo( |
||||
|
() => surahs.filter((s) => s.is_active !== false).length, |
||||
|
[surahs] |
||||
|
) |
||||
|
|
||||
|
const handleOpenSurahModal = async (surah: DuaItem, initialTab: 'verses' | 'info' = 'verses') => { |
||||
|
setSelectedSurah(surah) |
||||
|
setModalTab(initialTab) |
||||
|
setVerseSearch('') |
||||
|
setFormNameFa(extractLangName(surah.name, 'fa') || surah.name_display || '') |
||||
|
setFormNameAr(extractLangName(surah.name, 'ar') || '') |
||||
|
setFormNameEn(extractLangName(surah.name, 'en') || '') |
||||
|
setFormNameRu(extractLangName(surah.name, 'ru') || '') |
||||
|
setFormNameId(extractLangName(surah.name, 'id') || '') |
||||
|
setFormIsActive(surah.is_active !== false) |
||||
|
setIsLoadingDetail(true) |
||||
|
setSurahDetail(null) |
||||
|
setVerseDrafts({}) |
||||
|
|
||||
|
try { |
||||
|
const detail = await fetchDuaDetail(surah.index) |
||||
|
setSurahDetail(detail) |
||||
|
const drafts: Record<number, { text: string; translations: Record<string, string> }> = {} |
||||
|
;(detail.verses || []).forEach((v) => { |
||||
|
drafts[v.index] = { |
||||
|
text: v.text || '', |
||||
|
translations: { |
||||
|
fa: v.translations?.fa || '', |
||||
|
en: v.translations?.en || '', |
||||
|
ru: v.translations?.ru || '', |
||||
|
id: v.translations?.id || '', |
||||
|
}, |
||||
|
} |
||||
|
}) |
||||
|
setVerseDrafts(drafts) |
||||
|
} catch (err: any) { |
||||
|
toast.error(err?.message || 'خطا در دریافت آیات و ترجمههای سوره.') |
||||
|
} finally { |
||||
|
setIsLoadingDetail(false) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const handleToggleSurahStatus = async (surah: DuaItem) => { |
||||
|
const nextStatus = !(surah.is_active !== false) |
||||
|
setTogglingIndex(surah.index) |
||||
|
try { |
||||
|
await updateDua(surah.index, { is_active: nextStatus }) |
||||
|
setSurahs((prev) => |
||||
|
prev.map((item) => (item.index === surah.index ? { ...item, is_active: nextStatus } : item)) |
||||
|
) |
||||
|
toast.success( |
||||
|
nextStatus |
||||
|
? `${surah.name_display} فعال شد.` |
||||
|
: `${surah.name_display} غیرفعال شد.` |
||||
|
) |
||||
|
} catch (err: any) { |
||||
|
toast.error(err?.message || 'خطا در تغییر وضعیت سوره.') |
||||
|
} finally { |
||||
|
setTogglingIndex(null) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const handleSaveSurahInfo = async () => { |
||||
|
if (!selectedSurah) return |
||||
|
if (!formNameFa.trim() || !formNameAr.trim()) { |
||||
|
toast.error('نام فارسی و عربی سوره الزامی است.') |
||||
|
return |
||||
|
} |
||||
|
setIsSavingInfo(true) |
||||
|
try { |
||||
|
const updatedName = { |
||||
|
fa: formNameFa.trim(), |
||||
|
ar: formNameAr.trim(), |
||||
|
en: formNameEn.trim(), |
||||
|
ru: formNameRu.trim(), |
||||
|
id: formNameId.trim(), |
||||
|
} |
||||
|
const updated = await updateDua(selectedSurah.index, { |
||||
|
name: updatedName, |
||||
|
is_active: formIsActive, |
||||
|
}) |
||||
|
setSurahs((prev) => |
||||
|
prev.map((s) => |
||||
|
s.index === selectedSurah.index |
||||
|
? { |
||||
|
...s, |
||||
|
name: updatedName, |
||||
|
name_display: updated.name_display || formNameFa.trim(), |
||||
|
is_active: formIsActive, |
||||
|
} |
||||
|
: s |
||||
|
) |
||||
|
) |
||||
|
toast.success('اطلاعات ۵ زبانه سوره با موفقیت ذخیره شد.') |
||||
|
} catch (err: any) { |
||||
|
toast.error(err?.message || 'خطا در ذخیره اطلاعات سوره.') |
||||
|
} finally { |
||||
|
setIsSavingInfo(false) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const handleSaveAllVerses = async () => { |
||||
|
if (!surahDetail?.verses || surahDetail.verses.length === 0) return |
||||
|
|
||||
|
// Find all verses that have modifications compared to original
|
||||
|
const modifiedVerses = surahDetail.verses.filter((v) => { |
||||
|
const draft = verseDrafts[v.index] |
||||
|
if (!draft) return false |
||||
|
const textChanged = (draft.text ?? '') !== (v.text ?? '') |
||||
|
const faChanged = (draft.translations?.fa ?? '') !== (v.translations?.fa ?? '') |
||||
|
const enChanged = (draft.translations?.en ?? '') !== (v.translations?.en ?? '') |
||||
|
const ruChanged = (draft.translations?.ru ?? '') !== (v.translations?.ru ?? '') |
||||
|
const idChanged = (draft.translations?.id ?? '') !== (v.translations?.id ?? '') |
||||
|
return textChanged || faChanged || enChanged || ruChanged || idChanged |
||||
|
}) |
||||
|
|
||||
|
if (modifiedVerses.length === 0) { |
||||
|
toast.info('هیچ تغییری در متن آیات یا ترجمهها اعمال نشده است.') |
||||
|
return |
||||
|
} |
||||
|
|
||||
|
setIsSavingAllVerses(true) |
||||
|
try { |
||||
|
await Promise.all( |
||||
|
modifiedVerses.map((v) => { |
||||
|
const draft = verseDrafts[v.index] |
||||
|
return updateVerse(v.index, { |
||||
|
text: draft.text, |
||||
|
translations: draft.translations, |
||||
|
}) |
||||
|
}) |
||||
|
) |
||||
|
|
||||
|
// Update local surahDetail state so changes are reflected
|
||||
|
setSurahDetail((prev) => { |
||||
|
if (!prev) return null |
||||
|
return { |
||||
|
...prev, |
||||
|
verses: prev.verses.map((v) => { |
||||
|
const draft = verseDrafts[v.index] |
||||
|
if (!draft) return v |
||||
|
return { |
||||
|
...v, |
||||
|
text: draft.text, |
||||
|
translations: { ...v.translations, ...draft.translations }, |
||||
|
} |
||||
|
}), |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
toast.success(`تغییرات ${toFa(modifiedVerses.length)} آیه با موفقیت ذخیره شد.`) |
||||
|
} catch (err: any) { |
||||
|
toast.error(err?.message || 'خطا در ذخیرهسازی دسته جمعی آیات.') |
||||
|
} finally { |
||||
|
setIsSavingAllVerses(false) |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
const filteredVerses = useMemo(() => { |
||||
|
if (!surahDetail?.verses) return [] |
||||
|
if (!verseSearch.trim()) return surahDetail.verses |
||||
|
const q = verseSearch.trim().toLowerCase() |
||||
|
return surahDetail.verses.filter((v, idx) => { |
||||
|
const draft = verseDrafts[v.index] |
||||
|
const text = (draft?.text ?? v.text ?? '').toLowerCase() |
||||
|
const fa = (draft?.translations?.fa ?? v.translations?.fa ?? '').toLowerCase() |
||||
|
const en = (draft?.translations?.en ?? v.translations?.en ?? '').toLowerCase() |
||||
|
const ru = (draft?.translations?.ru ?? v.translations?.ru ?? '').toLowerCase() |
||||
|
const id = (draft?.translations?.id ?? v.translations?.id ?? '').toLowerCase() |
||||
|
return ( |
||||
|
text.includes(q) || |
||||
|
fa.includes(q) || |
||||
|
en.includes(q) || |
||||
|
ru.includes(q) || |
||||
|
id.includes(q) || |
||||
|
String(idx + 1).includes(q) |
||||
|
) |
||||
|
}) |
||||
|
}, [surahDetail, verseSearch, verseDrafts]) |
||||
|
|
||||
|
return ( |
||||
|
<div className="space-y-6"> |
||||
|
{/* KPI Cards */} |
||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4"> |
||||
|
<div className="rounded-2xl border border-border-soft bg-card/50 p-4 shadow-2xs"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<span className="text-xs font-medium text-grey-3">سورههای منتخب قرآن</span> |
||||
|
<div className="flex size-8 items-center justify-center rounded-xl bg-primary/10 text-primary"> |
||||
|
<Ic name="book" className="size-4" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div className="mt-2 flex items-baseline gap-2"> |
||||
|
<span className="text-2xl font-black text-foreground">{toFa(surahs.length)}</span> |
||||
|
<span className="text-xs text-grey-3">سوره مبارکه</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div className="rounded-2xl border border-border-soft bg-card/50 p-4 shadow-2xs"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<span className="text-xs font-medium text-grey-3">مجموع آیات ثبتشده</span> |
||||
|
<div className="flex size-8 items-center justify-center rounded-xl bg-emerald-500/10 text-emerald-500"> |
||||
|
<Ic name="sparkles" className="size-4" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div className="mt-2 flex items-baseline gap-2"> |
||||
|
<span className="text-2xl font-black text-foreground">{toFa(totalVersesCount)}</span> |
||||
|
<span className="text-xs text-grey-3">آیه با اعراب کامل</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div className="rounded-2xl border border-border-soft bg-card/50 p-4 shadow-2xs"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<span className="text-xs font-medium text-grey-3">پوشش ترجمه چندزبانه</span> |
||||
|
<div className="flex size-8 items-center justify-center rounded-xl bg-amber-500/10 text-amber-500"> |
||||
|
<Ic name="globe" className="size-4" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div className="mt-2 flex items-baseline gap-2"> |
||||
|
<span className="text-2xl font-black text-foreground">{toFa(5)}</span> |
||||
|
<span className="text-xs text-grey-3">زبان (FA, AR, EN, RU, ID)</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
<div className="rounded-2xl border border-border-soft bg-card/50 p-4 shadow-2xs"> |
||||
|
<div className="flex items-center justify-between"> |
||||
|
<span className="text-xs font-medium text-grey-3">سورههای فعال در دیتابیس</span> |
||||
|
<div className="flex size-8 items-center justify-center rounded-xl bg-sky-500/10 text-sky-500"> |
||||
|
<Ic name="check" className="size-4" /> |
||||
|
</div> |
||||
|
</div> |
||||
|
<div className="mt-2 flex items-baseline gap-2"> |
||||
|
<span className="text-2xl font-black text-foreground">{toFa(activeSurahsCount)}</span> |
||||
|
<span className="text-xs text-grey-3">آماده اتصال به اپلیکیشن</span> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* Filters & Search Bar */} |
||||
|
<div className="flex flex-col gap-3 rounded-2xl border border-border-soft bg-card/40 p-4 sm:flex-row sm:items-center sm:justify-between"> |
||||
|
<div className="relative flex-1 max-w-md"> |
||||
|
<Ic |
||||
|
name="search" |
||||
|
className="pointer-events-none absolute start-3 top-1/2 size-4 -translate-y-1/2 text-grey-3" |
||||
|
/> |
||||
|
<Input |
||||
|
value={searchTerm} |
||||
|
onChange={(e) => setSearchTerm(e.target.value)} |
||||
|
placeholder="جستجوی نام سوره (فارسی، عربی، انگلیسی، روسی، اندونزیایی) یا شماره سوره..." |
||||
|
className="ps-9 rounded-xl border-border-soft bg-background/60 text-xs" |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<div className="flex items-center gap-2"> |
||||
|
<Button |
||||
|
variant="outline" |
||||
|
size="sm" |
||||
|
onClick={() => loadSurahs(true)} |
||||
|
disabled={isRefreshing} |
||||
|
className="rounded-xl gap-1.5 text-xs" |
||||
|
> |
||||
|
<Ic name="refresh" className={`size-3.5 ${isRefreshing ? 'animate-spin' : ''}`} /> |
||||
|
<span>بروزرسانی</span> |
||||
|
</Button> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* Error State */} |
||||
|
{errorMessage && ( |
||||
|
<div className="rounded-2xl border border-rose-500/30 bg-rose-500/10 p-4 text-xs text-rose-400 flex items-center justify-between"> |
||||
|
<span>{errorMessage}</span> |
||||
|
<Button variant="outline" size="sm" onClick={() => loadSurahs(true)}> |
||||
|
تلاش مجدد |
||||
|
</Button> |
||||
|
</div> |
||||
|
)} |
||||
|
|
||||
|
{/* Surahs Table */} |
||||
|
<div className="overflow-hidden rounded-2xl border border-border-soft bg-card/40 shadow-xs"> |
||||
|
<div className="overflow-x-auto"> |
||||
|
<Table> |
||||
|
<TableHeader> |
||||
|
<TableRow className="border-border-soft bg-muted/30"> |
||||
|
<TableHead className="w-16 text-center text-xs font-bold">شماره</TableHead> |
||||
|
<TableHead className="min-w-[240px] text-start text-xs font-bold">نام سوره</TableHead> |
||||
|
<TableHead className="w-36 text-center text-xs font-bold">نزول و جزء</TableHead> |
||||
|
<TableHead className="w-28 text-center text-xs font-bold">تعداد آیات</TableHead> |
||||
|
<TableHead className="w-24 text-center text-xs font-bold">وضعیت</TableHead> |
||||
|
<TableHead className="w-36 text-end text-xs font-bold">عملیات</TableHead> |
||||
|
</TableRow> |
||||
|
</TableHeader> |
||||
|
<TableBody> |
||||
|
{isLoading ? ( |
||||
|
Array.from({ length: 6 }).map((_, idx) => ( |
||||
|
<TableRow key={idx} className="border-border-soft"> |
||||
|
<TableCell><ShimmerBox className="mx-auto h-7 w-7 rounded-lg" /></TableCell> |
||||
|
<TableCell><ShimmerLine className="h-5 w-48" /></TableCell> |
||||
|
<TableCell><ShimmerBox className="mx-auto h-6 w-20 rounded-lg" /></TableCell> |
||||
|
<TableCell><ShimmerBox className="mx-auto h-5 w-14 rounded-lg" /></TableCell> |
||||
|
<TableCell><ShimmerBox className="mx-auto h-5 w-14 rounded-lg" /></TableCell> |
||||
|
<TableCell><ShimmerBox className="ms-auto h-8 w-28 rounded-xl" /></TableCell> |
||||
|
</TableRow> |
||||
|
)) |
||||
|
) : filteredSurahs.length === 0 ? ( |
||||
|
<TableRow> |
||||
|
<TableCell colSpan={6} className="py-12 text-center text-xs text-grey-3"> |
||||
|
سورهای مطابق با فیلترهای انتخابی یافت نشد. |
||||
|
</TableCell> |
||||
|
</TableRow> |
||||
|
) : ( |
||||
|
filteredSurahs.map((surah) => { |
||||
|
const sNum = surah.index >= 6000 ? surah.index - 6000 : surah.index |
||||
|
const meta = SURAH_META_BY_NUMBER[sNum] |
||||
|
const nameFa = extractLangName(surah.name, 'fa') || surah.name_display |
||||
|
const nameAr = extractLangName(surah.name, 'ar') |
||||
|
const nameEn = extractLangName(surah.name, 'en') |
||||
|
const isActive = surah.is_active !== false |
||||
|
|
||||
|
return ( |
||||
|
<TableRow |
||||
|
key={surah.index} |
||||
|
className="border-border-soft transition-colors hover:bg-muted/20" |
||||
|
> |
||||
|
{/* شماره سوره */} |
||||
|
<TableCell className="text-center align-middle"> |
||||
|
<span |
||||
|
className="inline-flex size-7 items-center justify-center rounded-lg bg-muted/60 font-mono text-xs font-bold text-foreground" |
||||
|
title={`شناسه دیتابیس: #${surah.index}`} |
||||
|
> |
||||
|
{toFa(sNum)} |
||||
|
</span> |
||||
|
</TableCell> |
||||
|
|
||||
|
{/* نام سوره (فارسی، عربی و انگلیسی) */} |
||||
|
<TableCell className="align-middle"> |
||||
|
<div className="flex flex-col gap-0.5"> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<span className="font-bold text-sm text-foreground">{nameFa}</span> |
||||
|
{nameAr && ( |
||||
|
<span className="text-xs font-semibold text-primary/80"> |
||||
|
({nameAr}) |
||||
|
</span> |
||||
|
)} |
||||
|
</div> |
||||
|
{nameEn && ( |
||||
|
<span className="text-[11px] text-grey-3 font-medium"> |
||||
|
{nameEn} |
||||
|
</span> |
||||
|
)} |
||||
|
</div> |
||||
|
</TableCell> |
||||
|
|
||||
|
{/* نوع نزول و جزء */} |
||||
|
<TableCell className="text-center align-middle"> |
||||
|
<div className="inline-flex items-center justify-center gap-2"> |
||||
|
<Badge |
||||
|
variant="outline" |
||||
|
className={ |
||||
|
meta?.revelationType === 'مدنی' |
||||
|
? 'border-emerald-500/25 bg-emerald-500/10 text-emerald-500 text-[11px] px-2 py-0.5' |
||||
|
: 'border-amber-500/25 bg-amber-500/10 text-amber-500 text-[11px] px-2 py-0.5' |
||||
|
} |
||||
|
> |
||||
|
{meta?.revelationType || 'مکی'} |
||||
|
</Badge> |
||||
|
{meta?.juzLabel && ( |
||||
|
<span className="text-[11px] text-grey-3"> |
||||
|
{meta.juzLabel} |
||||
|
</span> |
||||
|
)} |
||||
|
</div> |
||||
|
</TableCell> |
||||
|
|
||||
|
{/* تعداد آیات */} |
||||
|
<TableCell className="text-center align-middle"> |
||||
|
<span className="text-xs font-semibold text-foreground"> |
||||
|
{toFa(surah.verses_count || 0)} آیه |
||||
|
</span> |
||||
|
</TableCell> |
||||
|
|
||||
|
{/* وضعیت فعال / غیرفعال */} |
||||
|
<TableCell className="text-center align-middle"> |
||||
|
<button |
||||
|
type="button" |
||||
|
disabled={togglingIndex === surah.index} |
||||
|
onClick={() => handleToggleSurahStatus(surah)} |
||||
|
className={`inline-flex items-center gap-1.5 rounded-full px-2.5 py-0.5 text-xs font-medium transition-all ${ |
||||
|
isActive |
||||
|
? 'bg-emerald-500/10 text-emerald-500 hover:bg-emerald-500/15' |
||||
|
: 'bg-muted text-grey-3 hover:bg-muted/80' |
||||
|
}`}
|
||||
|
> |
||||
|
<span |
||||
|
className={`size-1.5 rounded-full ${ |
||||
|
isActive ? 'bg-emerald-500' : 'bg-grey-3' |
||||
|
}`}
|
||||
|
/> |
||||
|
<span>{isActive ? 'فعال' : 'غیرفعال'}</span> |
||||
|
</button> |
||||
|
</TableCell> |
||||
|
|
||||
|
{/* عملیات */} |
||||
|
<TableCell className="text-end align-middle"> |
||||
|
<Button |
||||
|
variant="outline" |
||||
|
size="sm" |
||||
|
onClick={() => handleOpenSurahModal(surah, 'verses')} |
||||
|
className="h-8 rounded-xl gap-1.5 text-xs border-primary/25 bg-primary/5 text-primary hover:bg-primary/15" |
||||
|
> |
||||
|
<Ic name="book" className="size-3.5" /> |
||||
|
<span>آیات و ترجمهها</span> |
||||
|
</Button> |
||||
|
</TableCell> |
||||
|
</TableRow> |
||||
|
) |
||||
|
}) |
||||
|
)} |
||||
|
</TableBody> |
||||
|
</Table> |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{/* Modal for Editing Surah Titles & 5-Language Verses */} |
||||
|
<Dialog |
||||
|
open={Boolean(selectedSurah)} |
||||
|
onOpenChange={(open) => { |
||||
|
if (!open) setSelectedSurah(null) |
||||
|
}} |
||||
|
> |
||||
|
<DialogContent className="max-w-4xl max-h-[90vh]"> |
||||
|
<DialogHeader className="p-5 sm:p-6 pb-4"> |
||||
|
<DialogTitle className="flex flex-wrap items-center justify-between gap-3 text-base font-black"> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<Ic name="book" className="size-5 text-primary" /> |
||||
|
<span>{selectedSurah?.name_display}</span> |
||||
|
{selectedSurah && ( |
||||
|
<Badge variant="outline" className="font-mono text-xs"> |
||||
|
سوره {toFa(selectedSurah.index - 6000)} (#{selectedSurah.index}) |
||||
|
</Badge> |
||||
|
)} |
||||
|
</div> |
||||
|
|
||||
|
<div className="flex items-center gap-1.5 rounded-xl border border-border-soft bg-background/60 p-1"> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => setModalTab('verses')} |
||||
|
className={`rounded-lg px-3 py-1.5 text-xs font-bold transition-all ${ |
||||
|
modalTab === 'verses' |
||||
|
? 'bg-primary text-primary-foreground' |
||||
|
: 'text-grey-2 hover:text-foreground' |
||||
|
}`}
|
||||
|
> |
||||
|
آیات و ترجمههای ۵ زبانه ({toFa(surahDetail?.verses?.length || selectedSurah?.verses_count || 0)}) |
||||
|
</button> |
||||
|
<button |
||||
|
type="button" |
||||
|
onClick={() => setModalTab('info')} |
||||
|
className={`rounded-lg px-3 py-1.5 text-xs font-bold transition-all ${ |
||||
|
modalTab === 'info' |
||||
|
? 'bg-primary text-primary-foreground' |
||||
|
: 'text-grey-2 hover:text-foreground' |
||||
|
}`}
|
||||
|
> |
||||
|
عناوین ۵ زبانه سوره |
||||
|
</button> |
||||
|
</div> |
||||
|
</DialogTitle> |
||||
|
</DialogHeader> |
||||
|
|
||||
|
<DialogBody className="p-5 sm:p-6 space-y-4"> |
||||
|
{modalTab === 'info' ? ( |
||||
|
<div className="space-y-4"> |
||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2"> |
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-bold text-foreground">نام سوره (فارسی - FA)</label> |
||||
|
<Input |
||||
|
value={formNameFa} |
||||
|
onChange={(e) => setFormNameFa(e.target.value)} |
||||
|
className="rounded-xl text-xs" |
||||
|
/> |
||||
|
</div> |
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-bold text-foreground">نام سوره (عربی - AR)</label> |
||||
|
<Input |
||||
|
value={formNameAr} |
||||
|
onChange={(e) => setFormNameAr(e.target.value)} |
||||
|
className="rounded-xl text-xs" |
||||
|
/> |
||||
|
</div> |
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-bold text-foreground">نام سوره (انگلیسی - EN)</label> |
||||
|
<Input |
||||
|
dir="ltr" |
||||
|
value={formNameEn} |
||||
|
onChange={(e) => setFormNameEn(e.target.value)} |
||||
|
className="rounded-xl text-xs" |
||||
|
/> |
||||
|
</div> |
||||
|
<div className="space-y-1.5"> |
||||
|
<label className="text-xs font-bold text-foreground">نام سوره (روسی - RU)</label> |
||||
|
<Input |
||||
|
dir="ltr" |
||||
|
value={formNameRu} |
||||
|
onChange={(e) => setFormNameRu(e.target.value)} |
||||
|
className="rounded-xl text-xs" |
||||
|
/> |
||||
|
</div> |
||||
|
<div className="space-y-1.5 sm:col-span-2"> |
||||
|
<label className="text-xs font-bold text-foreground">نام سوره (اندونزیایی - ID)</label> |
||||
|
<Input |
||||
|
dir="ltr" |
||||
|
value={formNameId} |
||||
|
onChange={(e) => setFormNameId(e.target.value)} |
||||
|
className="rounded-xl text-xs" |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
</div> |
||||
|
) : ( |
||||
|
<div className="space-y-4"> |
||||
|
{/* Language Switcher & Verse Search */} |
||||
|
<div className="sticky top-0 z-10 -mx-5 sm:-mx-6 -mt-5 sm:-mt-6 p-4 px-5 sm:px-6 bg-card/95 backdrop-blur-xs border-b border-border-soft flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between shadow-2xs"> |
||||
|
<div className="relative flex-1"> |
||||
|
<Ic |
||||
|
name="search" |
||||
|
className="pointer-events-none absolute start-3 top-1/2 size-4 -translate-y-1/2 text-grey-3" |
||||
|
/> |
||||
|
<Input |
||||
|
value={verseSearch} |
||||
|
onChange={(e) => setVerseSearch(e.target.value)} |
||||
|
placeholder="جستجو در متن عربی یا ترجمههای آیات این سوره..." |
||||
|
className="ps-9 rounded-xl text-xs" |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<div className="flex flex-wrap items-center gap-1 rounded-xl border border-border-soft bg-background/60 p-1"> |
||||
|
{( |
||||
|
[ |
||||
|
{ code: 'fa', label: 'ترجمه فارسی (FA)' }, |
||||
|
{ code: 'en', label: 'English (EN)' }, |
||||
|
{ code: 'ru', label: 'Русский (RU)' }, |
||||
|
{ code: 'id', label: 'Indonesia (ID)' }, |
||||
|
] as const |
||||
|
).map((l) => ( |
||||
|
<button |
||||
|
key={l.code} |
||||
|
type="button" |
||||
|
onClick={() => setActiveTransLang(l.code)} |
||||
|
className={`rounded-lg px-2.5 py-1 text-xs font-bold transition-all ${ |
||||
|
activeTransLang === l.code |
||||
|
? 'bg-primary text-primary-foreground' |
||||
|
: 'text-grey-2 hover:text-foreground' |
||||
|
}`}
|
||||
|
> |
||||
|
{l.label} |
||||
|
</button> |
||||
|
))} |
||||
|
</div> |
||||
|
</div> |
||||
|
|
||||
|
{isLoadingDetail ? ( |
||||
|
<div className="space-y-3"> |
||||
|
{Array.from({ length: 4 }).map((_, i) => ( |
||||
|
<ShimmerBox key={i} className="h-32 w-full rounded-2xl" /> |
||||
|
))} |
||||
|
</div> |
||||
|
) : ( |
||||
|
<div className="space-y-3 pe-1"> |
||||
|
{filteredVerses.map((v, idx) => { |
||||
|
const draft = verseDrafts[v.index] || { |
||||
|
text: v.text, |
||||
|
translations: v.translations || {}, |
||||
|
} |
||||
|
const ayahNum = v.index % 1000 |
||||
|
const isVerseModified = |
||||
|
(draft.text ?? '') !== (v.text ?? '') || |
||||
|
(draft.translations?.fa ?? '') !== (v.translations?.fa ?? '') || |
||||
|
(draft.translations?.en ?? '') !== (v.translations?.en ?? '') || |
||||
|
(draft.translations?.ru ?? '') !== (v.translations?.ru ?? '') || |
||||
|
(draft.translations?.id ?? '') !== (v.translations?.id ?? '') |
||||
|
|
||||
|
return ( |
||||
|
<div |
||||
|
key={v.index} |
||||
|
style={ |
||||
|
idx >= 4 |
||||
|
? { |
||||
|
contentVisibility: 'auto', |
||||
|
containIntrinsicSize: 'auto none auto 260px', |
||||
|
} |
||||
|
: undefined |
||||
|
} |
||||
|
className="rounded-2xl border border-border-soft bg-background/50 p-4 space-y-3" |
||||
|
> |
||||
|
<div className="flex items-center justify-between gap-2"> |
||||
|
<div className="flex items-center gap-2"> |
||||
|
<Badge |
||||
|
variant="outline" |
||||
|
className="border-primary/30 bg-primary/10 text-primary font-bold text-xs" |
||||
|
> |
||||
|
آیه {toFa(ayahNum)} |
||||
|
</Badge> |
||||
|
<Badge variant="outline" className="text-[11px] text-grey-3"> |
||||
|
جزء {toFa(v.juz || 1)} |
||||
|
</Badge> |
||||
|
</div> |
||||
|
|
||||
|
{isVerseModified && ( |
||||
|
<span className="text-[10px] font-bold text-amber-500 bg-amber-500/10 border border-amber-500/20 px-2 py-0.5 rounded-lg flex items-center gap-1"> |
||||
|
<span className="size-1.5 rounded-full bg-amber-500 animate-pulse" /> |
||||
|
ویرایششده |
||||
|
</span> |
||||
|
)} |
||||
|
</div> |
||||
|
|
||||
|
<div className="space-y-1"> |
||||
|
<label className="text-[11px] font-bold text-grey-3"> |
||||
|
متن عربی آیه (AR): |
||||
|
</label> |
||||
|
<textarea |
||||
|
dir="rtl" |
||||
|
rows={2} |
||||
|
value={draft.text} |
||||
|
onChange={(e) => |
||||
|
setVerseDrafts((prev) => ({ |
||||
|
...prev, |
||||
|
[v.index]: { ...draft, text: e.target.value }, |
||||
|
})) |
||||
|
} |
||||
|
className="w-full rounded-xl border border-border-soft bg-card p-2.5 text-sm font-semibold leading-relaxed text-foreground focus:outline-none focus:ring-2 focus:ring-primary/40" |
||||
|
/> |
||||
|
</div> |
||||
|
|
||||
|
<div className="space-y-1"> |
||||
|
<label className="text-[11px] font-bold text-grey-3"> |
||||
|
ترجمه ({activeTransLang.toUpperCase()}): |
||||
|
</label> |
||||
|
<textarea |
||||
|
dir={activeTransLang === 'fa' ? 'rtl' : 'ltr'} |
||||
|
rows={2} |
||||
|
value={draft.translations?.[activeTransLang] || ''} |
||||
|
onChange={(e) => |
||||
|
setVerseDrafts((prev) => ({ |
||||
|
...prev, |
||||
|
[v.index]: { |
||||
|
...draft, |
||||
|
translations: { |
||||
|
...draft.translations, |
||||
|
[activeTransLang]: e.target.value, |
||||
|
}, |
||||
|
}, |
||||
|
})) |
||||
|
} |
||||
|
className="w-full rounded-xl border border-border-soft bg-card p-2.5 text-xs leading-relaxed text-foreground focus:outline-none focus:ring-2 focus:ring-primary/40" |
||||
|
/> |
||||
|
</div> |
||||
|
</div> |
||||
|
) |
||||
|
})} |
||||
|
</div> |
||||
|
)} |
||||
|
</div> |
||||
|
)} |
||||
|
</DialogBody> |
||||
|
|
||||
|
{/* Footer actions pinned at the bottom of the dialog */} |
||||
|
<DialogFooter className="p-4 sm:p-5 border-t border-border-soft bg-card/95 backdrop-blur-xs flex-row items-center justify-end gap-2 shrink-0"> |
||||
|
<Button |
||||
|
variant="outline" |
||||
|
onClick={() => setSelectedSurah(null)} |
||||
|
className="rounded-xl text-xs" |
||||
|
> |
||||
|
بستن |
||||
|
</Button> |
||||
|
{modalTab === 'info' ? ( |
||||
|
<Button |
||||
|
onClick={handleSaveSurahInfo} |
||||
|
disabled={isSavingInfo} |
||||
|
className="rounded-xl text-xs gap-1.5" |
||||
|
> |
||||
|
<Ic name="check" className="size-4" /> |
||||
|
<span>{isSavingInfo ? 'در حال ذخیره...' : 'ذخیره عناوین ۵ زبانه'}</span> |
||||
|
</Button> |
||||
|
) : ( |
||||
|
<Button |
||||
|
onClick={handleSaveAllVerses} |
||||
|
disabled={isSavingAllVerses || isLoadingDetail} |
||||
|
className="rounded-xl text-xs gap-1.5" |
||||
|
> |
||||
|
<Ic name="check" className="size-4" /> |
||||
|
<span> |
||||
|
{isSavingAllVerses |
||||
|
? 'در حال ذخیره...' |
||||
|
: 'ذخیره آیات و ترجمهها'} |
||||
|
</span> |
||||
|
</Button> |
||||
|
)} |
||||
|
</DialogFooter> |
||||
|
</DialogContent> |
||||
|
</Dialog> |
||||
|
</div> |
||||
|
) |
||||
|
} |
||||
@ -0,0 +1,96 @@ |
|||||
|
import { apiFetch } from '@/services/http' |
||||
|
|
||||
|
export interface BlogTag { |
||||
|
id: number |
||||
|
name: string |
||||
|
slug: string |
||||
|
} |
||||
|
|
||||
|
export interface BlogItem { |
||||
|
id: number |
||||
|
title: string |
||||
|
slug: string |
||||
|
content: string |
||||
|
summary: string |
||||
|
tags: BlogTag[] |
||||
|
tag_ids?: number[] |
||||
|
tag_names?: string[] |
||||
|
status: boolean |
||||
|
is_top_list: boolean |
||||
|
cover: string | null |
||||
|
cover_input?: string |
||||
|
language: number |
||||
|
language_code?: string |
||||
|
language_code_display?: string |
||||
|
created_at: string |
||||
|
updated_at: string |
||||
|
} |
||||
|
|
||||
|
export interface BlogListResponse { |
||||
|
count?: number |
||||
|
next?: string | null |
||||
|
previous?: string | null |
||||
|
results: BlogItem[] |
||||
|
} |
||||
|
|
||||
|
export async function fetchAdminBlogs(params?: { |
||||
|
language_code?: string |
||||
|
status?: string |
||||
|
search?: string |
||||
|
tag?: string |
||||
|
}): Promise<BlogItem[]> { |
||||
|
const query = new URLSearchParams() |
||||
|
if (params?.language_code && params.language_code !== 'ALL') { |
||||
|
query.set('language_code', params.language_code) |
||||
|
} |
||||
|
if (params?.status && params.status !== 'ALL') { |
||||
|
query.set('status', params.status) |
||||
|
} |
||||
|
if (params?.search) { |
||||
|
query.set('search', params.search) |
||||
|
} |
||||
|
if (params?.tag && params.tag !== 'ALL') { |
||||
|
query.set('tag', params.tag) |
||||
|
} |
||||
|
|
||||
|
const qs = query.toString() |
||||
|
const path = qs ? `blogs/admin/?${qs}` : 'blogs/admin/' |
||||
|
const data = await apiFetch<any>(path) |
||||
|
return Array.isArray(data) ? data : data?.results ?? [] |
||||
|
} |
||||
|
|
||||
|
export async function fetchAdminBlog(id: number): Promise<BlogItem> { |
||||
|
return await apiFetch<BlogItem>(`blogs/admin/${id}/`) |
||||
|
} |
||||
|
|
||||
|
export async function createAdminBlog(payload: Partial<BlogItem>): Promise<BlogItem> { |
||||
|
return await apiFetch<BlogItem>('blogs/admin/', { |
||||
|
method: 'POST', |
||||
|
body: payload, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
export async function updateAdminBlog(id: number, payload: Partial<BlogItem>): Promise<BlogItem> { |
||||
|
return await apiFetch<BlogItem>(`blogs/admin/${id}/`, { |
||||
|
method: 'PATCH', |
||||
|
body: payload, |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
export async function deleteAdminBlog(id: number): Promise<void> { |
||||
|
await apiFetch(`blogs/admin/${id}/`, { |
||||
|
method: 'DELETE', |
||||
|
}) |
||||
|
} |
||||
|
|
||||
|
export async function fetchBlogTags(): Promise<BlogTag[]> { |
||||
|
const data = await apiFetch<any>('blogs/admin/tags/') |
||||
|
return Array.isArray(data) ? data : data?.results ?? [] |
||||
|
} |
||||
|
|
||||
|
export async function createBlogTag(name: string): Promise<BlogTag> { |
||||
|
return await apiFetch<BlogTag>('blogs/admin/tags/', { |
||||
|
method: 'POST', |
||||
|
body: { name }, |
||||
|
}) |
||||
|
} |
||||
@ -0,0 +1,41 @@ |
|||||
|
import { create } from 'zustand' |
||||
|
import { fetchCustomTripOrders } from '@/features/custom-trip/services/custom-trip-api' |
||||
|
|
||||
|
interface CustomTripBadgeState { |
||||
|
pendingCount: number |
||||
|
awaitingPaymentCount: number |
||||
|
isLoading: boolean |
||||
|
lastFetched: number | null |
||||
|
fetchCounts: () => Promise<void> |
||||
|
setCounts: (pending: number, awaiting: number) => void |
||||
|
} |
||||
|
|
||||
|
export const useCustomTripBadgeStore = create<CustomTripBadgeState>((set, get) => ({ |
||||
|
pendingCount: 0, |
||||
|
awaitingPaymentCount: 0, |
||||
|
isLoading: false, |
||||
|
lastFetched: null, |
||||
|
fetchCounts: async () => { |
||||
|
const now = Date.now() |
||||
|
const last = get().lastFetched |
||||
|
if (last && now - last < 8000) return |
||||
|
|
||||
|
try { |
||||
|
set({ isLoading: true }) |
||||
|
const orders = await fetchCustomTripOrders() |
||||
|
const pending = orders.filter((o) => o.status === 'PENDING').length |
||||
|
const awaiting = orders.filter((o) => o.status === 'AWAITING_PAYMENT').length |
||||
|
set({ |
||||
|
pendingCount: pending, |
||||
|
awaitingPaymentCount: awaiting, |
||||
|
isLoading: false, |
||||
|
lastFetched: Date.now(), |
||||
|
}) |
||||
|
} catch { |
||||
|
set({ isLoading: false }) |
||||
|
} |
||||
|
}, |
||||
|
setCounts: (pending: number, awaiting: number) => { |
||||
|
set({ pendingCount: pending, awaitingPaymentCount: awaiting }) |
||||
|
}, |
||||
|
})) |
||||