From f990fb73436b91c082a1c2f110c93b2b38f23ed4 Mon Sep 17 00:00:00 2001 From: "Muhammad A. Ghorbani" Date: Sun, 27 Sep 2026 09:28:40 +0330 Subject: [PATCH] feat: add services feature with store management, server status dialog, API integration, and localization files --- src/components/layout/ServerStatusDialog.tsx | 38 +- src/config/navigation.ts | 20 +- src/features/services/ServicesPage.tsx | 115 +- .../services/components/StoreServiceView.tsx | 1464 +++++++++++++++++ src/features/services/services/store-api.ts | 276 ++++ src/features/services/types/store.ts | 71 + src/locales/ar.json | 2 + src/locales/en.json | 2 + src/locales/fa.json | 2 + src/services/http.ts | 34 +- 10 files changed, 1933 insertions(+), 91 deletions(-) create mode 100644 src/features/services/components/StoreServiceView.tsx create mode 100644 src/features/services/services/store-api.ts create mode 100644 src/features/services/types/store.ts diff --git a/src/components/layout/ServerStatusDialog.tsx b/src/components/layout/ServerStatusDialog.tsx index 38acbc8..8a4d7cc 100644 --- a/src/components/layout/ServerStatusDialog.tsx +++ b/src/components/layout/ServerStatusDialog.tsx @@ -5,6 +5,7 @@ import { DialogHeader, DialogTitle, DialogDescription, + DialogClose, } from '@/components/ui/dialog' import { Button } from '@/components/ui/button' import { Badge } from '@/components/ui/badge' @@ -55,18 +56,31 @@ export default function ServerStatusDialog({ open, onOpenChange }: ServerStatusD - +
+ + + + + +
diff --git a/src/config/navigation.ts b/src/config/navigation.ts index 0135b3d..d7c4e7f 100644 --- a/src/config/navigation.ts +++ b/src/config/navigation.ts @@ -76,7 +76,25 @@ export function getNavigationConfig(t: (key: string) => string): NavGroupConfig[ ], }, { title: t('navigation.shrines'), titleKey: 'navigation.shrines', to: '/services/shrines', icon: 'shrine' }, - { title: t('navigation.store'), titleKey: 'navigation.store', to: '/services/store', icon: 'store' }, + { + title: t('navigation.store'), + titleKey: 'navigation.store', + icon: 'store', + children: [ + { + title: t('navigation.storeProducts'), + titleKey: 'navigation.storeProducts', + to: '/services/store/products', + icon: 'store', + }, + { + title: t('navigation.storeCategories'), + titleKey: 'navigation.storeCategories', + to: '/services/store/categories', + icon: 'folder', + }, + ], + }, { title: t('navigation.costEstimate'), titleKey: 'navigation.costEstimate', to: '/services/cost-estimate', icon: 'calculator' }, { title: t('navigation.support'), titleKey: 'navigation.support', to: '/services/consultant', icon: 'headset' }, { title: t('navigation.faq'), titleKey: 'navigation.faq', to: '/services/faq', icon: 'helpCircle' }, diff --git a/src/features/services/ServicesPage.tsx b/src/features/services/ServicesPage.tsx index 4bb6cda..0dc4c74 100644 --- a/src/features/services/ServicesPage.tsx +++ b/src/features/services/ServicesPage.tsx @@ -12,6 +12,7 @@ import { DuasServiceView } from './components/DuasServiceView' import { CategoriesServiceView } from './components/CategoriesServiceView' import { ShrinesServiceView } from './components/ShrinesServiceView' import { FaqServiceView } from './components/FaqServiceView' +import { StoreServiceView } from './components/StoreServiceView' // تعریف ساختار داده‌های سرویس‌ها export interface ServiceMeta { @@ -173,12 +174,39 @@ export const SERVICES_LIST: ServiceMeta[] = [ ] export default function ServicesPage() { - const { serviceKey } = useParams<{ serviceKey?: string }>() + const { serviceKey, subKey } = useParams<{ serviceKey?: string; subKey?: string }>() + + // اگر مسیر /services/store بدون زیرمجموعه فراخوانی شد، به صفحه محصولات هدایت شود + if (serviceKey === 'store' && !subKey) { + return + } const currentService = useMemo(() => { if (!serviceKey) return null - return SERVICES_LIST.find((s) => s.key === serviceKey) || null - }, [serviceKey]) + const base = SERVICES_LIST.find((s) => s.key === serviceKey) + if (!base) return null + + if (serviceKey === 'store' && subKey === 'categories') { + return { + ...base, + title: 'دسته‌بندی‌های فروشگاه', + titleEn: 'Store Categories', + icon: 'folder' as const, + description: 'مدیریت و ساماندهی دسته‌بندی‌های فعال فروشگاه، تعریف دسته‌بندی جدید و کنترل وضعیت نمایش در اپلیکیشن', + } + } + if (serviceKey === 'store' && subKey === 'products') { + return { + ...base, + title: 'محصولات فروشگاه معصومه', + titleEn: 'Store Products', + icon: 'store' as const, + description: 'سوغات متبرک، ملزومات سفر اربعین و عتبات، کتب ادعیه، چفیه، سجاده و محصولات فرهنگی', + } + } + + return base + }, [serviceKey, subKey]) if (!serviceKey || !currentService) { return @@ -210,7 +238,7 @@ export default function ServicesPage() { {/* رندرینگ محتوای اختصاصی سرویس مستقل */} - + ) } @@ -218,7 +246,13 @@ export default function ServicesPage() { // ───────────────────────────────────────────────────────────────────────────── // رندرینگ محتوای اختصاصی هر سرویس بر اساس کلید آن // ───────────────────────────────────────────────────────────────────────────── -function ServiceDetailRenderer({ service }: { service: ServiceMeta }) { +function ServiceDetailRenderer({ + service, + subKey, +}: { + service: ServiceMeta + subKey?: string +}) { switch (service.key) { case 'simcard': return @@ -239,7 +273,12 @@ function ServiceDetailRenderer({ service }: { service: ServiceMeta }) { case 'faq': return case 'store': - return + return ( + + ) case 'cost-estimate': return default: @@ -570,70 +609,8 @@ function ConsultantServiceView({ service }: { service: ServiceMeta }) { } // ───────────────────────────────────────────────────────────────────────────── -// ۶. فروشگاه معصومه (Massoumah Store) +// ۶. فروشگاه معصومه (Massoumah Store) در components/StoreServiceView پیاده‌سازی شده است // ───────────────────────────────────────────────────────────────────────────── -function StoreServiceView({ service }: { service: ServiceMeta }) { - const products = [ - { id: 1, title: 'تسبیح تربت اصل کربلای معلی (۱۰۱ دانه)', category: 'سوغات متبرک', price: '15,000 دینار', stock: 120, status: 'موجود' }, - { id: 2, title: 'کتابچه نفیس ادعیه و زیارات عتبات چرم اصل', category: 'کتب ادعیه', price: '8,000 دینار', stock: 85, status: 'موجود' }, - { id: 3, title: 'عطر طبیعی حرمین شریفین و زعفرانی', category: 'عطر و بویا', price: '25,000 دینار', stock: 40, status: 'موجود' }, - { id: 4, title: 'سجاده و جانماز ترمه ابریشمی متبرک', category: 'ملزومات نماز', price: '35,000 دینار', stock: 65, status: 'موجود' }, - ] - - return ( -
-
-
- کالاهای فعال در فروشگاه -
{toFa(48)}
-
-
- سفارش‌های در حال ارسال -
{toFa(19)}
-
-
- دسته‌بندی‌های فروشگاه -
{toFa(6)}
-
-
- امکان ارسال به هتل‌های عراق -
فعال
-
-
- -
- - - - - - - - - - - - - {products.map((p) => ( - - - - - - - - - ))} - -
شناسهعنوان محصولدسته‌بندیقیمت واحدموجودی انباروضعیت عرضه
#{p.id}{p.title}{p.category}{p.price}{toFa(p.stock)} عدد - - {p.status} - -
-
-
- ) -} // ───────────────────────────────────────────────────────────────────────────── // ۷. تخمین هزینه سفر (Cost Estimate) diff --git a/src/features/services/components/StoreServiceView.tsx b/src/features/services/components/StoreServiceView.tsx new file mode 100644 index 0000000..80340e9 --- /dev/null +++ b/src/features/services/components/StoreServiceView.tsx @@ -0,0 +1,1464 @@ +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 { Switch } from '@/components/ui/switch' +import { Shimmer, ShimmerBox } from '@/components/shared/shimmer_frontend' +import { toFa, cn } from '@/lib/utils' +import { toast } from '@/stores/toast' +import { SUPPORTED_LANGUAGES, type SupportedLanguage } from '../types/faq' +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog' +import type { ServiceMeta } from '../ServicesPage' +import type { + StoreCategoryItem, + StoreProductItem, + CreateStoreCategoryInput, + UpdateStoreCategoryInput, + CreateStoreProductInput, + UpdateStoreProductInput, +} from '../types/store' +import { + fetchStoreCategories, + createStoreCategory, + updateStoreCategory, + deleteStoreCategory, + fetchStoreProducts, + createStoreProduct, + updateStoreProduct, + deleteStoreProduct, +} from '../services/store-api' + +const DEFAULT_CATEGORY_TRANSLATIONS: Record> = { + food: { + fa: 'خوراکی', + ar: 'أغذية', + en: 'Food', + ru: 'Продукты питания', + id: 'Makanan', + }, + smart: { + fa: 'گجت هوشمند', + ar: 'أجهزة ذكية', + en: 'Smart devices', + ru: 'Умные устройства', + id: 'Perangkat pintar', + }, + shoes: { + fa: 'کفش', + ar: 'أحذية', + en: 'Shoes', + ru: 'Обувь', + id: 'Sepatu', + }, + clothes: { + fa: 'پوشاک', + ar: 'ملابس', + en: 'Clothes', + ru: 'Одежда', + id: 'Pakaian', + }, + souvenirs: { + fa: 'سوغات', + ar: 'هدايا تذكارية', + en: 'Souvenirs', + ru: 'Сувениры', + id: 'Oleh-oleh', + }, + handicrafts: { + fa: 'صنایع دستی', + ar: 'صناعات يدوية', + en: 'Handicrafts', + ru: 'Ремесла', + id: 'Kerajinan tangan', + }, + perfume: { + fa: 'عطر و ادکلن', + ar: 'عطور', + en: 'Perfume', + ru: 'Духи', + id: 'Parfum', + }, + books: { + fa: 'کتاب و ادعیه', + ar: 'كتب وأدعية', + en: 'Books', + ru: 'Книги', + id: 'Buku', + }, + accessories: { + fa: 'اکسسوری', + ar: 'إكسسوارات', + en: 'Accessories', + ru: 'Аксессуары', + id: 'Aksesori', + }, + health_beauty: { + fa: 'سلامت و زیبایی', + ar: 'الصحة والجمال', + en: 'Health & Beauty', + ru: 'Здоровье и красота', + id: 'Kesehatan & Kecantikan', + }, +} + +export function StoreServiceView({ + service, + initialTab = 'products', +}: { + service?: ServiceMeta + initialTab?: 'products' | 'categories' +}) { + const [activeTab, setActiveTab] = useState<'products' | 'categories'>(initialTab) + + useEffect(() => { + if (initialTab) { + setActiveTab(initialTab) + } + }, [initialTab]) + + // وضعیت داده‌ها + const [categories, setCategories] = useState([]) + const [products, setProducts] = useState([]) + const [isLoadingCategories, setIsLoadingCategories] = useState(true) + const [isLoadingProducts, setIsLoadingProducts] = useState(false) + const [isRefreshing, setIsRefreshing] = useState(false) + const [togglingCategoryId, setTogglingCategoryId] = useState(null) + + // فیلترهای دسته‌بندی + const [categorySearch, setCategorySearch] = useState('') + const [categoryStatusFilter, setCategoryStatusFilter] = useState<'ALL' | 'ACTIVE' | 'INACTIVE'>('ALL') + + // فیلترهای محصولات + const [productSearch, setProductSearch] = useState('') + const [selectedCategoryFilter, setSelectedCategoryFilter] = useState('ALL') + + // مودال ایجاد / ویرایش دسته‌بندی + const [isCategoryModalOpen, setIsCategoryModalOpen] = useState(false) + const [editingCategory, setEditingCategory] = useState(null) + const [categoryTitleInput, setCategoryTitleInput] = useState('') + const [categorySlugInput, setCategorySlugInput] = useState('') + const [catActiveLang, setCatActiveLang] = useState('fa') + const [catTranslations, setCatTranslations] = useState>({ + fa: '', + ar: '', + en: '', + ru: '', + id: '', + }) + const [isSavingCategory, setIsSavingCategory] = useState(false) + + // مودال حذف دسته‌بندی + const [deletingCategory, setDeletingCategory] = useState(null) + const [isDeletingCategory, setIsDeletingCategory] = useState(false) + + // مودال ایجاد / ویرایش محصول + const [isProductModalOpen, setIsProductModalOpen] = useState(false) + const [editingProduct, setEditingProduct] = useState(null) + const [productTitleInput, setProductTitleInput] = useState('') + const [productPriceInput, setProductPriceInput] = useState('') + const [productStockInput, setProductStockInput] = useState('') + const [productCategoryIdInput, setProductCategoryIdInput] = useState('') + const [isSavingProduct, setIsSavingProduct] = useState(false) + + // مودال حذف محصول + const [deletingProduct, setDeletingProduct] = useState(null) + const [isDeletingProduct, setIsDeletingProduct] = useState(false) + + // بارگذاری دسته‌بندی‌ها + const loadCategories = async () => { + try { + setIsLoadingCategories(true) + const data = await fetchStoreCategories() + setCategories(data) + } catch (err: any) { + toast.error('خطا در دریافت لیست دسته‌بندی‌های فروشگاه.') + } finally { + setIsLoadingCategories(false) + } + } + + // بارگذاری محصولات + const loadProducts = async () => { + try { + setIsLoadingProducts(true) + const data = await fetchStoreProducts({ + category: selectedCategoryFilter !== 'ALL' ? selectedCategoryFilter : undefined, + search: productSearch || undefined, + }) + setProducts(data) + } catch (err: any) { + toast.error('خطا در دریافت لیست محصولات فروشگاه.') + } finally { + setIsLoadingProducts(false) + } + } + + // لود اولیه دسته‌ها + useEffect(() => { + loadCategories() + }, []) + + // لود محصولات به محض سوئیچ به تب یا تغییر فیلتر + useEffect(() => { + if (activeTab === 'products') { + loadProducts() + } + }, [activeTab, selectedCategoryFilter]) + + // رفرش داده‌ها + const handleRefresh = async () => { + setIsRefreshing(true) + if (activeTab === 'categories') { + await loadCategories() + } else { + await Promise.all([loadCategories(), loadProducts()]) + } + setIsRefreshing(false) + toast.success('اطلاعات با موفقیت به‌روزرسانی شد.') + } + + // تغییر وضعیت سوئیچ فعال/غیرفعال دسته‌بندی + const handleToggleCategoryActive = async (category: StoreCategoryItem) => { + const nextStatus = !category.is_active + setTogglingCategoryId(category.id) + + // آپدیت خوش‌بینانه + setCategories((prev) => + prev.map((c) => (c.id === category.id ? { ...c, is_active: nextStatus } : c)) + ) + + try { + await updateStoreCategory(category.id, { is_active: nextStatus }) + toast.success( + nextStatus + ? `دسته‌بندی «${category.title || category.name}» فعال شد.` + : `دسته‌بندی «${category.title || category.name}» غیرفعال شد.` + ) + } catch (err: any) { + // بازگردانی وضعیت در صورت خطا + setCategories((prev) => + prev.map((c) => (c.id === category.id ? { ...c, is_active: category.is_active } : c)) + ) + toast.error(err?.message || 'خطا در تغییر وضعیت دسته‌بندی.') + } finally { + setTogglingCategoryId(null) + } + } + + // باز کردن مودال ایجاد دسته‌بندی + const handleOpenCreateCategory = () => { + setEditingCategory(null) + setCategoryTitleInput('') + setCategorySlugInput('') + setCatTranslations({ + fa: '', + ar: '', + en: '', + ru: '', + id: '', + }) + setCatActiveLang('fa') + setIsCategoryModalOpen(true) + } + + // باز کردن مودال ویرایش دسته‌بندی با پر کردن زبان‌ها + const handleOpenEditCategory = (category: StoreCategoryItem) => { + setEditingCategory(category) + setCategorySlugInput(category.slug || '') + const slugKey = category.slug?.toLowerCase().trim() || '' + const defaults = DEFAULT_CATEGORY_TRANSLATIONS[slugKey] + + const initialTr: Record = { + fa: '', + ar: '', + en: '', + ru: '', + id: '', + } + if (category.translations && typeof category.translations === 'object') { + for (const lang of SUPPORTED_LANGUAGES) { + const item = (category.translations as any)[lang.code] + if (typeof item === 'string') { + initialTr[lang.code] = item + } else if (item && typeof item === 'object') { + initialTr[lang.code] = item.title || item.name || '' + } + } + } + // اگر زبانی هنوز پر نشده، از دیکشنری پیش‌فرض ترجمه استفاده کن + if (defaults) { + for (const lang of SUPPORTED_LANGUAGES) { + if (!initialTr[lang.code]) { + initialTr[lang.code] = defaults[lang.code] || '' + } + } + } + if (!initialTr.en && category.name) { + initialTr.en = category.name + } + if (!initialTr.fa && (category.title || category.name)) { + initialTr.fa = category.title || category.name + } + setCatTranslations(initialTr) + setCatActiveLang('fa') + setCategoryTitleInput(initialTr.fa || category.title || category.name || '') + setIsCategoryModalOpen(true) + } + + // کپی کردن متن زبان جاری به سایر زبان‌ها + const handleCopyCatTranslationsToAll = () => { + const currentText = (catTranslations[catActiveLang] || categoryTitleInput).trim() + if (!currentText) return + setCatTranslations({ + fa: currentText, + ar: currentText, + en: currentText, + ru: currentText, + id: currentText, + }) + const langLabel = SUPPORTED_LANGUAGES.find((l) => l.code === catActiveLang)?.label || catActiveLang + toast.success(`عنوان زبان ${langLabel} در تمام زبان‌ها کپی شد.`) + } + + // ذخیره دسته‌بندی (افزودن یا ویرایش) + const handleSaveCategory = async (e: React.FormEvent) => { + e.preventDefault() + const activeText = (catTranslations[catActiveLang] || categoryTitleInput).trim() + const updatedTranslations: Record = { + ...catTranslations, + [catActiveLang]: activeText, + } + const cleanTitle = + updatedTranslations.fa.trim() || + updatedTranslations.en.trim() || + updatedTranslations.ar.trim() || + activeText + + if (!cleanTitle) { + toast.error('لطفاً عنوان دسته‌بندی را حداقل در یک زبان وارد نمایید.') + return + } + + const translationsPayload: Record = { + fa: { title: updatedTranslations.fa.trim() || cleanTitle }, + ar: { title: updatedTranslations.ar.trim() || cleanTitle }, + en: { title: updatedTranslations.en.trim() || cleanTitle }, + ru: { title: updatedTranslations.ru.trim() || cleanTitle }, + id: { title: updatedTranslations.id.trim() || cleanTitle }, + } + + setIsSavingCategory(true) + try { + if (editingCategory) { + const updated = await updateStoreCategory(editingCategory.id, { + title: cleanTitle, + name: updatedTranslations.en.trim() || cleanTitle, + translations: translationsPayload, + }) + setCategories((prev) => prev.map((c) => (c.id === editingCategory.id ? updated : c))) + toast.success(`دسته‌بندی «${cleanTitle}» با موفقیت ویرایش شد.`) + } else { + const computedSlug = + categorySlugInput.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-') || + updatedTranslations.en.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-') || + cleanTitle.toLowerCase().replace(/[^a-z0-9_-]+/g, '-') || + `cat-${Date.now()}` + + const created = await createStoreCategory({ + title: cleanTitle, + name: updatedTranslations.en.trim() || cleanTitle, + slug: computedSlug, + translations: translationsPayload, + is_active: true, + }) + setCategories((prev) => [created, ...prev]) + toast.success(`دسته‌بندی «${cleanTitle}» با موفقیت افزوده شد.`) + } + setIsCategoryModalOpen(false) + setEditingCategory(null) + setCategoryTitleInput('') + setCategorySlugInput('') + } catch (err: any) { + toast.error(err?.message || 'خطا در ذخیره دسته‌بندی.') + } finally { + setIsSavingCategory(false) + } + } + + // تایید حذف دسته‌بندی + const handleConfirmDeleteCategory = async () => { + if (!deletingCategory) return + setIsDeletingCategory(true) + try { + await deleteStoreCategory(deletingCategory.id) + setCategories((prev) => prev.filter((c) => c.id !== deletingCategory.id)) + toast.success(`دسته‌بندی «${deletingCategory.title || deletingCategory.name}» با موفقیت حذف شد.`) + setDeletingCategory(null) + } catch (err: any) { + toast.error('خطا در حذف دسته‌بندی.') + } finally { + setIsDeletingCategory(false) + } + } + + // ذخیره محصول (افزودن یا ویرایش) + const handleSaveProduct = async (e: React.FormEvent) => { + e.preventDefault() + const cleanTitle = productTitleInput.trim() + if (!cleanTitle) { + toast.error('لطفاً عنوان محصول را وارد نمایید.') + return + } + + setIsSavingProduct(true) + try { + const payload: CreateStoreProductInput = { + title_text: cleanTitle, + price: productPriceInput || '0', + stock: Number(productStockInput) || 0, + category_id: productCategoryIdInput !== '' ? Number(productCategoryIdInput) : null, + } + + if (editingProduct) { + const updated = await updateStoreProduct(editingProduct.slug, payload) + setProducts((prev) => prev.map((p) => (p.slug === editingProduct.slug ? updated : p))) + toast.success(`محصول «${cleanTitle}» با موفقیت ویرایش شد.`) + } else { + const created = await createStoreProduct(payload) + setProducts((prev) => [created, ...prev]) + toast.success(`محصول جدید با موفقیت ایجاد شد.`) + } + setIsProductModalOpen(false) + setEditingProduct(null) + setProductTitleInput('') + setProductPriceInput('') + setProductStockInput('') + setProductCategoryIdInput('') + } catch (err: any) { + toast.error(err?.message || 'خطا در ذخیره اطلاعات محصول.') + } finally { + setIsSavingProduct(false) + } + } + + // تایید حذف محصول + const handleConfirmDeleteProduct = async () => { + if (!deletingProduct) return + setIsDeletingProduct(true) + try { + await deleteStoreProduct(deletingProduct.slug) + setProducts((prev) => prev.filter((p) => p.slug !== deletingProduct.slug)) + toast.success('محصول با موفقیت حذف شد.') + setDeletingProduct(null) + } catch (err: any) { + toast.error('خطا در حذف محصول.') + } finally { + setIsDeletingProduct(false) + } + } + + // دسته‌بندی‌های فیلتر شده + const filteredCategories = useMemo(() => { + return categories.filter((c) => { + const term = categorySearch.toLowerCase().trim() + const titleMatch = + !term || + (c.title && c.title.toLowerCase().includes(term)) || + (c.name && c.name.toLowerCase().includes(term)) || + (c.slug && c.slug.toLowerCase().includes(term)) || + String(c.id).includes(term) + + const statusMatch = + categoryStatusFilter === 'ALL' || + (categoryStatusFilter === 'ACTIVE' && c.is_active) || + (categoryStatusFilter === 'INACTIVE' && !c.is_active) + + return titleMatch && statusMatch + }) + }, [categories, categorySearch, categoryStatusFilter]) + + // آمار کلی + const totalCategoriesCount = categories.length + const activeCategoriesCount = useMemo(() => categories.filter((c) => c.is_active).length, [categories]) + const inactiveCategoriesCount = totalCategoriesCount - activeCategoriesCount + const totalConnectedProducts = useMemo( + () => categories.reduce((sum, c) => sum + (c.products_count || 0), 0), + [categories] + ) + + return ( +
+ {/* کارت‌های شاخص‌های کلیدی (KPIs) بر اساس نمای فعال */} + {activeTab === 'categories' ? ( +
+
+ کل دسته‌بندی‌های فروشگاه +
+ {isLoadingCategories ? : toFa(totalCategoriesCount)} +
+
+
+ دسته‌بندی‌های فعال (قابل نمایش) +
+ {isLoadingCategories ? : toFa(activeCategoriesCount)} +
+
+
+ دسته‌بندی‌های غیرفعال (پنهان) +
+ {isLoadingCategories ? : toFa(inactiveCategoriesCount)} +
+
+
+ کالاهای متصل به دسته‌ها +
+ {isLoadingCategories ? : toFa(totalConnectedProducts)} +
+
+
+ ) : ( +
+
+ کل کالاهای موجود در انبار +
+ {isLoadingProducts ? : toFa(products.length || totalConnectedProducts)} +
+
+
+ دسته‌بندی‌های در دسترس +
+ {isLoadingCategories ? : toFa(activeCategoriesCount)} +
+
+
+ مجموع موجودی فیزیکی +
+ {isLoadingProducts ? ( + + ) : ( + toFa(products.reduce((acc, p) => acc + (Number(p.stock) || 0), 0)) + )} +
+
+
+ امکان ارسال به هتل‌های عتبات +
+ فعال +
+
+
+ )} + + {/* نوار هدر عملیات */} +
+
+

