From a2a790aaeef0b98a99dfab961a87db29118f772b Mon Sep 17 00:00:00 2001 From: Muhammad Amin Ghorbani Date: Thu, 29 Oct 2026 21:09:46 +0330 Subject: [PATCH] feat: add pilgrim testimonials settings tab with multi-language support and management CRUD functionality --- src/config/navigation.ts | 6 + src/features/settings/SettingsPage.tsx | 7 +- .../components/TestimonialsSettingsTab.tsx | 1029 +++++++++++++++++ .../settings/services/settings-api.ts | 72 ++ src/features/settings/types.ts | 55 + src/locales/ar.json | 39 +- src/locales/en.json | 39 +- src/locales/fa.json | 39 +- 8 files changed, 1278 insertions(+), 8 deletions(-) create mode 100644 src/features/settings/components/TestimonialsSettingsTab.tsx diff --git a/src/config/navigation.ts b/src/config/navigation.ts index 93f2bad..6f86e4b 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -159,6 +159,12 @@ export function getNavigationConfig(t: (key: string) => string): NavGroupConfig[ to: '/settings/notifications', icon: 'bell', }, + { + title: t('navigation.settingsTestimonials'), + titleKey: 'navigation.settingsTestimonials', + to: '/settings/testimonials', + icon: 'star', + }, ], }, ], diff --git a/src/features/settings/SettingsPage.tsx b/src/features/settings/SettingsPage.tsx index 58bf642..6ae3193 100644 --- a/src/features/settings/SettingsPage.tsx +++ b/src/features/settings/SettingsPage.tsx @@ -6,6 +6,7 @@ import { Ic } from '@/icons' import { AdminManagementTab } from './components/AdminManagementTab' import { NotificationsTab } from './components/NotificationsTab' import { SocialMediaSettingsTab } from './components/SocialMediaSettingsTab' +import { TestimonialsSettingsTab } from './components/TestimonialsSettingsTab' export default function SettingsPage() { const { t } = useTranslation() @@ -13,12 +14,12 @@ export default function SettingsPage() { const navigate = useNavigate() React.useEffect(() => { - if (!section || !['admins', 'social', 'notifications'].includes(section)) { + if (!section || !['admins', 'social', 'notifications', 'testimonials'].includes(section)) { navigate('/settings/admins', { replace: true }) } }, [section, navigate]) - const activeSection = section && ['admins', 'social', 'notifications'].includes(section) + const activeSection = section && ['admins', 'social', 'notifications', 'testimonials'].includes(section) ? section : 'admins' @@ -54,6 +55,8 @@ export default function SettingsPage() { ) : activeSection === 'social' ? ( + ) : activeSection === 'testimonials' ? ( + ) : ( )} diff --git a/src/features/settings/components/TestimonialsSettingsTab.tsx b/src/features/settings/components/TestimonialsSettingsTab.tsx new file mode 100644 index 0000000..2845480 --- /dev/null +++ b/src/features/settings/components/TestimonialsSettingsTab.tsx @@ -0,0 +1,1029 @@ +import * as React from 'react' +import { useTranslation, useLanguageStore } from '@/i18n' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { Switch } from '@/components/ui/switch' +import { Badge } from '@/components/ui/badge' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import { + ServiceTranslation, + type ServiceLanguageCode, +} from '@/components/shared/ServiceTranslation' +import DeleteConfirmDialog from '@/components/shared/DeleteConfirmDialog' +import { Shimmer } from '@/components/shared/shimmer_frontend' +import { Ic } from '@/icons' +import { toast } from '@/stores/toast' +import { formatDualDate } from '@/lib/date-utils' +import { + fetchTestimonialSectionSettings, + saveTestimonialSectionSettings, + fetchAdminTestimonials, + createAdminTestimonial, + updateAdminTestimonial, + deleteAdminTestimonial, +} from '../services/settings-api' +import type { + AdminTestimonialItem, + TestimonialSectionConfigData, + TestimonialTranslations, + SectionTranslations, +} from '../types' + +const LANG_CODES: ServiceLanguageCode[] = ['fa', 'ar', 'en', 'ru', 'id'] + +export function TestimonialsSettingsTab() { + const { t } = useTranslation() + const currentLang = useLanguageStore((state) => state.language) + + // 1. Data States + const [sectionConfig, setSectionConfig] = React.useState(null) + const [testimonials, setTestimonials] = React.useState([]) + const [isLoading, setIsLoading] = React.useState(true) + const [isSavingSection, setIsSavingSection] = React.useState(false) + + // 2. Filters + const [search, setSearch] = React.useState('') + const [activeFilter, setActiveFilter] = React.useState<'all' | 'true' | 'false'>('all') + const [ratingFilter, setRatingFilter] = React.useState('all') + + // 3. Section Config Form + const [sectionLang, setSectionLang] = React.useState('fa') + const [sectionActive, setSectionActive] = React.useState(true) + const [sectionTranslations, setSectionTranslations] = React.useState({ + fa: { title: '', subtitle: '' }, + ar: { title: '', subtitle: '' }, + en: { title: '', subtitle: '' }, + ru: { title: '', subtitle: '' }, + id: { title: '', subtitle: '' }, + }) + const [isSectionOpen, setIsSectionOpen] = React.useState(false) + + // 4. Modal (Create / Edit) + const [isModalOpen, setIsModalOpen] = React.useState(false) + const [editingItem, setEditingItem] = React.useState(null) + const [modalLang, setModalLang] = React.useState('fa') + const [modalSlug, setModalSlug] = React.useState('') + const [modalRating, setModalRating] = React.useState(5) + const [modalOrder, setModalOrder] = React.useState(0) + const [modalIsActive, setModalIsActive] = React.useState(true) + const [modalAvatarUrl, setModalAvatarUrl] = React.useState('') + const [modalAvatarFile, setModalAvatarFile] = React.useState(null) + const [modalAvatarPreview, setModalAvatarPreview] = React.useState(null) + const [modalTranslations, setModalTranslations] = React.useState({ + fa: { author_name: '', location: '', tour_title: '', content: '' }, + ar: { author_name: '', location: '', tour_title: '', content: '' }, + en: { author_name: '', location: '', tour_title: '', content: '' }, + ru: { author_name: '', location: '', tour_title: '', content: '' }, + id: { author_name: '', location: '', tour_title: '', content: '' }, + }) + const [isSubmitting, setIsSubmitting] = React.useState(false) + + // 5. Delete Confirm + const [deleteTarget, setDeleteTarget] = React.useState(null) + const [isDeleting, setIsDeleting] = React.useState(false) + + // Initial Load + const loadData = React.useCallback(async () => { + setIsLoading(true) + try { + const [secRes, listRes] = await Promise.all([ + fetchTestimonialSectionSettings().catch(() => null), + fetchAdminTestimonials({ + search: search || undefined, + is_active: activeFilter !== 'all' ? activeFilter : undefined, + rating: ratingFilter !== 'all' ? ratingFilter : undefined, + }), + ]) + + if (secRes) { + setSectionConfig(secRes) + setSectionActive(secRes.is_active) + const trs: SectionTranslations = { + fa: { title: '', subtitle: '' }, + ar: { title: '', subtitle: '' }, + en: { title: '', subtitle: '' }, + ru: { title: '', subtitle: '' }, + id: { title: '', subtitle: '' }, + } + LANG_CODES.forEach((lc) => { + if (secRes.translations && secRes.translations[lc]) { + trs[lc] = { + title: secRes.translations[lc].title || '', + subtitle: secRes.translations[lc].subtitle || '', + } + } + }) + setSectionTranslations(trs) + } + + setTestimonials(listRes || []) + } catch (err: any) { + toast.error(err?.message || 'Error loading testimonials') + } finally { + setIsLoading(false) + } + }, [search, activeFilter, ratingFilter]) + + React.useEffect(() => { + loadData() + }, [loadData]) + + // Save Section Settings + const handleSaveSection = async () => { + setIsSavingSection(true) + try { + const updated = await saveTestimonialSectionSettings({ + is_active: sectionActive, + translations: sectionTranslations, + }) + setSectionConfig(updated) + toast.success(t('settings.saveSettingsSuccess')) + } catch (err: any) { + toast.error(err?.message || 'Error saving section settings') + } finally { + setIsSavingSection(false) + } + } + + // Open Create Modal + const handleOpenCreate = () => { + setEditingItem(null) + setModalSlug(`pilgrim-${Date.now()}`) + setModalRating(5) + setModalOrder((testimonials.length || 0) + 1) + setModalIsActive(true) + setModalAvatarUrl('') + setModalAvatarFile(null) + setModalAvatarPreview(null) + setModalLang(currentLang as ServiceLanguageCode || 'fa') + setModalTranslations({ + fa: { author_name: '', location: '', tour_title: '', content: '' }, + ar: { author_name: '', location: '', tour_title: '', content: '' }, + en: { author_name: '', location: '', tour_title: '', content: '' }, + ru: { author_name: '', location: '', tour_title: '', content: '' }, + id: { author_name: '', location: '', tour_title: '', content: '' }, + }) + setIsModalOpen(true) + } + + // Open Edit Modal + const handleOpenEdit = (item: AdminTestimonialItem) => { + setEditingItem(item) + setModalSlug(item.slug || `pilgrim-${item.id}`) + setModalRating(item.rating || 5) + setModalOrder(item.order || 0) + setModalIsActive(item.is_active) + setModalAvatarUrl(item.avatar_url || '') + setModalAvatarFile(null) + setModalAvatarPreview(item.avatar || item.avatar_url || null) + setModalLang(currentLang as ServiceLanguageCode || 'fa') + + const trs: TestimonialTranslations = { + fa: { author_name: '', location: '', tour_title: '', content: '' }, + ar: { author_name: '', location: '', tour_title: '', content: '' }, + en: { author_name: '', location: '', tour_title: '', content: '' }, + ru: { author_name: '', location: '', tour_title: '', content: '' }, + id: { author_name: '', location: '', tour_title: '', content: '' }, + } + LANG_CODES.forEach((lc) => { + const src = item.translations?.[lc] || {} + trs[lc] = { + author_name: src.author_name || (lc === 'fa' ? item.author_name : ''), + location: src.location || (lc === 'fa' ? item.location : ''), + tour_title: src.tour_title || (lc === 'fa' ? item.tour_title : ''), + content: src.content || (lc === 'fa' ? item.content : ''), + } + }) + setModalTranslations(trs) + setIsModalOpen(true) + } + + // Handle Avatar File Change + const handleAvatarFileSelect = (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (file) { + setModalAvatarFile(file) + const previewUrl = URL.createObjectURL(file) + setModalAvatarPreview(previewUrl) + } + } + + // Submit Modal (Create or Update) + const handleSubmitModal = async (e: React.FormEvent) => { + e.preventDefault() + const faName = modalTranslations.fa?.author_name?.trim() + const currentName = modalTranslations[modalLang]?.author_name?.trim() + if (!faName && !currentName) { + toast.error('لطفاً نام زائر را وارد نمایید.') + return + } + + setIsSubmitting(true) + try { + const formData = new FormData() + formData.append('slug', modalSlug.trim() || `pilgrim-${Date.now()}`) + formData.append('rating', String(modalRating)) + formData.append('order', String(modalOrder)) + formData.append('is_active', String(modalIsActive)) + if (modalAvatarUrl.trim()) { + formData.append('avatar_url', modalAvatarUrl.trim()) + } + if (modalAvatarFile) { + formData.append('avatar', modalAvatarFile) + } + formData.append('translations', JSON.stringify(modalTranslations)) + + if (editingItem) { + await updateAdminTestimonial(editingItem.id, formData) + toast.success(t('settings.testimonialSavedSuccess')) + } else { + await createAdminTestimonial(formData) + toast.success(t('settings.testimonialSavedSuccess')) + } + + setIsModalOpen(false) + loadData() + } catch (err: any) { + toast.error(err?.message || 'Error saving testimonial') + } finally { + setIsSubmitting(false) + } + } + + // Toggle Single Testimonial Active + const handleToggleActive = async (item: AdminTestimonialItem, nextActive: boolean) => { + try { + setTestimonials((prev) => + prev.map((tItem) => (tItem.id === item.id ? { ...tItem, is_active: nextActive } : tItem)) + ) + await updateAdminTestimonial(item.id, { is_active: nextActive, translations: item.translations }) + toast.success(t('settings.saveSettingsSuccess')) + } catch (err: any) { + toast.error(err?.message || 'Error updating status') + loadData() + } + } + + // Confirm Delete + const handleConfirmDelete = async () => { + if (!deleteTarget) return + setIsDeleting(true) + try { + await deleteAdminTestimonial(deleteTarget.id) + toast.success(t('settings.testimonialDeletedSuccess')) + setDeleteTarget(null) + loadData() + } catch (err: any) { + toast.error(err?.message || 'Error deleting testimonial') + } finally { + setIsDeleting(false) + } + } + + // Stats Calculations + const totalCount = testimonials.length + const activeCount = testimonials.filter((i) => i.is_active).length + const avgRating = totalCount > 0 + ? (testimonials.reduce((acc, cur) => acc + (cur.rating || 5), 0) / totalCount).toFixed(1) + : '5.0' + + // Completion Indicators for ServiceTranslation + const modalCompletedLangs = React.useMemo(() => { + const res: Record = { fa: false, ar: false, en: false, ru: false, id: false } + LANG_CODES.forEach((lc) => { + const tr = modalTranslations[lc] + if (tr && tr.author_name?.trim() && tr.content?.trim()) { + res[lc] = true + } + }) + return res + }, [modalTranslations]) + + const sectionCompletedLangs = React.useMemo(() => { + const res: Record = { fa: false, ar: false, en: false, ru: false, id: false } + LANG_CODES.forEach((lc) => { + const tr = sectionTranslations[lc] + if (tr && tr.title?.trim()) { + res[lc] = true + } + }) + return res + }, [sectionTranslations]) + + return ( +
+ {/* 1. Header Banner */} +
+
+
+
+ +
+

+ {t('settings.tabTestimonials')} +

+ + {t('settings.badgeTestimonials')} + +
+

+ {t('settings.testimonialsDescription')} +

+
+ +
+ +
+
+ + {/* 2. KPI Summary Cards */} +
+
+
+

{t('settings.totalTestimonials')}

+

+ {totalCount} +

+
+
+ +
+
+ +
+
+

{t('settings.activeTestimonials')}

+

+ {activeCount} +

+
+
+ +
+
+ +
+
+

{t('settings.averageRating')}

+
+ + {avgRating} + + / 5.0 +
+
+
+ +
+
+
+ + {/* 3. Section Header Configuration Card */} +
+
+
+
+ +

+ {t('settings.sectionHeaderCard')} +

+
+

+ {t('settings.sectionHeaderCardDesc')} +

+
+ +
+
+ {t('settings.sectionActive')} + +
+ + +
+
+ + {/* Expandable Section Editor */} + {isSectionOpen && ( +
+ setSectionLang(l)} + completedLangs={sectionCompletedLangs} + title="زبان عنوان و زیرعنوان بخش" + /> + +
+
+ + + setSectionTranslations((prev) => ({ + ...prev, + [sectionLang]: { + ...prev[sectionLang], + title: e.target.value, + }, + })) + } + placeholder="مثال: تجارب و دیدگاه‌های زائران همسفر" + className="bg-[#161920] border-white/10 text-sm" + /> +
+ +
+ +