feat(videos): add Video module in NestJS, build Video CRUD in Admin Panel, sync dynamic video fetching on frontend, and update catalog links to /catalog

This commit is contained in:
parsa aghaei 2026-07-27 11:54:16 +03:30
parent 85d5a11a5e
commit 52f0caccfc
17 changed files with 798 additions and 53 deletions

View File

@ -648,14 +648,90 @@
"sub_steps": [
{ "index": 1, "name": "update_refill_dashboard_and_ai_monitor", "description": "Render course reserve badge and next-order voucher info in dashboard, pet profile, and admin panel", "status": "done" }
]
},
{
"id": "EPIC-17-TASK-01",
"title": "PDF Catalog Link Replacement to Online Catalog Route",
"description": "Replace all static /canina.pdf links in Footer.tsx and SmartAdvisor.tsx with /catalog route.",
"architectural_layer": "presentation_ui",
"assigned_role": "06_dev_frontend",
"priority": "HIGH",
"status": "completed",
"max_files_allowed": 3,
"estimated_minutes": 10,
"dependency_task_ids": ["EPIC-16-TASK-03"],
"acceptance_criteria": [
"انتقال تمام لینک‌های کاتالوگ در فوتور و مشاور هوشمند به مسیر /catalog",
"عدم وجود لینک شکسته‌شده به فایل pdf محلی"
],
"sub_steps": [
{ "index": 1, "name": "replace_pdf_catalog_links", "description": "Update Footer.tsx and SmartAdvisor.tsx hrefs to /catalog", "status": "done" }
]
},
{
"id": "EPIC-17-TASK-02",
"title": "Backend Video Module, Prisma Schema & Initial Seed Data",
"description": "Create Video model in Prisma schema and build NestJS Video Module, Controller, and Service with public/admin CRUD endpoints.",
"architectural_layer": "business_logic",
"assigned_role": "05_dev_backend",
"priority": "HIGH",
"status": "completed",
"max_files_allowed": 5,
"estimated_minutes": 25,
"dependency_task_ids": ["EPIC-17-TASK-01"],
"acceptance_criteria": [
"ایجاد جدول Video در دیتابیس PostgreSQL و Prisma",
"ارائه APIهای کامل دریافت، ایجاد، ویرایش و حذف ویدئوها"
],
"sub_steps": [
{ "index": 1, "name": "create_backend_video_module", "description": "Add Video model in schema.prisma and build NestJS VideoModule", "status": "done" }
]
},
{
"id": "EPIC-17-TASK-03",
"title": "Admin Panel Video Management Page & Navigation Integration",
"description": "Build Videos.tsx in admin-panel featuring video table, create/edit video modal, delete confirmation, and sidebar menu link.",
"architectural_layer": "presentation_ui",
"assigned_role": "06_dev_frontend",
"priority": "HIGH",
"status": "completed",
"max_files_allowed": 4,
"estimated_minutes": 25,
"dependency_task_ids": ["EPIC-17-TASK-02"],
"acceptance_criteria": [
"افزوده شدن گزینه مدیریت ویدئوها به منوی اصلی پنل مدیریت",
"مدیریت کامل ویدئوها (ایجاد، مشاهده، تغییر پوستر، ویدئو، توضیحات و فیلد ویژه)"
],
"sub_steps": [
{ "index": 1, "name": "build_admin_videos_page", "description": "Create Videos.tsx and link in Sidebar.tsx and App.tsx", "status": "done" }
]
},
{
"id": "EPIC-17-TASK-04",
"title": "Frontend Dynamic Video Fetching & Synchronization",
"description": "Create videoService.ts and update VetGallery.tsx and VideosPage.tsx to fetch dynamic video data from NestJS API instead of hardcoded mock arrays.",
"architectural_layer": "presentation_ui",
"assigned_role": "06_dev_frontend",
"priority": "HIGH",
"status": "completed",
"max_files_allowed": 4,
"estimated_minutes": 20,
"dependency_task_ids": ["EPIC-17-TASK-03"],
"acceptance_criteria": [
"بارگذاری پویا ویدئوها در صفحه اصلی و صفحه اختصاصی /videos از دیتابیس بک‌اند",
"هماهنگی ۱۰۰٪ داده‌های ویدئو بین تمام صفحات سایت و پنل مدیریت"
],
"sub_steps": [
{ "index": 1, "name": "connect_frontend_videos_api", "description": "Create videoService.ts and wire VetGallery.tsx and VideosPage.tsx to NestJS backend API", "status": "done" }
]
}
],
"metadata": {
"total": 33,
"completed": 33,
"total": 37,
"completed": 37,
"in_progress": 0,
"pending": 0,
"generated_at": "2026-07-27T11:45:00Z",
"decomposition_pass": 14
"generated_at": "2026-07-27T11:55:00Z",
"decomposition_pass": 15
}
}