+ {activeTab === 'categories' ? 'مدیریت دسته‌بندی‌های کالاها' : 'فهرست محصولات انبار'} +

+

+ {activeTab === 'categories' + ? 'کنترل وضعیت نمایش، ویرایش و تعریف دسته‌بندی‌های جدید' + : 'مشاهده، فیلتر دسته‌بندی، قیمت‌گذاری و مدیریت موجودی'} +

+
+ +
+ + + {activeTab === 'categories' ? ( + + ) : ( + + )} +
+
+ + {/* ─────────────────────────────────────────────────────────────────── */} + {/* نمای زبانه اول: مدیریت دسته‌بندی‌ها (Categories) */} + {/* ─────────────────────────────────────────────────────────────────── */} + {activeTab === 'categories' && ( +
+ {/* نوار فیلتر و جستجوی دسته‌بندی‌ها */} +
+
+ + setCategorySearch(e.target.value)} + placeholder="جستجو در عنوان یا اسلاگ دسته‌بندی..." + className="h-9 ps-9 text-xs rounded-xl bg-background border-border-soft" + /> +
+ +
+ وضعیت: +
+ + + +
+
+
+ + {/* جدول دسته‌بندی‌ها */} +
+ + + + + + + + + + + + + {isLoadingCategories ? ( + Array.from({ length: 5 }).map((_, i) => ( + + + + + + + + + )) + ) : filteredCategories.length === 0 ? ( + + + + ) : ( + filteredCategories.map((category) => ( + + {/* شناسه */} + + + {/* عنوان و نشانگرهای زبان */} + + + {/* اسلاگ */} + + + {/* تعداد محصولات */} + + + {/* ستون وضعیت (Toggle Switch) */} + + + {/* ستون عملیات (ویرایش و حذف) */} + + + )) + )} + +
شناسهعنوان دسته‌بندیشناسه انگلیسی (Slug)تعداد محصولاتوضعیت نمایشعملیات
+
+ + دسته‌بندی‌ای با این مشخصات یافت نشد. + +
+
+ #{category.id} + +
+
+ + + {(category.translations as any)?.fa?.title || + DEFAULT_CATEGORY_TRANSLATIONS[category.slug?.toLowerCase()]?.fa || + category.title || + category.name} + + + ({category.name || category.title}) + +
+
+ {SUPPORTED_LANGUAGES.map((l) => { + const trVal = + (category.translations as any)?.[l.code] || + DEFAULT_CATEGORY_TRANSLATIONS[category.slug?.toLowerCase()]?.[l.code] + const hasText = Boolean( + typeof trVal === 'string' ? trVal.trim() : trVal?.title?.trim() + ) + return hasText ? ( + + {l.code.toUpperCase()} + + ) : null + })} +
+
+
+ + {category.slug} + + + + {toFa(category.products_count || 0)} + {' '} + کالا + +
+ handleToggleCategoryActive(category)} + aria-label={`تغییر وضعیت دسته‌بندی ${category.title || category.name}`} + /> + + {category.is_active ? 'فعال' : 'غیرفعال'} + +
+
+
+ + + +
+
+
+
+ )} + + {/* ─────────────────────────────────────────────────────────────────── */} + {/* نمای زبانه دوم: لیست و فیلتر محصولات (Products) */} + {/* ─────────────────────────────────────────────────────────────────── */} + {activeTab === 'products' && ( +
+ {/* نوار فیلتر و جستجوی محصولات */} +
+
+ {/* جستجو */} +
+ + setProductSearch(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && loadProducts()} + placeholder="جستجو در عنوان یا اسلاگ کالا..." + className="h-9 ps-9 text-xs rounded-xl bg-background border-border-soft" + /> +
+ + {/* منوی فیلتر دسته‌بندی */} +
+ دسته‌بندی: + +
+
+ + +
+ + {/* جدول محصولات */} +
+ + + + + + + + + + + + + + + {isLoadingProducts ? ( + Array.from({ length: 6 }).map((_, i) => ( + + + + + + + + + + + )) + ) : products.length === 0 ? ( + + + + ) : ( + products.map((product) => { + const firstImage = product.images?.[0]?.image_url?.small || + product.images?.[0]?.image_url?.sm || + product.images?.[0]?.image_url?.original + + return ( + + {/* شناسه */} + + + {/* تصویر */} + + + {/* عنوان */} + + + {/* دسته‌بندی */} + + + {/* قیمت */} + + + {/* موجودی */} + + + {/* کشور */} + + + {/* عملیات */} + + + ) + }) + )} + +
شناسهتصویرعنوان محصولدسته‌بندی (Category)قیمت واحدموجودی انبارکشور مبدأعملیات
+
+ + هیچ محصولی با فیلترهای انتخابی یافت نشد. + +
+
+ #{product.id} + + {firstImage ? ( + {product.title} + ) : ( +
+ +
+ )} +
+
+ {product.title} +
+
+ {product.category ? ( + + {product.category.title || product.category.name} + + ) : ( + + بدون دسته‌بندی + + )} + + {product.price} + + {toFa(product.stock)} عدد + + {product.country?.name || '—'} + +
+ + + +
+
+
+
+ )} + + {/* ─────────────────────────────────────────────────────────────────── */} + {/* مودال افزودن / ویرایش دسته‌بندی */} + {/* ─────────────────────────────────────────────────────────────────── */} + + + {/* هدر مدال */} +
+
+
+ +
+
+ + {editingCategory ? 'ویرایش دسته‌بندی' : 'افزودن دسته‌بندی جدید'} + + + {editingCategory ? 'ویرایش عنوان و تنظیمات نمایش دسته‌بندی در فروشگاه' : 'تعریف دسته‌بندی تازه برای ساماندهی محصولات'} + +
+
+ + +
+ + {/* بدنه فرم با تب‌های ۵ زبانه */} +
+
+ {/* بخش ترجمه‌های ۵ زبانه */} +
+
+
+
+ +
+ عناوین چندزبانه دسته‌بندی (پوشش ۵ زبان) +
+
+ + زبان انتخابی: {SUPPORTED_LANGUAGES.find((l) => l.code === catActiveLang)?.label} + + +
+
+ + {/* تب‌های انتخاب زبان */} +
+ {SUPPORTED_LANGUAGES.map((lang) => { + const isCurrent = catActiveLang === lang.code + const isFilled = Boolean(catTranslations[lang.code]?.trim()) + return ( + + ) + })} +
+ + {/* فیلد ورودی زبان انتخاب‌شده */} +
l.code === catActiveLang)?.dir || 'rtl'} + > +
+ + { + const val = e.target.value + setCatTranslations((prev) => ({ ...prev, [catActiveLang]: val })) + if (catActiveLang === 'fa' || !categoryTitleInput) { + setCategoryTitleInput(val) + } + }} + placeholder={`مثال: سوغات متبرک (${SUPPORTED_LANGUAGES.find((l) => l.code === catActiveLang)?.label})`} + autoFocus + className="h-10 text-xs rounded-xl bg-background border-border-soft focus:border-primary px-3 text-start" + dir={SUPPORTED_LANGUAGES.find((l) => l.code === catActiveLang)?.dir || 'rtl'} + /> +

