feat: complete app democking, fix types, add blogs api
This commit is contained in:
parent
6027e635db
commit
945c007a79
@ -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")
|
||||
}
|
||||
|
||||
65
backend/prisma/seed-blogs.ts
Normal file
65
backend/prisma/seed-blogs.ts
Normal 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
107
backend/prisma/seed-home.ts
Normal 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();
|
||||
});
|
||||
@ -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: [
|
||||
|
||||
17
backend/src/blogs/blogs.controller.ts
Normal file
17
backend/src/blogs/blogs.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
9
backend/src/blogs/blogs.module.ts
Normal file
9
backend/src/blogs/blogs.module.ts
Normal 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 {}
|
||||
36
backend/src/blogs/blogs.service.ts
Normal file
36
backend/src/blogs/blogs.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
1
backend/src/blogs/dto/create-blog.dto.ts
Normal file
1
backend/src/blogs/dto/create-blog.dto.ts
Normal file
@ -0,0 +1 @@
|
||||
export class CreateBlogDto {}
|
||||
4
backend/src/blogs/dto/update-blog.dto.ts
Normal file
4
backend/src/blogs/dto/update-blog.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateBlogDto } from './create-blog.dto';
|
||||
|
||||
export class UpdateBlogDto extends PartialType(CreateBlogDto) {}
|
||||
1
backend/src/blogs/entities/blog.entity.ts
Normal file
1
backend/src/blogs/entities/blog.entity.ts
Normal file
@ -0,0 +1 @@
|
||||
export class Blog {}
|
||||
1
backend/src/home/dto/create-home.dto.ts
Normal file
1
backend/src/home/dto/create-home.dto.ts
Normal file
@ -0,0 +1 @@
|
||||
export class CreateHomeDto {}
|
||||
4
backend/src/home/dto/update-home.dto.ts
Normal file
4
backend/src/home/dto/update-home.dto.ts
Normal file
@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateHomeDto } from './create-home.dto';
|
||||
|
||||
export class UpdateHomeDto extends PartialType(CreateHomeDto) {}
|
||||
1
backend/src/home/entities/home.entity.ts
Normal file
1
backend/src/home/entities/home.entity.ts
Normal file
@ -0,0 +1 @@
|
||||
export class Home {}
|
||||
12
backend/src/home/home.controller.ts
Normal file
12
backend/src/home/home.controller.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
9
backend/src/home/home.module.ts
Normal file
9
backend/src/home/home.module.ts
Normal 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 {}
|
||||
52
backend/src/home/home.service.ts
Normal file
52
backend/src/home/home.service.ts
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -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">
|
||||
|
||||
76
frontend/application/app/wiki/[slug]/page.tsx
Normal file
76
frontend/application/app/wiki/[slug]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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">مطالعه مقاله ←</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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]);
|
||||
|
||||
|
||||
@ -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');
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -27,6 +27,7 @@ export interface FAQ {
|
||||
|
||||
export interface Product {
|
||||
id: string;
|
||||
slug?: string;
|
||||
artNo: string;
|
||||
name: string;
|
||||
scientificTagline: string;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user