1716 lines
98 KiB
TypeScript
1716 lines
98 KiB
TypeScript
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<Product[]>([]);
|
||
const [categories, setCategories] = useState<Category[]>([]);
|
||
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<Record<string, boolean>>({});
|
||
const [mediaImageError, setMediaImageError] = useState(false);
|
||
const [availableSymptoms, setAvailableSymptoms] = useState<string[]>([
|
||
"درد مفاصل", "سختی در بلند شدن", "لنگیدن", "رشد سریع تولهسگ", "پاهای پرانتزی", "ضعف تاندون",
|
||
"اسهال", "یبوست", "اسهال مزمن", "بیاشتهایی", "ضعف بعد از بیماری", "ریزش مو", "خشکی پوست", "خارش", "جرم دندان"
|
||
]);
|
||
const [symptomInput, setSymptomInput] = useState('');
|
||
const [showSymptomSuggestions, setShowSymptomSuggestions] = useState(false);
|
||
const limit = 10;
|
||
|
||
// Sync state changes to URL search params
|
||
const updateUrlParams = useCallback((paramsObj: Record<string, string | number | undefined | null>) => {
|
||
const current = Object.fromEntries(searchParams.entries());
|
||
const merged = { ...current, ...paramsObj };
|
||
|
||
// Remove empty/default values to keep URL clean
|
||
const cleaned: Record<string, string> = {};
|
||
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<Product | null>(null);
|
||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(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<string, unknown> = {
|
||
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 (
|
||
<div className="space-y-6">
|
||
<div className="flex flex-col sm:flex-row justify-between gap-4">
|
||
<div>
|
||
<h2 className="text-2xl font-black text-gray-900 flex items-center gap-2">
|
||
<Box className="w-6 h-6 text-purple-600" />
|
||
مدیریت محصولات
|
||
</h2>
|
||
<p className="text-gray-500 font-medium mt-1">افزودن و ویرایش پیشرفته محصولات با تنظیمات سئو</p>
|
||
</div>
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
startIcon={Plus}
|
||
onClick={() => openModal()}
|
||
>
|
||
محصول جدید
|
||
</Button>
|
||
</div>
|
||
|
||
|
||
{/* Filters Bar */}
|
||
<div className="bg-white p-4 rounded-2xl shadow-sm border border-gray-200 flex flex-col md:flex-row gap-4">
|
||
<div className="relative flex-1">
|
||
<Search className="w-5 h-5 absolute right-4 top-1/2 -translate-y-1/2 text-gray-400" />
|
||
<input
|
||
type="text"
|
||
placeholder="جستجو حرفهای در نام فارسی، نام انگلیسی (Latin)، کد محصول، بارکد، ترکیبات و علائم..."
|
||
value={search}
|
||
onChange={(e) => {
|
||
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"
|
||
/>
|
||
</div>
|
||
<div className="flex flex-wrap sm:flex-nowrap gap-3">
|
||
<div className="relative w-full sm:w-56">
|
||
<Filter className="w-5 h-5 absolute right-4 top-1/2 -translate-y-1/2 text-gray-400" />
|
||
<select
|
||
value={categoryFilter}
|
||
onChange={(e) => {
|
||
const val = e.target.value;
|
||
setCategoryFilter(val);
|
||
setPage(1);
|
||
updateUrlParams({ category: 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 appearance-none bg-white text-sm"
|
||
>
|
||
<option value="">همه دستهبندیها</option>
|
||
{categories.map(c => <option key={c.id} value={c.id}>{typeof c === 'string' ? c : (c.nameFa || c.name)}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="relative w-full sm:w-44">
|
||
<select
|
||
value={suitableForFilter}
|
||
onChange={(e) => {
|
||
const val = e.target.value;
|
||
setSuitableForFilter(val);
|
||
setPage(1);
|
||
updateUrlParams({ suitableFor: val, page: 1 });
|
||
}}
|
||
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 focus:ring-2 focus:ring-purple-200 outline-none transition-all appearance-none bg-white text-sm"
|
||
>
|
||
<option value="">همه گونهها</option>
|
||
<option value="سگ">مخصوص سگ</option>
|
||
<option value="گربه">مخصوص گربه</option>
|
||
<option value="سگ و گربه">سگ و گربه</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white rounded-2xl shadow-sm border border-gray-200 overflow-hidden">
|
||
<div className="overflow-x-auto">
|
||
<table className="w-full text-right">
|
||
<thead className="bg-gray-50 border-b border-gray-100 select-none">
|
||
<tr>
|
||
<th className="py-4 px-6 text-sm font-bold text-gray-500">تصویر</th>
|
||
<th
|
||
onClick={() => handleSort('artNo')}
|
||
className="py-4 px-6 text-sm font-bold text-gray-700 hover:text-purple-600 cursor-pointer transition-colors"
|
||
>
|
||
<div className="flex items-center gap-1.5">
|
||
<span>کد (Art No)</span>
|
||
{sortBy === 'artNo' ? (
|
||
sortOrder === 'asc' ? <ArrowUp className="w-4 h-4 text-purple-600" /> : <ArrowDown className="w-4 h-4 text-purple-600" />
|
||
) : (
|
||
<ArrowUpDown className="w-3.5 h-3.5 text-gray-400 opacity-50" />
|
||
)}
|
||
</div>
|
||
</th>
|
||
<th
|
||
onClick={() => handleSort('nameFa')}
|
||
className="py-4 px-6 text-sm font-bold text-gray-700 hover:text-purple-600 cursor-pointer transition-colors"
|
||
>
|
||
<div className="flex items-center gap-1.5">
|
||
<span>نام محصول</span>
|
||
{sortBy === 'nameFa' ? (
|
||
sortOrder === 'asc' ? <ArrowUp className="w-4 h-4 text-purple-600" /> : <ArrowDown className="w-4 h-4 text-purple-600" />
|
||
) : (
|
||
<ArrowUpDown className="w-3.5 h-3.5 text-gray-400 opacity-50" />
|
||
)}
|
||
</div>
|
||
</th>
|
||
<th className="py-4 px-6 text-sm font-bold text-gray-500">دستهبندی</th>
|
||
<th
|
||
onClick={() => handleSort('priceValue')}
|
||
className="py-4 px-6 text-sm font-bold text-gray-700 hover:text-purple-600 cursor-pointer transition-colors"
|
||
>
|
||
<div className="flex items-center gap-1.5">
|
||
<span>موجودی / قیمت</span>
|
||
{sortBy === 'priceValue' ? (
|
||
sortOrder === 'asc' ? <ArrowUp className="w-4 h-4 text-purple-600" /> : <ArrowDown className="w-4 h-4 text-purple-600" />
|
||
) : (
|
||
<ArrowUpDown className="w-3.5 h-3.5 text-gray-400 opacity-50" />
|
||
)}
|
||
</div>
|
||
</th>
|
||
<th className="py-4 px-6 text-sm font-bold text-gray-500 w-24">عملیات</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100">
|
||
{isLoading ? (
|
||
<tr>
|
||
<td colSpan={6} className="py-12 text-center">
|
||
<Spinner size="lg" className="mx-auto text-purple-600" />
|
||
</td>
|
||
</tr>
|
||
) : products.length === 0 ? (
|
||
<tr>
|
||
<td colSpan={6} className="py-12 text-center text-gray-500 font-medium">محصولی یافت نشد</td>
|
||
</tr>
|
||
) : (
|
||
products.map((product) => (
|
||
<tr key={product.id} className="hover:bg-gray-50/50 transition-colors">
|
||
<td className="py-3 px-6">
|
||
{product.imageUrl && !imageErrors[product.id] ? (
|
||
<div className="w-12 h-12 rounded-xl bg-gray-50 border border-gray-200 p-1 flex items-center justify-center overflow-hidden">
|
||
<img
|
||
src={getProductImageUrl(product.imageUrl)}
|
||
alt=""
|
||
onError={() => setImageErrors(prev => ({ ...prev, [product.id]: true }))}
|
||
className="max-w-full max-h-full object-contain object-center"
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="w-12 h-12 rounded-xl bg-gray-100 flex items-center justify-center text-gray-400">
|
||
<ImageIcon className="w-5 h-5" />
|
||
</div>
|
||
)}
|
||
</td>
|
||
<td className="py-4 px-6 text-gray-500 font-mono text-sm">{product.artNo}</td>
|
||
<td className="py-4 px-6">
|
||
<div className="flex flex-col gap-0.5">
|
||
<span className="font-bold text-gray-900" dir="rtl">{product.nameFa || product.name || '-'}</span>
|
||
{product.nameEn && <span className="text-xs text-gray-400 font-mono" dir="ltr">{product.nameEn}</span>}
|
||
</div>
|
||
</td>
|
||
<td className="py-4 px-6 text-gray-600 text-sm">
|
||
{typeof product.category === 'object' && product.category !== null
|
||
? (product.category.nameFa || product.category.name || 'بدون دسته')
|
||
: (typeof product.category === 'string' ? product.category : 'بدون دسته')}
|
||
</td>
|
||
<td className="py-4 px-6">
|
||
<div className="flex flex-col">
|
||
<span className="font-bold text-gray-900">{Number(product.priceValue).toLocaleString()} تومان</span>
|
||
<span className="text-xs text-gray-500">{product.packageSize} موجودی</span>
|
||
</div>
|
||
</td>
|
||
<td className="py-4 px-6">
|
||
<div className="flex items-center gap-2">
|
||
<button onClick={() => openModal(product)} className="p-2 text-blue-600 hover:bg-blue-50 rounded-lg transition-colors">
|
||
<Edit2 className="w-4 h-4" />
|
||
</button>
|
||
<button onClick={() => setDeleteTargetId(product.id)} className="p-2 text-red-600 hover:bg-red-50 rounded-lg transition-colors">
|
||
<Trash2 className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
{!isLoading && totalPages > 1 && (
|
||
<div className="p-4 border-t border-gray-100 flex justify-center bg-gray-50/50">
|
||
<Pagination currentPage={page} totalPages={totalPages} onPageChange={setPage} />
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{isModalOpen && (
|
||
<div className="fixed inset-0 z-50 flex items-center justify-center p-3 sm:p-4 bg-gray-900/60 backdrop-blur-xs font-vazir animate-in fade-in duration-200" dir="rtl">
|
||
<div className="bg-white rounded-3xl w-full max-w-4xl max-h-[92dvh] sm:max-h-[88vh] flex flex-col shadow-2xl overflow-hidden border border-gray-100 animate-in zoom-in-95 duration-200">
|
||
<div className="px-5 sm:px-6 py-4 border-b border-gray-100 flex justify-between items-center bg-white shrink-0">
|
||
<div className="flex items-center gap-3">
|
||
<h3 className="text-base sm:text-lg font-black text-gray-900">
|
||
{editingProduct ? 'ویرایش پیشرفته محصول' : 'افزودن محصول جدید'}
|
||
</h3>
|
||
{editingProduct && formData.slug && (
|
||
<a
|
||
href={getStoreProductUrl(formData.slug)}
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-[11px] font-bold bg-purple-50 border border-purple-100 text-purple-600 hover:bg-purple-100 transition-all cursor-pointer"
|
||
>
|
||
مشاهده در سایت 🔗
|
||
</a>
|
||
)}
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => setIsModalOpen(false)}
|
||
className="w-8 h-8 rounded-xl bg-gray-50 text-gray-400 hover:text-gray-700 hover:bg-gray-100 flex items-center justify-center transition-colors cursor-pointer"
|
||
>
|
||
<X className="w-4 h-4" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex border-b border-gray-100 px-5 sm:px-6 pt-2 gap-3 sm:gap-4 overflow-x-auto shrink-0 bg-gray-50/50">
|
||
{[
|
||
{ id: 'general', label: 'اطلاعات پایه' },
|
||
{ id: 'pricing', label: 'موجودی و قیمت' },
|
||
{ id: 'seo', label: 'سئو (SEO)' },
|
||
{ id: 'media', label: 'رسانه' }
|
||
].map(tab => (
|
||
<button
|
||
key={tab.id}
|
||
type="button"
|
||
onClick={() => setActiveTab(tab.id)}
|
||
className={`pb-3 px-2 text-xs sm:text-sm font-bold border-b-2 whitespace-nowrap transition-colors cursor-pointer ${activeTab === tab.id ? 'border-purple-600 text-purple-700' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
<div className="p-4 sm:p-6 overflow-y-auto flex-1 overscroll-contain bg-gray-50/30">
|
||
<form id="productForm" onSubmit={handleSave} className="space-y-6">
|
||
|
||
|
||
{/* General Tab */}
|
||
<div className={activeTab === 'general' ? 'block space-y-4' : 'hidden'}>
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
||
<span>کد محصول (Art No) *</span>
|
||
<div className="group relative inline-block">
|
||
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
|
||
<div className="absolute top-full right-1/2 translate-x-1/2 mt-2 hidden group-hover:block w-52 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-50 font-vazir leading-relaxed">
|
||
کد کاتالوگ انحصاری کارخانه Canina آلمان (مانند 123490).
|
||
</div>
|
||
</div>
|
||
</label>
|
||
<input required type="text" value={formData.artNo} onChange={(e) => 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" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
||
<span>نام فارسی محصول *</span>
|
||
<div className="group relative inline-block">
|
||
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
|
||
<div className="absolute top-full right-1/2 translate-x-1/2 mt-2 hidden group-hover:block w-52 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-50 font-vazir leading-relaxed">
|
||
عنوان فارسی محصول که در صفحات و کاتالوگ فروشگاه نمایش داده میشود.
|
||
</div>
|
||
</div>
|
||
</label>
|
||
<input required type="text" value={formData.nameFa} onChange={(e) => 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="مثال: ژل دندان میکروسیلور" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
||
<span>نام انگلیسی / آلمانی (nameEn) *</span>
|
||
<div className="group relative inline-block">
|
||
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
|
||
<div className="absolute top-full right-1/2 translate-x-1/2 mt-2 hidden group-hover:block w-52 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-50 font-vazir leading-relaxed">
|
||
نام برند و وزن کالا به انگلیسی جهت استفاده در آدرس، فاکتورها و سئو.
|
||
</div>
|
||
</div>
|
||
</label>
|
||
<input required type="text" value={formData.nameEn} onChange={(e) => 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" />
|
||
</div>
|
||
<div className="space-y-2 md:col-span-2">
|
||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
||
<span>شعار علمی (Tagline)</span>
|
||
<div className="group relative inline-block">
|
||
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
|
||
<div className="absolute top-full right-1/2 translate-x-1/2 mt-2 hidden group-hover:block w-52 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-50 font-vazir leading-relaxed">
|
||
شعار کلیدی یا علمی کالا که در بالای صفحه محصول نمایش داده میشود.
|
||
</div>
|
||
</div>
|
||
</label>
|
||
<input type="text" value={formData.scientificTagline} onChange={(e) => 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" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
||
<span>دستهبندی *</span>
|
||
<div className="group relative inline-block">
|
||
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
|
||
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-52 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
|
||
دستهبندی اصلی محصول جهت تقسیمبندی در منوها و سیستم ناوبری سایت.
|
||
</div>
|
||
</div>
|
||
</label>
|
||
<select required value={formData.categoryId} onChange={(e) => setFormData({ ...formData, categoryId: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none bg-white">
|
||
<option value="">انتخاب کنید</option>
|
||
{categories.map(c => <option key={c.id} value={c.id}>{typeof c === 'string' ? c : (c.nameFa || c.name)}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
||
<span>مناسب برای</span>
|
||
<div className="group relative inline-block">
|
||
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
|
||
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-52 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
|
||
تعیین این که کالا برای سگ، گربه یا هر دو گونه مناسب است.
|
||
</div>
|
||
</div>
|
||
</label>
|
||
<select value={formData.suitableFor} onChange={(e) => setFormData({ ...formData, suitableFor: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none bg-white">
|
||
<option value="سگ">سگ</option>
|
||
<option value="گربه">گربه</option>
|
||
<option value="سگ و گربه">سگ و گربه</option>
|
||
</select>
|
||
</div>
|
||
<div className="space-y-2 md:col-span-2">
|
||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
||
<span>توضیح کوتاه</span>
|
||
<div className="group relative inline-block">
|
||
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
|
||
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-52 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
|
||
خلاصه ویژگیهای کلیدی کالا که در کارتهای فروشگاه و زیر عنوان محصول نمایش داده میشود.
|
||
</div>
|
||
</div>
|
||
</label>
|
||
<textarea rows={2} value={formData.shortDescription} onChange={(e) => setFormData({ ...formData, shortDescription: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"></textarea>
|
||
</div>
|
||
<div className="space-y-2 md:col-span-2">
|
||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
||
<span>توضیحات کامل</span>
|
||
<div className="group relative inline-block">
|
||
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
|
||
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-52 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
|
||
توضیحات کامل علمی، نحوه عملکرد مکمل و جزئیات ترکیبات محصول.
|
||
</div>
|
||
</div>
|
||
</label>
|
||
<textarea rows={5} value={formData.description} onChange={(e) => setFormData({ ...formData, description: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"></textarea>
|
||
</div>
|
||
<div className="space-y-2 md:col-span-2">
|
||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
||
<span>منطق دوز مصرفی (Dosage Logic)</span>
|
||
<div className="group relative inline-block">
|
||
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
|
||
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-52 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
|
||
فرمول نحوه مصرف روزانه کالا بر اساس وزن پت (مثال: ۲ میلیلیتر به ازای هر ۵ کیلوگرم وزن).
|
||
</div>
|
||
</div>
|
||
</label>
|
||
<textarea rows={2} value={formData.dosageLogic} onChange={(e) => setFormData({ ...formData, dosageLogic: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"></textarea>
|
||
</div>
|
||
<div className="space-y-2 md:col-span-2">
|
||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
||
<span>علائم درمانی مرتبط (Symptom Tags)</span>
|
||
<div className="group relative inline-block">
|
||
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
|
||
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-52 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
|
||
علائم متصل به این دارو جهت فیلترینگ و سرچ پویای کاربران (مثال: اسهال، خارش، ریزش مو).
|
||
</div>
|
||
</div>
|
||
</label>
|
||
<div className="flex flex-wrap gap-2 p-3 bg-white border border-gray-200 rounded-xl min-h-[50px] items-center focus-within:border-purple-500 focus-within:ring-2 focus-within:ring-purple-200 transition-all">
|
||
{formData.symptoms && formData.symptoms.map((symptom, idx) => (
|
||
<span key={idx} className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold bg-purple-50 border border-purple-200 text-purple-700">
|
||
{symptom}
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
const newSymptoms = formData.symptoms.filter((_, i) => i !== idx);
|
||
setFormData({ ...formData, symptoms: newSymptoms });
|
||
}}
|
||
className="text-purple-400 hover:text-purple-600 focus:outline-none transition-colors"
|
||
>
|
||
<X className="w-3.5 h-3.5" />
|
||
</button>
|
||
</span>
|
||
))}
|
||
<div className="relative flex-1 min-w-[200px]">
|
||
<input
|
||
type="text"
|
||
placeholder="جستجو یا تایپ علامت جدید..."
|
||
value={symptomInput}
|
||
onChange={(e) => {
|
||
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 && (
|
||
<div className="absolute top-full right-0 z-50 mt-1 w-64 max-h-48 overflow-y-auto bg-white border border-gray-200 rounded-xl shadow-xl p-1">
|
||
{availableSymptoms
|
||
.filter(s => s.includes(symptomInput) && !formData.symptoms.includes(s))
|
||
.map((s) => (
|
||
<button
|
||
type="button"
|
||
key={s}
|
||
onMouseDown={(e) => {
|
||
e.preventDefault(); // Prevent blur before click executes
|
||
setFormData({
|
||
...formData,
|
||
symptoms: [...(formData.symptoms || []), s]
|
||
});
|
||
setSymptomInput('');
|
||
setShowSymptomSuggestions(false);
|
||
}}
|
||
className="w-full text-right px-3 py-1.5 hover:bg-purple-50 hover:text-purple-700 text-xs font-bold rounded-lg transition-colors flex items-center justify-between"
|
||
>
|
||
<span>{s}</span>
|
||
<span className="text-[10px] text-gray-400">+ افزودن</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
<p className="text-[11px] text-gray-400 font-medium">از منوی پیشنهادی انتخاب کنید یا علامت جدید بنویسید و کلید Enter را بزنید.</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Dosage Calculator Field in General Tab */}
|
||
<div className="space-y-2 pt-2 border-t border-gray-100">
|
||
<label className="text-sm font-bold text-gray-700 flex items-center justify-between">
|
||
<span className="flex items-center gap-1.5">
|
||
<span>دستور مصرف بالینی و راهنمای دوز مصرفی</span>
|
||
<div className="group relative inline-block">
|
||
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
|
||
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-64 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
|
||
دستور مصرف روان و فارسی کالا (مانند: روزانه ۱ قرص به ازای هر ۱۰ کیلوگرم وزن بدن). این متن در کاتالوگ آنلاین و صفحه مشخصات کالا به صورت برجسته نمایش داده میشود.
|
||
</div>
|
||
</div>
|
||
</span>
|
||
<span className="text-xs text-purple-600 font-bold">متن راهنمای بالینی</span>
|
||
</label>
|
||
<textarea
|
||
rows={3}
|
||
value={formData.dosageLogic}
|
||
onChange={(e) => setFormData({ ...formData, dosageLogic: e.target.value })}
|
||
placeholder="مثال: روزانه ۱ قرص به ازای هر ۱۰ کیلوگرم وزن بدن، همراه با وعده غذایی مصرف شود."
|
||
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs font-vazir"
|
||
/>
|
||
<p className="text-[11px] text-gray-400">
|
||
این متن در بخش مشخصات علمی کالا، ماشینحساب دوز و کاتالوگ آنلاین برای پزشکان و خریداران نمایش داده میشود.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Pricing Tab */}
|
||
<div className={activeTab === 'pricing' ? 'block space-y-4' : 'hidden'}>
|
||
<div className="bg-amber-50/60 border border-amber-200 p-4 rounded-xl text-xs text-amber-800 flex items-start gap-2">
|
||
<HelpCircle className="w-4 h-4 text-amber-600 shrink-0 mt-0.5" />
|
||
<div>
|
||
<p className="font-bold mb-1">امنیت و حریم خصوصی قیمت خرید (سود و زیان)</p>
|
||
<p className="leading-relaxed text-[11px]">
|
||
قیمت خرید و درصدهای سود به هیچ عنوان در پاسخهای API عمومی فرانتاند سایت منتشر نمیشوند و فقط جهت محاسبات حسابداری و گزارشگیری سود/زیان در پنل ادمین استفاده میشوند.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||
{/* Buy Price */}
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-bold text-amber-900 flex items-center gap-1.5">
|
||
<span>قیمت خرید ما (تومان)</span>
|
||
<div className="group relative inline-block">
|
||
<HelpCircle className="w-3.5 h-3.5 text-amber-500 cursor-help" />
|
||
<div className="absolute top-full right-1/2 translate-x-1/2 mt-2 hidden group-hover:block w-52 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-50 font-vazir leading-relaxed">
|
||
مبلغ تمام شده خرید کالا برای مبنای محاسبات سود و محاسبه خودکار قیمت فروش با درصد.
|
||
</div>
|
||
</div>
|
||
</label>
|
||
<PriceInput
|
||
placeholder="مثال: ۵۰۰,۰۰۰"
|
||
value={formData.buyPrice}
|
||
onChange={(rawVal) => {
|
||
const bPrice = rawVal ? Number(rawVal) : '';
|
||
let pPrice = formData.priceValue;
|
||
let wPrice = formData.wholesalePrice;
|
||
let pMargin = formData.priceValueMarginPercent;
|
||
let wMargin = formData.wholesaleMarginPercent;
|
||
|
||
if (bPrice && pMargin !== '') {
|
||
pPrice = Math.round(Number(bPrice) * (1 + Number(pMargin) / 100));
|
||
} else if (bPrice && pPrice) {
|
||
pMargin = Math.round(((pPrice - Number(bPrice)) / Number(bPrice)) * 100);
|
||
}
|
||
|
||
if (bPrice && wMargin !== '') {
|
||
wPrice = Math.round(Number(bPrice) * (1 + Number(wMargin) / 100));
|
||
} else if (bPrice && wPrice) {
|
||
wMargin = Math.round(((Number(wPrice) - Number(bPrice)) / Number(bPrice)) * 100);
|
||
}
|
||
|
||
setFormData({
|
||
...formData,
|
||
buyPrice: bPrice,
|
||
priceValue: pPrice,
|
||
wholesalePrice: wPrice,
|
||
priceValueMarginPercent: pMargin,
|
||
wholesaleMarginPercent: wMargin,
|
||
priceDisplay: `${Number(pPrice || 0).toLocaleString('fa-IR')} تومان`,
|
||
});
|
||
}}
|
||
className="px-4 py-3 rounded-xl border border-amber-300 focus:border-amber-500 bg-amber-50/20 text-sm"
|
||
/>
|
||
</div>
|
||
|
||
{/* Retail Price & Margin */}
|
||
<div className="space-y-2">
|
||
<div className="flex justify-between items-center">
|
||
<label className="text-sm font-bold text-gray-700">قیمت فروش تکفروشی (تومان) *</label>
|
||
<span className="text-[10px] text-purple-600 font-bold">یا سود ٪ تکفروشی</span>
|
||
</div>
|
||
<div className="grid grid-cols-5 gap-2">
|
||
<div className="col-span-3">
|
||
<PriceInput
|
||
required
|
||
value={formData.priceValue}
|
||
onChange={(rawVal) => {
|
||
const val = Number(rawVal || 0);
|
||
const bPrice = formData.buyPrice ? Number(formData.buyPrice) : null;
|
||
const pMargin = bPrice && val ? Math.round(((val - bPrice) / bPrice) * 100) : '';
|
||
setFormData({
|
||
...formData,
|
||
priceValue: val,
|
||
priceValueMarginPercent: pMargin,
|
||
priceDisplay: `${val.toLocaleString('fa-IR')} تومان`,
|
||
});
|
||
}}
|
||
className="px-3 py-3 rounded-xl border border-gray-200 focus:border-purple-500 text-sm"
|
||
/>
|
||
</div>
|
||
<div className="col-span-2 relative">
|
||
<input
|
||
type="number"
|
||
placeholder="٪ سود"
|
||
value={formData.priceValueMarginPercent}
|
||
onChange={(e) => {
|
||
const margin = e.target.value !== '' ? Number(e.target.value) : '';
|
||
const bPrice = formData.buyPrice ? Number(formData.buyPrice) : null;
|
||
let pPrice = formData.priceValue;
|
||
if (bPrice && margin !== '') {
|
||
pPrice = Math.round(bPrice * (1 + Number(margin) / 100));
|
||
}
|
||
setFormData({
|
||
...formData,
|
||
priceValueMarginPercent: margin,
|
||
priceValue: pPrice,
|
||
priceDisplay: `${Number(pPrice || 0).toLocaleString('fa-IR')} تومان`,
|
||
});
|
||
}}
|
||
className="w-full px-2 py-3 rounded-xl border border-purple-200 focus:border-purple-500 text-center font-bold text-xs bg-purple-50/30 outline-none"
|
||
dir="ltr"
|
||
/>
|
||
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400 text-xs pointer-events-none">٪</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Wholesale B2B Price & Margin */}
|
||
<div className="space-y-2">
|
||
<div className="flex justify-between items-center">
|
||
<label className="text-sm font-bold text-purple-700">قیمت اختصاصی B2B (تومان)</label>
|
||
<span className="text-[10px] text-purple-600 font-bold">یا سود ٪ B2B</span>
|
||
</div>
|
||
<div className="grid grid-cols-5 gap-2">
|
||
<div className="col-span-3">
|
||
<PriceInput
|
||
placeholder="قیمت B2B"
|
||
value={formData.wholesalePrice}
|
||
onChange={(rawVal) => {
|
||
const val = rawVal ? Number(rawVal) : '';
|
||
const bPrice = formData.buyPrice ? Number(formData.buyPrice) : null;
|
||
const wMargin = bPrice && val ? Math.round(((Number(val) - bPrice) / bPrice) * 100) : '';
|
||
setFormData({
|
||
...formData,
|
||
wholesalePrice: val,
|
||
wholesaleMarginPercent: wMargin,
|
||
});
|
||
}}
|
||
className="px-3 py-3 rounded-xl border border-purple-200 focus:border-purple-600 bg-purple-50/20 text-sm"
|
||
/>
|
||
</div>
|
||
<div className="col-span-2 relative">
|
||
<input
|
||
type="number"
|
||
placeholder="٪ سود"
|
||
value={formData.wholesaleMarginPercent}
|
||
onChange={(e) => {
|
||
const margin = e.target.value !== '' ? Number(e.target.value) : '';
|
||
const bPrice = formData.buyPrice ? Number(formData.buyPrice) : null;
|
||
let wPrice = formData.wholesalePrice;
|
||
if (bPrice && margin !== '') {
|
||
wPrice = Math.round(bPrice * (1 + Number(margin) / 100));
|
||
}
|
||
setFormData({
|
||
...formData,
|
||
wholesaleMarginPercent: margin,
|
||
wholesalePrice: wPrice,
|
||
});
|
||
}}
|
||
className="w-full px-2 py-3 rounded-xl border border-purple-200 focus:border-purple-500 text-center font-bold text-xs bg-purple-50/30 outline-none"
|
||
dir="ltr"
|
||
/>
|
||
<span className="absolute left-2 top-1/2 -translate-y-1/2 text-gray-400 text-xs pointer-events-none">٪</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 pt-2">
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-bold text-gray-700">موجودی *</label>
|
||
<input required type="number" min="0" value={formData.packageSize} onChange={(e) => setFormData({ ...formData, packageSize: Number(e.target.value) })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none" dir="ltr" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-bold text-gray-700">واحد سنجش</label>
|
||
<input type="text" placeholder="مثال: عدد / بسته" value={formData.unit} onChange={(e) => setFormData({ ...formData, unit: e.target.value })} className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-sm" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Pre-order Configuration (TASK-25.13) */}
|
||
<div className="p-4 bg-purple-50 rounded-2xl border border-purple-100 space-y-4">
|
||
<div className="flex items-center justify-between">
|
||
<div>
|
||
<h4 className="font-bold text-purple-900 text-sm">قابلیت پیشخرید کالا (Pre-Order Mode)</h4>
|
||
<p className="text-xs text-purple-700 mt-0.5">امکان پیشخرید کالا قبل از موجود شدن رسمی با بیعانه یا ثبت رایگان.</p>
|
||
</div>
|
||
<label className="relative inline-flex items-center cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
className="sr-only peer"
|
||
checked={formData.isPreorder}
|
||
onChange={(e) => setFormData({ ...formData, isPreorder: e.target.checked })}
|
||
/>
|
||
<div className="w-12 h-6 bg-gray-200 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-purple-600"></div>
|
||
</label>
|
||
</div>
|
||
|
||
{formData.isPreorder && (
|
||
<div className="pt-2 border-t border-purple-200/60">
|
||
<label className="block text-xs font-bold text-purple-900 mb-1">مبلغ بیعانه پیشخرید (تومان - ۰ برای ثبت رایگان)</label>
|
||
<input
|
||
type="number"
|
||
min="0"
|
||
placeholder="مثلاً: 200000 یا 0 برای پیشخرید بدون بیعانه"
|
||
value={formData.preorderDeposit}
|
||
onChange={(e) => setFormData({ ...formData, preorderDeposit: e.target.value ? Number(e.target.value) : '' })}
|
||
className="w-full px-4 py-2.5 rounded-xl border border-purple-200 bg-white font-mono text-xs outline-none focus:ring-2 focus:ring-purple-500"
|
||
dir="ltr"
|
||
/>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="space-y-2 col-span-1 md:col-span-2 pt-2">
|
||
<label className="text-sm font-bold text-gray-700 flex items-center justify-between">
|
||
<span>منطق و دوز دقیق مصرفی (Dosage Calculator Fields)</span>
|
||
<span className="text-xs text-purple-600 font-bold">فرمت JSON یا راهنمای متنی</span>
|
||
</label>
|
||
<textarea
|
||
rows={3}
|
||
value={formData.dosageLogic}
|
||
onChange={(e) => setFormData({ ...formData, dosageLogic: e.target.value })}
|
||
placeholder='{"baseDosage": 1, "perKg": 10, "unit": "قرص", "maxPerDay": 3}'
|
||
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none font-mono text-xs"
|
||
/>
|
||
<p className="text-[11px] text-gray-400">
|
||
این مقادیر توسط محاسبهگر هوشمند دوز مکمل در فرانتاند خوانده میشود.
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
|
||
{/* SEO Tab */}
|
||
<div className={activeTab === 'seo' ? 'block' : 'hidden'}>
|
||
{(() => {
|
||
const derivedSlug = formData.slug || slugify(formData.nameEn);
|
||
const derivedMetaTitle = formData.metaTitle || `${formData.nameFa || 'نام محصول'} | خرید و قیمت مکمل کنینا`;
|
||
const derivedMetaDescription = formData.metaDescription || (formData.shortDescription || 'خرید آنلاین مکمل اصل کنینا آلمان با بالاترین کیفیت بالینی و ضمانت اصالت فیزیکی...');
|
||
const derivedCanonicalUrl = formData.canonicalUrl || `/shop/${derivedSlug}`;
|
||
|
||
return (
|
||
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
|
||
{/* Right Column: SEO Inputs */}
|
||
<div className="lg:col-span-7 space-y-4">
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
||
<span>نامک (Slug) محصول</span>
|
||
<div className="group relative inline-block">
|
||
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
|
||
<div className="absolute top-full right-1/2 translate-x-1/2 mt-2 hidden group-hover:block w-52 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-50 font-vazir leading-relaxed">
|
||
آدرس مستقیم محصول در سایت. در صورت خالی بودن، به طور خودکار از روی نام انگلیسی ساخته میشود.
|
||
</div>
|
||
</div>
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.slug}
|
||
onChange={(e) => setFormData({ ...formData, slug: e.target.value })}
|
||
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"
|
||
dir="ltr"
|
||
placeholder={derivedSlug}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
||
<span>عنوان متا (Meta Title)</span>
|
||
<div className="group relative inline-block">
|
||
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
|
||
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-52 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
|
||
عنوان اصلی صفحه در گوگل (حداکثر ۶۰ کاراکتر). در صورت خالی بودن، از ساختار پیشفرض استفاده میشود.
|
||
</div>
|
||
</div>
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.metaTitle}
|
||
onChange={(e) => setFormData({ ...formData, metaTitle: e.target.value })}
|
||
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"
|
||
placeholder={derivedMetaTitle}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
||
<span>توضیحات متا (Meta Description)</span>
|
||
<div className="group relative inline-block">
|
||
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
|
||
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-52 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
|
||
خلاصه توضیحات صفحه در نتایج جستجو (حداکثر ۱۵۵ کاراکتر). در صورت خالی بودن، از توضیحات کوتاه محصول استفاده میشود.
|
||
</div>
|
||
</div>
|
||
</label>
|
||
<textarea
|
||
rows={3}
|
||
value={formData.metaDescription}
|
||
onChange={(e) => setFormData({ ...formData, metaDescription: e.target.value })}
|
||
className="w-full px-4 py-3 rounded-xl border border-gray-200 focus:border-purple-500 outline-none"
|
||
placeholder={derivedMetaDescription}
|
||
></textarea>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
||
<span>کلمات کلیدی سئو (Keywords)</span>
|
||
<div className="group relative inline-block">
|
||
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
|
||
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-52 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
|
||
کلمات کلیدی مرتبط با محصول که با کاما (,) از هم جدا شدهاند.
|
||
</div>
|
||
</div>
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.keywords}
|
||
onChange={(e) => 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="مثال: مکمل سگ, تقویت مفاصل, کنهیدروکس"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-2">
|
||
<label className="text-sm font-bold text-gray-700 flex items-center gap-1.5">
|
||
<span>آدرس کانونی (Canonical URL)</span>
|
||
<div className="group relative inline-block">
|
||
<HelpCircle className="w-3.5 h-3.5 text-gray-400 hover:text-purple-600 cursor-help" />
|
||
<div className="absolute bottom-full right-1/2 translate-x-1/2 mb-2 hidden group-hover:block w-52 p-2.5 bg-gray-900 text-white text-[10px] rounded-lg shadow-xl text-center z-30 font-vazir leading-relaxed">
|
||
آدرس اصلی محصول جهت جلوگیری از جریمه محتوای تکراری در گوگل.
|
||
</div>
|
||
</div>
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={formData.canonicalUrl}
|
||
onChange={(e) => 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}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Left Column: Sticky Google SERP Preview */}
|
||
<div className="lg:col-span-5 lg:sticky lg:top-4 bg-gray-100 border border-gray-200 rounded-2xl p-5 space-y-3">
|
||
<span className="text-[10px] font-black text-gray-400 uppercase tracking-widest block">پیشنمایش نتایج در گوگل (Google SERP Preview)</span>
|
||
|
||
<div className="bg-white rounded-xl p-4 border border-gray-100 shadow-sm text-right font-sans" dir="rtl">
|
||
{/* Domain / Breadcrumb */}
|
||
<div className="flex items-center gap-1 text-xs text-[#202124] mb-1">
|
||
<span className="font-medium text-gray-800">caninoiran.ir</span>
|
||
<span className="text-gray-400">›</span>
|
||
<span className="text-gray-500">shop</span>
|
||
<span className="text-gray-400">›</span>
|
||
<span className="text-gray-500 max-w-[150px] truncate">{derivedSlug}</span>
|
||
</div>
|
||
|
||
{/* Title */}
|
||
<h4 className="text-[19px] text-[#1a0dab] hover:underline cursor-pointer leading-tight font-medium font-sans mb-1 select-none">
|
||
{derivedMetaTitle}
|
||
</h4>
|
||
|
||
{/* Description */}
|
||
<p className="text-sm text-[#4d5156] leading-relaxed font-sans select-none break-words">
|
||
{derivedMetaDescription}
|
||
</p>
|
||
</div>
|
||
<p className="text-[10px] text-gray-400 font-medium">تغییرات فیلدهای سمت راست، بلافاصله در پیشنمایش بالا منعکس میشود.</p>
|
||
</div>
|
||
</div>
|
||
);
|
||
})()}
|
||
</div>
|
||
|
||
{/* Media Tab */}
|
||
<div className={activeTab === 'media' ? 'block space-y-6' : 'hidden'}>
|
||
{/* Main Product Image Section */}
|
||
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm space-y-4">
|
||
<div className="flex items-center justify-between border-b pb-2">
|
||
<span className="text-sm font-bold text-gray-800 flex items-center gap-2">
|
||
<ImageIcon className="w-4 h-4 text-purple-600" />
|
||
تصویر اصلی و کاور محصول (Main Cover Image)
|
||
</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setMediaTargetField('imageUrl');
|
||
setIsMediaSelectorOpen(true);
|
||
}}
|
||
className="bg-purple-600 hover:bg-purple-700 text-white px-4 py-1.5 rounded-xl text-xs font-bold transition-all cursor-pointer flex items-center gap-1.5"
|
||
>
|
||
<ImageIcon className="w-3.5 h-3.5" />
|
||
انتخاب تصویر اصلی
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex flex-col sm:flex-row gap-4 items-center">
|
||
<input
|
||
type="text"
|
||
value={formData.imageUrl}
|
||
onChange={(e) => {
|
||
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 && (
|
||
<div className="relative group w-16 h-16 shrink-0 rounded-xl border border-gray-200 overflow-hidden bg-gray-50 flex items-center justify-center">
|
||
{mediaImageError ? (
|
||
<ImageIcon className="w-6 h-6 text-gray-300" />
|
||
) : (
|
||
<img
|
||
src={getProductImageUrl(formData.imageUrl)}
|
||
alt="Cover"
|
||
className="w-full h-full object-contain"
|
||
onError={() => setMediaImageError(true)}
|
||
/>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Product Gallery Images (Multiple Angles & Views) */}
|
||
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm space-y-4">
|
||
<div className="flex items-center justify-between border-b pb-2">
|
||
<div>
|
||
<h4 className="font-bold text-gray-800 text-sm flex items-center gap-2">
|
||
<ImageIcon className="w-4 h-4 text-blue-600" />
|
||
گالری تصاویر جانبی محصول (Gallery Images)
|
||
</h4>
|
||
<p className="text-[11px] text-gray-400 font-medium mt-0.5">تصاویر زوایای مختلف، پشت بستهبندی، جدول ارزش غذایی و مشخصات فنی</p>
|
||
</div>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setMediaTargetField('gallery');
|
||
setIsMediaSelectorOpen(true);
|
||
}}
|
||
className="bg-blue-600 hover:bg-blue-700 text-white px-4 py-1.5 rounded-xl text-xs font-bold transition-all cursor-pointer flex items-center gap-1.5"
|
||
>
|
||
<Plus className="w-3.5 h-3.5" />
|
||
افزودن عکس به گالری
|
||
</button>
|
||
</div>
|
||
|
||
{formData.images && formData.images.length > 0 ? (
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 md:grid-cols-6 gap-3">
|
||
{formData.images.map((imgUrl, idx) => (
|
||
<div key={idx} className="relative group rounded-xl border border-gray-200 overflow-hidden bg-gray-50 aspect-square flex items-center justify-center p-2 shadow-xs">
|
||
<img
|
||
src={getProductImageUrl(imgUrl)}
|
||
alt={`Gallery ${idx + 1}`}
|
||
className="max-h-full max-w-full object-contain"
|
||
/>
|
||
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center gap-1.5 p-1">
|
||
<button
|
||
type="button"
|
||
title="حذف از گالری"
|
||
onClick={() => {
|
||
const updated = formData.images.filter((_, i) => i !== idx);
|
||
setFormData({ ...formData, images: updated });
|
||
}}
|
||
className="w-7 h-7 rounded-lg bg-red-600 hover:bg-red-700 text-white flex items-center justify-center transition-colors"
|
||
>
|
||
<X className="w-3.5 h-3.5" />
|
||
</button>
|
||
</div>
|
||
<span className="absolute bottom-1 right-1 bg-black/60 text-white text-[9px] px-1.5 py-0.5 rounded font-mono">
|
||
#{idx + 1}
|
||
</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="text-center py-6 text-gray-400 bg-gray-50/50 rounded-xl border border-dashed border-gray-200">
|
||
<ImageIcon className="w-8 h-8 mx-auto mb-2 opacity-30 text-gray-400" />
|
||
<p className="text-xs font-bold text-gray-500">هنوز عکس جانبی به گالری محصول اضافه نشده است.</p>
|
||
<p className="text-[10px] text-gray-400 mt-1">با زدن دکمه «افزودن عکس به گالری» میتوانید چند عکس اضافه نمایید.</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Multimedia Attachments */}
|
||
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm space-y-6">
|
||
<div>
|
||
<h4 className="font-bold text-gray-800 text-sm border-b pb-2">پیوستهای مالتیمدیا و مستندات علمی تخصصی</h4>
|
||
<p className="text-[11px] text-gray-500 mt-1">مدیریت فایلهای صوتی، تصویری و کاتالوگهای اختصاصی این محصول همراه با عنوان، توضیحات و کاور اختصاصی.</p>
|
||
</div>
|
||
|
||
{/* Section 1: Podcast Management Card */}
|
||
<div className="bg-purple-50/40 border border-purple-100 rounded-2xl p-4 space-y-3">
|
||
<div className="flex items-center justify-between border-b border-purple-100/80 pb-2">
|
||
<span className="text-xs font-black text-purple-900 flex items-center gap-1.5">
|
||
🎧 تنظیمات پادکست و بررسی صوتی بالینی
|
||
</span>
|
||
{formData.podcastUrl && (
|
||
<span className="text-[10px] bg-purple-200 text-purple-800 font-bold px-2 py-0.5 rounded-full">
|
||
فایل صوتی متصل است
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
<div className="space-y-1">
|
||
<label className="text-xs font-bold text-gray-700 block">لینک یا فایل پادکست (Audio/MP3 URL)</label>
|
||
<div className="flex gap-1.5">
|
||
<input
|
||
type="text"
|
||
value={formData.podcastUrl}
|
||
onChange={(e) => 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"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setMediaTargetField('podcastUrl');
|
||
setIsMediaSelectorOpen(true);
|
||
}}
|
||
className="px-2.5 py-1 bg-purple-600 text-white hover:bg-purple-700 rounded-lg text-xs font-bold whitespace-nowrap cursor-pointer"
|
||
>
|
||
انتخاب فایل
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="text-xs font-bold text-gray-700 block">تصویر کاور اختصاصی پادکست (اختیاری)</label>
|
||
<div className="flex gap-1.5">
|
||
<input
|
||
type="text"
|
||
value={formData.podcastCover}
|
||
onChange={(e) => 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"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setMediaTargetField('podcastCover');
|
||
setIsMediaSelectorOpen(true);
|
||
}}
|
||
className="px-2.5 py-1 bg-purple-100 text-purple-700 hover:bg-purple-200 rounded-lg text-xs font-bold whitespace-nowrap cursor-pointer"
|
||
>
|
||
انتخاب کاور
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="text-xs font-bold text-gray-700 block">عنوان پادکست</label>
|
||
<input
|
||
type="text"
|
||
value={formData.podcastTitle}
|
||
onChange={(e) => setFormData({ ...formData, podcastTitle: e.target.value })}
|
||
placeholder="مثلاً: بررسی بالینی اثر مکمل بر سلامت مفاصل سگها"
|
||
className="w-full px-3 py-2 border rounded-xl text-xs bg-white font-medium"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="text-xs font-bold text-gray-700 block">توضیحات کوتاه پادکست</label>
|
||
<textarea
|
||
rows={2}
|
||
value={formData.podcastDescription}
|
||
onChange={(e) => setFormData({ ...formData, podcastDescription: e.target.value })}
|
||
placeholder="توضیحات و نکات مطرح شده در پادکست تخصصی..."
|
||
className="w-full px-3 py-2 border rounded-xl text-xs bg-white font-medium resize-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Section 2: Video Management Card */}
|
||
<div className="bg-blue-50/40 border border-blue-100 rounded-2xl p-4 space-y-3">
|
||
<div className="flex items-center justify-between border-b border-blue-100/80 pb-2">
|
||
<span className="text-xs font-black text-blue-900 flex items-center gap-1.5">
|
||
🎬 تنظیمات ویدیو و راهنمای مصرف
|
||
</span>
|
||
{formData.videoUrl && (
|
||
<span className="text-[10px] bg-blue-200 text-blue-800 font-bold px-2 py-0.5 rounded-full">
|
||
ویدیو متصل است
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
<div className="space-y-1">
|
||
<label className="text-xs font-bold text-gray-700 block">لینک مستقیم یا کدفریم ویدیو (Video URL / Embed)</label>
|
||
<div className="flex gap-1.5">
|
||
<input
|
||
type="text"
|
||
value={formData.videoUrl}
|
||
onChange={(e) => setFormData({ ...formData, videoUrl: e.target.value })}
|
||
placeholder="https://... MP4 or Aparat iframe"
|
||
className="w-full px-3 py-2 border rounded-xl text-xs font-mono bg-white"
|
||
dir="ltr"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setMediaTargetField('videoUrl');
|
||
setIsMediaSelectorOpen(true);
|
||
}}
|
||
className="px-2.5 py-1 bg-blue-600 text-white hover:bg-blue-700 rounded-lg text-xs font-bold whitespace-nowrap cursor-pointer"
|
||
>
|
||
انتخاب فایل
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="text-xs font-bold text-gray-700 block">تصویر پوستر / کاور ویدیو (اختیاری)</label>
|
||
<div className="flex gap-1.5">
|
||
<input
|
||
type="text"
|
||
value={formData.videoCover}
|
||
onChange={(e) => setFormData({ ...formData, videoCover: e.target.value })}
|
||
placeholder="در صورت خالی بودن، تصویر محصول استفاده میشود"
|
||
className="w-full px-3 py-2 border rounded-xl text-xs font-mono bg-white"
|
||
dir="ltr"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setMediaTargetField('videoCover');
|
||
setIsMediaSelectorOpen(true);
|
||
}}
|
||
className="px-2.5 py-1 bg-blue-100 text-blue-700 hover:bg-blue-200 rounded-lg text-xs font-bold whitespace-nowrap cursor-pointer"
|
||
>
|
||
انتخاب کاور
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="text-xs font-bold text-gray-700 block">عنوان ویدیو</label>
|
||
<input
|
||
type="text"
|
||
value={formData.videoTitle}
|
||
onChange={(e) => setFormData({ ...formData, videoTitle: e.target.value })}
|
||
placeholder="مثلاً: راهنمای نحوه خوراندن و دوز مصرف مکمل"
|
||
className="w-full px-3 py-2 border rounded-xl text-xs bg-white font-medium"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="text-xs font-bold text-gray-700 block">توضیحات کوتاه ویدیو</label>
|
||
<textarea
|
||
rows={2}
|
||
value={formData.videoDescription}
|
||
onChange={(e) => setFormData({ ...formData, videoDescription: e.target.value })}
|
||
placeholder="توضیحات ویدیوی آموزشی و نکات کلیدی..."
|
||
className="w-full px-3 py-2 border rounded-xl text-xs bg-white font-medium resize-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Section 3: PDF / Document Management Card */}
|
||
<div className="bg-amber-50/40 border border-amber-100 rounded-2xl p-4 space-y-3">
|
||
<div className="flex items-center justify-between border-b border-amber-100/80 pb-2">
|
||
<span className="text-xs font-black text-amber-900 flex items-center gap-1.5">
|
||
📄 تنظیمات کاتالوگ و مستندات PDF
|
||
</span>
|
||
{formData.pdfUrl && (
|
||
<span className="text-[10px] bg-amber-200 text-amber-800 font-bold px-2 py-0.5 rounded-full">
|
||
فایل PDF متصل است
|
||
</span>
|
||
)}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||
<div className="space-y-1">
|
||
<label className="text-xs font-bold text-gray-700 block">لینک یا فایل کاتالوگ (PDF URL)</label>
|
||
<div className="flex gap-1.5">
|
||
<input
|
||
type="text"
|
||
value={formData.pdfUrl}
|
||
onChange={(e) => setFormData({ ...formData, pdfUrl: e.target.value })}
|
||
placeholder="https://... /uploads/catalog.pdf"
|
||
className="w-full px-3 py-2 border rounded-xl text-xs font-mono bg-white"
|
||
dir="ltr"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setMediaTargetField('pdfUrl');
|
||
setIsMediaSelectorOpen(true);
|
||
}}
|
||
className="px-2.5 py-1 bg-amber-600 text-white hover:bg-amber-700 rounded-lg text-xs font-bold whitespace-nowrap cursor-pointer"
|
||
>
|
||
انتخاب فایل
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="text-xs font-bold text-gray-700 block">تصویر پیشنمایش کاتالوگ (اختیاری)</label>
|
||
<div className="flex gap-1.5">
|
||
<input
|
||
type="text"
|
||
value={formData.pdfCover}
|
||
onChange={(e) => setFormData({ ...formData, pdfCover: e.target.value })}
|
||
placeholder="در صورت خالی بودن، تصویر محصول استفاده میشود"
|
||
className="w-full px-3 py-2 border rounded-xl text-xs font-mono bg-white"
|
||
dir="ltr"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setMediaTargetField('pdfCover');
|
||
setIsMediaSelectorOpen(true);
|
||
}}
|
||
className="px-2.5 py-1 bg-amber-100 text-amber-800 hover:bg-amber-200 rounded-lg text-xs font-bold whitespace-nowrap cursor-pointer"
|
||
>
|
||
انتخاب کاور
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="text-xs font-bold text-gray-700 block">عنوان فایل PDF</label>
|
||
<input
|
||
type="text"
|
||
value={formData.pdfTitle}
|
||
onChange={(e) => setFormData({ ...formData, pdfTitle: e.target.value })}
|
||
placeholder="مثلاً: کاتالوگ جامع مشخصات فنی و مطالعات بالینی"
|
||
className="w-full px-3 py-2 border rounded-xl text-xs bg-white font-medium"
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<label className="text-xs font-bold text-gray-700 block">توضیحات فایل PDF</label>
|
||
<textarea
|
||
rows={2}
|
||
value={formData.pdfDescription}
|
||
onChange={(e) => setFormData({ ...formData, pdfDescription: e.target.value })}
|
||
placeholder="توضیحات و محتویات کاتالوگ و بروشور رسمی..."
|
||
className="w-full px-3 py-2 border rounded-xl text-xs bg-white font-medium resize-none"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</form>
|
||
</div>
|
||
|
||
<div className="p-4 border-t border-gray-100 bg-gray-50 flex justify-end gap-2.5 rounded-b-2xl">
|
||
<Button
|
||
variant="secondary"
|
||
size="sm"
|
||
type="button"
|
||
onClick={() => setIsModalOpen(false)}
|
||
>
|
||
انصراف
|
||
</Button>
|
||
<Button
|
||
variant="primary"
|
||
size="sm"
|
||
type="submit"
|
||
form="productForm"
|
||
>
|
||
ذخیره محصول
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
|
||
{/* Media Selector Modal */}
|
||
<MediaSelector
|
||
isOpen={isMediaSelectorOpen}
|
||
onClose={() => setIsMediaSelectorOpen(false)}
|
||
onSelect={(url) => {
|
||
if ((mediaTargetField as string) === 'gallery') {
|
||
if (!formData.images.includes(url)) {
|
||
setFormData({ ...formData, images: [...formData.images, url] });
|
||
}
|
||
} else {
|
||
setFormData({ ...formData, [mediaTargetField]: url });
|
||
if (mediaTargetField === 'imageUrl') setMediaImageError(false);
|
||
}
|
||
}}
|
||
selectedUrl={
|
||
(mediaTargetField as string) === 'gallery'
|
||
? undefined
|
||
: formData[mediaTargetField]?.startsWith('http')
|
||
? formData[mediaTargetField]
|
||
: formData[mediaTargetField]
|
||
? `${BASE_DOMAIN}${formData[mediaTargetField]}`
|
||
: undefined
|
||
}
|
||
/>
|
||
|
||
<ConfirmModal
|
||
isOpen={!!deleteTargetId}
|
||
title="حذف محصول"
|
||
message="آیا از حذف این محصول مطمئن هستید؟ این عملیات قابل بازگشت نیست."
|
||
onConfirm={confirmDelete}
|
||
onCancel={() => setDeleteTargetId(null)}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|