fix(admin-panel): fix blog bugs and add ingredients editor, draft autosave, and category seeding
This commit is contained in:
parent
ebc61d5be2
commit
913e6acd3d
@ -445,7 +445,7 @@ export class AdminService {
|
||||
skip,
|
||||
take: limit,
|
||||
orderBy: { [sortField]: sortDirection },
|
||||
include: { category: true, symptoms: true },
|
||||
include: { category: true, symptoms: true, ingredientList: true },
|
||||
}),
|
||||
this.prisma.product.count({ where }),
|
||||
]);
|
||||
@ -516,6 +516,7 @@ export class AdminService {
|
||||
scientificTagline: data.scientificTagline || '',
|
||||
description: data.description || '',
|
||||
shortDescription: data.shortDescription || '',
|
||||
ingredients: data.ingredients || null,
|
||||
categoryId: data.categoryId || '',
|
||||
categorySlug: data.categorySlug || 'general',
|
||||
buyPrice: data.buyPrice !== undefined ? data.buyPrice : 0,
|
||||
@ -587,6 +588,18 @@ export class AdminService {
|
||||
});
|
||||
}
|
||||
|
||||
if (data.ingredientList && Array.isArray(data.ingredientList)) {
|
||||
const uniqueIngs = Array.from(new Set(data.ingredientList.map((i: string) => i.trim()).filter(Boolean)));
|
||||
if (uniqueIngs.length > 0) {
|
||||
await this.prisma.productIngredient.createMany({
|
||||
data: uniqueIngs.map((ingredient: string) => ({
|
||||
productId: product.id,
|
||||
ingredient,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.revalidationService.revalidateProduct(
|
||||
product.slug,
|
||||
product.artNo,
|
||||
@ -658,6 +671,7 @@ export class AdminService {
|
||||
scientificTagline: data.scientificTagline,
|
||||
description: data.description,
|
||||
shortDescription: data.shortDescription,
|
||||
ingredients: data.ingredients !== undefined ? data.ingredients : undefined,
|
||||
categoryId: data.categoryId,
|
||||
categorySlug: data.categorySlug,
|
||||
buyPrice: data.buyPrice !== undefined ? data.buyPrice : undefined,
|
||||
@ -742,11 +756,27 @@ export class AdminService {
|
||||
}
|
||||
}
|
||||
|
||||
if (data.ingredientList !== undefined && Array.isArray(data.ingredientList)) {
|
||||
await this.prisma.productIngredient.deleteMany({ where: { productId: id } });
|
||||
if (data.ingredientList.length > 0) {
|
||||
const uniqueIngs = Array.from(new Set(data.ingredientList.map((i: string) => i.trim()).filter(Boolean)));
|
||||
await this.prisma.productIngredient.createMany({
|
||||
data: uniqueIngs.map((ingredient: string) => ({
|
||||
productId: id,
|
||||
ingredient,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.revalidationService.revalidateProduct(
|
||||
product.slug,
|
||||
product.artNo,
|
||||
);
|
||||
return product;
|
||||
return this.prisma.product.findUnique({
|
||||
where: { id },
|
||||
include: { category: true, symptoms: true, ingredientList: true },
|
||||
});
|
||||
}
|
||||
|
||||
async deleteProduct(id: string) {
|
||||
|
||||
@ -1,16 +1,42 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException, OnModuleInit } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PaginationDto } from '../common/dto/pagination.dto';
|
||||
import { RevalidationService } from '../common/revalidation/revalidation.service';
|
||||
|
||||
@Injectable()
|
||||
export class BlogsService {
|
||||
export class BlogsService implements OnModuleInit {
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private revalidationService: RevalidationService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.seedDefaultCategories();
|
||||
}
|
||||
|
||||
private async seedDefaultCategories() {
|
||||
try {
|
||||
const defaultCategories = [
|
||||
{ name: 'تغذیه و رژیم غذایی', slug: 'nutrition', order: 1 },
|
||||
{ name: 'سلامت گوارش و متابولیسم', slug: 'digestion', order: 2 },
|
||||
{ name: 'مفاصل، استخوان و اسکلت', slug: 'joints-bones', order: 3 },
|
||||
{ name: 'پوست، مو و آلرژی', slug: 'skin-coat', order: 4 },
|
||||
{ name: 'ایمنی، پیشگیری و مراقبت بالینی', slug: 'health-prevention', order: 5 },
|
||||
];
|
||||
|
||||
for (const cat of defaultCategories) {
|
||||
await this.prisma.blogCategory.upsert({
|
||||
where: { slug: cat.slug },
|
||||
update: { name: cat.name, order: cat.order },
|
||||
create: { name: cat.name, slug: cat.slug, order: cat.order },
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('[BlogsService] Failed to seed default blog categories:', err);
|
||||
}
|
||||
}
|
||||
|
||||
async getBlogs(
|
||||
query: PaginationDto & {
|
||||
status?: string;
|
||||
|
||||
@ -38,6 +38,16 @@ export class ProductDto {
|
||||
@IsString()
|
||||
shortDescription?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'متن ترکیبات و مواد تشکیلدهنده محصول' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
ingredients?: string;
|
||||
|
||||
@ApiPropertyOptional({ description: 'لیست ترکیبات کلیدی و مواد مؤثره محصول' })
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
ingredientList?: string[];
|
||||
|
||||
@ApiPropertyOptional({ description: 'شناسه دستهبندی' })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
|
||||
@ -12,7 +12,18 @@ export class MediaService {
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
return items.map((item) => {
|
||||
const seenUrls = new Set<string>();
|
||||
const deduplicated = [];
|
||||
|
||||
for (const item of items) {
|
||||
const normalizedUrl = (item.url || '').trim().toLowerCase();
|
||||
if (normalizedUrl && seenUrls.has(normalizedUrl)) {
|
||||
continue;
|
||||
}
|
||||
if (normalizedUrl) {
|
||||
seenUrls.add(normalizedUrl);
|
||||
}
|
||||
|
||||
let cleanFilename = item.filename;
|
||||
// Check if filename contains common mojibake patterns from latin1 decoding
|
||||
if (cleanFilename && /[ØÙÚÛ]/.test(cleanFilename)) {
|
||||
@ -25,11 +36,14 @@ export class MediaService {
|
||||
// Keep original if decoding fails
|
||||
}
|
||||
}
|
||||
return {
|
||||
|
||||
deduplicated.push({
|
||||
...item,
|
||||
filename: cleanFilename,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return deduplicated;
|
||||
}
|
||||
|
||||
async uploadFile(file: Express.Multer.File) {
|
||||
|
||||
@ -19,7 +19,7 @@ import { RedisModule } from '../redis/redis.module';
|
||||
JwtModule.registerAsync({
|
||||
useFactory: () => ({
|
||||
secret: getJwtSecret(),
|
||||
signOptions: { expiresIn: '15m' },
|
||||
signOptions: { expiresIn: '2h' },
|
||||
}),
|
||||
}),
|
||||
],
|
||||
|
||||
@ -6,9 +6,10 @@ export class RevalidationService {
|
||||
|
||||
private get frontendUrl(): string {
|
||||
const raw =
|
||||
process.env.FRONTEND_INTERNAL_URL ||
|
||||
process.env.FRONTEND_URL ||
|
||||
process.env.NEXT_PUBLIC_SITE_URL ||
|
||||
'http://localhost:3000';
|
||||
(process.env.NODE_ENV === 'production' ? 'http://frontend_prod:8080' : 'http://localhost:3000');
|
||||
return raw.replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
|
||||
@ -47,6 +47,13 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||
const [filterType, setFilterType] = useState<'all' | 'image' | 'video' | 'audio' | 'pdf'>('all');
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const [pendingSelectedUrl, setPendingSelectedUrl] = useState<string | null>(selectedUrl || null);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedUrl) {
|
||||
setPendingSelectedUrl(selectedUrl);
|
||||
}
|
||||
}, [selectedUrl]);
|
||||
|
||||
const fetchMedia = useCallback(async () => {
|
||||
try {
|
||||
@ -233,10 +240,20 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const filteredMedia = mediaList.filter(m => {
|
||||
if (filterType === 'all') return true;
|
||||
return getFileType(m.filename, m.mimetype) === filterType;
|
||||
});
|
||||
const filteredMedia = (() => {
|
||||
const list = mediaList.filter(m => {
|
||||
if (filterType === 'all') return true;
|
||||
return getFileType(m.filename, m.mimetype) === filterType;
|
||||
});
|
||||
const seen = new Set<string>();
|
||||
return list.filter(m => {
|
||||
const key = (m.url || '').trim().toLowerCase();
|
||||
if (!key) return true;
|
||||
if (seen.has(key)) return false;
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
@ -362,18 +379,23 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 gap-4">
|
||||
{paginatedList.map((media) => {
|
||||
const fileUrl = media.url.startsWith('http') ? media.url : `${BASE_DOMAIN}${media.url}`;
|
||||
const isSelected = selectedUrl && (fileUrl === selectedUrl || media.url === selectedUrl);
|
||||
const isSelected =
|
||||
(pendingSelectedUrl && (fileUrl === pendingSelectedUrl || media.url === pendingSelectedUrl)) ||
|
||||
(selectedUrl && (fileUrl === selectedUrl || media.url === selectedUrl));
|
||||
const fileType = getFileType(media.filename, media.mimetype);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={media.id}
|
||||
onClick={() => {
|
||||
setPendingSelectedUrl(fileUrl);
|
||||
}}
|
||||
onDoubleClick={() => {
|
||||
onSelect(fileUrl);
|
||||
if (!multiple) onClose();
|
||||
}}
|
||||
className={`group relative bg-white rounded-2xl overflow-hidden border-2 cursor-pointer transition-all hover:shadow-lg hover:shadow-purple-100 ${
|
||||
isSelected ? 'border-purple-600 ring-2 ring-purple-300' : 'border-gray-200 hover:border-purple-400'
|
||||
isSelected ? 'border-purple-600 ring-2 ring-purple-300 shadow-md shadow-purple-100' : 'border-gray-200 hover:border-purple-400'
|
||||
}`}
|
||||
>
|
||||
{/* Top Action Icons */}
|
||||
@ -471,6 +493,43 @@ export default function MediaSelector({ isOpen, onClose, onSelect, multiple = fa
|
||||
})()}
|
||||
</div>
|
||||
|
||||
{/* Modal Action Footer */}
|
||||
<div className="px-6 py-3.5 border-t border-gray-200 bg-gray-50 flex items-center justify-between shrink-0">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 font-medium">
|
||||
{pendingSelectedUrl ? (
|
||||
<span className="flex items-center gap-1.5 text-purple-700 font-bold">
|
||||
<CheckCircle2 className="w-4 h-4 text-purple-600" />
|
||||
<span>۱ فایل انتخاب شد (آماده تایید)</span>
|
||||
</span>
|
||||
) : (
|
||||
<span>روی هر کارت کلیک کنید تا انتخاب شود یا با دابلکلیک بلافاصله ثبت کنید.</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-xl text-xs font-bold bg-white hover:bg-gray-100 text-gray-700 border border-gray-300 transition cursor-pointer"
|
||||
>
|
||||
انصراف
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!pendingSelectedUrl}
|
||||
onClick={() => {
|
||||
if (pendingSelectedUrl) {
|
||||
onSelect(pendingSelectedUrl);
|
||||
if (!multiple) onClose();
|
||||
}
|
||||
}}
|
||||
className="px-5 py-2 rounded-xl text-xs font-bold bg-purple-600 hover:bg-purple-700 disabled:opacity-50 disabled:cursor-not-allowed text-white transition shadow-sm cursor-pointer flex items-center gap-1.5"
|
||||
>
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
<span>انتخاب و ثبت فایل</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<ConfirmModal
|
||||
|
||||
@ -43,9 +43,10 @@ export default function Pagination({ currentPage, totalPages, onPageChange }: Pa
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-2.5 py-1 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-xs font-medium transition whitespace-nowrap"
|
||||
className="px-2.5 py-1 bg-purple-600 hover:bg-purple-700 text-white rounded-lg text-xs font-bold transition whitespace-nowrap cursor-pointer flex items-center gap-1"
|
||||
>
|
||||
تایید
|
||||
<span>برو</span>
|
||||
<span className="text-[10px]">↵</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
|
||||
@ -150,6 +150,9 @@ export default function Blogs() {
|
||||
// Auto-slug manual override flag
|
||||
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
|
||||
|
||||
// Unsaved Local Draft State
|
||||
const [hasSavedDraft, setHasSavedDraft] = useState(false);
|
||||
|
||||
const updateUrlParams = (paramsObj: Record<string, string | number | undefined | null>) => {
|
||||
const current = Object.fromEntries(searchParams.entries());
|
||||
const merged = { ...current, ...paramsObj };
|
||||
@ -308,6 +311,63 @@ export default function Blogs() {
|
||||
}
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
const DRAFT_STORAGE_KEY = 'canina_admin_blog_draft_v1';
|
||||
|
||||
// Check for saved draft on mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(DRAFT_STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (parsed && (parsed.title || parsed.content)) {
|
||||
setHasSavedDraft(true);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-save unsaved blog form content
|
||||
useEffect(() => {
|
||||
if (!isModalOpen) return;
|
||||
if (editingBlog) return; // Don't auto-save over existing published blogs
|
||||
if (!formData.title && !formData.content) return;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
try {
|
||||
localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(formData));
|
||||
setHasSavedDraft(true);
|
||||
} catch {
|
||||
// ignore quota
|
||||
}
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
}, [formData, isModalOpen, editingBlog]);
|
||||
|
||||
const restoreDraft = () => {
|
||||
try {
|
||||
const saved = localStorage.getItem(DRAFT_STORAGE_KEY);
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
setFormData((prev) => ({ ...prev, ...parsed }));
|
||||
toast.success('پیشنویس ذخیرهشده با موفقیت بازیابی شد');
|
||||
setHasSavedDraft(false);
|
||||
}
|
||||
} catch {
|
||||
toast.error('خطا در بازیابی پیشنویس');
|
||||
}
|
||||
};
|
||||
|
||||
const discardDraft = () => {
|
||||
try {
|
||||
localStorage.removeItem(DRAFT_STORAGE_KEY);
|
||||
setHasSavedDraft(false);
|
||||
toast.success('پیشنویس محلی پاک شد');
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const closeModal = () => {
|
||||
setIsModalOpen(false);
|
||||
@ -407,6 +467,11 @@ export default function Blogs() {
|
||||
toast.error('اسلاگ (Slug) مقاله الزامی است');
|
||||
return;
|
||||
}
|
||||
if (formData.imageUrl && !formData.imageAlt?.trim()) {
|
||||
setActiveFormTab('media');
|
||||
toast.error('لطفاً متن جایگزین تصویر (Alt) را در تب تصویر وارد کنید (برای سئو الزامی است)');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
@ -420,6 +485,12 @@ export default function Blogs() {
|
||||
} else {
|
||||
await api.post('/admin/blogs', payload);
|
||||
toast.success('مقاله جدید با موفقیت ذخیره شد');
|
||||
try {
|
||||
localStorage.removeItem(DRAFT_STORAGE_KEY);
|
||||
setHasSavedDraft(false);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
closeModal();
|
||||
fetchBlogs();
|
||||
@ -759,6 +830,32 @@ export default function Blogs() {
|
||||
}
|
||||
>
|
||||
<div className="space-y-6 text-right font-vazir">
|
||||
{/* Draft Recovery Banner */}
|
||||
{hasSavedDraft && !editingBlog && (
|
||||
<div className="p-3.5 bg-amber-50 border border-amber-200 rounded-2xl flex items-center justify-between gap-3 text-xs text-amber-900 animate-in fade-in duration-200">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
|
||||
<span className="font-bold">یک پیشنویس ذخیرهنشده از آخرین ویرایش شما در مرورگر موجود است.</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={restoreDraft}
|
||||
className="px-3 py-1 bg-amber-600 hover:bg-amber-700 text-white rounded-xl font-black text-xs transition cursor-pointer"
|
||||
>
|
||||
بازیابی پیشنویس
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={discardDraft}
|
||||
className="px-3 py-1 bg-white hover:bg-amber-100 text-amber-800 border border-amber-300 rounded-xl font-bold text-xs transition cursor-pointer"
|
||||
>
|
||||
نادیده گرفتن
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Tabs */}
|
||||
<div className="flex items-center gap-2 border-b border-gray-100 pb-2 overflow-x-auto">
|
||||
{[
|
||||
@ -1669,7 +1766,19 @@ export default function Blogs() {
|
||||
onClose={() => setIsMediaSelectorOpen(false)}
|
||||
onSelect={(url) => {
|
||||
if (mediaSelectTarget === 'featured') {
|
||||
setFormData({ ...formData, imageUrl: url });
|
||||
let derivedAlt = formData.imageAlt;
|
||||
if (!derivedAlt || derivedAlt.trim() === '') {
|
||||
try {
|
||||
const filename = url.split('/').pop()?.split('?')[0] || '';
|
||||
const baseName = filename.replace(/\.[^/.]+$/, '');
|
||||
// Clean dashes, underscores and clean decoded name
|
||||
const cleanName = decodeURIComponent(baseName).replace(/[-_]+/g, ' ').trim();
|
||||
derivedAlt = cleanName || formData.title || 'تصویر مقاله';
|
||||
} catch {
|
||||
derivedAlt = formData.title || 'تصویر مقاله';
|
||||
}
|
||||
}
|
||||
setFormData({ ...formData, imageUrl: url, imageAlt: derivedAlt });
|
||||
} else {
|
||||
setFormData({ ...formData, ogImage: url });
|
||||
}
|
||||
|
||||
@ -114,6 +114,8 @@ export interface Product {
|
||||
pdfDescription?: string;
|
||||
pdfCover?: string;
|
||||
symptoms?: Array<{ symptom: string } | string>;
|
||||
ingredients?: string;
|
||||
ingredientList?: Array<{ ingredient: string } | string>;
|
||||
isPreorder?: boolean;
|
||||
preorderDeposit?: string | number;
|
||||
totalSold?: number;
|
||||
@ -292,6 +294,8 @@ export default function Products() {
|
||||
onSetOfAction: '',
|
||||
specialBadge: '',
|
||||
contraindications: '',
|
||||
ingredients: '',
|
||||
ingredientList: [] as string[],
|
||||
symptoms: [] as string[],
|
||||
isPreorder: false,
|
||||
preorderDeposit: '' as number | string,
|
||||
@ -552,6 +556,8 @@ export default function Products() {
|
||||
pdfDescription: product.pdfDescription || '',
|
||||
pdfCover: product.pdfCover || '',
|
||||
symptoms: product.symptoms ? product.symptoms.map((s: { symptom: string } | string) => typeof s === 'string' ? s : s.symptom) : [],
|
||||
ingredients: product.ingredients || '',
|
||||
ingredientList: product.ingredientList ? product.ingredientList.map((i: { ingredient: string } | string) => typeof i === 'string' ? i : i.ingredient) : [],
|
||||
isPreorder: Boolean(product.isPreorder),
|
||||
preorderDeposit: product.preorderDeposit !== undefined && product.preorderDeposit !== null ? product.preorderDeposit : '',
|
||||
roundingStep: product.roundingStep !== undefined && product.roundingStep !== null ? product.roundingStep : '',
|
||||
@ -582,6 +588,8 @@ export default function Products() {
|
||||
onSetOfAction: '',
|
||||
specialBadge: '',
|
||||
contraindications: '',
|
||||
ingredients: '',
|
||||
ingredientList: [],
|
||||
suitableFor: 'سگ و گربه',
|
||||
imageUrl: '', metaTitle: '', metaDescription: '', keywords: '', canonicalUrl: '', slug: '',
|
||||
stockStatus: 'IN_STOCK', noIndex: false, noFollow: false, ogImage: '', featuredImageAlt: '',
|
||||
@ -930,6 +938,8 @@ export default function Products() {
|
||||
specialBadge: (formData.specialBadge || '').trim() || undefined,
|
||||
contraindications: (formData.contraindications || '').trim() || undefined,
|
||||
slug: derivedSlug,
|
||||
ingredients: (formData.ingredients || '').trim() || undefined,
|
||||
ingredientList: formData.ingredientList || [],
|
||||
symptoms: formData.symptoms || [],
|
||||
isPreorder: Boolean(formData.isPreorder),
|
||||
preorderDeposit: formData.preorderDeposit ? String(formData.preorderDeposit) : undefined,
|
||||
@ -2201,6 +2211,97 @@ export default function Products() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ingredients & Active Substances Editor */}
|
||||
<div className="bg-white p-5 rounded-2xl border border-gray-200 shadow-sm space-y-4">
|
||||
<div>
|
||||
<h4 className="text-sm font-black text-gray-800 flex items-center gap-2">
|
||||
<FlaskConical className="w-4 h-4 text-purple-600" />
|
||||
<span>ترکیبات و مواد تشکیلدهنده (Ingredients & Active Substances)</span>
|
||||
</h4>
|
||||
<p className="text-xs text-gray-500 mt-0.5">
|
||||
متن کامل فرمولاسیون دارویی، درصد مواد اولیه کلیدی (صدف سبز، پیت، تاورین، ویتامینها) و تگهای قابل جستجو.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-bold text-gray-700 block">
|
||||
متن و فرمولاسیون کامل ترکیبات (Ingredients Text):
|
||||
</label>
|
||||
<textarea
|
||||
rows={3}
|
||||
value={formData.ingredients}
|
||||
onChange={(e) => setFormData({ ...formData, ingredients: e.target.value })}
|
||||
placeholder="مثال: عصاره صدف لبسبز (Perna canaliculus) ۱۵٪، جلبک دریایی، گلوکوزامین..."
|
||||
className="w-full px-4 py-2.5 rounded-xl border border-gray-200 focus:border-purple-500 outline-none text-xs font-bold font-vazir leading-relaxed"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2 pt-2 border-t border-gray-100">
|
||||
<label className="text-xs font-bold text-gray-700 flex items-center justify-between">
|
||||
<span>مواد مؤثره کلیدی (تگهای دانشنامه ترکیبات):</span>
|
||||
<span className="text-[10px] text-purple-600 font-bold">برای لینک به دانشنامه و فیلترها</span>
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
id="newIngredientInput"
|
||||
placeholder="نام ماده موثره را بنویسید و افزودن را بزنید..."
|
||||
className="flex-1 px-3 py-2 rounded-xl border border-gray-200 text-xs outline-none bg-white font-vazir"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
const input = e.currentTarget;
|
||||
const val = input.value.trim();
|
||||
if (val && !formData.ingredientList.includes(val)) {
|
||||
setFormData({ ...formData, ingredientList: [...formData.ingredientList, val] });
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const input = document.getElementById('newIngredientInput') as HTMLInputElement;
|
||||
if (input && input.value.trim()) {
|
||||
const val = input.value.trim();
|
||||
if (!formData.ingredientList.includes(val)) {
|
||||
setFormData({ ...formData, ingredientList: [...formData.ingredientList, val] });
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-xl text-xs font-bold hover:bg-purple-700 transition cursor-pointer"
|
||||
>
|
||||
افزودن
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{formData.ingredientList && formData.ingredientList.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5 pt-2">
|
||||
{formData.ingredientList.map((ing, idx) => (
|
||||
<span
|
||||
key={idx}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 bg-purple-50 text-purple-800 text-xs font-bold rounded-xl border border-purple-200"
|
||||
>
|
||||
<span>{ing}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const updated = formData.ingredientList.filter((_, i) => i !== idx);
|
||||
setFormData({ ...formData, ingredientList: updated });
|
||||
}}
|
||||
className="text-purple-400 hover:text-red-600 transition"
|
||||
>
|
||||
<X className="w-3 h-3" />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* FAQ Tab */}
|
||||
|
||||
@ -64,7 +64,11 @@ async function handleRevalidate(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (path) {
|
||||
revalidatePath(path);
|
||||
try {
|
||||
revalidatePath(path, 'page');
|
||||
} catch {
|
||||
revalidatePath(path);
|
||||
}
|
||||
revalidated.path = path;
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user