fix(admin-panel): fix blog bugs and add ingredients editor, draft autosave, and category seeding
Some checks failed
Deploy Canina / deploy (push) Successful in 2m20s
E2E Playwright Tests / Run Full E2E & Security Suites (push) Failing after 6s

This commit is contained in:
پارسا آقایی 2026-09-24 03:06:17 +03:30
parent ebc61d5be2
commit 913e6acd3d
11 changed files with 375 additions and 20 deletions

View File

@ -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) {

View File

@ -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;

View File

@ -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()

View File

@ -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) {

View File

@ -19,7 +19,7 @@ import { RedisModule } from '../redis/redis.module';
JwtModule.registerAsync({
useFactory: () => ({
secret: getJwtSecret(),
signOptions: { expiresIn: '15m' },
signOptions: { expiresIn: '2h' },
}),
}),
],

View File

@ -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(/\/+$/, '');
}

View File

@ -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

View File

@ -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>

View File

@ -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 });
}

View File

@ -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 */}

View File

@ -64,7 +64,11 @@ async function handleRevalidate(request: NextRequest) {
}
if (path) {
revalidatePath(path);
try {
revalidatePath(path, 'page');
} catch {
revalidatePath(path);
}
revalidated.path = path;
}