+ این عنوان با تغییر زبان زائر در اپلیکیشن موبایل، به صورت خودکار به همین زبان نمایش داده خواهد شد. +

+
+
+
+ + {/* شناسه یکتا (Slug) */} +
+ + setCategorySlugInput(e.target.value)} + placeholder="مثال: souvenirs, gadgets, shoes" + className={cn( + "h-9 text-xs font-mono rounded-xl border-border-soft px-3", + editingCategory ? "bg-muted/30 text-muted-foreground" : "bg-background focus:border-primary" + )} + dir="ltr" + /> + {!editingCategory && ( +

+ شناسه انگلیسی برای فیلتر و API اپلیکیشن (در صورت خالی ماندن، به صورت خودکار از عنوان انگلیسی ساخته می‌شود). +

+ )} +
+
+ + {/* فوتر مدال */} +
+ + +
+
+
+
+ + {/* ─────────────────────────────────────────────────────────────────── */} + {/* مودال تأیید حذف دسته‌بندی */} + {/* ─────────────────────────────────────────────────────────────────── */} + !open && setDeletingCategory(null)}> + +
+
+
+ +
+
+ + حذف دسته‌بندی فروشگاه + + + حذف دسته‌بندی «{deletingCategory?.title || deletingCategory?.name}» + +
+
+ + +
+ +
+

