This commit is contained in:
hamid zarghami
2025-09-20 16:14:57 +03:30
parent 40c3729d65
commit 1086135ecb
15 changed files with 593 additions and 132 deletions
+14 -6
View File
@@ -1,34 +1,42 @@
import { PRIMARY_COLOR } from '@/config/const'
import { ArrowLeft } from 'iconsax-react'
import Image from 'next/image'
import Link from 'next/link'
import React from 'react'
import { Blog } from '../types/Types'
const BlogItem = () => {
interface BlogItemProps {
blog: Blog
}
const BlogItem: React.FC<BlogItemProps> = ({ blog }) => {
return (
<div className='flex-1 flex flex-col sm:flex-row gap-4 sm:gap-6'>
<div className=' flex flex-col sm:flex-row gap-4 sm:gap-6'>
<Image
width={200}
height={160}
src={'https://picsum.photos/200/160'}
src={blog.imageUrl || 'https://picsum.photos/200/160'}
className='w-full sm:max-w-[200px] sm:max-h-[160px] h-48 sm:h-auto object-cover sm:object-contain'
alt='blog'
alt={blog.title_fa}
/>
<div className='flex-1'>
<h6 className='font-bold text-base sm:text-lg'>
سامسونگ اولین لپتاپ OLED یکپارچه را معرفی میکند
{blog.title_fa}
</h6>
<p className='mt-3 sm:mt-4 text-sm line-clamp-2 sm:line-clamp-3 text-[#7F7F7F] leading-6 sm:leading-7'>
لپ تاپ گلکسی بوک 3 سامسونگ دارای یک پنل OLED خواهد بود که در آن از فناوری سلولی استفاده میشود و حسگرها مستقیماً در نمایشگر قرار میگیرند تا صفحه لمسی نازکتر و سبکتری داشته باشد.
{blog.description}
</p>
<Link href={`/blogs/${blog._id}`}>
<div className='flex mt-3 sm:mt-4 text-primary gap-1.5 items-center text-sm cursor-pointer'>
<div className='text-sm'>
ادامه مطلب
</div>
<ArrowLeft size={18} color={PRIMARY_COLOR} />
</div>
</Link>
</div>
</div>
)
+51 -11
View File
@@ -1,6 +1,30 @@
import { FC } from 'react'
import { BlogCategory } from '../types/Types'
interface CategoriesProps {
categories: BlogCategory[]
selectedCategory?: string | null
onCategorySelect?: (slug: string | null) => void
}
const Categories: FC<CategoriesProps> = ({
categories,
selectedCategory,
onCategorySelect
}) => {
if (!categories || categories.length === 0) {
return (
<div className='max-w-[292px] w-full'>
<div className='h-20 bg-primary rounded-[10px] flex items-center px-4 text-white text-lg'>
دسته بندی مطالب
</div>
<div className='text-center text-sm text-[#8C8C8C] mt-4'>
هیچ دستهبندی یافت نشد
</div>
</div>
)
}
const Categories: FC = () => {
return (
<div className='max-w-[292px] w-full'>
<div className='h-20 bg-primary rounded-[10px] flex items-center px-4 text-white text-lg'>
@@ -8,18 +32,34 @@ const Categories: FC = () => {
</div>
<div className='flex flex-col gap-3 mt-4'>
<div className='h-14 bg-[#F8F8F8] rounded-[10px] px-4 flex items-center justify-between'>
<div className='font-medium text-[#8C8C8C]'>موبایل</div>
<div className='size-3 rounded-full bg-[#D9D9D9]'></div>
{/* All Categories */}
<div
onClick={() => onCategorySelect?.(null)}
className={`h-14 rounded-[10px] px-4 flex items-center justify-between transition-colors cursor-pointer ${selectedCategory === null
? 'bg-primary text-white'
: 'bg-[#F8F8F8] text-[#8C8C8C] hover:bg-[#E8E8E8]'
}`}
>
<div className='font-medium'>همه مطالب</div>
<div className={`size-3 rounded-full ${selectedCategory === null ? 'bg-white' : 'bg-[#D9D9D9]'
}`}></div>
</div>
<div className='h-14 bg-[#F8F8F8] rounded-[10px] px-4 flex items-center justify-between'>
<div className='font-medium text-[#8C8C8C]'>موبایل</div>
<div className='size-3 rounded-full bg-[#D9D9D9]'></div>
</div>
<div className='h-14 bg-[#F8F8F8] rounded-[10px] px-4 flex items-center justify-between'>
<div className='font-medium text-[#8C8C8C]'>موبایل</div>
<div className='size-3 rounded-full bg-[#D9D9D9]'></div>
{/* Category Items */}
{categories.map((category) => (
<div
key={category._id}
onClick={() => onCategorySelect?.(category.slug)}
className={`h-14 rounded-[10px] px-4 flex items-center justify-between transition-colors cursor-pointer ${selectedCategory === category.slug
? 'bg-primary text-white'
: 'bg-[#F8F8F8] text-[#8C8C8C] hover:bg-[#E8E8E8]'
}`}
>
<div className='font-medium'>{category.title_fa}</div>
<div className={`size-3 rounded-full ${selectedCategory === category.slug ? 'bg-white' : 'bg-[#D9D9D9]'
}`}></div>
</div>
))}
</div>
</div>
)
+9 -3
View File
@@ -1,7 +1,12 @@
import { FC } from 'react'
import MiniBlogItem from './MiniBlogItem'
import { Blog } from '../types/Types'
const LastPost: FC = () => {
interface LastPostProps {
blogs: Blog[]
}
const LastPost: FC<LastPostProps> = ({ blogs }) => {
return (
<div className='w-full'>
<div className='font-bold text-base sm:text-lg mb-4'>
@@ -9,8 +14,9 @@ const LastPost: FC = () => {
</div>
<div className='space-y-4'>
<MiniBlogItem />
<MiniBlogItem />
{blogs.map((blog) => (
<MiniBlogItem key={blog._id} blog={blog} />
))}
</div>
</div>
)
+13 -5
View File
@@ -1,27 +1,35 @@
import Image from 'next/image'
import Link from 'next/link'
import React from 'react'
import { Blog } from '../types/Types'
const MiniBlogItem = () => {
interface MiniBlogItemProps {
blog: Blog
}
const MiniBlogItem: React.FC<MiniBlogItemProps> = ({ blog }) => {
return (
<Link href={`/blogs/${blog._id}`} className='block'>
<div className='flex gap-3 sm:gap-4 mt-4 sm:mt-6'>
<Image
width={123}
height={123}
src={'https://picsum.photos/123/123'}
alt='last post'
src={blog.imageUrl || 'https://picsum.photos/123/123'}
alt={blog.title_fa}
className='w-20 h-20 sm:max-w-[100px] sm:max-h-[100px] object-cover sm:object-contain flex-shrink-0'
/>
<div className='flex-1 min-w-0'>
<h6 className='text-sm font-bold leading-5'>
رندرهای گلکسی M54 5G
{blog.title_fa}
</h6>
<p className='mt-2 sm:mt-3 text-xs text-[#7F7F7F] line-clamp-2 leading-5'>
طبق رندرها، گلکسی M54 5G فاقد ماژول دوربین است و سنسورهای دوربین هر....
{blog.description}
</p>
</div>
</div>
</Link>
)
}
+138
View File
@@ -0,0 +1,138 @@
import { FC } from 'react'
import { Button } from '@/components/ui/button'
import { ChevronLeft, ChevronRight } from 'lucide-react'
interface PaginationProps {
currentPage: number
totalPages: number
totalItems: number
itemsPerPage: number
onPageChange: (page: number) => void
}
const Pagination: FC<PaginationProps> = ({
currentPage,
totalPages,
totalItems,
itemsPerPage,
onPageChange
}) => {
if (totalPages <= 1) return null
const startItem = (currentPage - 1) * itemsPerPage + 1
const endItem = Math.min(currentPage * itemsPerPage, totalItems)
const getPageNumbers = () => {
const pages = []
const maxVisiblePages = 5
if (totalPages <= maxVisiblePages) {
for (let i = 1; i <= totalPages; i++) {
pages.push(i)
}
} else {
if (currentPage <= 3) {
for (let i = 1; i <= 5; i++) {
pages.push(i)
}
} else if (currentPage >= totalPages - 2) {
for (let i = totalPages - 4; i <= totalPages; i++) {
pages.push(i)
}
} else {
for (let i = currentPage - 2; i <= currentPage + 2; i++) {
pages.push(i)
}
}
}
return pages
}
const pageNumbers = getPageNumbers()
return (
<div className='mt-8'>
{/* Pagination Info */}
<div className='text-center text-sm text-muted-foreground mb-4'>
نمایش {startItem} تا {endItem} از {totalItems} مطلب
</div>
{/* Pagination Controls */}
<div className='flex items-center justify-center gap-2'>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage - 1)}
disabled={currentPage === 1}
className='flex items-center gap-1'
>
<ChevronRight className='h-4 w-4' />
قبلی
</Button>
<div className='flex items-center gap-1'>
{/* First page */}
{currentPage > 3 && totalPages > 5 && (
<>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(1)}
className='w-10 h-10'
>
1
</Button>
{currentPage > 4 && (
<span className='px-2 text-muted-foreground'>...</span>
)}
</>
)}
{/* Page numbers */}
{pageNumbers.map((pageNumber) => (
<Button
key={pageNumber}
variant={currentPage === pageNumber ? "default" : "outline"}
size="sm"
onClick={() => onPageChange(pageNumber)}
className='w-10 h-10'
>
{pageNumber}
</Button>
))}
{/* Last page */}
{currentPage < totalPages - 2 && totalPages > 5 && (
<>
{currentPage < totalPages - 3 && (
<span className='px-2 text-muted-foreground'>...</span>
)}
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(totalPages)}
className='w-10 h-10'
>
{totalPages}
</Button>
</>
)}
</div>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(currentPage + 1)}
disabled={currentPage === totalPages}
className='flex items-center gap-1'
>
بعدی
<ChevronLeft className='h-4 w-4' />
</Button>
</div>
</div>
)
}
export default Pagination
+37
View File
@@ -0,0 +1,37 @@
import * as api from "../service/Service";
import { useQuery } from "@tanstack/react-query";
import { BlogResponse, BlogCategory } from "../types/Types";
export const useGetBlogs = (page = 1, limit = 20) => {
return useQuery<BlogResponse>({
queryKey: ["blogs", page, limit],
queryFn: () => api.getBlogs(page, limit),
});
};
export const useGetBlogCategories = () => {
return useQuery<BlogCategory[]>({
queryKey: ["blog-categories"],
queryFn: () => api.getBlogCategories(),
});
};
export const useGetBlogDetails = (id: string) => {
return useQuery<BlogResponse>({
queryKey: ["blog-details", id],
queryFn: () => api.getBlogDetails(id),
enabled: !!id,
});
};
export const useGetBlogsByCategory = (
slug: string,
page: number = 1,
limit: number = 10
) => {
return useQuery<BlogResponse>({
queryKey: ["blogs-by-category", slug, page, limit],
queryFn: () => api.getBlogsByCategory(slug, page, limit),
enabled: !!slug,
});
};
+60 -6
View File
@@ -1,28 +1,82 @@
'use client'
import { NextPage } from 'next'
import { useState } from 'react'
import Layout from '@/hoc/Layout'
import Categories from './components/Categories'
import BlogItem from './components/BlogItem'
import LastPost from './components/LastPost'
import Pagination from './components/Pagination'
import { useGetBlogCategories, useGetBlogs, useGetBlogsByCategory } from './hooks/useBlogData'
import PageLoading from '@/components/PageLoading'
import Error from '@/components/Error'
const BlogsPage: NextPage = () => {
const [currentPage, setCurrentPage] = useState(1)
const [selectedCategory, setSelectedCategory] = useState<string | null>(null)
const { data: blogCategories, isLoading: categoriesLoading, error: categoriesError } = useGetBlogCategories()
// Always call both hooks, but use the appropriate one
const { data: allBlogs, isLoading: allBlogsLoading, error: allBlogsError } = useGetBlogs(currentPage, 10)
const { data: categoryBlogs, isLoading: categoryBlogsLoading, error: categoryBlogsError } = useGetBlogsByCategory(selectedCategory || '', currentPage, 10)
// Use the appropriate data based on selected category
const blogs = selectedCategory ? categoryBlogs : allBlogs
const blogsLoading = selectedCategory ? categoryBlogsLoading : allBlogsLoading
const blogsError = selectedCategory ? categoryBlogsError : allBlogsError
if (blogsLoading || categoriesLoading) {
return <PageLoading />
}
if (blogsError || categoriesError) {
return <Error errorText="خطا در بارگذاری مطالب" />
}
const handlePageChange = (page: number) => {
setCurrentPage(page)
window.scrollTo({ top: 0, behavior: 'smooth' })
}
const handleCategorySelect = (slug: string | null) => {
setSelectedCategory(slug)
setCurrentPage(1) // Reset to first page when changing category
}
const pager = blogs?.results?.pager
return (
<div className='flex flex-col lg:flex-row gap-6 lg:gap-24 p-4 sm:p-6 lg:p-10'>
{/* Categories - Hidden on mobile, shown on desktop */}
<div className='hidden lg:block'>
<Categories />
<Categories
categories={blogCategories || []}
selectedCategory={selectedCategory}
onCategorySelect={handleCategorySelect}
/>
</div>
{/* Main content */}
<div className='flex-1 flex flex-col gap-6'>
<BlogItem />
<BlogItem />
<BlogItem />
<BlogItem />
{blogs?.results?.blogs?.map((blog) => (
<BlogItem key={blog._id} blog={blog} />
))}
{/* Pagination */}
{pager && (
<Pagination
currentPage={currentPage}
totalPages={pager.totalPages}
totalItems={pager.totalItems}
itemsPerPage={10}
onPageChange={handlePageChange}
/>
)}
</div>
{/* Last Post - Full width on mobile, sidebar on desktop */}
<div className='lg:max-w-[340px] w-full'>
<LastPost />
<LastPost blogs={blogs?.results?.blogs?.slice(0, 2) || []} />
</div>
</div>
)
+36
View File
@@ -0,0 +1,36 @@
import axios from "@/config/axios";
import { BlogResponse, BlogCategory } from "../types/Types";
export const getBlogs = async (
page: number,
limit: number
): Promise<BlogResponse> => {
const { data } = await axios.get(`/blogs?page=${page}&limit=${limit}`);
return data;
};
export const getBlogCategories = async (): Promise<BlogCategory[]> => {
try {
const { data } = await axios.get("/blogs/categories");
return data.results?.categories || [];
} catch (error) {
console.error("Error fetching blog categories:", error);
return [];
}
};
export const getBlogDetails = async (id: string): Promise<BlogResponse> => {
const { data } = await axios.get(`/blogs/${id}`);
return data;
};
export const getBlogsByCategory = async (
slug: string,
page: number = 1,
limit: number = 10
): Promise<BlogResponse> => {
const { data } = await axios.get(
`/blogs/category/${slug}?page=${page}&limit=${limit}`
);
return data;
};
+65
View File
@@ -0,0 +1,65 @@
export interface BlogCategory {
_id: string;
title_fa: string;
title_en: string;
slug: string;
icon: string;
imageUrl: string;
description: string;
variants: number[];
hierarchy: string[];
leaf: boolean;
parent: string | null;
deleted: boolean;
createdAt: string;
updatedAt: string;
url: string;
children: BlogCategory[];
theme?: string;
}
export interface BlogAuthor {
fullName: string;
}
export interface Blog {
_id: string;
title_fa: string;
slug: string;
category: BlogCategory;
description: string;
content: string;
author: BlogAuthor;
view: number;
tags: string[];
imageUrl: string;
createdAt: string;
}
export interface BlogPager {
page: number;
limit: number;
totalItems: number;
totalPages: number;
prevPage: boolean;
nextPage: boolean;
}
export interface BlogResults {
pager: BlogPager;
blogs: Blog[];
}
export interface BlogCategoryResponse {
status: number;
success: boolean;
results: {
data: BlogCategory[];
};
}
export interface BlogResponse {
status: number;
success: boolean;
results: BlogResults;
}
+42 -12
View File
@@ -1,37 +1,67 @@
import { ArrowLeft } from 'iconsax-react'
import Image from 'next/image'
import Link from 'next/link'
import { FC } from 'react'
import { Blog } from '@/app/blogs/types/Types'
import { PRIMARY_COLOR } from '@/config/const'
import moment from 'moment-jalaali'
const BlogCard: FC = () => {
interface BlogCardProps {
blog?: Blog;
isLoading?: boolean;
}
const BlogCard: FC<BlogCardProps> = ({ blog, isLoading = false }) => {
if (isLoading) {
return (
<div className='bg-white p-3 rounded-2xl shadow w-full overflow-hidden'>
<div className='bg-white p-3 rounded-2xl shadow w-full overflow-hidden animate-pulse h-full flex flex-col'>
<div className='w-full h-[120px] sm:h-[181px] bg-gray-200 rounded-xl sm:rounded-2xl'></div>
<div className='mt-3 h-4 w-16 bg-gray-200 rounded'></div>
<div className='mt-2 h-5 w-full bg-gray-200 rounded'></div>
<div className='mt-2 h-4 w-3/4 bg-gray-200 rounded flex-1'></div>
<div className='mt-4 sm:mt-6 flex justify-between items-center'>
<div className='h-3 w-16 bg-gray-200 rounded'></div>
<div className='h-3 w-20 bg-gray-200 rounded'></div>
</div>
</div>
)
}
if (!blog) return null;
return (
<Link href={`/blogs/${blog._id}`}>
<div className='bg-white p-3 rounded-2xl shadow w-full overflow-hidden hover:shadow-lg transition-shadow cursor-pointer h-full flex flex-col'>
<Image
src='https://picsum.photos/200/300?grayscale'
src={blog.imageUrl || 'https://picsum.photos/200/300?grayscale'}
width={357}
height={201}
alt=''
alt={blog.title_fa}
className='w-full h-[120px] sm:h-[181px] object-cover rounded-xl sm:rounded-2xl'
/>
<div className='mt-3'>
<h6 className='text-primary text-sm'>تکنولوژی</h6>
<h6 className='text-primary text-sm'>{blog.category?.title_fa || 'عمومی'}</h6>
</div>
<h4 className='mt-2 text-sm sm:text-base'>
نسل جدید پردازنده اینتل
<h4 className='mt-2 text-sm sm:text-base line-clamp-2'>
{blog.title_fa}
</h4>
<p className='mt-2 text-xs sm:text-sm text-[#7F7F7F] font-light leading-5 sm:leading-6'>
لورم ایپسوم متن ساختگی با تولید سادگی نامفهوم از صنعت چاپ، و با استفاده از طراحان گرافیک است....
<p className='mt-2 text-xs sm:text-sm text-[#7F7F7F] font-light leading-5 sm:leading-6 line-clamp-2 flex-1'>
{blog.description}
</p>
<div className='mt-4 sm:mt-6 flex justify-between items-center'>
<div className='text-xs sm:text-sm font-light text-[#7F7F7F]'>1401/7/21</div>
<div className='flex gap-1 items-center text-[#7F7F7F]'>
<div className='text-xs sm:text-sm font-light text-[#7F7F7F]'>
{moment(blog.createdAt, 'jYYYY/jMM/jDD HH:mm:ss').format('jYYYY-jMM-jDD')}
</div>
<div className='flex gap-1 items-center text-primary hover:text-primary transition-colors'>
<div className='text-xs sm:text-sm font-light'>ادامه مطلب</div>
<ArrowLeft size={14} color='#7F7F7F' className='sm:w-4 sm:h-4' />
<ArrowLeft size={14} color={PRIMARY_COLOR} className='sm:w-4 sm:h-4' />
</div>
</div>
</div>
</Link>
)
}
+29 -9
View File
@@ -1,22 +1,42 @@
'use client'
import { FC } from 'react'
import Link from 'next/link'
import BlogCard from './BlogCard'
import GridWrapper from '@/components/GridWrapper'
import { useGetBlogs } from '@/app/blogs/hooks/useBlogData';
const Blogs: FC = () => {
const { data, error } = useGetBlogs(1, 5)
if (error || !data?.results?.blogs) {
return (
<div>
<h4 className='text-[#012B4D] text-lg'>رسانه و مطالب </h4>
<h4 className='text-[#012B4D] text-lg'>رسانه و مطالب</h4>
<div className='mt-2 flex gap-1 text-sm text-[#8C8C8C]'>
برای مشاهده ی آخرین مطالب رسانه و مطالب <span className='text-primary'>اینجا کلیک کنید</span>
برای مشاهده ی آخرین مطالب رسانه و مطالب <Link href="/blogs" className='text-primary hover:underline'>اینجا کلیک کنید</Link>
</div>
<div className='mt-10'>
<GridWrapper desktop={5} mobile={1} gapDesktop={24} gapMobile={16} >
<BlogCard />
<BlogCard />
<BlogCard />
<BlogCard />
<BlogCard />
</GridWrapper >
<div className='text-center text-gray-500'>خطا در بارگذاری مطالب</div>
</div>
</div>
)
}
const blogs = data.results.blogs
return (
<div>
<h4 className='text-[#012B4D] text-lg'>رسانه و مطالب</h4>
<div className='mt-2 flex gap-1 text-sm text-[#8C8C8C]'>
برای مشاهده ی آخرین مطالب رسانه و مطالب <Link href="/blogs" className='text-primary hover:underline'>اینجا کلیک کنید</Link>
</div>
<div className='mt-10'>
<GridWrapper className='items-stretch' desktop={5} mobile={1} gapDesktop={24} gapMobile={16}>
{blogs.map((blog) => (
<BlogCard key={blog._id} blog={blog} />
))}
</GridWrapper>
</div>
</div>
)
@@ -1,3 +1,4 @@
'use client'
import { FC } from 'react'
import {
Carousel,
@@ -7,9 +8,14 @@ import {
CarouselPrevious,
} from "@/components/ui/carousel"
import ProductCard from '@/components/ProductCard'
import { useGetPopularProducts } from '../hooks/useHomeData'
const PopularProducts: FC = () => {
const { data } = useGetPopularProducts();
if (!!data?.results?.popularProducts?.length)
return (
<div>
<h4 className='text-[#012B4D] text-lg'>محبوب ترین محصولات</h4>
@@ -38,6 +44,7 @@ const PopularProducts: FC = () => {
</div>
</div>
)
return null
}
export default PopularProducts
+7
View File
@@ -12,3 +12,10 @@ export const getLandingQueryOptions = {
queryKey: ["landing"],
queryFn: api.getLanding,
};
export const useGetPopularProducts = () => {
return useQuery({
queryKey: ["popular-products"],
queryFn: api.getPopularProducts,
});
};
+5
View File
@@ -5,3 +5,8 @@ export const getLanding = async (): Promise<ILandingResponse> => {
const { data } = await axios.get<ILandingResponse>("/landing");
return data;
};
export const getPopularProducts = async () => {
const { data } = await axios.get("/product/popular/all");
return data;
};
+2 -2
View File
@@ -1,6 +1,6 @@
export const TOKEN_NAME = "sh_token";
export const REFRESH_TOKEN_NAME = "sh_refresh_token";
// export const BASE_URL = "https://shop-api.dev.danakcorp.com";
export const BASE_URL = "http://192.168.1.111:4000";
export const BASE_URL = "https://shop-api.dev.danakcorp.com";
// export const BASE_URL = "http://192.168.1.111:4000";
export const PRIMARY_COLOR = "#015699";