View File

@ -7,12 +7,12 @@
"status": "COMPLETE",
"checkpoint": {
"active_agent": "01_auditor",
"current_ticket_id": "EPIC-16-TASK-03",
"current_ticket_id": "EPIC-17-TASK-04",
"sub_step": {
"index": 1,
"total": 1,
"name": "update_refill_dashboard_and_ai_monitor",
"description": "Smart Refill Course Reservation model, next-order 5% cashback voucher, and abuse prevention complete and 100% verified"
"name": "connect_frontend_videos_api",
"description": "PDF catalog link replacement to /catalog and Full-stack Video Management System (NestJS API, Prisma DB, Admin Videos page, and frontend sync) complete and 100% verified"
}
},
"review_phase": {

View File

@ -374,3 +374,19 @@ model SmartAdvisorRule {
@@map("smart_advisor_rules")
}
model Video {
id String @id @default(uuid()) @db.Uuid
title String @db.VarChar(200)
doctor String @db.VarChar(150)
duration String @default("02:00") @db.VarChar(20)
thumbnail String? @db.Text
videoUrl String @map("video_url") @db.Text
description String? @db.Text
viewsCount Int @default(0) @map("views_count")
isFeatured Boolean @default(false) @map("is_featured")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz()
@@map("videos")
}

View File

@ -315,6 +315,58 @@ async function main() {
console.error('Failed to seed home components:', err);
}
console.log('Seeding Initial Videos...');
await prisma.video.deleteMany();
await prisma.video.createMany({
data: [
{
title: "نحوه آماده‌سازی کانی‌هیدروکس GAG",
doctor: "دکتر کلاوس هنینگ",
duration: "۰۱:۴۵",
thumbnail: "https://images.unsplash.com/photo-1576091160550-217359f42f8c?auto=format&fit=crop&q=80&w=600",
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
description: "آموزش گام به گام آماده‌سازی پودر Canhydrox GAG برای جذب حداکثری در دستگاه گوارش.",
isFeatured: true,
},
{
title: "اهمیت صدف لب‌سبز برای بازسازی غضروف",
doctor: "دکتر النا اشمیت",
duration: "۰۲:۲۰",
thumbnail: "https://images.unsplash.com/photo-1599443015574-be5fe8a05783?auto=format&fit=crop&q=80&w=600",
videoUrl: "https://www.w3schools.com/html/movie.mp4",
description: "چرا صدف لب‌سبز نیوزیلندی نایاب‌ترین و موثرترین ماده برای بازسازی مفصل است؟",
isFeatured: true,
},
{
title: "تغذیه هوشمند توله‌سگ‌های نژاد بزرگ",
doctor: "دکتر هلگا فیشر",
duration: "۰۳:۱۰",
thumbnail: "/assets/images/regenerated_image_1779109861747.png",
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
description: "دوز مصرفی مکمل‌های کلسیم در نژادهای بزرگ برای جلوگیری از بدشکلی‌های استخوانی.",
isFeatured: true,
},
{
title: "تاثیر امگا ۳ بر ریزش مو فصلی",
doctor: "دکتر ماری کونیگ",
duration: "۰۵:۱۵",
thumbnail: "https://images.unsplash.com/photo-1516734212186-a967f81ad0d7?auto=format&fit=crop&q=80&w=600",
videoUrl: "https://www.w3schools.com/html/movie.mp4",
description: "چربی‌های ضروری چگونه لایه محافظ پوست را بازسازی می‌کنند.",
isFeatured: false,
},
{
title: "سم‌زدایی گوارشی با اسید هومیک",
doctor: "دکتر ریکاردو کوخ",
duration: "۰۴:۰۰",
thumbnail: "https://images.unsplash.com/photo-1581594693702-fbdc51b2763b?auto=format&fit=crop&q=80&w=600",
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
description: "مکانیزم عمل Moor-Tranke در جذب توکسین‌های روده‌ای.",
isFeatured: false,
}
]
});
console.log('All Seeding completed successfully!');
}

View File

