feat: complete app democking, fix types, add blogs api

This commit is contained in:
پارسا آقایی 2026-06-12 22:53:57 +03:30
parent 6027e635db
commit 945c007a79
25 changed files with 557 additions and 56 deletions

View File

@ -140,6 +140,7 @@ model Product {
symptoms ProductSymptom[]
reminders Reminder[]
orderItems OrderItem[]
advisorRules SmartAdvisorRule[]
@@index([categorySlug])
@@index([suitableFor])
@ -321,3 +322,43 @@ model ScientificTerm {
@@map("scientific_terms")
}
model HeroBanner {
id String @id @default(uuid()) @db.Uuid
title String @db.VarChar(200)
subtitle String? @db.Text
imageUrl String @map("image_url") @db.Text
buttonText String? @map("button_text") @db.VarChar(100)
buttonLink String? @map("button_link") @db.Text
isActive Boolean @default(true) @map("is_active")
order Int @default(0)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
@@map("hero_banners")
}
model VetTestimonial {
id String @id @default(uuid()) @db.Uuid
vetName String @map("vet_name") @db.VarChar(150)
clinicName String? @map("clinic_name") @db.VarChar(150)
imageUrl String? @map("image_url") @db.Text
quote String @db.Text
rating Int @default(5)
isActive Boolean @default(true) @map("is_active")
order Int @default(0)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
@@map("vet_testimonials")
}
model SmartAdvisorRule {
id String @id @default(uuid()) @db.Uuid
condition String @db.VarChar(200)
targetPetType String? @map("target_pet_type") @db.VarChar(50)
recommendedProduct String @map("recommended_product_id") @db.Uuid
reason String @db.Text
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz()
product Product @relation(fields: [recommendedProduct], references: [id], onDelete: Cascade)
@@map("smart_advisor_rules")
}

View File

@ -0,0 +1,65 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Start seeding blogs...');
// Create a default admin author if not exists
let admin = await prisma.user.findFirst({ where: { role: 'admin' } });
if (!admin) {
admin = await prisma.user.create({
data: {
id: "123e4567-e89b-12d3-a456-426614174000",
firstName: 'مدیر',
lastName: 'سیستم',
mobile: '09000000000',
role: 'admin',
password: 'hashed_password' // just dummy
}
});
}
const blogs = [
{
title: 'اهمیت کلسیم در رشد استخوان توله‌سگ‌ها',
slug: 'calcium-importance-for-puppies',
content: '<p>کلسیم یکی از مهم‌ترین مواد معدنی برای رشد ساختار استخوانی است. توله‌سگ‌ها در ماه‌های اول زندگی به کلسیم بیشتری نیاز دارند...</p>',
imageUrl: '/images/wiki/calcium.webp',
metaTitle: 'اهمیت کلسیم در رشد سگ',
metaDescription: 'چرا کلسیم برای توله سگ ها مهم است؟',
keywords: 'کلسیم, سگ, رشد',
authorId: admin.id
},
{
title: 'ویتامین‌های گروه B و سلامت پوست و مو',
slug: 'b-vitamins-and-skin-health',
content: '<p>ویتامین‌های B Complex نقش حیاتی در حفظ سلامت پوست و درخشش موی گربه‌ها و سگ‌ها ایفا می‌کنند...</p>',
imageUrl: '/images/wiki/vitamins.webp',
metaTitle: 'ویتامین ب در سلامت پت',
metaDescription: 'تاثیر ویتامین بی بر پوست و مو',
keywords: 'ویتامین B, پوست, مو, گربه, سگ',
authorId: admin.id
}
];
for (const blog of blogs) {
await prisma.blog.upsert({
where: { slug: blog.slug },
update: blog,
create: blog,
});
}
console.log('Seeding blogs finished.');
}
main()
.then(async () => {
await prisma.$disconnect();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
process.exit(1);
});

107
backend/prisma/seed-home.ts Normal file
View File

