Merge branch 'master' of https://repo.hurasoft.com/tieptk/nguyencongpc_nextjs
This commit is contained in:
@@ -1,53 +0,0 @@
|
|||||||
'use client';
|
|
||||||
import React from 'react';
|
|
||||||
import { useParams } from 'next/navigation';
|
|
||||||
import Link from 'next/link';
|
|
||||||
import { findCategoryBySlug } from '@/lib/category';
|
|
||||||
|
|
||||||
const CategoryPage: React.FC = () => {
|
|
||||||
const params = useParams();
|
|
||||||
const slugArray = params?.slug as string[];
|
|
||||||
|
|
||||||
// tìm danh mục hiện tại theo slug
|
|
||||||
const category: Category | null = findCategoryBySlug(slugArray, categories);
|
|
||||||
|
|
||||||
if (!category) {
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto py-8">
|
|
||||||
<h1 className="text-2xl font-bold">Không tìm thấy danh mục</h1>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="container mx-auto py-8">
|
|
||||||
{/* Breadcrumb */}
|
|
||||||
<nav className="mb-4 text-sm text-gray-600">
|
|
||||||
{slugArray.map((slug, idx) => (
|
|
||||||
<span key={idx}>
|
|
||||||
<Link href={`/${slugArray.slice(0, idx + 1).join('/')}`}>{slug}</Link>
|
|
||||||
{idx < slugArray.length - 1 && ' / '}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Tiêu đề danh mục */}
|
|
||||||
<h1 className="mb-6 text-2xl font-bold">{category.name}</h1>
|
|
||||||
|
|
||||||
{/* Nếu có danh mục con thì hiển thị */}
|
|
||||||
{category.children && category.children.length > 0 ? (
|
|
||||||
<div className="grid grid-cols-2 gap-6 md:grid-cols-4">
|
|
||||||
{category.children.map((child) => (
|
|
||||||
<div key={child.id} className="rounded-lg border p-4">
|
|
||||||
<Link href={`/${slugArray.join('/')}/${child.slug}`}>{child.name}</Link>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p>Danh mục này chưa có danh mục con hoặc sản phẩm.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CategoryPage;
|
|
||||||
17
src/app/[slug]/page.tsx
Normal file
17
src/app/[slug]/page.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
'use client';
|
||||||
|
import { useParams } from 'next/navigation';
|
||||||
|
import { productCategoryData } from '@/data/product/category';
|
||||||
|
|
||||||
|
// import component
|
||||||
|
import CategoryPage from '@components/layout/product/Category';
|
||||||
|
|
||||||
|
export default function DynamicPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const slug = ('/' + params?.slug) as string;
|
||||||
|
|
||||||
|
if (productCategoryData.find((c) => c.current_category.url == slug)) {
|
||||||
|
return <CategoryPage slug={slug} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div>404 Không tìm thấy</div>;
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ export default function RootLayout({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Header />
|
<Header />
|
||||||
{children}
|
<main>{children}</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
46
src/components/common/Breadcrumb.tsx
Normal file
46
src/components/common/Breadcrumb.tsx
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
'use client';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { FaHouse, FaAngleRight } from 'react-icons/fa6';
|
||||||
|
|
||||||
|
interface BreadcrumbItem {
|
||||||
|
name: string | undefined;
|
||||||
|
url: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Breadcrumb = ({ items }: { items: BreadcrumbItem[] }) => {
|
||||||
|
return (
|
||||||
|
<nav className="box-breadcrumb-global mb-4 text-sm text-gray-600">
|
||||||
|
<ol itemScope itemType="http://schema.org/BreadcrumbList" className="flex gap-2">
|
||||||
|
<li
|
||||||
|
itemProp="itemListElement"
|
||||||
|
itemScope
|
||||||
|
itemType="http://schema.org/ListItem"
|
||||||
|
className="flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Link href="/" itemProp="item">
|
||||||
|
<span itemProp="name" className="flex items-center gap-2">
|
||||||
|
<span style={{ fontSize: 0 }}>Trang chủ</span> <FaHouse className="text-gray-700" />
|
||||||
|
</span>
|
||||||
|
</Link>{' '}
|
||||||
|
<FaAngleRight className="text-gray-700" />
|
||||||
|
<meta itemProp="position" content="1" />
|
||||||
|
</li>
|
||||||
|
{items.map((item, idx) => (
|
||||||
|
<li
|
||||||
|
key={idx}
|
||||||
|
itemProp="itemListElement"
|
||||||
|
itemScope
|
||||||
|
itemType="http://schema.org/ListItem"
|
||||||
|
className="flex items-center"
|
||||||
|
>
|
||||||
|
<Link href={item.url ?? '/'} itemProp="item">
|
||||||
|
<span itemProp="name">{item?.name}</span>
|
||||||
|
</Link>
|
||||||
|
<meta itemProp="position" content={(idx + 1).toString()} />
|
||||||
|
{idx < items.length - 1 && <span className="mx-1">/</span>}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -4,7 +4,7 @@ import Link from 'next/link';
|
|||||||
import { FaCaretDown } from 'react-icons/fa';
|
import { FaCaretDown } from 'react-icons/fa';
|
||||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||||
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
||||||
import ItemProduct from './ItemProduct';
|
import ItemProduct from '@/components/common/ItemProduct';
|
||||||
|
|
||||||
import { InfoCategory } from '@/types';
|
import { InfoCategory } from '@/types';
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { Swiper, SwiperSlide } from 'swiper/react';
|
|||||||
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { bannerData } from './bannerData';
|
import { bannerData } from '@/data/banner';
|
||||||
|
|
||||||
const SliderHome: React.FC = () => {
|
const SliderHome: React.FC = () => {
|
||||||
// data banner slider
|
// data banner slider
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ const IconFixRight: React.FC = () => {
|
|||||||
className="lazy"
|
className="lazy"
|
||||||
/>
|
/>
|
||||||
<div className="contact-info">
|
<div className="contact-info">
|
||||||
<b className="d-block">Chat Facebook</b>
|
<b className="block">Chat Facebook</b>
|
||||||
<span>(8h-22h30)</span>
|
<span>(8h-22h30)</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -57,14 +57,14 @@ const IconFixRight: React.FC = () => {
|
|||||||
className="flex items-center"
|
className="flex items-center"
|
||||||
style={{
|
style={{
|
||||||
background: '#fff',
|
background: '#fff',
|
||||||
bottom: '135px',
|
bottom: '145px',
|
||||||
width: '170px',
|
width: '170px',
|
||||||
padding: '10px',
|
padding: '10px',
|
||||||
borderRadius: '15px',
|
borderRadius: '15px',
|
||||||
boxShadow: '0 1px 2px 1px #01010133',
|
boxShadow: '0 1px 2px 1px #01010133',
|
||||||
color: '#007bff',
|
color: '#007bff',
|
||||||
fontSize: '14px',
|
fontSize: '14px',
|
||||||
height: '65px',
|
height: '50px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
@@ -76,7 +76,7 @@ const IconFixRight: React.FC = () => {
|
|||||||
style={{ marginRight: '10px' }}
|
style={{ marginRight: '10px' }}
|
||||||
/>
|
/>
|
||||||
<div className="contact-info">
|
<div className="contact-info">
|
||||||
<b className="d-block">Chat Zalo</b>
|
<b className="block">Chat Zalo</b>
|
||||||
<span>(8h-22h30)</span>
|
<span>(8h-22h30)</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||||
|
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { bannerData } from '@/data/banner';
|
||||||
|
|
||||||
|
const BannerCategory = () => {
|
||||||
|
const dataSlider = bannerData[0].product_list;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="box-banner-category">
|
||||||
|
<Swiper
|
||||||
|
modules={[Autoplay, Navigation, Pagination]}
|
||||||
|
spaceBetween={12}
|
||||||
|
slidesPerView={1}
|
||||||
|
loop={true}
|
||||||
|
>
|
||||||
|
{dataSlider?.banner_category_2023?.map((banner, index) => (
|
||||||
|
<SwiperSlide key={index}>
|
||||||
|
<Link href={banner.desUrl} className="item-banner boder-radius-10">
|
||||||
|
<Image
|
||||||
|
src={banner.fileUrl}
|
||||||
|
width={1909}
|
||||||
|
height={57}
|
||||||
|
alt={banner.title}
|
||||||
|
priority={true}
|
||||||
|
className="boder-radius-10"
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
</SwiperSlide>
|
||||||
|
))}
|
||||||
|
</Swiper>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BannerCategory;
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
'use client';
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
|
import { PriceFilter, AttributeFilterList, BrandFilter } from '@/types';
|
||||||
|
import { FaXmark } from 'react-icons/fa6';
|
||||||
|
|
||||||
|
interface Filters {
|
||||||
|
price_filter_list?: PriceFilter[];
|
||||||
|
attribute_filter_list?: AttributeFilterList[];
|
||||||
|
brand_filter_list?: BrandFilter[];
|
||||||
|
current_category?: { url: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
const ActiveFilters: React.FC<{ filters: Filters }> = ({ filters }) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const fullUrl = `${pathname}?${searchParams.toString()}`;
|
||||||
|
|
||||||
|
const selectedPrice = filters.price_filter_list?.filter((f) => fullUrl.includes(f.url)) ?? [];
|
||||||
|
const selectedBrand = filters.brand_filter_list?.filter((f) => fullUrl.includes(f.url)) ?? [];
|
||||||
|
const selectedAttr =
|
||||||
|
filters.attribute_filter_list?.flatMap((attr) =>
|
||||||
|
attr.value_list.filter((v) => pathname.includes(v.url)),
|
||||||
|
) ?? [];
|
||||||
|
|
||||||
|
const allSelected = [...selectedPrice, ...selectedBrand, ...selectedAttr];
|
||||||
|
const isFiltered = allSelected.length;
|
||||||
|
|
||||||
|
console.log(isFiltered);
|
||||||
|
|
||||||
|
if (isFiltered === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="info-filter-category flex gap-3">
|
||||||
|
<p className="title">Lọc theo:</p>
|
||||||
|
<div className="list-filter-category list-filter-active list-filter-last flex flex-wrap items-center gap-3">
|
||||||
|
{selectedPrice.map((item) => (
|
||||||
|
<Link key={item.url} href={item.url} className="item flex items-center gap-2 bg-white">
|
||||||
|
{item.name} <FaXmark />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{selectedBrand.map((item) => (
|
||||||
|
<Link key={item.url} href={item.url} className="item flex items-center gap-2 bg-white">
|
||||||
|
{item.name} <FaXmark />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
{selectedAttr.map((val) => (
|
||||||
|
<Link key={val.url} href={val.url} className="item flex items-center gap-2 bg-white">
|
||||||
|
{val.name} <FaXmark />
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{isFiltered >= 1 && (
|
||||||
|
<Link
|
||||||
|
href={filters.current_category?.url ?? '#'}
|
||||||
|
className="item delete-filter-all flex items-center gap-2 bg-white"
|
||||||
|
>
|
||||||
|
Xóa tất cả <FaXmark />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ActiveFilters;
|
||||||
106
src/components/layout/product/Category/BoxFilter/index.tsx
Normal file
106
src/components/layout/product/Category/BoxFilter/index.tsx
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
'use client';
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { PriceFilter, AttributeFilterList, BrandFilter } from '@/types';
|
||||||
|
import { FaSortDown } from 'react-icons/fa6';
|
||||||
|
import ActiveFilters from './ActiveFilters';
|
||||||
|
|
||||||
|
interface Filters {
|
||||||
|
price_filter_list?: PriceFilter[];
|
||||||
|
attribute_filter_list?: AttributeFilterList[];
|
||||||
|
brand_filter_list?: BrandFilter[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BoxFilterProps {
|
||||||
|
filters: Filters;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BoxFilter: React.FC<BoxFilterProps> = ({ filters }) => {
|
||||||
|
const { price_filter_list, attribute_filter_list, brand_filter_list } = filters;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="box-filter-category boder-radius-10">
|
||||||
|
{/* khoảng giá */}
|
||||||
|
{price_filter_list && (
|
||||||
|
<div className="info-filter-category flex gap-10">
|
||||||
|
<p className="title">Khoảng giá:</p>
|
||||||
|
<div className="list-filter-category flex flex-1 flex-wrap items-center gap-2">
|
||||||
|
{price_filter_list.map((ItemPrice, index) => (
|
||||||
|
<div
|
||||||
|
key={index}
|
||||||
|
className={`item item-cetner flex gap-4 ${ItemPrice.is_selected == '1' ? 'current' : ''}`}
|
||||||
|
>
|
||||||
|
<Link href={ItemPrice.url}>{ItemPrice.name}</Link>
|
||||||
|
<a href={ItemPrice.url}>
|
||||||
|
(${ItemPrice.is_selected == '1' ? 'Xóa' : ItemPrice.count})
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* chọn thiêu tiêu trí */}
|
||||||
|
{attribute_filter_list && (
|
||||||
|
<div className="info-filter-category flex gap-10">
|
||||||
|
<p className="title">Chọn theo tiêu chí:</p>
|
||||||
|
<div className="list-filter-category flex flex-1 flex-wrap items-center gap-3">
|
||||||
|
{/* thương hiệu */}
|
||||||
|
{brand_filter_list && brand_filter_list.length > 0 && (
|
||||||
|
<div className={`item ${brand_filter_list[0].is_selected === '1' ? 'current' : ''}`}>
|
||||||
|
<div className="flex items-center">
|
||||||
|
{brand_filter_list[0].is_selected === '1' ? (
|
||||||
|
<span>{brand_filter_list[0].name}</span>
|
||||||
|
) : (
|
||||||
|
<span>Thương hiệu</span>
|
||||||
|
)}
|
||||||
|
<FaSortDown size={16} style={{ marginBottom: 8 }} />
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
{brand_filter_list.map((item, idx) => (
|
||||||
|
<li key={idx} className="flex items-center gap-3">
|
||||||
|
<Link href={item.url}>{item.name}</Link>
|
||||||
|
<Link href={item.url}>({item.is_selected === '1' ? 'Xóa' : item.count})</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Attribute filter */}
|
||||||
|
{attribute_filter_list && attribute_filter_list.length > 0 && (
|
||||||
|
<>
|
||||||
|
{attribute_filter_list.map((attr, idx) => (
|
||||||
|
<div
|
||||||
|
key={idx}
|
||||||
|
className={`item ${attr.value_list[0]?.is_selected === '1' ? 'current' : ''}`}
|
||||||
|
>
|
||||||
|
<a href="javascript:void(0)" className="flex items-center">
|
||||||
|
{attr.value_list[0]?.is_selected === '1' ? (
|
||||||
|
<span>{attr.value_list[0].name}</span>
|
||||||
|
) : (
|
||||||
|
<span>{attr.name}</span>
|
||||||
|
)}
|
||||||
|
<FaSortDown size={16} style={{ marginBottom: 8 }} />
|
||||||
|
</a>
|
||||||
|
<ul>
|
||||||
|
{attr.value_list.map((val) => (
|
||||||
|
<li key={val.id} className="flex items-center gap-3">
|
||||||
|
<Link href={val.url}>{val.name}</Link>
|
||||||
|
<Link href={val.url}>{val.is_selected === '1' ? 'Xóa' : val.count}</Link>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ActiveFilters filters={filters} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export default BoxFilter;
|
||||||
93
src/components/layout/product/Category/BoxSort/index.tsx
Normal file
93
src/components/layout/product/Category/BoxSort/index.tsx
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
'use client';
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
|
import { FaGrip, FaList } from 'react-icons/fa6';
|
||||||
|
|
||||||
|
interface SortItem {
|
||||||
|
key: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SortProps {
|
||||||
|
sort_by_collection: SortItem[];
|
||||||
|
product_display_type?: 'grid' | 'list';
|
||||||
|
}
|
||||||
|
|
||||||
|
const BoxSort: React.FC<SortProps> = ({ sort_by_collection, product_display_type }) => {
|
||||||
|
const pathname = usePathname();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="box-sort-category flex items-center justify-between">
|
||||||
|
<div className="sort-option flex items-center gap-3">
|
||||||
|
{sort_by_collection
|
||||||
|
.filter((item) =>
|
||||||
|
['price-desc', 'price-asc', 'comment', 'rating', 'name'].includes(item.key),
|
||||||
|
)
|
||||||
|
.map((item) => {
|
||||||
|
let label: string | null = null;
|
||||||
|
let iconClass: string | null = null;
|
||||||
|
|
||||||
|
switch (item.key) {
|
||||||
|
case 'price-desc':
|
||||||
|
label = 'Giá giảm dần';
|
||||||
|
iconClass = 'sprite-more sprite-gia-giam-category';
|
||||||
|
break;
|
||||||
|
case 'price-asc':
|
||||||
|
label = 'Giá tăng dần';
|
||||||
|
iconClass = 'sprite-more sprite-gia-tang-cateogry';
|
||||||
|
break;
|
||||||
|
case 'comment':
|
||||||
|
label = 'Trao đổi';
|
||||||
|
iconClass = 'sprite-more sprite-trao-doi-category';
|
||||||
|
break;
|
||||||
|
case 'rating':
|
||||||
|
label = 'Đánh giá';
|
||||||
|
iconClass = 'sprite-more sprite-danh-gia-category';
|
||||||
|
break;
|
||||||
|
case 'name':
|
||||||
|
label = 'Tên A->Z';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.key}
|
||||||
|
href={item.url}
|
||||||
|
className={`item flex items-center ${
|
||||||
|
pathname.includes(item.key) ? 'selected' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{iconClass && <i className={iconClass}></i>}
|
||||||
|
<span>{label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="sort-bar-select-category flex items-center gap-3">
|
||||||
|
<a
|
||||||
|
href="javascript:;"
|
||||||
|
className={`item-sort-bar d-flex align-items-center ${
|
||||||
|
product_display_type === 'grid' ? 'active' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => {
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaGrip />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
href="javascript:;"
|
||||||
|
className={`item-sort-bar ${product_display_type === 'list' ? 'active' : ''}`}
|
||||||
|
onClick={() => {
|
||||||
|
console.log('Set display to list');
|
||||||
|
window.location.reload();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaList />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default BoxSort;
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
'use client';
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import Image from 'next/image';
|
||||||
|
import { ChildCategory } from '@/types';
|
||||||
|
|
||||||
|
interface BoxCategoryChildProps {
|
||||||
|
item: ChildCategory;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ItemCategoryChild: React.FC<BoxCategoryChildProps> = ({ item }) => {
|
||||||
|
const ItemImage = item.big_image
|
||||||
|
? item.big_image
|
||||||
|
: item.thumnail
|
||||||
|
? item.thumnail
|
||||||
|
: '/static/assets/nguyencong_2023/images/favicon.png';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li>
|
||||||
|
<Link href={item.url}>
|
||||||
|
<div className="border-img lazy flex items-center justify-center">
|
||||||
|
<Image src={ItemImage} width={60} height={60} alt={item.title} />
|
||||||
|
</div>
|
||||||
|
<p className="txt font-weight-500">{item.title}</p>
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ItemCategoryChild;
|
||||||
124
src/components/layout/product/Category/index.tsx
Normal file
124
src/components/layout/product/Category/index.tsx
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
'use client';
|
||||||
|
import React from 'react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
import type { CategoryData } from '@/types';
|
||||||
|
import { productCategoryData } from '@/data/product/category';
|
||||||
|
import { findCategoryBySlug } from '@/lib/category';
|
||||||
|
|
||||||
|
// box
|
||||||
|
import { Breadcrumb } from '@components/common/Breadcrumb';
|
||||||
|
import BannerCategory from './BannerCategory';
|
||||||
|
import ItemCategoryChild from './ItemCategoryChild';
|
||||||
|
import BoxFilter from './BoxFilter';
|
||||||
|
import BoxSort from './BoxSort';
|
||||||
|
import ItemProduct from '@/components/common/ItemProduct';
|
||||||
|
|
||||||
|
interface CategoryPageProps {
|
||||||
|
slug: string; // khai báo prop slug
|
||||||
|
}
|
||||||
|
|
||||||
|
const CategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
|
||||||
|
// Ép kiểu dữ liệu từ index.ts về CategoryData[] nếu cần
|
||||||
|
const categories = productCategoryData as unknown as CategoryData[];
|
||||||
|
const currentCategory = findCategoryBySlug(slug, categories);
|
||||||
|
|
||||||
|
const breadcrumbItems = currentCategory?.current_category?.path?.path?.map((p) => ({
|
||||||
|
name: p.name,
|
||||||
|
url: p.url,
|
||||||
|
})) ?? [
|
||||||
|
{ name: 'Trang chủ', url: '/' },
|
||||||
|
{ name: currentCategory?.current_category.name, url: currentCategory?.current_category.url },
|
||||||
|
];
|
||||||
|
// Trường hợp không tìm thấy danh mục
|
||||||
|
if (!currentCategory) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 py-50">
|
||||||
|
<div className="max-w-md rounded-2xl bg-white p-8 text-center shadow-xl">
|
||||||
|
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-blue-100">
|
||||||
|
<svg
|
||||||
|
className="h-8 w-8 text-blue-600"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9.172 9.172a4 4 0 015.656 5.656M6.343 6.343a8 8 0 0111.314 11.314M12 12l.01.01"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="text-2xl font-bold text-gray-800">Không tìm thấy danh mục</h1>
|
||||||
|
|
||||||
|
<p className="mt-3 text-gray-600">
|
||||||
|
Đường dẫn <code className="rounded bg-gray-100 px-2 py-0.5 text-sm">{slug}</code> không
|
||||||
|
tồn tại hoặc đã bị xoá.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="mt-6 inline-flex items-center justify-center rounded-lg bg-blue-600 px-6 py-2.5 font-medium text-white transition hover:bg-blue-700 focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
||||||
|
>
|
||||||
|
← Về trang chủ
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// lấy sản phẩm
|
||||||
|
const products = Object.values(currentCategory.product_list);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="page-category">
|
||||||
|
<div className="container">
|
||||||
|
<Breadcrumb items={breadcrumbItems} />
|
||||||
|
|
||||||
|
<BannerCategory />
|
||||||
|
|
||||||
|
<h1 className="name-category font-bold">{currentCategory.current_category.name}</h1>
|
||||||
|
<div className="box-content-category">
|
||||||
|
<ul className="category-child boder-radius-10 flex flex-wrap justify-center">
|
||||||
|
{currentCategory.current_category.children?.map((item, index) => (
|
||||||
|
<ItemCategoryChild item={item} key={index} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{/* filter */}
|
||||||
|
<BoxFilter filters={currentCategory} />
|
||||||
|
|
||||||
|
<div className="box-list-product-category boder-radius-10">
|
||||||
|
{/* filter sort */}
|
||||||
|
<BoxSort
|
||||||
|
sort_by_collection={currentCategory.sort_by_collection}
|
||||||
|
product_display_type="grid"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* list product */}
|
||||||
|
|
||||||
|
<div className="list-product-category grid grid-cols-5 gap-3">
|
||||||
|
{products.map((item, index) => (
|
||||||
|
<ItemProduct key={index} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="paging flex items-center justify-center">
|
||||||
|
{currentCategory.paging_collection.map((item, index) => (
|
||||||
|
<Link
|
||||||
|
key={index}
|
||||||
|
href={item.url}
|
||||||
|
className={`item ${item.is_active === '1' ? 'current' : ''}`}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default CategoryPage;
|
||||||
@@ -753,5 +753,74 @@ export const bannerData: BannerType = [
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
product_list: {
|
||||||
|
banner_category_2023: [
|
||||||
|
{
|
||||||
|
id: '487',
|
||||||
|
display:
|
||||||
|
'<img border=0 src="\/media\/banner\/02_Decf1b77a9ec940fcf31fa7359fe29e35f1.webp" width=\'1200\' height=\'161\' alt=""\/>',
|
||||||
|
fileUrl:
|
||||||
|
'https://nguyencongpc.vn\/media\/banner\/02_Decf1b77a9ec940fcf31fa7359fe29e35f1.webp',
|
||||||
|
desUrl: '\/ad.php?id=487',
|
||||||
|
title: '',
|
||||||
|
width: 1200,
|
||||||
|
height: 161,
|
||||||
|
fileType: '',
|
||||||
|
summary: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '446',
|
||||||
|
display:
|
||||||
|
"<a href=\"\/ad.php?id=446\" target='_blank' rel='nofollow'>\r\n <img border=0 src=\"\/media\/banner\/06_May6f05ef9a99dc3e76c80669c1f693db8e.jpg\" width='2392' height='320' alt=\"\"\/><\/a>",
|
||||||
|
fileUrl:
|
||||||
|
'https://nguyencongpc.vn\/media\/banner\/06_May6f05ef9a99dc3e76c80669c1f693db8e.jpg',
|
||||||
|
desUrl: '\/ad.php?id=446',
|
||||||
|
title: '',
|
||||||
|
width: 2392,
|
||||||
|
height: 320,
|
||||||
|
fileType: '',
|
||||||
|
summary: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '359',
|
||||||
|
display:
|
||||||
|
"<a href=\"\/ad.php?id=359\" target='_blank' rel='nofollow'>\r\n <img border=0 src=\"\/media\/banner\/05_Febeb00e3c3f19ceda40733b5d4d8f335ed.webp\" width='1500' height='201' alt=\"\"\/><\/a>",
|
||||||
|
fileUrl:
|
||||||
|
'https://nguyencongpc.vn\/media\/banner\/05_Febeb00e3c3f19ceda40733b5d4d8f335ed.webp',
|
||||||
|
desUrl: '\/ad.php?id=359',
|
||||||
|
title: '',
|
||||||
|
width: 1500,
|
||||||
|
height: 201,
|
||||||
|
fileType: 'banner',
|
||||||
|
summary: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '331',
|
||||||
|
display:
|
||||||
|
"<a href=\"\/ad.php?id=331\" target='_blank' rel='nofollow'>\r\n <img border=0 src=\"\/media\/banner\/07_Febc979a257d61b60e2e33a39a767dbc909.webp\" width='1200' height='161' alt=\"\"\/><\/a>",
|
||||||
|
fileUrl:
|
||||||
|
'https://nguyencongpc.vn\/media\/banner\/07_Febc979a257d61b60e2e33a39a767dbc909.webp',
|
||||||
|
desUrl: '\/ad.php?id=331',
|
||||||
|
title: '',
|
||||||
|
width: 1200,
|
||||||
|
height: 161,
|
||||||
|
fileType: 'banner',
|
||||||
|
summary: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '443',
|
||||||
|
display:
|
||||||
|
"<a href=\"\/ad.php?id=443\" target='_blank' rel='nofollow'>\r\n <img border=0 src=\"\/media\/banner\/07_Febf902e9f33cf7cd48e7649ea8f8d33122.webp\" width='2392' height='320' alt=\"\"\/><\/a>",
|
||||||
|
fileUrl:
|
||||||
|
'https://nguyencongpc.vn\/media\/banner\/07_Febf902e9f33cf7cd48e7649ea8f8d33122.webp',
|
||||||
|
desUrl: '\/ad.php?id=443',
|
||||||
|
title: '',
|
||||||
|
width: 2392,
|
||||||
|
height: 320,
|
||||||
|
fileType: 'banner',
|
||||||
|
summary: '',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,59 +1,16 @@
|
|||||||
import { CategoryDetail } from '@/types';
|
import { CategoryData, CurrentCategory } from '@/types';
|
||||||
|
|
||||||
|
// Hàm helper để lấy URL an toàn từ các cấu trúc dữ liệu khác nhau
|
||||||
|
function getSlug(url: string): string {
|
||||||
|
const parts = url.split('/').filter(Boolean);
|
||||||
|
return parts[parts.length - 1];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tìm danh mục theo mảng slug (ví dụ: ["pc-gaming","cao-cap","rtx-4090"])
|
* Tìm danh mục theo mảng slug (ví dụ: ["pc-gaming","cao-cap","rtx-4090"])
|
||||||
*/
|
*/
|
||||||
export function findCategoryBySlug(
|
export function findCategoryBySlug(slug: string, categories: CategoryData[]): CategoryData | null {
|
||||||
slugs: string[],
|
const found = categories.find((item) => item.current_category.url === slug);
|
||||||
categories: CategoryDetail[],
|
|
||||||
): CategoryDetail | null {
|
|
||||||
let currentList: CategoryDetail[] = categories;
|
|
||||||
let currentCategory: CategoryDetail | null = null;
|
|
||||||
|
|
||||||
for (const slug of slugs) {
|
return found ?? null;
|
||||||
const found = currentList.find((cat) => cat.slug === slug);
|
|
||||||
if (!found) return null;
|
|
||||||
currentCategory = found;
|
|
||||||
currentList = found.children || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return currentCategory;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Lấy tất cả danh mục con của một danh mục
|
|
||||||
*/
|
|
||||||
export function getChildren(category: CategoryDetail): CategoryDetail[] {
|
|
||||||
return category.children || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tìm danh mục theo id
|
|
||||||
*/
|
|
||||||
export function findCategoryById(id: number, categories: CategoryDetail[]): CategoryDetail | null {
|
|
||||||
for (const cat of categories) {
|
|
||||||
if (cat.id === id) return cat;
|
|
||||||
if (cat.children) {
|
|
||||||
const found = findCategoryById(id, cat.children);
|
|
||||||
if (found) return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tìm đường dẫn breadcrumb từ root đến danh mục hiện tại
|
|
||||||
*/
|
|
||||||
export function getBreadcrumb(slugs: string[], categories: CategoryDetail[]): CategoryDetail[] {
|
|
||||||
const breadcrumb: CategoryDetail[] = [];
|
|
||||||
let currentList: CategoryDetail[] = categories;
|
|
||||||
|
|
||||||
for (const slug of slugs) {
|
|
||||||
const found = currentList.find((cat) => cat.slug === slug);
|
|
||||||
if (!found) break;
|
|
||||||
breadcrumb.push(found);
|
|
||||||
currentList = found.children || [];
|
|
||||||
}
|
|
||||||
|
|
||||||
return breadcrumb;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,15 +63,6 @@ html {
|
|||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
line-height: 16px;
|
line-height: 16px;
|
||||||
}
|
}
|
||||||
a,
|
|
||||||
a:hover {
|
|
||||||
color: inherit;
|
|
||||||
-webkit-transition: color 0.2s ease-out;
|
|
||||||
-o-transition: color 0.2s ease-out;
|
|
||||||
transition: color 0.2s ease-out;
|
|
||||||
outline: 0;
|
|
||||||
text-decoration: none;
|
|
||||||
}
|
|
||||||
a:hover {
|
a:hover {
|
||||||
color: var(--color-primary);
|
color: var(--color-primary);
|
||||||
}
|
}
|
||||||
@@ -438,7 +429,7 @@ textarea::placeholder {
|
|||||||
box-shadow: 0 1px 2px 1px #01010133;
|
box-shadow: 0 1px 2px 1px #01010133;
|
||||||
color: #007bff;
|
color: #007bff;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
height: 65px;
|
height: 50px;
|
||||||
}
|
}
|
||||||
.global-fixed-right .messenger img {
|
.global-fixed-right .messenger img {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
|
|||||||
@@ -38,10 +38,15 @@ export interface BannerHomePageData {
|
|||||||
banner_collection_pc: BannerItem[];
|
banner_collection_pc: BannerItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BannerCategory {
|
||||||
|
banner_category_2023: BannerItem[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface TemplateBanner {
|
export interface TemplateBanner {
|
||||||
footer: BannerFooterData;
|
footer: BannerFooterData;
|
||||||
header: BannerHeaderData;
|
header: BannerHeaderData;
|
||||||
homepage: BannerHomePageData;
|
homepage: BannerHomePageData;
|
||||||
|
product_list: BannerCategory;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type BannerType = TemplateBanner[];
|
export type BannerType = TemplateBanner[];
|
||||||
@@ -1 +1,2 @@
|
|||||||
export * from '@/types/global/Menu';
|
export * from '@/types/global/Menu';
|
||||||
|
export * from '@/types/global/TypeListProduct';
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
export * from '@/types/home/Banner';
|
export * from '@/types/global/Banner';
|
||||||
export * from '@/types/home/TypeArticle';
|
export * from '@/types/home/TypeArticle';
|
||||||
export * from '@/types/home/TypeListProduct';
|
|
||||||
export * from '@/types/home/TypeListProductDeal';
|
export * from '@/types/home/TypeListProductDeal';
|
||||||
|
|||||||
@@ -1,24 +1,151 @@
|
|||||||
export interface CategoryDetail {
|
import { Product } from '@/types';
|
||||||
id: number;
|
|
||||||
|
export interface ChildCategory {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
parentId: string;
|
||||||
|
thumnail: string;
|
||||||
|
big_image: string;
|
||||||
|
isParent: string;
|
||||||
|
url: string;
|
||||||
|
is_featured: string;
|
||||||
|
summary: string;
|
||||||
|
}
|
||||||
|
interface FilterCollection {
|
||||||
|
url: string;
|
||||||
|
key: string;
|
||||||
name: string;
|
name: string;
|
||||||
slug: string;
|
}
|
||||||
description?: string;
|
export interface SortCollection {
|
||||||
image?: string;
|
url: string;
|
||||||
parentId?: number;
|
key: string;
|
||||||
children?: CategoryDetail[];
|
name: string;
|
||||||
|
}
|
||||||
|
interface DisplayCollection {
|
||||||
|
url: string;
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
interface PagingCollection {
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
is_active: string;
|
||||||
|
}
|
||||||
|
interface CategoryCollection {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
count: string;
|
||||||
|
is_selected: string;
|
||||||
|
}
|
||||||
|
export interface BrandFilter {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
brand_index: string;
|
||||||
|
image: string;
|
||||||
|
count: string;
|
||||||
|
is_selected: string;
|
||||||
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CategoryDetailNew {
|
export interface PriceFilter {
|
||||||
|
count: string;
|
||||||
|
is_selected: string;
|
||||||
|
name: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CurrentCategory {
|
||||||
|
id: string;
|
||||||
|
catPath: string;
|
||||||
|
childListId: string;
|
||||||
|
display_option: string;
|
||||||
|
request_path: string;
|
||||||
|
url: string;
|
||||||
|
url_hash: string;
|
||||||
|
name: string;
|
||||||
|
summary: string;
|
||||||
|
static_html: string;
|
||||||
|
tags: string[];
|
||||||
|
isParent: string;
|
||||||
|
imgUrl: string;
|
||||||
|
img_big: string;
|
||||||
|
useImg: string;
|
||||||
|
toUrl: string;
|
||||||
|
parentId: string;
|
||||||
|
proCount: string;
|
||||||
|
attr_count: string;
|
||||||
|
priceRange: string;
|
||||||
|
keyword: string;
|
||||||
|
ordering: string;
|
||||||
|
status: string;
|
||||||
|
createDate: string;
|
||||||
|
createBy: string;
|
||||||
|
lastUpdate: string;
|
||||||
|
lastUpdateBy: string;
|
||||||
|
meta_title: string;
|
||||||
|
meta_keyword: string;
|
||||||
|
meta_description: string;
|
||||||
|
url_canonical: string;
|
||||||
|
visit: string;
|
||||||
|
like_count: string;
|
||||||
|
is_featured: string;
|
||||||
|
redirect_url: string;
|
||||||
|
template: string;
|
||||||
|
number_display: string;
|
||||||
|
brand_url: string;
|
||||||
|
hide_menu: string;
|
||||||
|
extend: string;
|
||||||
|
price_filter: [];
|
||||||
|
path: {
|
||||||
|
path: { id: string; url: string; name: string }[];
|
||||||
|
path_url: string;
|
||||||
|
};
|
||||||
|
image: string;
|
||||||
|
big_image: string;
|
||||||
|
is_parent: string;
|
||||||
|
parent_id: string;
|
||||||
|
path_id: string;
|
||||||
|
display_info: string;
|
||||||
|
related: [];
|
||||||
|
children?: ChildCategory[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AttributeValue {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
api_key: string;
|
||||||
|
description: string;
|
||||||
|
count: string;
|
||||||
|
is_selected: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AttributeFilterList {
|
||||||
|
name: string;
|
||||||
|
filter_code: string;
|
||||||
|
is_selected: string;
|
||||||
|
value_list: AttributeValue[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CategoryData {
|
||||||
title: string;
|
title: string;
|
||||||
keywords: string;
|
keywords: string;
|
||||||
description: string;
|
description: string;
|
||||||
canonical: string;
|
canonical: string;
|
||||||
image: string;
|
image: string;
|
||||||
}
|
h1: string;
|
||||||
|
current_category: CurrentCategory;
|
||||||
export interface current_category {
|
other_filter_collection: FilterCollection[];
|
||||||
id: string;
|
sort_by_collection: SortCollection[];
|
||||||
catPath: string;
|
display_by_collection: DisplayCollection[];
|
||||||
childListId: string;
|
paging_collection: PagingCollection[];
|
||||||
display_option: string;
|
paging: string;
|
||||||
|
paging_count: string;
|
||||||
|
product_count: string;
|
||||||
|
product_list: Product[];
|
||||||
|
price_filter_list: PriceFilter[];
|
||||||
|
category_collection: CategoryCollection[];
|
||||||
|
attribute_filter_list: AttributeFilterList[];
|
||||||
|
brand_filter_list: BrandFilter[];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user