@ -18,6 +18,7 @@ import { WikiModule } from './wiki/wiki.module';
import { SeoModule } from './seo/seo.module';
import { CmsModule } from './cms/cms.module';
import { WholesaleModule } from './wholesale/wholesale.module';
import { VideosModule } from './videos/videos.module';
@Module({
imports: [
@ -40,6 +41,7 @@ import { WholesaleModule } from './wholesale/wholesale.module';
SeoModule,
CmsModule,
WholesaleModule,
VideosModule,
],
controllers: [MetricsController],
providers: [

View File

@ -0,0 +1,41 @@
import { IsString, IsNotEmpty, IsOptional, IsBoolean } from 'class-validator';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
export class CreateVideoDto {
@ApiProperty({ description: 'عنوان ویدئوی آموزشی', example: 'نحوه آماده‌سازی کانی‌هیدروکس GAG' })
@IsString()
@IsNotEmpty()
title: string;
@ApiProperty({ description: 'نام دکتر/ارائه‌دهنده', example: 'دکتر کلاوس هنینگ' })
@IsString()
@IsNotEmpty()
doctor: string;
@ApiPropertyOptional({ description: 'مدت زمان ویدئو', example: '۰۲:۳۰' })
@IsString()
@IsOptional()
duration?: string;
@ApiPropertyOptional({ description: 'آدرس تصویر کاور', example: 'https://example.com/thumb.jpg' })
@IsString()
@IsOptional()
thumbnail?: string;
@ApiProperty({ description: 'آدرس فایل ویدئو', example: 'https://example.com/video.mp4' })
@IsString()
@IsNotEmpty()
videoUrl: string;
@ApiPropertyOptional({ description: 'توضیحات تکمیلی ویدئو' })
@IsString()
@IsOptional()
description?: string;
@ApiPropertyOptional({ description: 'آیا ویدئوی ویژه/صفحه اصلی است؟', example: true })
@IsBoolean()
@IsOptional()
isFeatured?: boolean;
}
export class UpdateVideoDto extends CreateVideoDto {}

View File

@ -0,0 +1,51 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards } from '@nestjs/common';
import { VideosService } from './videos.service';
import { CreateVideoDto } from './dto/create-video.dto';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { ApiTags, ApiOperation, ApiQuery, ApiBearerAuth } from '@nestjs/swagger';
@ApiTags('Videos - مشاوره ویدئویی و آکادمی')
@Controller('videos')
export class VideosController {
constructor(private readonly videosService: VideosService) {}
@Get()
@ApiOperation({ summary: 'دریافت لیست ویدئوها (عمومی)' })
@ApiQuery({ name: 'page', required: false })
@ApiQuery({ name: 'limit', required: false })
@ApiQuery({ name: 'search', required: false })
@ApiQuery({ name: 'featured', required: false })
findAll(@Query() query: any) {
return this.videosService.findAll(query);
}
@Get(':id')
@ApiOperation({ summary: 'دریافت جزئیات یک ویدئو' })
findOne(@Param('id') id: string) {
return this.videosService.findOne(id);
}
@Post()
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'افزودن ویدئوی جدید (ادمین)' })
create(@Body() createVideoDto: CreateVideoDto) {
return this.videosService.create(createVideoDto);
}
@Put(':id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'ویرایش ویدئو (ادمین)' })
update(@Param('id') id: string, @Body() updateVideoDto: Partial<CreateVideoDto>) {
return this.videosService.update(id, updateVideoDto);
}
@Delete(':id')
@UseGuards(JwtAuthGuard)
@ApiBearerAuth()
@ApiOperation({ summary: 'حذف ویدئو (ادمین)' })
remove(@Param('id') id: string) {
return this.videosService.remove(id);
}
}

View File

@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { VideosController } from './videos.controller';
import { VideosService } from './videos.service';
@Module({
controllers: [VideosController],
providers: [VideosService],
exports: [VideosService],
})
export class VideosModule {}

View File

@ -0,0 +1,98 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { CreateVideoDto } from './dto/create-video.dto';
@Injectable()
export class VideosService {
constructor(private readonly prisma: PrismaService) {}
async findAll(query: any) {
const page = Number(query.page) || 1;
const limit = Number(query.limit) || 20;
const skip = (page - 1) * limit;
const where: any = {};
if (query.search) {
where.OR = [
{ title: { contains: query.search, mode: 'insensitive' } },
{ doctor: { contains: query.search, mode: 'insensitive' } },
{ description: { contains: query.search, mode: 'insensitive' } },
];
}
if (query.featured !== undefined) {
where.isFeatured = query.featured === 'true' || query.featured === true;
}
const [data, total] = await Promise.all([
this.prisma.video.findMany({
where,
skip,
take: limit,
orderBy: [{ isFeatured: 'desc' }, { createdAt: 'desc' }],
}),
this.prisma.video.count({ where }),
]);
return {
data,
meta: {
total,
page,
limit,
lastPage: Math.ceil(total / limit),
},
};
}
async findOne(id: string) {
const video = await this.prisma.video.findUnique({
where: { id },
});
if (!video) {
throw new NotFoundException('ویدئوی مورد نظر یافت نشد');
}
// Increment view count
await this.prisma.video.update({
where: { id },
data: { viewsCount: { increment: 1 } },
});
return video;
}
async create(dto: CreateVideoDto) {
return this.prisma.video.create({
data: {
title: dto.title,
doctor: dto.doctor,
duration: dto.duration || '۰۲:۰۰',
thumbnail: dto.thumbnail,
videoUrl: dto.videoUrl,
description: dto.description,
isFeatured: dto.isFeatured ?? false,
},
});
}
async update(id: string, dto: Partial<CreateVideoDto>) {
await this.findOne(id);
return this.prisma.video.update({
where: { id },
data: {
title: dto.title,
doctor: dto.doctor,
duration: dto.duration,
thumbnail: dto.thumbnail,
videoUrl: dto.videoUrl,
description: dto.description,
isFeatured: dto.isFeatured,
},
});
}
async remove(id: string) {
await this.findOne(id);
return this.prisma.video.delete({
where: { id },
});
}
}

View File

@ -18,6 +18,7 @@ import UITexts from './pages/UITexts';
import Media from './pages/Media';
import CMS from './pages/CMS';
import WholesaleApplications from './pages/WholesaleApplications';
import Videos from './pages/Videos';
function App() {
return (
@ -37,6 +38,7 @@ function App() {
<Route path="categories" element={<Categories />} />
<Route path="blogs" element={<Blogs />} />
<Route path="wiki" element={<Wiki />} />
<Route path="videos" element={<Videos />} />
<Route path="pets" element={<Pets />} />
<Route path="ui-texts" element={<UITexts />} />
<Route path="media" element={<Media />} />

View File

@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { Users, ShoppingCart, Tag, Settings, LogOut, TrendingUp, FolderTree, FileText, BookOpen, Heart, Package, LayoutDashboard, Languages, Image } from 'lucide-react';
import { Users, ShoppingCart, Tag, Settings, LogOut, TrendingUp, FolderTree, FileText, BookOpen, Heart, Package, LayoutDashboard, Languages, Image, Video } from 'lucide-react';
import api from '../services/api';
const menuGroups = [
@ -33,6 +33,7 @@ const menuGroups = [
items: [
{ icon: FileText, label: 'وبلاگ', path: '/blogs' },
{ icon: BookOpen, label: 'دانشنامه', path: '/wiki' },
{ icon: Video, label: 'مدیریت ویدئوها', path: '/videos' },
{ icon: Image, label: 'بنرها و نظرات (CMS)', path: '/cms' },
]
},

View File

@ -0,0 +1,341 @@
import React, { useState, useEffect } from 'react';
import { Plus, Search, Edit2, Trash2, Video as VideoIcon, Star, CheckCircle, X } from 'lucide-react';
import api from '../services/api';
interface Video {
id: string;
title: string;
doctor: string;
duration: string;
thumbnail: string;
videoUrl: string;
description: string;
viewsCount: number;
isFeatured: boolean;
createdAt: string;
}
export default function Videos() {
const [videos, setVideos] = useState<Video[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [isModalOpen, setIsModalOpen] = useState(false);
const [editingVideo, setEditingVideo] = useState<Video | null>(null);
const [formData, setFormData] = useState({
title: '',
doctor: '',
duration: '۰۲:۰۰',
thumbnail: '',
videoUrl: '',
description: '',
isFeatured: false,
});
const fetchVideos = async () => {
setIsLoading(true);
try {
const res = await api.get('/videos', {
params: { search: searchTerm, limit: 100 },
});
setVideos(res.data.data || res.data || []);
} catch (err) {
console.error('Failed to fetch videos:', err);
} finally {
setIsLoading(false);
}
};
useEffect(() => {
fetchVideos();
}, [searchTerm]);
const handleOpenModal = (video?: Video) => {
if (video) {
setEditingVideo(video);
setFormData({
title: video.title,
doctor: video.doctor,
duration: video.duration || '۰۲:۰۰',
thumbnail: video.thumbnail || '',
videoUrl: video.videoUrl,
description: video.description || '',
isFeatured: Boolean(video.isFeatured),
});
} else {
setEditingVideo(null);
setFormData({
title: '',
doctor: '',
duration: '۰۲:۰۰',
thumbnail: '',
videoUrl: '',
description: '',
isFeatured: false,
});
}
setIsModalOpen(true);
};
const handleSave = async (e: React.FormEvent) => {
e.preventDefault();
try {
if (editingVideo) {
await api.put(`/videos/${editingVideo.id}`, formData);
} else {
await api.post('/videos', formData);
}
setIsModalOpen(false);
fetchVideos();
} catch (err) {
console.error('Failed to save video:', err);
alert('خطا در ذخیره‌سازی ویدئو');
}
};
const handleDelete = async (id: string) => {
if (!window.confirm('آیا از حذف این ویدئوی آموزشی اطمینان دارید؟')) return;
try {
await api.delete(`/videos/${id}`);
fetchVideos();
} catch (err) {
console.error('Failed to delete video:', err);
alert('خطا در حذف ویدئو');
}
};
return (
<div className="space-y-6 font-vazir text-right" dir="rtl">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 bg-white p-6 rounded-2xl border border-gray-100 shadow-xs">
<div>
<h1 className="text-2xl font-black text-gray-900 flex items-center gap-3">
<VideoIcon className="w-7 h-7 text-purple-600" />
مدیریت ویدئوها و مشاوره دامپزشکی
</h1>
<p className="text-sm text-gray-500 font-bold mt-1">
مدیریت ویدیوهای آکادمی آموزشی و مشاوره اختصاصی دامپزشک کانینا
</p>
</div>
<button
onClick={() => handleOpenModal()}
className="bg-purple-600 hover:bg-purple-700 text-white font-bold px-5 py-3 rounded-xl flex items-center justify-center gap-2 transition-all shadow-md"
>
<Plus className="w-5 h-5" />
افزودن ویدئوی جدید
</button>
</div>
{/* Search Bar */}
<div className="bg-white p-4 rounded-2xl border border-gray-100 shadow-xs flex items-center gap-4">
<Search className="w-5 h-5 text-gray-400 shrink-0" />
<input
type="text"
placeholder="جستجو بر اساس عنوان یا نام پزشک..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full bg-transparent border-none text-sm focus:outline-none font-bold"
/>
</div>
{/* Videos Grid / Table */}
<div className="bg-white rounded-2xl border border-gray-100 overflow-hidden shadow-xs">
<div className="overflow-x-auto">
<table className="w-full text-right border-collapse">
<thead>
<tr className="bg-gray-50 text-gray-500 text-xs font-black uppercase tracking-wider border-b border-gray-100">
<th className="py-4 px-6">کاور & عنوان</th>
<th className="py-4 px-6">دکتر / ارائهدهنده</th>
<th className="py-4 px-6">زمان</th>
<th className="py-4 px-6">نمایش ویژه (صفحه اصلی)</th>
<th className="py-4 px-6 text-center">عملیات ادمین</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 text-sm font-bold text-gray-700">
{isLoading ? (
<tr>
<td colSpan={5} className="py-12 text-center text-gray-400">
در حال دریافت لیست ویدئوها...
</td>
</tr>
) : videos.length === 0 ? (
<tr>
<td colSpan={5} className="py-12 text-center text-gray-400">
هیچ ویدئویی ثبت نشده است.
</td>
</tr>
) : (
videos.map((video) => (
<tr key={video.id} className="hover:bg-gray-50/50 transition-colors">
<td className="py-4 px-6">
<div className="flex items-center gap-4">
<img
src={video.thumbnail || 'https://images.unsplash.com/photo-1576091160550-217359f42f8c?auto=format&fit=crop&q=80&w=200'}
alt={video.title}
className="w-16 h-10 object-cover rounded-lg border border-gray-200 shrink-0"
/>
<div>
<h4 className="font-black text-gray-900 leading-snug">{video.title}</h4>
<p className="text-xs text-gray-400 line-clamp-1 mt-0.5">{video.description}</p>
</div>
</div>
</td>
<td className="py-4 px-6 font-black text-purple-700">{video.doctor}</td>
<td className="py-4 px-6 font-mono dir-ltr text-right">{video.duration}</td>
<td className="py-4 px-6">
{video.isFeatured ? (
<span className="inline-flex items-center gap-1.5 px-3 py-1 bg-amber-50 text-amber-700 border border-amber-200 rounded-full text-xs font-black">
<Star className="w-3.5 h-3.5 text-amber-500 fill-amber-500" />
ویژه (صفحه اصلی)
</span>
) : (
<span className="text-xs text-gray-400">عادی</span>
)}
</td>
<td className="py-4 px-6">
<div className="flex items-center justify-center gap-2">
<button
onClick={() => handleOpenModal(video)}
className="p-2 hover:bg-purple-50 text-purple-600 rounded-xl transition-colors"
title="ویرایش"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(video.id)}
className="p-2 hover:bg-red-50 text-red-600 rounded-xl transition-colors"
title="حذف"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
{/* Create / Edit Modal */}
{isModalOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-xs">
<div className="bg-white w-full max-w-xl rounded-3xl p-6 sm:p-8 space-y-6 shadow-2xl relative border border-gray-100 max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between border-b border-gray-100 pb-4">
<h3 className="text-xl font-black text-gray-900">
{editingVideo ? 'ویرایش ویدئوی آموزشی' : 'افزودن ویدئوی جدید'}
</h3>
<button
onClick={() => setIsModalOpen(false)}
className="w-9 h-9 flex items-center justify-center rounded-xl hover:bg-gray-100 text-gray-400 hover:text-gray-700"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleSave} className="space-y-4">
<div className="space-y-1.5">
<label className="text-xs font-black text-gray-700 block">عنوان ویدئو</label>
<input
type="text"
required
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="مثلاً: نحوه آماده‌سازی کانی‌هیدروکس GAG"
className="w-full bg-gray-50 border border-gray-200 rounded-xl py-3 px-4 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500/20 font-bold"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1.5">
<label className="text-xs font-black text-gray-700 block">نام دکتر / ارائهدهنده</label>
<input
type="text"
required
value={formData.doctor}
onChange={(e) => setFormData({ ...formData, doctor: e.target.value })}
placeholder="دکتر کلاوس هنینگ"
className="w-full bg-gray-50 border border-gray-200 rounded-xl py-3 px-4 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500/20 font-bold"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-black text-gray-700 block">مدت زمان (مثلاً ۰۲:۳۰)</label>
<input
type="text"
value={formData.duration}
onChange={(e) => setFormData({ ...formData, duration: e.target.value })}
className="w-full bg-gray-50 border border-gray-200 rounded-xl py-3 px-4 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500/20 font-bold"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-xs font-black text-gray-700 block">آدرس تصویر کاور (Thumbnail URL)</label>
<input
type="text"
value={formData.thumbnail}
onChange={(e) => setFormData({ ...formData, thumbnail: e.target.value })}
placeholder="https://..."
className="w-full bg-gray-50 border border-gray-200 rounded-xl py-3 px-4 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500/20 font-bold"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-black text-gray-700 block">آدرس ویدئو (MP4 / Stream URL)</label>
<input
type="text"
required
value={formData.videoUrl}
onChange={(e) => setFormData({ ...formData, videoUrl: e.target.value })}
placeholder="https://..."
className="w-full bg-gray-50 border border-gray-200 rounded-xl py-3 px-4 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500/20 font-bold"
/>
</div>
<div className="space-y-1.5">
<label className="text-xs font-black text-gray-700 block">توضیحات تکمیلی</label>
<textarea
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
className="w-full bg-gray-50 border border-gray-200 rounded-xl py-3 px-4 text-sm focus:outline-none focus:ring-2 focus:ring-purple-500/20 h-24 font-bold"
placeholder="توضیحات مختصر در مورد محتوای ویدئو..."
/>
</div>
<div className="flex items-center gap-3 p-4 bg-amber-50 rounded-xl border border-amber-200">
<input
type="checkbox"
id="isFeaturedCheck"
checked={formData.isFeatured}
onChange={(e) => setFormData({ ...formData, isFeatured: e.target.checked })}
className="w-4 h-4 text-amber-600 rounded focus:ring-amber-500 cursor-pointer"
/>
<label htmlFor="isFeaturedCheck" className="text-xs font-black text-amber-900 cursor-pointer">
نمایش به عنوان ویدئوی ویژه در بخش «مشاوره ویدئویی» صفحه اصلی سایت
</label>
</div>
<div className="pt-4 flex justify-end gap-3">
<button
type="button"
onClick={() => setIsModalOpen(false)}
className="px-5 py-2.5 rounded-xl border border-gray-200 text-gray-600 font-bold text-sm hover:bg-gray-50"
>
انصراف
</button>
<button
type="submit"
className="px-6 py-2.5 rounded-xl bg-purple-600 text-white font-bold text-sm hover:bg-purple-700 shadow-md"
>
ذخیره اطلاعات
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}

View File

@ -1,6 +1,6 @@
"use client";
import React, { useState, useEffect } from "react";
import { Phone, Mail, MapPin, Instagram, ShieldCheck, Globe, ArrowUp } from "lucide-react";
import { Phone, Mail, MapPin, Instagram, ShieldCheck, Globe, ArrowUp, Download } from "lucide-react";
import { useSettingsStore } from "../lib/store/settingsStore";
import Link from "next/link";
@ -95,13 +95,13 @@ export default function Footer({ onNavigate, onShopNavigate, onB2BOpen }: {
</Link>
</li>
<li>
<Link
href="/catalog"
className="hover:text-canina-gold hover:translate-x-[-4px] transition-all cursor-pointer flex items-center gap-2 group text-amber-200"
>
<div className="w-1.5 h-1.5 rounded-full bg-canina-gold group-hover:w-4 transition-all" />
<span>دانلود کاتالوگ آنلاین محصولات (PDF)</span>
</Link>
<Link
href="/catalog"
className="inline-flex items-center gap-3 px-6 py-3 bg-canina-blue/10 border border-canina-blue/20 rounded-2xl text-canina-blue text-xs font-black hover:bg-canina-blue hover:text-white transition-all shadow-sm group"
>
<Download className="w-4 h-4 group-hover:-translate-y-0.5 transition-transform" />
<span>مشاهده و دانلود کاتالوگ آنلاین محصولات</span>
</Link>
</li>
<li onClick={onB2BOpen} className="hover:text-canina-blue hover:translate-x-[-4px] transition-all cursor-pointer flex items-center gap-2 group text-blue-200">
<div className="w-1.5 h-1.5 rounded-full bg-canina-blue group-hover:w-4 transition-all" />

View File

@ -191,15 +191,13 @@ export default function SmartAdvisor({ onComplete, rules = [] }: SmartAdvisorPro
</p>
<div className="mt-4 flex justify-center">
<a
href="/canina.pdf"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-4 py-2 bg-amber-50 border border-amber-200 text-amber-800 rounded-xl text-xs font-black hover:bg-amber-100 transition-colors shadow-xs"
<Link
href="/catalog"
className="inline-flex items-center gap-2 px-4 py-2 bg-medical-gray-50 border border-medical-gray-200 rounded-xl text-medical-gray-600 text-xs font-bold hover:bg-canina-blue hover:text-white transition-all shadow-sm"
>
<FileText className="w-4 h-4 text-amber-600" />
<span>مشاهده جدول دوز کاتالوگ رسمی آلمان (PDF)</span>
</a>
<Download className="w-4 h-4" />
<span>مشاهده کاتالوگ و جدول دوز آلمان</span>
</Link>
</div>
</div>

View File

@ -5,23 +5,23 @@ import { motion, AnimatePresence } from "motion/react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useSettingsStore } from "../lib/store/settingsStore";
import SafeImage from "./SafeImage";
import { videoService, Video } from "../lib/services/videoService";
const VIDEOS = [
const FALLBACK_VIDEOS = [
{
id: "v1",
title: "نحوه آماده‌سازی کانی‌هیدروکس",
title: "نحوه آماده‌سازی کانی‌هیدروکس GAG",
doctor: "دکتر کلاوس هنینگ",
duration: "۱:۴۵",
duration: "۰۱:۴۵",
thumbnail: "https://images.unsplash.com/photo-1576091160550-217359f42f8c?auto=format&fit=crop&q=80&w=400",
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4", // Placeholder real-looking video
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
description: "آموزش گام به گام آماده‌سازی پودر Canhydrox GAG برای جذب حداکثری در دستگاه گوارش."
},
{
id: "v2",
title: "اهمیت صدف لب‌سبز",
doctor: "دکتر النا اشمیت",
duration: "۲:۲۰",
duration: "۰۲:۲۰",
thumbnail: "https://images.unsplash.com/photo-1599443015574-be5fe8a05783?auto=format&fit=crop&q=80&w=400",
videoUrl: "https://www.w3schools.com/html/movie.mp4",
description: "چرا صدف لب‌سبز نیوزیلندی نایاب‌ترین و موثرترین ماده برای بازسازی مفصل است؟"
@ -30,7 +30,7 @@ const VIDEOS = [
id: "v3",
title: "تغذیه هوشمند توله‌سگ‌ها",
doctor: "دکتر هلگا فیشر",
duration: "۳:۱۰",
duration: "۰۳:۱۰",
thumbnail: "/assets/images/regenerated_image_1779109861747.png",
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
description: "دوز مصرفی مکمل‌های کلسیم در نژادهای بزرگ برای جلوگیری از بدشکلی‌های استخوانی."
@ -40,18 +40,30 @@ const VIDEOS = [
export default function VetGallery({ testimonials = [] }: { testimonials?: any[] }) {
const router = useRouter();
const getText = useSettingsStore(state => state.getText);
const displayItems = testimonials.length > 0 ? testimonials.map((t, idx) => ({
id: t.id || `t-${idx}`,
title: t.clinicName ? `${t.vetName}${t.clinicName}` : t.vetName,
doctor: t.vetName,
duration: "۲:۰۰",
thumbnail: t.imageUrl || "https://images.unsplash.com/photo-1576091160550-217359f42f8c?auto=format&fit=crop&q=80&w=400",
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
description: t.quote,
quote: t.quote
})) : VIDEOS;
const [apiVideos, setApiVideos] = useState<Video[]>([]);
const [selectedVideo, setSelectedVideo] = useState<any>(null);
React.useEffect(() => {
videoService.getVideos({ featured: true, limit: 3 }).then((data) => {
if (data && data.length > 0) {
setApiVideos(data);
}
});
}, []);
const displayItems = testimonials.length > 0
? testimonials.map((t, idx) => ({
id: t.id || `t-${idx}`,
title: t.clinicName ? `${t.vetName}${t.clinicName}` : t.vetName,
doctor: t.vetName,
duration: "۰۲:۰۰",
thumbnail: t.imageUrl || "https://images.unsplash.com/photo-1576091160550-217359f42f8c?auto=format&fit=crop&q=80&w=400",
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
description: t.quote,
quote: t.quote
}))
: (apiVideos.length > 0 ? apiVideos : FALLBACK_VIDEOS);
return (
<section className="py-24 bg-white font-vazir" dir="rtl">
<div className="max-w-7xl mx-auto px-4">

View File

@ -5,21 +5,24 @@ import { ChevronRight, PlayCircle, Star, ShieldCheck, X, Search, Clock, User } f
import SafeImage from "./SafeImage";
const ALL_VIDEOS = [
import { videoService, Video } from "../lib/services/videoService";
import { useRouter } from 'next/navigation';
const FALLBACK_VIDEOS: Video[] = [
{
id: "v1",
title: "نحوه آماده‌سازی کانی‌هیدروکس",
title: "نحوه آماده‌سازی کانی‌هیدروکس GAG",
doctor: "دکتر کلاوس هنینگ",
duration: "۱:۴۵",
duration: "۰۱:۴۵",
thumbnail: "https://images.unsplash.com/photo-1576091160550-217359f42f8c?auto=format&fit=crop&q=80&w=600",
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
description: "آموزش گام به گام آماده‌سازی پودر Canhydrox GAG برای جذب حداکثری در دستگاه گوارش."
},
{
id: "v2",
title: "اهمیت صدف لب‌سبز برای غضروف",
title: "اهمیت صدف لب‌سبز برای بازسازی غضروف",
doctor: "دکتر النا اشمیت",
duration: "۲:۲۰",
duration: "۰۲:۲۰",
thumbnail: "https://images.unsplash.com/photo-1599443015574-be5fe8a05783?auto=format&fit=crop&q=80&w=600",
videoUrl: "https://www.w3schools.com/html/movie.mp4",
description: "چرا صدف لب‌سبز نیوزیلندی نایاب‌ترین و موثرترین ماده برای بازسازی مفصل است؟"
@ -28,7 +31,7 @@ const ALL_VIDEOS = [
id: "v3",
title: "تغذیه هوشمند توله‌سگ‌های نژاد بزرگ",
doctor: "دکتر هلگا فیشر",
duration: "۳:۱۰",
duration: "۰۳:۱۰",
thumbnail: "/assets/images/regenerated_image_1779109861747.png",
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
description: "دوز مصرفی مکمل‌های کلسیم در نژادهای بزرگ برای جلوگیری از بدشکلی‌های استخوانی."
@ -37,7 +40,7 @@ const ALL_VIDEOS = [
id: "v4",
title: "تاثیر امگا ۳ بر ریزش مو فصلی",
doctor: "دکتر ماری کونیگ",
duration: "۵:۱۵",
duration: "۰۵:۱۵",
thumbnail: "https://images.unsplash.com/photo-1516734212186-a967f81ad0d7?auto=format&fit=crop&q=80&w=600",
videoUrl: "https://www.w3schools.com/html/movie.mp4",
description: "چربی‌های ضروری چگونه لایه محافظ پوست را بازسازی می‌کنند."
@ -46,21 +49,28 @@ const ALL_VIDEOS = [
id: "v5",
title: "سم‌زدایی گوارشی با اسید هومیک",
doctor: "دکتر ریکاردو کوخ",
duration: "۴:۰۰",
duration: "۰۴:۰۰",
thumbnail: "https://images.unsplash.com/photo-1581594693702-fbdc51b2763b?auto=format&fit=crop&q=80&w=600",
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
description: "مکانیزم عمل Moor-Tranke در جذب توکسین‌های روده‌ای."
}
];
import { useRouter } from 'next/navigation';
export default function VideosPage() {
const router = useRouter();
const [selectedVideo, setSelectedVideo] = useState<typeof ALL_VIDEOS[0] | null>(null);
const [videos, setVideos] = useState<Video[]>(FALLBACK_VIDEOS);
const [selectedVideo, setSelectedVideo] = useState<Video | null>(null);
const [search, setSearch] = useState("");
const filteredVideos = ALL_VIDEOS.filter(v =>
React.useEffect(() => {
videoService.getVideos({ limit: 100 }).then((data) => {
if (data && data.length > 0) {
setVideos(data);
}
});
}, []);
const filteredVideos = videos.filter(v =>
v.title.includes(search) || v.doctor.includes(search)
);

View File

@ -0,0 +1,35 @@
import api from './api';
export interface Video {
id: string;
title: string;
doctor: string;
duration: string;
thumbnail: string;
videoUrl: string;
description: string;
viewsCount?: number;
isFeatured?: boolean;
}
export const videoService = {
getVideos: async (params?: { featured?: boolean; limit?: number; search?: string }) => {
try {
const res = await api.get('/videos', { params });
return res.data.data || res.data || [];
} catch (error) {
console.error('Failed to fetch videos from API:', error);
return [];
}
},
getVideoById: async (id: string) => {
try {
const res = await api.get(`/videos/${id}`);
return res.data;
} catch (error) {
console.error('Failed to fetch video details:', error);
return null;
}
}
};