feat: add product generation and pagination components

- Implemented product generation for categories with a helper function to create 50 products per category.
- Added image assets for English and Persian language support.
- Created a reusable Pagination component for navigating through product lists.
- Developed ProductList component to display products with pagination support.
- Defined TypeScript interfaces for product list props and API response structure.
This commit is contained in:
2025-06-08 15:52:05 +03:30
parent 929960b26f
commit 7073310a22
31 changed files with 424 additions and 205 deletions
+1 -1
View File
@@ -15,7 +15,7 @@ export default async function RootLayout({
children: React.ReactNode;
params: ILayoutLocaleParams;
}>) {
const locale = params.locale;
const locale = await params.locale;
let messages;
try {
messages = (await import(`../../../messages/${locale}.json`)).default;
+8 -6
View File
@@ -2,13 +2,10 @@ import Footer from '@/components/layout/footer/Footer';
import '@/app/globals.css';
import 'leaflet/dist/leaflet.css';
import { notFound } from 'next/navigation';
// import { cookies } from 'next/headers';
import { NextIntlClientProvider } from 'next-intl';
import type { ILayoutLocaleParams } from '@/models/layout.d';
// export const metadata: Metadata = {
// title: 'Create Next App',
// description: 'Generated by create next app',
// };
import { cookies } from 'next/headers';
export default async function RootLayout({
children,
@@ -17,7 +14,12 @@ export default async function RootLayout({
children: React.ReactNode;
params: ILayoutLocaleParams;
}>) {
const locale = params.locale;
const paramsData = await params;
const locale = paramsData.locale;
// const cookieStore = await cookies();
// // Set locale cookie on the server
// cookieStore.set('locale', locale, { path: '/', httpOnly: false });
const fontClass = locale === 'fa' ? 'font-fa' : 'font-en';
@@ -3,8 +3,10 @@ import images from '@/assets/images/images';
import Breadcrumb from '@/components/layout/breadcrumb';
import { IBreadcrumbItem } from '@/components/layout/breadcrumb/breadcrumb';
import InnerPageBanner from '@/components/layout/innerPages/banner';
import Pagination from '@/components/layout/pagination';
import ProductCategoryCardInProducts from '@/components/pages/productCategories/ProductCategoryCardInProducts';
import ProductCard from '@/components/pages/products/ProductCard';
import ProductList from '@/components/pages/products/productList';
import { ILayoutLocaleParams } from '@/models/layout';
import { getProductCategoryById, getProducts } from '@/services/products';
import { getTranslations } from 'next-intl/server';
@@ -33,6 +35,8 @@ export default async function ProductPage({
{ title: productCategory.title },
] as IBreadcrumbItem[];
const refetch = () => {};
return (
<main className="">
<InnerPageBanner
@@ -49,11 +53,11 @@ export default async function ProductPage({
<h2 className="mb-6 text-3xl font-semibold text-gray-500">
{productCategory.title} Products
</h2>
<div className="grid gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{products.map((product, index) => (
<ProductCard {...product} key={index} />
))}
</div>
<ProductList
initialProducts={products.data}
initialPagination={products.pagination}
categoryId={productCategoryId}
/>
</div>
</div>
</main>
@@ -0,0 +1,25 @@
import { productCategories } from '../../data';
import type { IProductCardProps } from '@/components/pages/products/productCard.d';
// Helper to generate 50 products for a category
export function generateProductsForCategory(
categoryId: string,
categoryTitle: string,
): IProductCardProps[] {
return Array.from({ length: 50 }, (_, i) => ({
id: `${categoryId}-${i + 1}`,
title: `${categoryTitle} Product ${i + 1}`,
size: `W ${10 + i} * L${20 + i}`,
imageSrc: `/images/product${(i % 4) + 1}.png`, // Example image path
// Add other fields from IProductCardProps if needed
}));
}
// Map of categoryId to products
export const productsByCategory: Record<string, IProductCardProps[]> =
Object.fromEntries(
productCategories.map((cat) => [
cat.id,
generateProductsForCategory(cat.id, cat.title),
]),
);
@@ -1,29 +1,36 @@
import { NextResponse, NextRequest } from 'next/server';
// import type { NextApiRequestContext } from 'next';
import { cookies } from 'next/headers';
import { productCategories } from '../../data';
import images from '@/assets/images/images';
import { generateProductsForCategory } from './data';
export async function GET(request: NextRequest, context: any) {
const cookieStore = await cookies();
const lang = cookieStore.get('lang')?.value || 'en';
const lang = cookieStore.get('locale')?.value || 'en';
const category = productCategories.find(
(cat) => cat.id === context.params.id,
);
const params = context.params;
const category = productCategories.find((cat) => cat.id === params.id);
if (!category) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const product = {
title: 'Product title',
size: 'W 18 * L10',
imageSrc: '/product1.png',
};
const products = Array.from({ length: 10 }, () => product);
// Pagination logic
const { searchParams } = new URL(request.url);
const page = parseInt(searchParams.get('page') || '1', 10);
const perPage = 20;
const allProducts = generateProductsForCategory(category.id, category.title);
const start = (page - 1) * perPage;
const end = start + perPage;
const paginatedProducts = allProducts.slice(start, end);
return NextResponse.json({
data: products,
data: paginatedProducts,
pagination: {
page,
perPage,
total: allProducts.length,
totalPages: Math.ceil(allProducts.length / perPage),
},
});
}
+22 -30
View File
@@ -1,45 +1,37 @@
import { NextResponse, NextRequest } from 'next/server';
// import type { NextApiRequestContext } from 'next';
'use server';
import { NextResponse, NextRequest } from 'next/server';
import { cookies } from 'next/headers';
import { productCategories } from '../data';
export async function GET(request: NextRequest, context: any) {
export async function GET(
request: NextRequest,
context: { params: { id: string } },
) {
const cookieStore = await cookies();
const lang = cookieStore.get('lang')?.value || 'en';
const lang = cookieStore.get('locale')?.value || 'en';
const category = productCategories.find(
(cat) => cat.id === context.params.id,
);
const params = await context.params;
const { id } = params;
let category = productCategories.find((cat) => cat.id === id);
if (!category) {
return NextResponse.json({ error: 'Not found' }, { status: 404 });
}
const titleData =
lang === 'fa' && category.fa_title ? category.fa_title : category.title;
const priceTypeTitleData =
lang === 'fa' && category.fa_priceTypeTitle
? category.fa_priceTypeTitle
: category.priceTypeTitle;
const bestForTitleData =
lang === 'fa' && category.fa_bestForTitle
? category.fa_bestForTitle
: category.bestForTitle;
const descriptionData =
lang === 'fa' && category.fa_description
? category.fa_description
: category.description;
if (lang === 'fa') {
category = {
...category,
title: category.fa_title || category.title,
priceTypeTitle: category.fa_priceTypeTitle || category.priceTypeTitle,
bestForTitle: category.fa_bestForTitle || category.bestForTitle,
description: category.fa_description || category.description,
};
}
return NextResponse.json({
data: {
id: category.id,
icon: category.icon,
typesCount: category.typesCount,
title: titleData,
priceTypeTitle: priceTypeTitleData,
bestForTitle: bestForTitleData,
description: descriptionData,
},
data: category,
});
}
+15 -13
View File
@@ -23,23 +23,25 @@ export async function GET(request: Request) {
description,
fa_description,
}) => {
const titleData = lang === 'fa' && fa_title ? fa_title : title;
const priceTypeTitleData =
lang === 'fa' && fa_priceTypeTitle
? fa_priceTypeTitle
: priceTypeTitle;
const bestForTitleData =
lang === 'fa' && fa_bestForTitle ? fa_bestForTitle : bestForTitle;
const descriptionData =
lang === 'fa' && fa_description ? fa_description : description;
if (lang === 'fa') {
return {
id,
icon,
typesCount,
title: fa_title || title,
priceTypeTitle: fa_priceTypeTitle || priceTypeTitle,
bestForTitle: fa_bestForTitle || bestForTitle,
description: fa_description || description,
};
}
return {
id,
icon,
typesCount,
title: titleData,
priceTypeTitle: priceTypeTitleData,
bestForTitle: bestForTitleData,
description: descriptionData,
title,
priceTypeTitle,
bestForTitle,
description,
};
},
);
-42
View File
@@ -1,42 +0,0 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { productCategories } from '../product_categories/data';
export async function GET(request: Request) {
const data = productCategories.find(
(productCategory) => productCategory.id === request.url.split('/').pop(),
);
const cookieStore = cookies();
const lang = (await cookieStore).get('lang')?.value || 'fa';
// if (lang === 'fa') {
// data = productCategories.find(
// ({
// id,
// title,
// fa_title,
// icon,
// typesCount,
// priceTypeTitle,
// fa_priceTypeTitle,
// bestForTitle,
// fa_bestForTitle,
// }) => {
// const titleData = lang === 'fa' && fa_title ? fa_title : title;
// const priceTypeTitleData =
// lang === 'fa' && fa_priceTypeTitle
// ? fa_priceTypeTitle
// : priceTypeTitle;
// const bestForTitleData =
// lang === 'fa' && fa_bestForTitle ? fa_bestForTitle : bestForTitle;
// return {data}
// }
return NextResponse.json({ data });
}
export async function POST(request: Request) {
const data = await request.json();
return NextResponse.json({ message: 'Received POST request', data });
}
+5
View File
@@ -6,6 +6,11 @@
html{
font-size: 16px;
scroll-snap-stop: always;
}
body{
scroll-snap-stop: always;
}
@media (width <=48rem) {
+8 -4
View File
@@ -7,17 +7,19 @@ import aboutImg_1 from './about-img-1.png';
import aboutImg_2 from './about-img-2.png';
import homeProductBack from './home_product_back.jpeg';
import whatWeDoImage from './what-we-do-image.png';
import blogImg_1 from './blog_img_1.jpeg'
import projectImg_1 from './project_img_1.jpeg'
import blogImg_1 from './blog_img_1.jpeg';
import projectImg_1 from './project_img_1.jpeg';
import product1 from './product1.png';
import notFound from './404.png'
import notFound from './404.png';
import langEn from './lang_en.png';
import langFa from './lang_fa.png';
export default {
homeBanner,
logo,
aboutBanner,
blogImg_1,
blogBanner,
projectImg_1,
projectImg_1,
factoryVector,
aboutImg_1,
aboutImg_2,
@@ -25,4 +27,6 @@ export default {
homeProductBack,
whatWeDoImage,
product1,
langEn,
langFa,
};
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1020 B

+37 -16
View File
@@ -1,28 +1,49 @@
'use client';
import Link from 'next/link';
import images from '@/assets/images/images';
import { TLocales } from '@/models/layout';
import Image from 'next/image';
import { usePathname } from 'next/navigation';
export default function LanguageSwitch() {
const pathname = usePathname();
const pathWithoutLocale = pathname.replace(/^\/(en|fa)/, '');
const languages = [
{
title: 'EN',
image: images.langEn,
locale: 'en',
},
{
title: 'FA',
image: images.langFa,
locale: 'fa',
},
];
const changeLocale = (locale: TLocales) => {
const currentLocale = pathname.split('/')[1];
if (currentLocale === locale) return;
let newPath = pathname.replace(/^\/(en|fa)/, '');
if (newPath === '/') newPath = '';
window.location.pathname = `/${locale}${newPath}`;
};
return (
<div className="flex items-center justify-center">
<Link
href={`/en${pathWithoutLocale === '/' ? '' : pathWithoutLocale}`}
className="mx-2 text-gray-800 hover:text-blue-500"
>
EN
</Link>
<span className="text-gray-800">|</span>
<Link
href={`/fa${pathWithoutLocale === '/' ? '' : pathWithoutLocale}`}
className="mx-2 text-gray-800 hover:text-blue-500"
>
FA
</Link>
<div className="flex items-center justify-center gap-3">
{languages.map((language) => (
<div
className="flex cursor-pointer flex-col items-center justify-center gap-2 px-1"
onClick={() => changeLocale(language.locale as TLocales)}
key={language.title}
>
<Image src={language.image} alt={language.title} />
<span className="text-xs font-normal text-white">
{language.title}
</span>
</div>
))}
</div>
);
}
+1 -1
View File
@@ -11,7 +11,7 @@ import { useTranslations } from 'next-intl';
import routeFactory from '@/assets/constants/routeFactory';
export default function Navbar() {
const [open, setOpen] = useState(false);
const [open, setOpen] = useState<boolean>(false);
const pathname = usePathname();
const params = useParams();
const locale = params.locale as string;
@@ -0,0 +1,55 @@
'use client';
import Button, { IconButton } from '@/components/uikit/button';
import React from 'react';
interface PaginationProps {
page: number;
totalPages: number;
onPageChange?: (page: number) => void;
className?: string;
}
export default function Pagination({
page,
totalPages,
onPageChange = () => {},
className,
}: PaginationProps) {
if (totalPages <= 1) return null;
const pages = [];
for (let i = 1; i <= totalPages; i++) {
pages.push(i);
}
return (
<nav
className={`mt-8 flex items-center justify-center gap-2 ${className || ''}`}
>
<IconButton
disabled={page === 1}
onClick={() => onPageChange(page - 1)}
className="disabled:opacity-50"
>
&lt;
</IconButton>
{pages.map((p) => (
<IconButton
key={p}
className={`${p === page ? 'opacity-50' : ''}`}
onClick={() => onPageChange(p)}
>
{p}
</IconButton>
))}
<IconButton
disabled={page === totalPages}
onClick={() => onPageChange(page + 1)}
className="disabled:opacity-50"
>
&gt;
</IconButton>
</nav>
);
}
@@ -0,0 +1,38 @@
'use client';
import { useState } from 'react';
import ProductCard from '../ProductCard';
import Pagination from '@/components/layout/pagination';
import { getProducts } from '@/services/products';
import { IProductListProps } from './productList';
export default function ProductList({
initialProducts,
initialPagination,
categoryId,
}: IProductListProps) {
const [products, setProducts] = useState(initialProducts);
const [pagination, setPagination] = useState(initialPagination);
const fetchPage = (page: number) => {
getProducts(categoryId, page)
.then((res) => {
setProducts(res.data);
setPagination(res.pagination);
})
.catch((error) => {
console.error('Failed to fetch products:', error);
});
};
return (
<>
<div className="grid gap-3 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{products.map((product, index) => (
<ProductCard {...product} key={index} />
))}
</div>
<Pagination {...pagination} onPageChange={fetchPage} />
</>
);
}
@@ -0,0 +1,8 @@
import { IPagination } from '@/models/response';
import { IProductCardProps } from '../productCard';
export interface IProductListProps {
initialProducts: IProductCardProps[];
initialPagination: IPagination;
categoryId: string;
}
+1
View File
@@ -8,6 +8,7 @@ export interface IBaseButtonProps
disabled?: boolean;
className?: string;
link?: URL | string;
onClick?: () => void;
}
export interface ITextButtonProps extends IBaseButtonProps {
+9 -1
View File
@@ -11,6 +11,7 @@ const Button: React.FC<ITextButtonProps> = ({
endIcon,
className,
link,
onClick,
...props
}) => {
const wrapperClass = `group bg-primary relative h-12 cursor-pointer rounded-[0.5625rem] p-[0.125rem] flex w-fit ${className || ''}`;
@@ -22,7 +23,12 @@ const Button: React.FC<ITextButtonProps> = ({
</ButtonBody>
</Link>
) : (
<button disabled={disabled || loading} {...props} className={wrapperClass}>
<button
disabled={disabled || loading}
{...props}
onClick={onClick}
className={wrapperClass}
>
<ButtonBody {...props} loading={loading} endIcon={endIcon}>
{children}
</ButtonBody>
@@ -82,6 +88,7 @@ export const IconButton: React.FC<IIconButtonProps> = ({
size = 'medium',
color = 'primary',
className,
onClick,
...props
}) => {
const colorClass =
@@ -95,6 +102,7 @@ export const IconButton: React.FC<IIconButtonProps> = ({
className={`group relative flex cursor-pointer items-center justify-center rounded-[0.5625rem] ${colorClass} ${sizeClass} ${className || ''} `}
disabled={disabled || loading}
{...props}
onClick={onClick}
>
{loading ? (
<svg
+4 -16
View File
@@ -1,30 +1,18 @@
import axios from 'axios';
// Create an Axios instance
const api = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3000/api',
withCredentials: true, // Send cookies with requests if needed
headers: {
'Content-Type': 'application/json',
},
withCredentials: true, // Enable sending cookies with requests
});
api.interceptors.request.use(
(config) => {
// Example: Add lang cookie from browser (client-side only)
if (typeof window !== 'undefined') {
const match = document.cookie.match(/(^| )lang=([^;]+)/);
if (match) {
config.headers['lang'] = decodeURIComponent(match[2]);
}
}
return config;
},
(error) => Promise.reject(error),
);
api.interceptors.response.use(
(response) => response,
(error) => {
return Promise.reject(error);
},
);
export default api;
+39 -4
View File
@@ -1,8 +1,43 @@
import createMiddleware from 'next-intl/middleware';
import { routing } from './i18n/routing';
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export default createMiddleware(routing);
const PUBLIC_FILE = /\.(.*)$/;
const locales = ['en', 'fa'];
const defaultLocale = 'en';
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Ignore public files and API routes
if (pathname.startsWith('/api') || PUBLIC_FILE.test(pathname)) {
return NextResponse.next();
}
// Check if the pathname already includes a locale
const matchedLocale = locales.find(
(locale) => pathname.startsWith(`/${locale}/`) || pathname === `/${locale}`,
);
const locale = matchedLocale || defaultLocale;
let response: NextResponse;
// If locale is missing, redirect and set cookie
const pathnameIsMissingLocale = !matchedLocale;
if (pathnameIsMissingLocale) {
response = NextResponse.redirect(
new URL(`/${defaultLocale}${pathname}`, request.url),
);
} else {
response = NextResponse.next();
}
// Set the locale cookie for all responses
response.cookies.set('locale', locale, { path: '/' });
return response;
}
export const config = {
matcher: '/((?!api|trpc|_next|_vercel|.*\\..*).*)',
matcher: ['/((?!_next/static|_next/image|favicon.ico|api).*)'],
};
+11
View File
@@ -0,0 +1,11 @@
export interface IResponse<T> {
pagination: IPagination;
data: T;
}
export interface IPagination {
page: number;
perPage: number;
total: number;
totalPages: number;
}
+30 -7
View File
@@ -1,32 +1,55 @@
'use server';
import type { IProductCategoryCardProps } from '@/components/pages/productCategories/productCategoryCard.d';
import type { IProductCardProps } from '@/components/pages/products/productCard.d';
import api from '@/lib/axios';
import { IResponse } from '@/models/response';
import { cookies } from 'next/headers';
export function getProductCategories() {
export async function getProductCategories() {
const cookiesStore = await cookies();
return api
.get('/product_categories')
.get('/product_categories', {
headers: {
Cookie: cookiesStore.toString(),
},
})
.then(({ data }) => {
return Promise.resolve(data.data);
})
.catch((error) => Promise.reject(error));
}
export function getProductCategoryById(
export async function getProductCategoryById(
categoryId: string,
): Promise<IProductCategoryCardProps> {
const cookiesStore = await cookies();
return api
.get(`/product_categories/${categoryId}`)
.get(`/product_categories/${categoryId}`, {
headers: {
Cookie: cookiesStore.toString(),
},
})
.then(({ data }) => {
return Promise.resolve(data.data);
})
.catch((error) => Promise.reject(error));
}
export function getProducts(categoryId: string): Promise<IProductCardProps[]> {
export async function getProducts(
categoryId: string,
page = 1,
): Promise<IResponse<IProductCardProps[]>> {
const cookiesStore = await cookies();
return api
.get(`/product_categories/${categoryId}/products`)
.get(`/product_categories/${categoryId}/products`, {
headers: {
Cookie: cookiesStore.toString(),
},
params: { page },
})
.then(({ data }) => {
return Promise.resolve(data.data);
return Promise.resolve(data);
})
.catch((error) => Promise.reject(error));
}