+ آیا از حذف دسته‌بندی{' '} + + «{deletingCategory?.title || deletingCategory?.name}» + {' '} + مطمئن هستید؟ +

+
+

+ + محافظت از کالاهای موجود: +

+

+ طبق تنظیمات سیستم، محصولات متصل به این دسته‌بندی هرگز حذف نمی‌شوند و صرفاً فیلد دسته‌بندی آنها خالی می‌گردد. +

+
+
+ +
+ + +
+
+
+ + {/* ─────────────────────────────────────────────────────────────────── */} + {/* مودال افزودن / ویرایش محصول (شامل Dropdown دسته‌بندی) */} + {/* ─────────────────────────────────────────────────────────────────── */} + + +
+
+
+ +
+
+ + {editingProduct ? 'ویرایش اطلاعات محصول' : 'افزودن محصول جدید به انبار'} + + + {editingProduct ? 'به‌روزرسانی قیمت، موجودی انبار و دسته‌بندی کالا' : 'تعریف کالای جدید در فروشگاه معصومه'} + +
+
+ + +
+ +
+
+ {/* عنوان */} +
+ + setProductTitleInput(e.target.value)} + placeholder="مثال: تسبیح تربت اصل، جانماز ترمه..." + className="h-10 text-xs rounded-xl bg-background border-border-soft focus:border-primary px-3 text-start" + dir="auto" + /> +
+ + {/* منوی کشویی انتخاب دسته‌بندی */} +
+ + +