@ -0,0 +1,107 @@
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
console.log('Seeding Home Page Components...');
// 1. Seed Hero Banners
const banners = [
{
title: 'محصولات تخصصی مراقبت از مفاصل',
subtitle: 'برای سگ‌های مسن و پرتحرک، تضمین سلامت و شادابی با کلاژن فعال',
imageUrl: '/images/slider/slide1.webp', // Assuming we have these in public/images
buttonText: 'مشاهده محصولات مفاصل',
buttonLink: '/shop/joints',
order: 1,
isActive: true,
},
{
title: 'ویتامین‌های ضروری برای رشد توله سگ‌ها',
subtitle: 'رشد کامل استخوان‌ها و تقویت سیستم ایمنی از ماه اول',
imageUrl: '/images/slider/slide2.webp',
buttonText: 'خرید ویتامین توله',
buttonLink: '/shop/puppy',
order: 2,
isActive: true,
}
];
await prisma.heroBanner.deleteMany(); // Reset
for (const b of banners) {
await prisma.heroBanner.create({ data: b });
}
console.log('Seeded Hero Banners.');
// 2. Seed Vet Testimonials
const vets = [
{
vetName: 'دکتر علیرضا رضایی',
clinicName: 'کلینیک دامپزشکی پایتخت',
imageUrl: '/images/vets/vet1.webp',
quote: 'مکمل‌های مفصلی کانینا از نظر سرعت جذب و اثربخشی در سگ‌های نژاد بزرگ بی‌نظیر هستند. ما نتایج فوق‌العاده‌ای روی دیسپلازی هیپ مشاهده کردیم.',
rating: 5,
order: 1,
isActive: true,
},
{
vetName: 'دکتر سارا شمس',
clinicName: 'بیمارستان دامپزشکی تهران',
imageUrl: '/images/vets/vet2.webp',
quote: 'من همیشه برای ریزش مو و مشکلات پوستی گربه‌ها، قرص‌های مخمر و بیوتین کانینا را تجویز می‌کنم. نتایج معمولاً در کمتر از ۳ هفته قابل مشاهده است.',
rating: 5,
order: 2,
isActive: true,
}
];
await prisma.vetTestimonial.deleteMany();
for (const v of vets) {
await prisma.vetTestimonial.create({ data: v });
}
console.log('Seeded Vet Testimonials.');
// 3. Seed Smart Advisor Rules
// We need to fetch an actual product ID to link
const product = await prisma.product.findFirst();
if (product) {
const rules = [
{
condition: 'ریزش مو شدید',
targetPetType: 'سگ',
reason: 'حاوی بیوتین و روی بالا برای توقف ریزش مو و براق شدن پوشش',
recommendedProduct: product.id,
},
{
condition: 'لنگش یا ضعف مفاصل',
targetPetType: 'سگ',
reason: 'غنی از گلوکزامین، کندرویتین و پودر صدف سبز نیوزلندی برای ترمیم غضروف',
recommendedProduct: product.id,
},
{
condition: 'بی‌اشتهایی',
targetPetType: 'گربه',
reason: 'مخمر خالص و ویتامین‌های گروه B برای افزایش اشتها و شادابی',
recommendedProduct: product.id,
}
];
await prisma.smartAdvisorRule.deleteMany();
for (const r of rules) {
await prisma.smartAdvisorRule.create({ data: r });
}
console.log('Seeded Smart Advisor Rules.');
} else {
console.log('No products found in DB! Skipping Smart Advisor Rules seeding.');
}
}
main()
.catch(e => {
console.error(e);
process.exit(1);
})
.finally(async () => {
await prisma.$disconnect();
});

View File

