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:
parent
85d5a11a5e
commit
52f0caccfc
@ -648,14 +648,90 @@
|
|||||||
"sub_steps": [
|
"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" }
|
{ "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": {
|
"metadata": {
|
||||||
"total": 33,
|
"total": 37,
|
||||||
"completed": 33,
|
"completed": 37,
|
||||||
"in_progress": 0,
|
"in_progress": 0,
|
||||||
"pending": 0,
|
"pending": 0,
|
||||||
"generated_at": "2026-07-27T11:45:00Z",
|
"generated_at": "2026-07-27T11:55:00Z",
|
||||||
"decomposition_pass": 14
|
"decomposition_pass": 15
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -7,12 +7,12 @@
|
|||||||
"status": "COMPLETE",
|
"status": "COMPLETE",
|
||||||
"checkpoint": {
|
"checkpoint": {
|
||||||
"active_agent": "01_auditor",
|
"active_agent": "01_auditor",
|
||||||
"current_ticket_id": "EPIC-16-TASK-03",
|
"current_ticket_id": "EPIC-17-TASK-04",
|
||||||
"sub_step": {
|
"sub_step": {
|
||||||
"index": 1,
|
"index": 1,
|
||||||
"total": 1,
|
"total": 1,
|
||||||
"name": "update_refill_dashboard_and_ai_monitor",
|
"name": "connect_frontend_videos_api",
|
||||||
"description": "Smart Refill Course Reservation model, next-order 5% cashback voucher, and abuse prevention complete and 100% verified"
|
"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": {
|
"review_phase": {
|
||||||
|
|||||||
@ -374,3 +374,19 @@ model SmartAdvisorRule {
|
|||||||
|
|
||||||
@@map("smart_advisor_rules")
|
@@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")
|
||||||
|
}
|
||||||
|
|||||||
@ -315,6 +315,58 @@ async function main() {
|
|||||||
console.error('Failed to seed home components:', err);
|
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!');
|
console.log('All Seeding completed successfully!');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import { WikiModule } from './wiki/wiki.module';
|
|||||||
import { SeoModule } from './seo/seo.module';
|
import { SeoModule } from './seo/seo.module';
|
||||||
import { CmsModule } from './cms/cms.module';
|
import { CmsModule } from './cms/cms.module';
|
||||||
import { WholesaleModule } from './wholesale/wholesale.module';
|
import { WholesaleModule } from './wholesale/wholesale.module';
|
||||||
|
import { VideosModule } from './videos/videos.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@ -40,6 +41,7 @@ import { WholesaleModule } from './wholesale/wholesale.module';
|
|||||||
SeoModule,
|
SeoModule,
|
||||||
CmsModule,
|
CmsModule,
|
||||||
WholesaleModule,
|
WholesaleModule,
|
||||||
|
VideosModule,
|
||||||
],
|
],
|
||||||
controllers: [MetricsController],
|
controllers: [MetricsController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
41
backend/src/videos/dto/create-video.dto.ts
Normal file
41
backend/src/videos/dto/create-video.dto.ts
Normal 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 {}
|
||||||
51
backend/src/videos/videos.controller.ts
Normal file
51
backend/src/videos/videos.controller.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/src/videos/videos.module.ts
Normal file
10
backend/src/videos/videos.module.ts
Normal 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 {}
|
||||||
98
backend/src/videos/videos.service.ts
Normal file
98
backend/src/videos/videos.service.ts
Normal 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 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -18,6 +18,7 @@ import UITexts from './pages/UITexts';
|
|||||||
import Media from './pages/Media';
|
import Media from './pages/Media';
|
||||||
import CMS from './pages/CMS';
|
import CMS from './pages/CMS';
|
||||||
import WholesaleApplications from './pages/WholesaleApplications';
|
import WholesaleApplications from './pages/WholesaleApplications';
|
||||||
|
import Videos from './pages/Videos';
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
return (
|
return (
|
||||||
@ -37,6 +38,7 @@ function App() {
|
|||||||
<Route path="categories" element={<Categories />} />
|
<Route path="categories" element={<Categories />} />
|
||||||
<Route path="blogs" element={<Blogs />} />
|
<Route path="blogs" element={<Blogs />} />
|
||||||
<Route path="wiki" element={<Wiki />} />
|
<Route path="wiki" element={<Wiki />} />
|
||||||
|
<Route path="videos" element={<Videos />} />
|
||||||
<Route path="pets" element={<Pets />} />
|
<Route path="pets" element={<Pets />} />
|
||||||
<Route path="ui-texts" element={<UITexts />} />
|
<Route path="ui-texts" element={<UITexts />} />
|
||||||
<Route path="media" element={<Media />} />
|
<Route path="media" element={<Media />} />
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
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';
|
import api from '../services/api';
|
||||||
|
|
||||||
const menuGroups = [
|
const menuGroups = [
|
||||||
@ -33,6 +33,7 @@ const menuGroups = [
|
|||||||
items: [
|
items: [
|
||||||
{ icon: FileText, label: 'وبلاگ', path: '/blogs' },
|
{ icon: FileText, label: 'وبلاگ', path: '/blogs' },
|
||||||
{ icon: BookOpen, label: 'دانشنامه', path: '/wiki' },
|
{ icon: BookOpen, label: 'دانشنامه', path: '/wiki' },
|
||||||
|
{ icon: Video, label: 'مدیریت ویدئوها', path: '/videos' },
|
||||||
{ icon: Image, label: 'بنرها و نظرات (CMS)', path: '/cms' },
|
{ icon: Image, label: 'بنرها و نظرات (CMS)', path: '/cms' },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
341
frontend/admin-panel/src/pages/Videos.tsx
Normal file
341
frontend/admin-panel/src/pages/Videos.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import React, { useState, useEffect } from "react";
|
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 { useSettingsStore } from "../lib/store/settingsStore";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
@ -97,10 +97,10 @@ export default function Footer({ onNavigate, onShopNavigate, onB2BOpen }: {
|
|||||||
<li>
|
<li>
|
||||||
<Link
|
<Link
|
||||||
href="/catalog"
|
href="/catalog"
|
||||||
className="hover:text-canina-gold hover:translate-x-[-4px] transition-all cursor-pointer flex items-center gap-2 group text-amber-200"
|
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"
|
||||||
>
|
>
|
||||||
<div className="w-1.5 h-1.5 rounded-full bg-canina-gold group-hover:w-4 transition-all" />
|
<Download className="w-4 h-4 group-hover:-translate-y-0.5 transition-transform" />
|
||||||
<span>دانلود کاتالوگ آنلاین محصولات (PDF)</span>
|
<span>مشاهده و دانلود کاتالوگ آنلاین محصولات</span>
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</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">
|
<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">
|
||||||
|
|||||||
@ -191,15 +191,13 @@ export default function SmartAdvisor({ onComplete, rules = [] }: SmartAdvisorPro
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="mt-4 flex justify-center">
|
<div className="mt-4 flex justify-center">
|
||||||
<a
|
<Link
|
||||||
href="/canina.pdf"
|
href="/catalog"
|
||||||
target="_blank"
|
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"
|
||||||
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"
|
|
||||||
>
|
>
|
||||||
<FileText className="w-4 h-4 text-amber-600" />
|
<Download className="w-4 h-4" />
|
||||||
<span>مشاهده جدول دوز کاتالوگ رسمی آلمان (PDF)</span>
|
<span>مشاهده کاتالوگ و جدول دوز آلمان</span>
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -5,23 +5,23 @@ import { motion, AnimatePresence } from "motion/react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useSettingsStore } from "../lib/store/settingsStore";
|
import { useSettingsStore } from "../lib/store/settingsStore";
|
||||||
import SafeImage from "./SafeImage";
|
import { videoService, Video } from "../lib/services/videoService";
|
||||||
|
|
||||||
const VIDEOS = [
|
const FALLBACK_VIDEOS = [
|
||||||
{
|
{
|
||||||
id: "v1",
|
id: "v1",
|
||||||
title: "نحوه آمادهسازی کانیهیدروکس",
|
title: "نحوه آمادهسازی کانیهیدروکس GAG",
|
||||||
doctor: "دکتر کلاوس هنینگ",
|
doctor: "دکتر کلاوس هنینگ",
|
||||||
duration: "۱:۴۵",
|
duration: "۰۱:۴۵",
|
||||||
thumbnail: "https://images.unsplash.com/photo-1576091160550-217359f42f8c?auto=format&fit=crop&q=80&w=400",
|
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 برای جذب حداکثری در دستگاه گوارش."
|
description: "آموزش گام به گام آمادهسازی پودر Canhydrox GAG برای جذب حداکثری در دستگاه گوارش."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "v2",
|
id: "v2",
|
||||||
title: "اهمیت صدف لبسبز",
|
title: "اهمیت صدف لبسبز",
|
||||||
doctor: "دکتر النا اشمیت",
|
doctor: "دکتر النا اشمیت",
|
||||||
duration: "۲:۲۰",
|
duration: "۰۲:۲۰",
|
||||||
thumbnail: "https://images.unsplash.com/photo-1599443015574-be5fe8a05783?auto=format&fit=crop&q=80&w=400",
|
thumbnail: "https://images.unsplash.com/photo-1599443015574-be5fe8a05783?auto=format&fit=crop&q=80&w=400",
|
||||||
videoUrl: "https://www.w3schools.com/html/movie.mp4",
|
videoUrl: "https://www.w3schools.com/html/movie.mp4",
|
||||||
description: "چرا صدف لبسبز نیوزیلندی نایابترین و موثرترین ماده برای بازسازی مفصل است؟"
|
description: "چرا صدف لبسبز نیوزیلندی نایابترین و موثرترین ماده برای بازسازی مفصل است؟"
|
||||||
@ -30,7 +30,7 @@ const VIDEOS = [
|
|||||||
id: "v3",
|
id: "v3",
|
||||||
title: "تغذیه هوشمند تولهسگها",
|
title: "تغذیه هوشمند تولهسگها",
|
||||||
doctor: "دکتر هلگا فیشر",
|
doctor: "دکتر هلگا فیشر",
|
||||||
duration: "۳:۱۰",
|
duration: "۰۳:۱۰",
|
||||||
thumbnail: "/assets/images/regenerated_image_1779109861747.png",
|
thumbnail: "/assets/images/regenerated_image_1779109861747.png",
|
||||||
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
|
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
|
||||||
description: "دوز مصرفی مکملهای کلسیم در نژادهای بزرگ برای جلوگیری از بدشکلیهای استخوانی."
|
description: "دوز مصرفی مکملهای کلسیم در نژادهای بزرگ برای جلوگیری از بدشکلیهای استخوانی."
|
||||||
@ -40,17 +40,29 @@ const VIDEOS = [
|
|||||||
export default function VetGallery({ testimonials = [] }: { testimonials?: any[] }) {
|
export default function VetGallery({ testimonials = [] }: { testimonials?: any[] }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const getText = useSettingsStore(state => state.getText);
|
const getText = useSettingsStore(state => state.getText);
|
||||||
const displayItems = testimonials.length > 0 ? testimonials.map((t, idx) => ({
|
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}`,
|
id: t.id || `t-${idx}`,
|
||||||
title: t.clinicName ? `${t.vetName} — ${t.clinicName}` : t.vetName,
|
title: t.clinicName ? `${t.vetName} — ${t.clinicName}` : t.vetName,
|
||||||
doctor: t.vetName,
|
doctor: t.vetName,
|
||||||
duration: "۲:۰۰",
|
duration: "۰۲:۰۰",
|
||||||
thumbnail: t.imageUrl || "https://images.unsplash.com/photo-1576091160550-217359f42f8c?auto=format&fit=crop&q=80&w=400",
|
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",
|
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
|
||||||
description: t.quote,
|
description: t.quote,
|
||||||
quote: t.quote
|
quote: t.quote
|
||||||
})) : VIDEOS;
|
}))
|
||||||
const [selectedVideo, setSelectedVideo] = useState<any>(null);
|
: (apiVideos.length > 0 ? apiVideos : FALLBACK_VIDEOS);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="py-24 bg-white font-vazir" dir="rtl">
|
<section className="py-24 bg-white font-vazir" dir="rtl">
|
||||||
|
|||||||
@ -5,21 +5,24 @@ import { ChevronRight, PlayCircle, Star, ShieldCheck, X, Search, Clock, User } f
|
|||||||
import SafeImage from "./SafeImage";
|
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",
|
id: "v1",
|
||||||
title: "نحوه آمادهسازی کانیهیدروکس",
|
title: "نحوه آمادهسازی کانیهیدروکس GAG",
|
||||||
doctor: "دکتر کلاوس هنینگ",
|
doctor: "دکتر کلاوس هنینگ",
|
||||||
duration: "۱:۴۵",
|
duration: "۰۱:۴۵",
|
||||||
thumbnail: "https://images.unsplash.com/photo-1576091160550-217359f42f8c?auto=format&fit=crop&q=80&w=600",
|
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",
|
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
|
||||||
description: "آموزش گام به گام آمادهسازی پودر Canhydrox GAG برای جذب حداکثری در دستگاه گوارش."
|
description: "آموزش گام به گام آمادهسازی پودر Canhydrox GAG برای جذب حداکثری در دستگاه گوارش."
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "v2",
|
id: "v2",
|
||||||
title: "اهمیت صدف لبسبز برای غضروف",
|
title: "اهمیت صدف لبسبز برای بازسازی غضروف",
|
||||||
doctor: "دکتر النا اشمیت",
|
doctor: "دکتر النا اشمیت",
|
||||||
duration: "۲:۲۰",
|
duration: "۰۲:۲۰",
|
||||||
thumbnail: "https://images.unsplash.com/photo-1599443015574-be5fe8a05783?auto=format&fit=crop&q=80&w=600",
|
thumbnail: "https://images.unsplash.com/photo-1599443015574-be5fe8a05783?auto=format&fit=crop&q=80&w=600",
|
||||||
videoUrl: "https://www.w3schools.com/html/movie.mp4",
|
videoUrl: "https://www.w3schools.com/html/movie.mp4",
|
||||||
description: "چرا صدف لبسبز نیوزیلندی نایابترین و موثرترین ماده برای بازسازی مفصل است؟"
|
description: "چرا صدف لبسبز نیوزیلندی نایابترین و موثرترین ماده برای بازسازی مفصل است؟"
|
||||||
@ -28,7 +31,7 @@ const ALL_VIDEOS = [
|
|||||||
id: "v3",
|
id: "v3",
|
||||||
title: "تغذیه هوشمند تولهسگهای نژاد بزرگ",
|
title: "تغذیه هوشمند تولهسگهای نژاد بزرگ",
|
||||||
doctor: "دکتر هلگا فیشر",
|
doctor: "دکتر هلگا فیشر",
|
||||||
duration: "۳:۱۰",
|
duration: "۰۳:۱۰",
|
||||||
thumbnail: "/assets/images/regenerated_image_1779109861747.png",
|
thumbnail: "/assets/images/regenerated_image_1779109861747.png",
|
||||||
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
|
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
|
||||||
description: "دوز مصرفی مکملهای کلسیم در نژادهای بزرگ برای جلوگیری از بدشکلیهای استخوانی."
|
description: "دوز مصرفی مکملهای کلسیم در نژادهای بزرگ برای جلوگیری از بدشکلیهای استخوانی."
|
||||||
@ -37,7 +40,7 @@ const ALL_VIDEOS = [
|
|||||||
id: "v4",
|
id: "v4",
|
||||||
title: "تاثیر امگا ۳ بر ریزش مو فصلی",
|
title: "تاثیر امگا ۳ بر ریزش مو فصلی",
|
||||||
doctor: "دکتر ماری کونیگ",
|
doctor: "دکتر ماری کونیگ",
|
||||||
duration: "۵:۱۵",
|
duration: "۰۵:۱۵",
|
||||||
thumbnail: "https://images.unsplash.com/photo-1516734212186-a967f81ad0d7?auto=format&fit=crop&q=80&w=600",
|
thumbnail: "https://images.unsplash.com/photo-1516734212186-a967f81ad0d7?auto=format&fit=crop&q=80&w=600",
|
||||||
videoUrl: "https://www.w3schools.com/html/movie.mp4",
|
videoUrl: "https://www.w3schools.com/html/movie.mp4",
|
||||||
description: "چربیهای ضروری چگونه لایه محافظ پوست را بازسازی میکنند."
|
description: "چربیهای ضروری چگونه لایه محافظ پوست را بازسازی میکنند."
|
||||||
@ -46,21 +49,28 @@ const ALL_VIDEOS = [
|
|||||||
id: "v5",
|
id: "v5",
|
||||||
title: "سمزدایی گوارشی با اسید هومیک",
|
title: "سمزدایی گوارشی با اسید هومیک",
|
||||||
doctor: "دکتر ریکاردو کوخ",
|
doctor: "دکتر ریکاردو کوخ",
|
||||||
duration: "۴:۰۰",
|
duration: "۰۴:۰۰",
|
||||||
thumbnail: "https://images.unsplash.com/photo-1581594693702-fbdc51b2763b?auto=format&fit=crop&q=80&w=600",
|
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",
|
videoUrl: "https://www.w3schools.com/html/mov_bbb.mp4",
|
||||||
description: "مکانیزم عمل Moor-Tranke در جذب توکسینهای رودهای."
|
description: "مکانیزم عمل Moor-Tranke در جذب توکسینهای رودهای."
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
import { useRouter } from 'next/navigation';
|
|
||||||
|
|
||||||
export default function VideosPage() {
|
export default function VideosPage() {
|
||||||
const router = useRouter();
|
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 [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)
|
v.title.includes(search) || v.doctor.includes(search)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
35
frontend/application/lib/services/videoService.ts
Normal file
35
frontend/application/lib/services/videoService.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue
Block a user