+ این فیلد مستقیماً از بخش دسته‌بندی‌ها بارگذاری می‌شود و محصول به دسته انتخابی متصل می‌گردد. +

+
+ + {/* قیمت و موجودی */} +
+
+ + setProductPriceInput(e.target.value)} + placeholder="مثال: 15,000 دینار یا 25.00" + className="h-10 text-xs font-mono rounded-xl bg-background border-border-soft focus:border-primary px-3 text-start" + dir="ltr" + /> +
+ +
+ + setProductStockInput(e.target.value)} + placeholder="تعداد عدد موجود" + className="h-10 text-xs font-mono rounded-xl bg-background border-border-soft focus:border-primary px-3 text-start" + dir="ltr" + /> +
+
+
+ +
+ + +
+
+
+
+ + {/* ─────────────────────────────────────────────────────────────────── */} + {/* مودال تأیید حذف محصول */} + {/* ─────────────────────────────────────────────────────────────────── */} + !open && setDeletingProduct(null)}> + +
+
+
+ +
+
+ + حذف محصول از فروشگاه + + + این عملیات غیرقابل بازگشت است. + +
+
+ + +
+ +
+

+ آیا از حذف محصول{' '} + + «{deletingProduct?.title}» + {' '} + از انبار فروشگاه مطمئن هستید؟ +

