import { useState, useEffect, useCallback } from 'react'; import { useSearchParams } from 'react-router-dom'; import { Search, Filter, Box, Plus, Edit2, Trash2, Image as ImageIcon, X, HelpCircle, ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'; import { toast } from 'react-hot-toast'; import api, { BASE_DOMAIN } from '../services/api'; import Spinner from '../components/ui/Spinner'; import Pagination from '../components/ui/Pagination'; import MediaSelector from '../components/ui/MediaSelector'; import ConfirmModal from '../components/ui/ConfirmModal'; import PriceInput from '../components/ui/PriceInput'; import Button from '../components/ui/Button'; export interface Category { id: string; nameFa: string; nameEn?: string; name?: string; } export interface Product { name?: string; category?: Category | string; id: string; artNo: string; nameFa: string; nameEn?: string; scientificTagline?: string; description?: string; shortDescription?: string; categoryId?: string; priceValue?: number; wholesalePrice?: number; buyPrice?: number; priceDisplay?: string; unit?: string; packageSize?: number; dosageLogic?: string; suitableFor?: string; imageUrl?: string; images?: string[]; metaTitle?: string; metaDescription?: string; keywords?: string; canonicalUrl?: string; slug?: string; podcastUrl?: string; podcastTitle?: string; podcastDescription?: string; podcastCover?: string; videoUrl?: string; videoTitle?: string; videoDescription?: string; videoCover?: string; pdfUrl?: string; pdfTitle?: string; pdfDescription?: string; pdfCover?: string; symptoms?: Array<{ symptom: string } | string>; isPreorder?: boolean; preorderDeposit?: string | number; } export default function Products() { const [products, setProducts] = useState([]); const [categories, setCategories] = useState([]); const [isLoading, setIsLoading] = useState(true); // URL and String Helper Functions const getProductImageUrl = (url: string) => { if (!url) return ''; if (url.startsWith('http')) { const path = url.replace(/^https?:\/\/[^/]+/, ''); return `${BASE_DOMAIN}${path}`; } return url.startsWith('/') ? `${BASE_DOMAIN}${url}` : `${BASE_DOMAIN}/${url}`; }; const getStoreProductUrl = (slug: string) => { const isDev = window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1'; if (isDev) { return `http://localhost:4000/shop/${slug}`; } // Production fallback based on actual URL const storeOrigin = window.location.origin.replace('admin', 'www'); return `${storeOrigin}/shop/${slug}`; }; const slugify = (text: string) => { return text .toLowerCase() .trim() .replace(/\s+/g, '-') .replace(/[^a-z0-9-]/g, '') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); }; // URL Query Params Management const [searchParams, setSearchParams] = useSearchParams(); // Filters initialized from URL const [search, setSearch] = useState(() => searchParams.get('search') || ''); const [categoryFilter, setCategoryFilter] = useState(() => searchParams.get('category') || ''); const [suitableForFilter, setSuitableForFilter] = useState(() => searchParams.get('suitableFor') || ''); const [page, setPage] = useState(() => Number(searchParams.get('page')) || 1); const [sortBy, setSortBy] = useState(() => searchParams.get('sortBy') || 'createdAt'); const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>(() => (searchParams.get('sortOrder') as 'asc' | 'desc') || 'desc'); const [totalPages, setTotalPages] = useState(1); const [imageErrors, setImageErrors] = useState>({}); const [mediaImageError, setMediaImageError] = useState(false); const [availableSymptoms, setAvailableSymptoms] = useState([ "درد مفاصل", "سختی در بلند شدن", "لنگیدن", "رشد سریع توله‌سگ", "پاهای پرانتزی", "ضعف تاندون", "اسهال", "یبوست", "اسهال مزمن", "بی‌اشتهایی", "ضعف بعد از بیماری", "ریزش مو", "خشکی پوست", "خارش", "جرم دندان" ]); const [symptomInput, setSymptomInput] = useState(''); const [showSymptomSuggestions, setShowSymptomSuggestions] = useState(false); const limit = 10; // Sync state changes to URL search params const updateUrlParams = useCallback((paramsObj: Record) => { const current = Object.fromEntries(searchParams.entries()); const merged = { ...current, ...paramsObj }; // Remove empty/default values to keep URL clean const cleaned: Record = {}; Object.entries(merged).forEach(([key, val]) => { if (val !== undefined && val !== null && String(val).trim() !== '' && !(key === 'page' && String(val) === '1') && !(key === 'sortBy' && String(val) === 'createdAt') && !(key === 'sortOrder' && String(val) === 'desc')) { cleaned[key] = String(val); } }); setSearchParams(cleaned, { replace: true }); }, [searchParams, setSearchParams]); const handleSort = (field: string) => { let newOrder: 'asc' | 'desc' = 'asc'; if (sortBy === field) { newOrder = sortOrder === 'asc' ? 'desc' : 'asc'; } setSortBy(field); setSortOrder(newOrder); setPage(1); updateUrlParams({ sortBy: field, sortOrder: newOrder, page: 1 }); }; // Modal State const [isModalOpen, setIsModalOpen] = useState(false); const [isMediaSelectorOpen, setIsMediaSelectorOpen] = useState(false); const [mediaTargetField, setMediaTargetField] = useState<'imageUrl' | 'podcastUrl' | 'podcastCover' | 'videoUrl' | 'videoCover' | 'pdfUrl' | 'pdfCover' | 'gallery'>('imageUrl'); const [editingProduct, setEditingProduct] = useState(null); const [deleteTargetId, setDeleteTargetId] = useState(null); const [activeTab, setActiveTab] = useState('general'); const [formData, setFormData] = useState({ artNo: '', nameFa: '', nameEn: '', scientificTagline: '', description: '', shortDescription: '', categoryId: '', buyPrice: '' as number | string, priceValue: 0, wholesalePrice: '' as number | string, priceValueMarginPercent: '' as number | string, wholesaleMarginPercent: '' as number | string, priceDisplay: '', unit: '', packageSize: 0, dosageLogic: '', suitableFor: 'سگ و گربه', imageUrl: '', metaTitle: '', metaDescription: '', keywords: '', canonicalUrl: '', slug: '', images: [] as string[], podcastUrl: '', podcastTitle: '', podcastDescription: '', podcastCover: '', videoUrl: '', videoTitle: '', videoDescription: '', videoCover: '', pdfUrl: '', pdfTitle: '', pdfDescription: '', pdfCover: '', symptoms: [] as string[], isPreorder: false, preorderDeposit: '' as number | string }); const fetchData = useCallback(async () => { try { setIsLoading(true); // Fetch products with full query params const prodRes = await api.get('/admin/products', { params: { page, limit, search: search || undefined, categoryId: categoryFilter || undefined, suitableFor: suitableForFilter || undefined, sortBy, sortOrder } }); if (prodRes.data?.data) { setProducts(prodRes.data.data); setTotalPages(prodRes.data.meta?.lastPage || 1); } // Fetch categories separately try { const catRes = await api.get('/admin/categories'); if (catRes.data?.data) { setCategories(catRes.data.data); } else if (Array.isArray(catRes.data)) { setCategories(catRes.data); } } catch (err) { console.warn('Could not fetch categories', err); } } catch (err: unknown) { console.error(err); toast.error('خطا در دریافت لیست محصولات'); } finally { setIsLoading(false); } }, [page, search, categoryFilter, suitableForFilter, sortBy, sortOrder]); useEffect(() => { const timer = setTimeout(() => { fetchData(); }, 400); return () => clearTimeout(timer); }, [fetchData]); const openModal = (product: Product | null = null) => { setMediaImageError(false); if (product) { setEditingProduct(product); const bPrice = product.buyPrice ? Number(product.buyPrice) : ''; const pPrice = Number(product.priceValue || 0); const wPrice = product.wholesalePrice ? Number(product.wholesalePrice) : ''; const pMargin = bPrice && pPrice ? Math.round(((pPrice - Number(bPrice)) / Number(bPrice)) * 100) : ''; const wMargin = bPrice && wPrice ? Math.round(((wPrice - Number(bPrice)) / Number(bPrice)) * 100) : ''; setFormData({ artNo: product.artNo || '', nameFa: product.nameFa || '', nameEn: product.nameEn || '', scientificTagline: product.scientificTagline || '', description: product.description || '', shortDescription: product.shortDescription || '', categoryId: product.categoryId || '', buyPrice: bPrice, priceValue: pPrice, wholesalePrice: wPrice, priceValueMarginPercent: pMargin, wholesaleMarginPercent: wMargin, priceDisplay: product.priceDisplay || '', unit: product.unit || '', packageSize: Number(product.packageSize) || 0, dosageLogic: product.dosageLogic || '', suitableFor: product.suitableFor || 'سگ و گربه', imageUrl: product.imageUrl || '', slug: product.slug || '', metaTitle: product.metaTitle || '', metaDescription: product.metaDescription || '', keywords: product.keywords || '', canonicalUrl: product.canonicalUrl || '', images: Array.isArray(product.images) ? product.images : [], podcastUrl: product.podcastUrl || '', podcastTitle: product.podcastTitle || '', podcastDescription: product.podcastDescription || '', podcastCover: product.podcastCover || '', videoUrl: product.videoUrl || '', videoTitle: product.videoTitle || '', videoDescription: product.videoDescription || '', videoCover: product.videoCover || '', pdfUrl: product.pdfUrl || '', pdfTitle: product.pdfTitle || '', pdfDescription: product.pdfDescription || '', pdfCover: product.pdfCover || '', symptoms: product.symptoms ? product.symptoms.map((s: { symptom: string } | string) => typeof s === 'string' ? s : s.symptom) : [], isPreorder: Boolean(product.isPreorder), preorderDeposit: product.preorderDeposit || '' }); } else { setEditingProduct(null); setFormData({ artNo: '', nameFa: '', nameEn: '', scientificTagline: '', description: '', shortDescription: '', categoryId: '', buyPrice: '', priceValue: 0, wholesalePrice: '', priceValueMarginPercent: '', wholesaleMarginPercent: '', priceDisplay: '', unit: '', packageSize: 0, dosageLogic: '', suitableFor: 'سگ و گربه', imageUrl: '', metaTitle: '', metaDescription: '', keywords: '', canonicalUrl: '', slug: '', images: [], podcastUrl: '', podcastTitle: '', podcastDescription: '', podcastCover: '', videoUrl: '', videoTitle: '', videoDescription: '', videoCover: '', pdfUrl: '', pdfTitle: '', pdfDescription: '', pdfCover: '', symptoms: [], isPreorder: false, preorderDeposit: '' }); } setActiveTab('general'); setIsModalOpen(true); }; const handleSave = async (e: React.FormEvent) => { e.preventDefault(); // Explicit Tab-Aware Client-Side Validation if (!formData.artNo?.trim()) { setActiveTab('general'); toast.error('لطفاً کد محصول (Art No) را وارد نمایید'); return; } if (!formData.nameFa?.trim()) { setActiveTab('general'); toast.error('لطفاً نام فارسی محصول را وارد نمایید'); return; } if (!formData.nameEn?.trim()) { setActiveTab('general'); toast.error('لطفاً نام انگلیسی محصول را وارد نمایید'); return; } if (!formData.categoryId?.trim()) { setActiveTab('general'); toast.error('لطفاً دسته‌بندی محصول را انتخاب نمایید'); return; } if (formData.priceValue === undefined || formData.priceValue === null || isNaN(Number(formData.priceValue))) { setActiveTab('pricing'); toast.error('لطفاً قیمت فروش محصول را وارد نمایید'); return; } try { const derivedSlug = (formData.slug || '').trim() || slugify(formData.nameEn || ''); const finalPayload: Record = { artNo: (formData.artNo || '').trim(), nameFa: (formData.nameFa || '').trim(), nameEn: (formData.nameEn || '').trim(), scientificTagline: (formData.scientificTagline || '').trim() || undefined, description: (formData.description || '').trim() || undefined, shortDescription: (formData.shortDescription || '').trim() || undefined, categoryId: (formData.categoryId || '').trim(), buyPrice: formData.buyPrice ? Number(formData.buyPrice) : 0, priceValue: Number(formData.priceValue || 0), wholesalePrice: formData.wholesalePrice ? Number(formData.wholesalePrice) : undefined, priceValueMarginPercent: formData.priceValueMarginPercent !== '' ? Number(formData.priceValueMarginPercent) : undefined, wholesaleMarginPercent: formData.wholesaleMarginPercent !== '' ? Number(formData.wholesaleMarginPercent) : undefined, priceDisplay: formData.priceDisplay || `${Number(formData.priceValue || 0).toLocaleString('fa-IR')} تومان`, unit: (formData.unit || '').trim() || undefined, packageSize: Number(formData.packageSize) || 0, dosageLogic: (formData.dosageLogic || '').trim() || undefined, suitableFor: formData.suitableFor || 'سگ و گربه', imageUrl: (formData.imageUrl || '').trim() || undefined, images: formData.images || [], podcastUrl: (formData.podcastUrl || '').trim() || undefined, podcastTitle: (formData.podcastTitle || '').trim() || undefined, podcastDescription: (formData.podcastDescription || '').trim() || undefined, podcastCover: (formData.podcastCover || '').trim() || undefined, videoUrl: (formData.videoUrl || '').trim() || undefined, videoTitle: (formData.videoTitle || '').trim() || undefined, videoDescription: (formData.videoDescription || '').trim() || undefined, videoCover: (formData.videoCover || '').trim() || undefined, pdfUrl: (formData.pdfUrl || '').trim() || undefined, pdfTitle: (formData.pdfTitle || '').trim() || undefined, pdfDescription: (formData.pdfDescription || '').trim() || undefined, pdfCover: (formData.pdfCover || '').trim() || undefined, metaTitle: (formData.metaTitle || '').trim() || `${formData.nameFa || 'نام محصول'} | خرید و قیمت مکمل کنینا`, metaDescription: (formData.metaDescription || '').trim() || formData.shortDescription || 'خرید آنلاین مکمل اصل کنینا آلمان با بالاترین کیفیت بالینی...', keywords: (formData.keywords || '').trim() || undefined, canonicalUrl: (formData.canonicalUrl || '').trim() || `/shop/${derivedSlug}`, slug: derivedSlug, symptoms: formData.symptoms || [], isPreorder: Boolean(formData.isPreorder), preorderDeposit: formData.preorderDeposit ? String(formData.preorderDeposit) : undefined }; if (editingProduct) { const res = await api.put(`/admin/products/${editingProduct.id}`, finalPayload); const updated = res.data?.data || res.data; if (updated && updated.id) { setProducts(prev => prev.map(p => p.id === editingProduct.id ? { ...p, ...updated } : p)); } toast.success('محصول با موفقیت ویرایش شد'); } else { const res = await api.post('/admin/products', finalPayload); const created = res.data?.data || res.data; if (created && created.id) { setProducts(prev => [created, ...prev]); } toast.success('محصول با موفقیت ایجاد شد'); } setIsModalOpen(false); setImageErrors({}); await fetchData(); } catch (error: unknown) { console.error('Save failed', error); const axiosErr = error as { response?: { data?: { message?: string } }; message?: string; _toastShown?: boolean }; const serverMsg = axiosErr.response?.data?.message || axiosErr.message; if (serverMsg && !axiosErr._toastShown) { toast.error(`خطا در ذخیره محصول: ${serverMsg}`); } } }; const confirmDelete = async () => { if (!deleteTargetId) return; try { await api.delete(`/admin/products/${deleteTargetId}`); toast.success('محصول با موفقیت حذف شد'); setImageErrors({}); fetchData(); } catch (error) { console.error('Delete failed', error); toast.error('خطا در حذف محصول'); } finally { setDeleteTargetId(null); } }; return (

مدیریت محصولات

افزودن و ویرایش پیشرفته محصولات با تنظیمات سئو

{/* Filters Bar */}
{ const val = e.target.value; setSearch(val); setPage(1); updateUrlParams({ search: val, page: 1 }); }} className="w-full pl-4 pr-12 py-3 rounded-xl border border-gray-200 focus:border-purple-500 focus:ring-2 focus:ring-purple-200 outline-none transition-all text-sm" />
{isLoading ? ( ) : products.length === 0 ? ( ) : ( products.map((product) => ( )) )}
تصویر handleSort('artNo')} className="py-4 px-6 text-sm font-bold text-gray-700 hover:text-purple-600 cursor-pointer transition-colors" >
کد (Art No) {sortBy === 'artNo' ? ( sortOrder === 'asc' ? : ) : ( )}
handleSort('nameFa')} className="py-4 px-6 text-sm font-bold text-gray-700 hover:text-purple-600 cursor-pointer transition-colors" >
نام محصول {sortBy === 'nameFa' ? ( sortOrder === 'asc' ? : ) : ( )}
دسته‌بندی handleSort('priceValue')} className="py-4 px-6 text-sm font-bold text-gray-700 hover:text-purple-600 cursor-pointer transition-colors" >
موجودی / قیمت {sortBy === 'priceValue' ? ( sortOrder === 'asc' ? : ) : ( )}
عملیات
محصولی یافت نشد
{product.imageUrl && !imageErrors[product.id] ? (
setImageErrors(prev => ({ ...prev, [product.id]: true }))} className="max-w-full max-h-full object-contain object-center" />
) : (
)}
{product.artNo}
{product.nameFa || product.name || '-'} {product.nameEn && {product.nameEn}}
{typeof product.category === 'object' && product.category !== null ? (product.category.nameFa || product.category.name || 'بدون دسته') : (typeof product.category === 'string' ? product.category : 'بدون دسته')}
{Number(product.priceValue).toLocaleString()} تومان {product.packageSize} موجودی
{!isLoading && totalPages > 1 && (
)}
{isModalOpen && (

{editingProduct ? 'ویرایش پیشرفته محصول' : 'افزودن محصول جدید'}

{editingProduct && formData.slug && ( مشاهده در سایت 🔗 )}
{[ { id: 'general', label: 'اطلاعات پایه' }, { id: 'pricing', label: 'موجودی و قیمت' }, { id: 'seo', label: 'سئو (SEO)' }, { id: 'media', label: 'رسانه' } ].map(tab => ( ))}
{/* General Tab */}
setFormData({ ...formData, artNo: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono" dir="ltr" />
setFormData({ ...formData, nameFa: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" dir="rtl" placeholder="مثال: ژل دندان میکروسیلور" />
setFormData({ ...formData, nameEn: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono" dir="ltr" placeholder="e.g. Canina Mikrosilber Zahngel - 50ml" />
setFormData({ ...formData, scientificTagline: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" />
{formData.symptoms && formData.symptoms.map((symptom, idx) => ( {symptom} ))}
{ setSymptomInput(e.target.value); setShowSymptomSuggestions(true); }} onFocus={() => setShowSymptomSuggestions(true)} className="w-full bg-transparent border-none outline-none text-sm text-right font-vazir" onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); const val = symptomInput.trim(); if (val && !formData.symptoms.includes(val)) { setFormData({ ...formData, symptoms: [...(formData.symptoms || []), val] }); if (!availableSymptoms.includes(val)) { setAvailableSymptoms([...availableSymptoms, val]); } setSymptomInput(''); setShowSymptomSuggestions(false); } } }} /> {/* Autocomplete Suggestions Popup */} {showSymptomSuggestions && availableSymptoms.filter(s => s.includes(symptomInput) && !formData.symptoms.includes(s)).length > 0 && (
{availableSymptoms .filter(s => s.includes(symptomInput) && !formData.symptoms.includes(s)) .map((s) => ( ))}
)}

از منوی پیشنهادی انتخاب کنید یا علامت جدید بنویسید و کلید Enter را بزنید.

{/* Dosage Calculator Field in General Tab */}
setFormData({ ...formData, keywords: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" placeholder="مثال: مکمل سگ, تقویت مفاصل, کنهیدروکس" />
setFormData({ ...formData, canonicalUrl: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-sm" dir="ltr" placeholder={derivedCanonicalUrl} />
{/* Left Column: Sticky Google SERP Preview */}
پیش‌نمایش نتایج در گوگل (Google SERP Preview)
{/* Domain / Breadcrumb */}
caninoiran.ir › shop › {derivedSlug}
{/* Title */}

{derivedMetaTitle}

{/* Description */}

{derivedMetaDescription}

تغییرات فیلدهای سمت راست، بلافاصله در پیش‌نمایش بالا منعکس می‌شود.

); })()}
{/* Media Tab */}
{/* Main Product Image Section */}
تصویر اصلی و کاور محصول (Main Cover Image)
{ setFormData({ ...formData, imageUrl: e.target.value }); setMediaImageError(false); }} placeholder="آدرس اینترنتی یا مسیر عکس اصلی (/uploads/cover.jpg)" className="flex-1 w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-xs" dir="ltr" /> {formData.imageUrl && (
{mediaImageError ? ( ) : ( Cover setMediaImageError(true)} /> )}
)}
{/* Product Gallery Images (Multiple Angles & Views) */}

گالری تصاویر جانبی محصول (Gallery Images)

تصاویر زوایای مختلف، پشت بسته‌بندی، جدول ارزش غذایی و مشخصات فنی

{formData.images && formData.images.length > 0 ? (
{formData.images.map((imgUrl, idx) => (
{`Gallery
#{idx + 1}
))}
) : (

هنوز عکس جانبی به گالری محصول اضافه نشده است.

با زدن دکمه «افزودن عکس به گالری» می‌توانید چند عکس اضافه نمایید.

)}
{/* Multimedia Attachments */}

پیوست‌های مالتی‌مدیا و مستندات علمی تخصصی

مدیریت فایل‌های صوتی، تصویری و کاتالوگ‌های اختصاصی این محصول همراه با عنوان، توضیحات و کاور اختصاصی.

{/* Section 1: Podcast Management Card */}
🎧 تنظیمات پادکست و بررسی صوتی بالینی {formData.podcastUrl && ( فایل صوتی متصل است )}
setFormData({ ...formData, podcastUrl: e.target.value })} placeholder="https://... /uploads/audio.mp3" className="w-full px-3 py-2 border rounded-xl text-xs font-mono bg-white" dir="ltr" />
setFormData({ ...formData, podcastCover: e.target.value })} placeholder="در صورت خالی بودن، تصویر اصلی محصول استفاده می‌شود" className="w-full px-3 py-2 border rounded-xl text-xs font-mono bg-white" dir="ltr" />
setFormData({ ...formData, podcastTitle: e.target.value })} placeholder="مثلاً: بررسی بالینی اثر مکمل بر سلامت مفاصل سگ‌ها" className="w-full px-3 py-2 border rounded-xl text-xs bg-white font-medium" />