@ -11,6 +11,8 @@ import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import { APP_GUARD } from '@nestjs/core';
import { MetricsController } from './common/metrics.controller';
import { AdminModule } from './admin/admin.module';
import { HomeModule } from './home/home.module';
import { BlogsModule } from './blogs/blogs.module';
@Module({
imports: [
@ -27,6 +29,8 @@ import { AdminModule } from './admin/admin.module';
limit: 100,
}]),
AdminModule,
HomeModule,
BlogsModule,
],
controllers: [MetricsController],
providers: [

View File

@ -0,0 +1,17 @@
import { Controller, Get, Param } from '@nestjs/common';
import { BlogsService } from './blogs.service';
@Controller('blogs')
export class BlogsController {
constructor(private readonly blogsService: BlogsService) {}
@Get()
findAll() {
return this.blogsService.findAll();
}
@Get(':slug')
findOne(@Param('slug') slug: string) {
return this.blogsService.findOneBySlug(slug);
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { BlogsService } from './blogs.service';
import { BlogsController } from './blogs.controller';
@Module({
controllers: [BlogsController],
providers: [BlogsService],
})
export class BlogsModule {}

View File

@ -0,0 +1,36 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class BlogsService {
constructor(private prisma: PrismaService) {}
async findAll() {
return this.prisma.blog.findMany({
where: { isPublished: true },
orderBy: { createdAt: 'desc' },
include: {
author: {
select: { firstName: true, lastName: true }
}
}
});
}
async findOneBySlug(slug: string) {
const blog = await this.prisma.blog.findUnique({
where: { slug, isPublished: true },
include: {
author: {
select: { firstName: true, lastName: true }
}
}
});
if (!blog) {
throw new NotFoundException('مقاله یافت نشد');
}
return blog;
}
}

View File

@ -0,0 +1 @@
export class CreateBlogDto {}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateBlogDto } from './create-blog.dto';
export class UpdateBlogDto extends PartialType(CreateBlogDto) {}

View File

@ -0,0 +1 @@
export class Blog {}

View File

@ -0,0 +1 @@
export class CreateHomeDto {}

View File

@ -0,0 +1,4 @@
import { PartialType } from '@nestjs/swagger';
import { CreateHomeDto } from './create-home.dto';
export class UpdateHomeDto extends PartialType(CreateHomeDto) {}

View File

@ -0,0 +1 @@
export class Home {}

View File

@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { HomeService } from './home.service';
@Controller('home')
export class HomeController {
constructor(private readonly homeService: HomeService) {}
@Get()
getHomeData() {
return this.homeService.getHomeData();
}
}

View File

@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { HomeService } from './home.service';
import { HomeController } from './home.controller';
@Module({
controllers: [HomeController],
providers: [HomeService],
})
export class HomeModule {}

View File

@ -0,0 +1,52 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
@Injectable()
export class HomeService {
constructor(private prisma: PrismaService) {}
async getHomeData() {
// Fetch Banners
const heroBanners = await this.prisma.heroBanner.findMany({
where: { isActive: true },
orderBy: { order: 'asc' },
});
// Fetch Vet Testimonials
const vetTestimonials = await this.prisma.vetTestimonial.findMany({
where: { isActive: true },
orderBy: { order: 'asc' },
});
// Fetch Smart Advisor Rules
const smartAdvisorRules = await this.prisma.smartAdvisorRule.findMany({
include: {
product: {
select: {
id: true,
name: true,
slug: true,
imageUrl: true,
priceValue: true,
priceDisplay: true,
categorySlug: true,
categoryId: true
}
}
}
});
// We can also fetch featured products here if needed, or rely on a separate endpoint
const featuredProducts = await this.prisma.product.findMany({
take: 4,
orderBy: { createdAt: 'desc' }, // Or any other criteria
});
return {
heroBanners,
vetTestimonials,
smartAdvisorRules,
featuredProducts
};
}
}

View File

@ -4,14 +4,29 @@ import FeaturedProducts from "../components/FeaturedProducts";
import VetGallery from "../components/VetGallery";
import Link from 'next/link';
export default function Home() {
async function getHomeData() {
try {
const res = await fetch('http://localhost:4000/api/home', { next: { revalidate: 60 } });
if (!res.ok) throw new Error('Failed to fetch home data');
return res.json();
} catch (error) {
console.error('Home data fetch error:', error);
return null;
}
}
export default async function Home() {
const data = await getHomeData();
const banners = data?.heroBanners || [];
const testimonials = data?.vetTestimonials || [];
const rules = data?.smartAdvisorRules || [];
return (
<>
<Hero />
{/* SmartAdvisor uses uiStore/userStore internally and handles routing automatically */}
<SmartAdvisor />
<Hero banners={banners} />
<SmartAdvisor rules={rules} />
<FeaturedProducts />
<VetGallery />
<VetGallery testimonials={testimonials} />
{/* Navigation to Sections */}
<section className="py-20 bg-medical-gray-50 border-y border-medical-gray-100">

View File

@ -0,0 +1,76 @@
import Link from 'next/link';
import { ChevronRight } from 'lucide-react';
import type { Metadata } from 'next';
async function getBlog(slug: string) {
try {
const res = await fetch(`http://localhost:4000/api/blogs/${slug}`, { next: { revalidate: 60 } });
if (!res.ok) throw new Error('Failed to fetch blog');
return res.json();
} catch (error) {
console.error(error);
return null;
}
}
export async function generateMetadata({ params }: { params: { slug: string } }): Promise<Metadata> {
const blog = await getBlog(params.slug);
if (!blog) return { title: 'مقاله یافت نشد' };
return {
title: blog.metaTitle || blog.title,
description: blog.metaDescription || blog.title,
};
}
export default async function BlogPage({ params }: { params: { slug: string } }) {
const blog = await getBlog(params.slug);
if (!blog) {
return (
<div className="min-h-[50vh] flex flex-col items-center justify-center font-vazir text-center" dir="rtl">
<h1 className="text-3xl font-black text-medical-gray-900 mb-4">مقالهای یافت نشد</h1>
<Link href="/wiki" className="text-canina-blue font-bold flex items-center gap-2">
<ChevronRight className="w-4 h-4" />
بازگشت به دانشنامه
</Link>
</div>
);
}
return (
<div className="bg-white py-20 px-4 font-vazir" dir="rtl">
<div className="max-w-4xl mx-auto">
<Link href="/wiki" className="inline-flex items-center gap-2 text-medical-gray-400 hover:text-canina-blue transition-colors font-bold mb-8">
<ChevronRight className="w-5 h-5" />
بازگشت به مقالات
</Link>
{blog.imageUrl && (
<div className="aspect-video rounded-[2rem] overflow-hidden mb-12 shadow-xl border border-medical-gray-100">
<img src={blog.imageUrl} alt={blog.title} className="w-full h-full object-cover" />
</div>
)}
<h1 className="text-4xl lg:text-5xl font-black text-medical-gray-900 mb-6 leading-tight">
{blog.title}
</h1>
<div className="flex items-center gap-4 text-sm font-bold text-medical-gray-400 mb-12 pb-8 border-b border-medical-gray-100">
<span>نوشته شده در {new Date(blog.createdAt).toLocaleDateString('fa-IR')}</span>
{blog.author && (
<>
<span className="w-1 h-1 rounded-full bg-medical-gray-300" />
<span>توسط {blog.author.firstName} {blog.author.lastName}</span>
</>
)}
</div>
<div
className="prose prose-lg prose-medical-gray max-w-none prose-headings:font-black prose-p:leading-relaxed prose-a:text-canina-blue"
dangerouslySetInnerHTML={{ __html: blog.content }}
/>
</div>
</div>
);
}

View File

@ -1,11 +1,60 @@
import IngredientWiki from "../../components/IngredientWiki";
import Link from 'next/link';
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'دانشنامه ترکیبات دارویی',
description: 'اطلاعات کامل در مورد مواد موثره، ویتامین‌ها و ترکیبات گیاهی به کار رفته در محصولات تخصصی کانینا ایران.',
title: 'دانشنامه و مقالات علمی',
description: 'مقالات تخصصی و آموزشی درباره تغذیه و سلامت حیوانات خانگی',
};
export default function WikiPage() {
return <IngredientWiki />;
async function getBlogs() {
try {
const res = await fetch('http://localhost:4000/api/blogs', { next: { revalidate: 60 } });
if (!res.ok) throw new Error('Failed to fetch blogs');
return res.json();
} catch (error) {
console.error(error);
return [];
}
}
export default async function WikiPage() {
const blogs = await getBlogs();
return (
<div className="bg-white py-20 px-4 font-vazir" dir="rtl">
<div className="max-w-7xl mx-auto">
<h1 className="text-4xl font-black text-medical-gray-900 mb-12 text-center">دانشنامه و مقالات علمی</h1>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{blogs.map((blog: any) => (
<Link key={blog.id} href={`/wiki/${blog.slug}`} className="group block">
<div className="bg-medical-gray-50 rounded-[2rem] overflow-hidden border border-medical-gray-100 hover:shadow-2xl transition-all duration-300 h-full flex flex-col">
{blog.imageUrl && (
<div className="aspect-video overflow-hidden">
<img src={blog.imageUrl} alt={blog.title} className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500" />
</div>
)}
<div className="p-6 flex-1 flex flex-col">
<h3 className="text-xl font-black text-medical-gray-900 mb-4 group-hover:text-canina-blue transition-colors">
{blog.title}
</h3>
<div className="text-sm text-medical-gray-500 line-clamp-3 mb-6" dangerouslySetInnerHTML={{ __html: blog.content.substring(0, 150) + '...' }} />
<div className="mt-auto flex items-center justify-between text-xs font-bold text-medical-gray-400">
<span>{new Date(blog.createdAt).toLocaleDateString('fa-IR')}</span>
<span className="text-canina-blue">مطالعه مقاله &larr;</span>
</div>
</div>
</div>
</Link>
))}
{blogs.length === 0 && (
<div className="col-span-full py-20 text-center text-medical-gray-400 font-bold">
مقالهای یافت نشد.
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -62,10 +62,14 @@ function Typewriter({ text }: { text: string }) {
import { useRouter } from 'next/navigation';
export default function Hero() {
export default function Hero({ banners = [] }: { banners?: any[] }) {
const router = useRouter();
const getText = useSettingsStore(state => state.getText);
const title = getText('hero_title', "تخصص آلمانی در خدمت\nسلامت پت‌های خانگی");
const title = banners.length > 0 ? banners[0].title : getText('hero_title', "تخصص آلمانی در خدمت\nسلامت پت‌های خانگی");
const subtitle = banners.length > 0 ? banners[0].subtitle : getText(
'hero_desc',
'بیش از <span className="font-bold text-canina-blue">۴۰ سال</span> تجربه نوآورانه در تولید مکمل‌های درمانی با بالاترین استاندارد کیفی «گرید دارویی اختصاصی». راهکار هوشمند برای هر نیاز بالینی.'
);
const titleParts = title.split('\n');
return (
@ -97,10 +101,7 @@ export default function Hero() {
<p
className="text-lg lg:text-xl text-medical-gray-600 mb-10 max-w-2xl mx-auto lg:mx-0 leading-relaxed font-vazir animate-fade-in"
dangerouslySetInnerHTML={{
__html: getText(
'hero_desc',
'بیش از <span className="font-bold text-canina-blue">۴۰ سال</span> تجربه نوآورانه در تولید مکمل‌های درمانی با بالاترین استاندارد کیفی «گرید دارویی اختصاصی». راهکار هوشمند برای هر نیاز بالینی.'
)
__html: subtitle
}}
/>
@ -169,8 +170,8 @@ export default function Hero() {
>
<div className="aspect-square bg-medical-gray-100 rounded-[2rem] overflow-hidden border-4 border-white shadow-2xl relative">
<img
src="https://images.unsplash.com/photo-1583337130417-3346a1be7dee?auto=format&fit=crop&q=80&w=800"
alt="German Veterinary Expert"
src={banners.length > 0 && banners[0].imageUrl ? banners[0].imageUrl : "https://images.unsplash.com/photo-1583337130417-3346a1be7dee?auto=format&fit=crop&q=80&w=800"}
alt={banners.length > 0 ? banners[0].title : "German Veterinary Expert"}
className="w-full h-full object-cover grayscale-[20%] hover:grayscale-0 transition-all duration-700"
referrerPolicy="no-referrer"
/>

View File

@ -585,6 +585,8 @@ export default function PetProfile({ initialView, advisorNeed }: {
}
// 3. Detailed Dashboard View
if (!activePet) return null;
return (
<div className="min-h-screen bg-medical-gray-50 py-12 px-4 font-vazir" dir="rtl">
<div className="max-w-7xl mx-auto space-y-10">
@ -811,7 +813,7 @@ export default function PetProfile({ initialView, advisorNeed }: {
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
onClick={() => onProductClick(p)}
onClick={() => router.push(`/shop/${p.slug || p.id}`)}
className="bg-white rounded-[2.5rem] p-6 border border-medical-gray-200 hover:shadow-2xl transition-all cursor-pointer group flex flex-col justify-between relative overflow-hidden"
>
{reason && (
@ -966,7 +968,7 @@ export default function PetProfile({ initialView, advisorNeed }: {
<Package className="w-12 h-12" />
</div>
<p className="text-medical-gray-400 font-black text-xl italic">هنوز سفارشی برای {activePet.name} ثبت نکردهاید.</p>
<button onClick={() => onBack?.()} className="bg-canina-blue text-white px-10 py-4 rounded-2xl font-black hover:scale-105 transition-all">شروع اولین خرید</button>
<button onClick={() => router.push('/shop')} className="bg-canina-blue text-white px-10 py-4 rounded-2xl font-black hover:scale-105 transition-all">شروع اولین خرید</button>
</div>
) : (
<div className="space-y-5">
@ -978,7 +980,7 @@ export default function PetProfile({ initialView, advisorNeed }: {
animate={{ opacity: 1, x: 0 }}
whileHover={{ x: -10, backgroundColor: "rgba(249, 250, 251, 1)" }}
className="p-6 bg-white rounded-[2.5rem] border border-medical-gray-200 flex items-center justify-between gap-6 transition-all cursor-pointer group shadow-sm hover:shadow-xl"
onClick={() => onProductClick(item.product)}
onClick={() => router.push(`/shop/${item.product.slug || item.product.id}`)}
>
{/* Info on Left (Text side) */}
<div className="flex-1 text-right">

View File

@ -149,7 +149,8 @@ export default function ProductPage({ productSlug }: { productSlug: string }) {
// Suggest quantity inside useEffect to avoid render-phase state update
useEffect(() => {
const suggestedQty = (calculation?.duration < 30 && calculation?.duration > 0) ? 2 : 1;
const duration = calculation?.duration ?? 0;
const suggestedQty = (duration < 30 && duration > 0) ? 2 : 1;
setItemQuantity(suggestedQty);
}, [calculation?.duration]);

View File

@ -34,7 +34,7 @@ const MEDICAL_OPTIONS = [
{ id: "appetite", label: "بی‌اشتهایی", condition: "بی‌اشتهایی" },
];
export default function SmartAdvisor({ onComplete }: SmartAdvisorProps = {}) {
export default function SmartAdvisor({ onComplete, rules = [] }: SmartAdvisorProps & { rules?: any[] }) {
const router = useRouter();
const { isLoggedIn } = useUserStore();
const setLoginModalOpen = useUIStore(state => state.setLoginModalOpen);
@ -80,7 +80,7 @@ export default function SmartAdvisor({ onComplete }: SmartAdvisorProps = {}) {
if (!isLoggedIn) {
setLoginModalOpen(true, data);
} else {
const newPet = { ...data, id: Date.now().toString() };
const newPet: any = { ...data, id: Date.now().toString(), reminders: [], logs: [], consumptions: [] };
addPet(newPet);
setActivePet(newPet.id);
router.push('/profile');

View File

@ -34,9 +34,10 @@ const VIDEOS = [
}
];
export default function VetGallery() {
export default function VetGallery({ testimonials = [] }: { testimonials?: any[] }) {
const router = useRouter();
const [selectedVideo, setSelectedVideo] = useState<typeof VIDEOS[0] | null>(null);
const displayItems = testimonials.length > 0 ? testimonials : VIDEOS;
const [selectedVideo, setSelectedVideo] = useState<any>(null);
return (
<section className="py-24 bg-white font-vazir" dir="rtl">
@ -63,45 +64,36 @@ export default function VetGallery() {
</button>
</div>
<div className="grid md:grid-cols-2 lg:grid-cols-3 gap-8">
{VIDEOS.map((video, idx) => (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{displayItems.map((video: any) => (
<motion.div
key={video.id}
initial={{ opacity: 0, y: 30 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: idx * 0.1 }}
className="group cursor-pointer"
whileHover={{ y: -8 }}
onClick={() => setSelectedVideo(video)}
className="group cursor-pointer"
>
<div className="relative aspect-video rounded-[2rem] overflow-hidden mb-6 shadow-xl">
<div className="relative aspect-video rounded-[2rem] overflow-hidden mb-4 shadow-xl">
<div className="absolute inset-0 bg-medical-gray-900/20 group-hover:bg-transparent transition-colors z-10" />
<img
src={video.thumbnail}
alt={video.title}
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700"
referrerPolicy="no-referrer"
src={video.thumbnail || video.imageUrl || '/images/vets/vet1.webp'}
alt={video.title || video.vetName}
className="w-full h-full object-cover grayscale-[30%] group-hover:grayscale-0 transition-all duration-700"
/>
<div className="absolute inset-0 bg-black/30 group-hover:bg-black/20 transition-colors flex items-center justify-center">
<button onClick={() => router.push('/videos')} className="w-16 h-16 rounded-full bg-white/10 backdrop-blur-md flex items-center justify-center group-hover:scale-110 transition-transform border border-white/20 relative z-10">
<PlayCircle className="w-10 h-10" />
</button>
<div className="absolute inset-0 flex items-center justify-center z-20">
<div className="w-16 h-16 bg-white/90 backdrop-blur-sm rounded-full flex items-center justify-center text-canina-blue scale-90 group-hover:scale-100 group-hover:bg-white transition-all shadow-xl">
<Play className="w-6 h-6 ml-1" />
</div>
</div>
<div className="absolute bottom-4 right-4 bg-black/60 backdrop-blur-md text-white px-3 py-1 rounded-lg text-[10px] font-black">
{video.duration}
<div className="absolute bottom-4 right-4 bg-black/60 backdrop-blur-md text-white px-2 py-1 rounded-lg text-xs font-black z-20 font-vazir">
{video.duration || '۱:۰۰'}
</div>
</div>
<div className="px-4">
<div className="flex items-center gap-2 mb-2">
<ShieldCheck className="w-4 h-4 text-canina-blue" />
<span className="text-xs font-black text-canina-blue">{video.doctor}</span>
</div>
<h4 className="text-xl font-black text-medical-gray-900 group-hover:text-canina-blue transition-colors mb-3">
{video.title}
</h4>
<p className="text-sm text-medical-gray-500 font-medium leading-relaxed line-clamp-2 italic">
"{video.description}"
</p>
<h4 className="text-lg font-black text-medical-gray-900 mb-2 group-hover:text-canina-blue transition-colors leading-tight font-vazir">
{video.title || video.quote?.substring(0, 40) + '...'}
</h4>
<div className="flex items-center gap-2 text-medical-gray-500 font-bold text-sm font-vazir">
<ShieldCheck className="w-4 h-4 text-canina-blue" />
{video.doctor || video.vetName}
</div>
</motion.div>
))}

View File

@ -27,6 +27,7 @@ export interface FAQ {
export interface Product {
id: string;
slug?: string;
artNo: string;
name: string;
scientificTagline: string;