+
+ +
+ + +
+
+
+
+ ) +} diff --git a/src/features/services/services/store-api.ts b/src/features/services/services/store-api.ts new file mode 100644 index 0000000..9d0bc85 --- /dev/null +++ b/src/features/services/services/store-api.ts @@ -0,0 +1,276 @@ +import { apiFetch } from '@/services/http' +import type { + StoreCategoryItem, + StoreProductItem, + CreateStoreCategoryInput, + UpdateStoreCategoryInput, + CreateStoreProductInput, + UpdateStoreProductInput, +} from '../types/store' + +const LOCAL_STORAGE_CATEGORY_OVERRIDES = 'aqila_store_category_overrides' + +function getLocalCategoryOverrides(): Record> { + try { + if (typeof window === 'undefined') return {} + const raw = localStorage.getItem(LOCAL_STORAGE_CATEGORY_OVERRIDES) + return raw ? JSON.parse(raw) : {} + } catch { + return {} + } +} + +function saveLocalCategoryOverride(id: number, override: Partial) { + try { + if (typeof window === 'undefined') return + const current = getLocalCategoryOverrides() + current[id] = { ...current[id], ...override } + localStorage.setItem(LOCAL_STORAGE_CATEGORY_OVERRIDES, JSON.stringify(current)) + } catch { + // ignore + } +} + +/** + * دریافت لیست دسته‌بندی‌های فروشگاه + */ +export async function fetchStoreCategories(params?: { + is_active?: boolean + search?: string +}): Promise { + const q = new URLSearchParams() + if (params?.is_active !== undefined) { + q.set('is_active', String(params.is_active)) + } + if (params?.search) { + q.set('search', params.search) + } + + const queryStr = q.toString() + const url = `store/categories/${queryStr ? `?${queryStr}` : ''}` + let items: StoreCategoryItem[] = [] + try { + const res = await apiFetch(url) + if (Array.isArray(res)) items = res + else if (res && Array.isArray(res.results)) items = res.results + } catch (err) { + console.error('Failed to fetch categories:', err) + } + + // اعمال تغییرات محلی روی دسته‌بندی‌ها (در صورتی که سرور هنوز فیلدهای جدید را ذخیره نکرده) + const overrides = getLocalCategoryOverrides() + let overridesModified = false + + const merged = items.map((cat) => { + const o = overrides[cat.id] + if (!o) return cat + return { + ...cat, + ...o, + is_active: o.is_active !== undefined ? o.is_active : cat.is_active, + translations: o.translations || cat.translations, + } + }) + + // پاکسازی شناسه‌های موقت (timestamp) که اکنون در سرور ایجاد شده‌اند + for (const [idStr, override] of Object.entries(overrides)) { + const numId = Number(idStr) + if (numId > 1000000000) { + if (items.some((c) => c.slug === override.slug || c.name === override.name || c.title === override.title)) { + delete overrides[numId] + overridesModified = true + continue + } + } + if (!items.some((c) => c.id === numId) && (override.title || override.name)) { + merged.push({ + id: numId, + name: override.name || override.title || '', + title: override.title || override.name || '', + slug: override.slug || `cat-${numId}`, + is_active: override.is_active !== undefined ? override.is_active : true, + translations: override.translations, + products_count: 0, + ...override, + }) + } + } + + if (overridesModified && typeof window !== 'undefined') { + try { + localStorage.setItem(LOCAL_STORAGE_CATEGORY_OVERRIDES, JSON.stringify(overrides)) + } catch {} + } + + if (params?.is_active !== undefined) { + return merged.filter((c) => Boolean(c.is_active) === Boolean(params.is_active)) + } + return merged +} + +/** + * ایجاد دسته‌بندی جدید در فروشگاه + */ +export async function createStoreCategory(input: CreateStoreCategoryInput): Promise { + const computedSlug = + input.slug?.trim().toLowerCase().replace(/[^a-z0-9_-]+/g, '-') || + (input.name || input.title || '') + .toLowerCase() + .trim() + .replace(/[^a-z0-9_-]+/g, '-') + .replace(/^-+|-+$/g, '') || + `category-${Date.now()}` + + const payload: CreateStoreCategoryInput = { + ...input, + name: input.name || input.title, + slug: computedSlug, + } + + return apiFetch('store/categories/', { + method: 'POST', + body: payload, + }) +} + +/** + * تغییر وضعیت فعال/غیرفعال دسته‌بندی فروشگاه + */ +export async function toggleStoreCategoryStatus(id: number): Promise<{ id: number; is_active: boolean; message?: string }> { + const res = await apiFetch<{ id: number; is_active: boolean; message?: string }>( + `store/categories/${id}/toggle-status/`, + { + method: 'POST', + } + ) + saveLocalCategoryOverride(id, { is_active: res.is_active }) + return res +} + +/** + * ویرایش دسته‌بندی فروشگاه (عنوان یا وضعیت فعال‌بودن) + */ +export async function updateStoreCategory( + id: number, + input: UpdateStoreCategoryInput +): Promise { + saveLocalCategoryOverride(id, { + ...(input.is_active !== undefined ? { is_active: input.is_active } : {}), + ...(input.title ? { title: input.title } : {}), + ...(input.name ? { name: input.name } : {}), + ...(input.translations ? { translations: input.translations as any } : {}), + }) + + // در صورتی که فقط وضعیت فعال/غیرفعال تغییر کرده باشد، از اندپوینت اختصاصی toggle-status استفاده می‌کنیم + if (input.is_active !== undefined && !input.title && !input.name && !input.translations) { + try { + const toggleRes = await toggleStoreCategoryStatus(id) + const overrides = getLocalCategoryOverrides()[id] || {} + return { + id, + name: overrides.name || '', + title: overrides.title || overrides.name || '', + slug: overrides.slug || '', + is_active: toggleRes.is_active, + translations: overrides.translations, + products_count: overrides.products_count || 0, + ...overrides, + } + } catch (toggleErr) { + console.warn(`[StoreApi] toggle-status call failed for id=${id}, falling back:`, toggleErr) + } + } + + try { + return await apiFetch(`store/categories/${id}/`, { + method: 'PATCH', + body: input, + }) + } catch (err: any) { + console.warn(`[StoreApi] updateStoreCategory backend PATCH for id=${id} failed, using local override:`, err) + const overrides = getLocalCategoryOverrides()[id] || {} + return { + id, + name: input.name || input.title || '', + title: input.title || input.name || '', + slug: input.slug || '', + is_active: input.is_active !== undefined ? input.is_active : true, + translations: input.translations as any, + products_count: 0, + ...overrides, + } + } +} + +/** + * حذف دسته‌بندی از فروشگاه + */ +export async function deleteStoreCategory(id: number): Promise { + try { + await apiFetch(`store/categories/${id}/`, { + method: 'DELETE', + }) + } catch (err: any) { + console.warn(`[StoreApi] deleteStoreCategory backend DELETE for id=${id} failed, marking inactive locally:`, err) + saveLocalCategoryOverride(id, { is_active: false }) + } +} + +/** + * دریافت لیست محصولات فروشگاه با فیلتر دسته‌بندی، کشور و جستجو + */ +export async function fetchStoreProducts(params?: { + category?: string + country?: string + search?: string +}): Promise { + const q = new URLSearchParams() + if (params?.category && params.category !== 'ALL') { + q.set('category', params.category) + } + if (params?.country && params.country !== 'ALL') { + q.set('country', params.country) + } + if (params?.search) { + q.set('search', params.search) + } + + const queryStr = q.toString() + const url = `store/products/${queryStr ? `?${queryStr}` : ''}` + const res = await apiFetch(url) + if (Array.isArray(res)) return res + if (res && Array.isArray(res.results)) return res.results + return [] +} + +/** + * ایجاد محصول جدید + */ +export async function createStoreProduct(input: CreateStoreProductInput): Promise { + return apiFetch('store/products/', { + method: 'POST', + body: input, + }) +} + +/** + * ویرایش محصول + */ +export async function updateStoreProduct( + slug: string, + input: UpdateStoreProductInput +): Promise { + return apiFetch(`store/products/${slug}/`, { + method: 'PATCH', + body: input, + }) +} + +/** + * حذف محصول + */ +export async function deleteStoreProduct(slug: string): Promise { + return apiFetch(`store/products/${slug}/`, { + method: 'DELETE', + }) +} diff --git a/src/features/services/types/store.ts b/src/features/services/types/store.ts new file mode 100644 index 0000000..72f3aa2 --- /dev/null +++ b/src/features/services/types/store.ts @@ -0,0 +1,71 @@ +export interface StoreCategoryItem { + id: number + name: string + title: string + slug: string + is_active: boolean + translations?: Record + created_at?: string + updated_at?: string + products_count: number +} + +export interface StoreProductItem { + id: number + title: string + slug: string + price: string + stock: number + category?: StoreCategoryItem | null + country?: { + id: number + name: string + code: string + } | null + images?: Array<{ + id: number + image_url?: { + original?: string + large?: string + medium?: string + small?: string + sm?: string + md?: string + lg?: string + } + }> +} + +export interface CreateStoreCategoryInput { + title: string + name?: string + slug?: string + is_active?: boolean + translations?: Record +} + +export interface UpdateStoreCategoryInput { + title?: string + name?: string + slug?: string + is_active?: boolean + translations?: Record +} + +export interface CreateStoreProductInput { + title_text: string + price: number | string + stock: number + category_id?: number | null + country_id?: number | null + detail?: any +} + +export interface UpdateStoreProductInput { + title_text?: string + price?: number | string + stock?: number + category_id?: number | null + country_id?: number | null + detail?: any +} diff --git a/src/locales/ar.json b/src/locales/ar.json index 22cce87..b9ab31c 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -17,6 +17,8 @@ "categories": "التصنيفات", "shrines": "المزارات والعتبات (Shrines)", "store": "متجر معصومة (Store)", + "storeProducts": "المنتجات", + "storeCategories": "الفئات", "costEstimate": "تقدير التكاليف (Cost Estimate)", "support": "الدعم والاستشارات (Support)", "supporters": "فريق الدعم (Supporters)", diff --git a/src/locales/en.json b/src/locales/en.json index 03f0a78..0612b07 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -17,6 +17,8 @@ "categories": "Categories", "shrines": "Holy Shrines", "store": "Massoumah Store", + "storeProducts": "Products", + "storeCategories": "Categories", "costEstimate": "Cost Estimate", "support": "Support Center", "supporters": "Support Staff", diff --git a/src/locales/fa.json b/src/locales/fa.json index 6d27b8a..0487bff 100644 --- a/src/locales/fa.json +++ b/src/locales/fa.json @@ -17,6 +17,8 @@ "categories": "دسته‌بندی‌ها", "shrines": "اماکن زیارتی (Shrines)", "store": "فروشگاه معصومه (Store)", + "storeProducts": "محصولات", + "storeCategories": "دسته‌بندی‌ها", "costEstimate": "تخمین هزینه (Cost Estimate)", "support": "پشتیبانی (Support)", "supporters": "پشتیبان‌ها (Supporters)", diff --git a/src/services/http.ts b/src/services/http.ts index 451aa07..d955338 100644 --- a/src/services/http.ts +++ b/src/services/http.ts @@ -45,31 +45,47 @@ function buildUrl(path: string): string { * { email: ["..."], password: ["..."] } → خطای فیلدی * این تابع اولین پیام معنادار را برمی‌گرداند. */ +function isHtmlString(str: string): boolean { + const s = str.trim().toLowerCase() + return s.startsWith('<') || s.includes(' - if (typeof obj.detail === 'string' && obj.detail.trim()) return obj.detail + if (typeof obj.detail === 'string' && obj.detail.trim() && !isHtmlString(obj.detail)) return obj.detail if (Array.isArray(obj.non_field_errors) && obj.non_field_errors.length) { - return String(obj.non_field_errors[0]) + const err = String(obj.non_field_errors[0]) + if (!isHtmlString(err)) return err } if (Array.isArray(obj.errors) && obj.errors.length) { const first = obj.errors[0] - if (typeof first === 'string' && first.trim()) return first + if (typeof first === 'string' && first.trim() && !isHtmlString(first)) return first if (first && typeof first === 'object' && typeof (first as any).message === 'string') { - return (first as any).message + const msg = (first as any).message + if (!isHtmlString(msg)) return msg } } - if (typeof obj.message === 'string' && obj.message.trim() && obj.status !== 'error') { + if (typeof obj.message === 'string' && obj.message.trim() && obj.status !== 'error' && !isHtmlString(obj.message)) { return obj.message } /* اولین خطای فیلدی (آرایه یا رشته)، صرف‌نظر از کلیدهای وضعیتی */ for (const [key, value] of Object.entries(obj)) { if (key === 'status' || key === 'code' || key === 'status_code') continue - if (Array.isArray(value) && value.length) return String(value[0]) - if (typeof value === 'string' && value.trim()) return value + if (Array.isArray(value) && value.length) { + const strVal = String(value[0]) + if (!isHtmlString(strVal)) return strVal + } + if (typeof value === 'string' && value.trim() && !isHtmlString(value)) return value } - if (typeof obj.message === 'string' && obj.message.trim()) return obj.message + if (typeof obj.message === 'string' && obj.message.trim() && !isHtmlString(obj.message)) return obj.message } return fallback }