This commit is contained in:
2026-03-13 13:54:45 +07:00
parent a8e30f32a0
commit 25111ff10e
120 changed files with 4213 additions and 4859 deletions

View File

@@ -0,0 +1,29 @@
'use client';
import ItemArticle from '@/components/Common/ItemArticle';
import { getArticles } from '@/lib/api/article';
import { useApiData } from '@/hooks/useApiData';
import type { ListArticle } from '@/types/article/TypeListArticle';
export const ArticleTopLeft = () => {
const { data: articles } = useApiData(
() => getArticles(),
[],
{ initialData: [] as ListArticle },
);
return (
<div className="flex gap-3">
<div className="box-left">
{articles.slice(0, 1).map((item) => (
<ItemArticle item={item} key={item.id} />
))}
</div>
<div className="box-right flex flex-1 flex-col gap-3">
{articles.slice(0, 4).map((item) => (
<ItemArticle item={item} key={item.id} />
))}
</div>
</div>
);
};

View File

@@ -0,0 +1,42 @@
'use client';
import Link from 'next/link';
import { getArticles } from '@/lib/api/article';
import { useApiData } from '@/hooks/useApiData';
import type { ListArticle } from '@/types/article/TypeListArticle';
export const ArticleTopRight = () => {
const { data: articles } = useApiData(
() => getArticles(),
[],
{ initialData: [] as ListArticle },
);
return (
<div className="col-right-article box-view-article flex-1">
<form
method="get"
action="/tim-bai"
name="search"
className="boder-radius-10 border-box-article article-search-container"
>
<input type="text" name="q" placeholder="Tìm kiếm bài viết" defaultValue="" />
<button type="submit" className="fas fa-search"></button>
</form>
<div className="boder-radius-10 border-box-article">
<div className="title-box-article font-bold">Xem nhiều</div>
<ul className="list-most-view-article flex flex-col gap-4">
{articles.slice(0, 6).map((item, index) => (
<li className="item-most-view-article flex items-center gap-2" key={item.id}>
<span className="number flex items-center justify-center font-[600]">{index + 1}</span>
<Link href={item.url} className="line-clamp-2 flex-1">
{item.title}
</Link>
</li>
))}
</ul>
</div>
</div>
);
};

View File

@@ -0,0 +1,132 @@
'use client';
import Link from 'next/link';
import Image from 'next/image';
import type { TypeArticleCatePage } from '@/types/article/TypeArticleCatePage';
import { Breadcrumb } from '@components/Common/Breadcrumb';
import { ErrorLink } from '@components/Common/Error';
import { ArticleTopLeft } from '../ArticleTopLeft';
import { ArticleTopRight } from '../ArticleTopRight';
import ItemArticle from '@/components/Common/ItemArticle';
import PreLoader from '@/components/Common/PreLoader';
import { getArticleCategories, getArticleCategoryDetail, getArticles } from '@/lib/api/article';
import { useApiData } from '@/hooks/useApiData';
import type { TypeArticleCategory } from '@/types/article/ListCategoryArticle';
import type { ListArticle } from '@/types/article/TypeListArticle';
interface CategoryPageProps {
slug: string;
}
const ArticleCategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
const { data: currentCategory, isLoading } = useApiData(
() => getArticleCategoryDetail(slug),
[slug],
{ initialData: null as TypeArticleCatePage | null },
);
const { data: categories } = useApiData(
() => getArticleCategories(),
[],
{ initialData: [] as TypeArticleCategory[] },
);
const { data: articles } = useApiData(
() => getArticles(),
[],
{ initialData: [] as ListArticle },
);
if (isLoading) {
return <PreLoader />;
}
if (!currentCategory) {
return <ErrorLink />;
}
const breadcrumbItems = [
{ name: 'Tin tức', url: '/tin-tuc' },
{ name: currentCategory.category_info.name, url: currentCategory.category_info.request_path },
];
const articleList = Object.values(currentCategory.article_list);
return (
<>
<div className="container">
<Breadcrumb items={breadcrumbItems} />
</div>
<section className="page-article page-article-category container">
<div className="tabs-category-article flex items-center">
{categories.map((item, index) => (
<Link
href={item.url}
key={`${item.id}-${index}`}
className={`item-tab-article ${currentCategory.title === item.title ? 'active' : ''}`}
>
<h2 className="title-cate-article font-[400]">{item.title}</h2>
</Link>
))}
</div>
<div className="box-article-home-top grid grid-cols-3 gap-3">
<div className="col-left-article border-box-article box-new-article boder-radius-10 col-span-2">
<ArticleTopLeft />
</div>
<ArticleTopRight />
</div>
<div className="box-article-home-middle mt-5 grid grid-cols-3 gap-3">
<div className="box-article-tech col-left-article boder-radius-10 border-box-article col-span-2">
<p className="title-box-article font-[600]">{currentCategory.title}</p>
<div className="list-article-tech">
{articleList.slice(0, 9).map((item) => (
<ItemArticle item={item} key={item.id} />
))}
</div>
<Link
href="/tin-cong-nghe"
className="btn-article-col flex items-center justify-center gap-2 font-[500]"
>
Xem tất cả
</Link>
</div>
<div className="col-right-article page-hompage flex-1">
<div className="box-article-global border-box-article boder-radius-10">
<p className="title-box-article font-bold">Tin nổi bật</p>
<div className="list-article-global flex flex-col gap-2">
{articles.slice(0, 5).map((item) => (
<div className="item-article flex gap-4" key={item.id}>
<Link href={item.url} className="img-article boder-radius-10 relative">
<Image
className="boder-radius-10"
src={item.image.original}
fill
alt={item.title}
/>
<i className="sprite sprite-icon-play-video-detail icon-video-feature incon-play-youtube"></i>
<i className="sprite sprite-play-youtube incon-play-youtube"></i>
</Link>
<div className="content-article content-article-item flex flex-1 flex-col">
<Link href={item.url} className="title-article">
<h3 className="line-clamp-2 font-[400]">{item.title}</h3>
</Link>
<p className="time-article flex items-center gap-2">
<i className="sprite sprite-clock-item-article"></i>
<span>{item.createDate}</span>
</p>
<p className="descreption-article line-clamp-2">{item.summary}</p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</section>
</>
);
};
export default ArticleCategoryPage;

View File

@@ -0,0 +1,113 @@
'use client';
import { useMemo } from 'react';
type HeadingItem = {
id: string;
text: string;
level: number;
children?: HeadingItem[];
};
function convertToSlug(text: string) {
return text
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/đ/g, 'd')
.replace(/[^\w ]+/g, '')
.trim()
.replace(/\s+/g, '-');
}
// Hàm xây dựng cây TOC từ danh sách heading
function buildTree(headings: HeadingItem[]): HeadingItem[] {
const root: HeadingItem[] = [];
const stack: HeadingItem[] = [];
headings.forEach((h) => {
const node = { ...h, children: [] };
while (stack.length && stack[stack.length - 1].level >= node.level) {
stack.pop();
}
if (stack.length === 0) {
root.push(node);
} else {
stack[stack.length - 1].children!.push(node);
}
stack.push(node);
});
return root;
}
function renderTree(nodes: HeadingItem[]) {
return (
<ol>
{nodes.map((n) => (
<li key={n.id}>
<a
href={`#${n.id}`}
onClick={(e) => {
e.preventDefault();
const el = document.getElementById(n.id);
if (el) {
const y = el.getBoundingClientRect().top + window.scrollY - 120;
window.scrollTo({ top: y, behavior: 'smooth' });
}
}}
className="text-blue-600 hover:underline"
>
{n.text}
</a>
{n.children && n.children.length > 0 && renderTree(n.children)}
</li>
))}
</ol>
);
}
export default function TocBox({ htmlContent }: { htmlContent: string }) {
const { headingsTree, contentWithIds } = useMemo(() => {
const parser = new DOMParser();
const doc = parser.parseFromString(htmlContent, 'text/html');
const nodes = doc.querySelectorAll('h1,h2,h3,h4,h5,h6');
const flat: HeadingItem[] = Array.from(nodes).map((node) => {
const text = node.textContent || '';
const id = convertToSlug(text);
node.setAttribute('id', id);
return {
id,
text,
level: parseInt(node.tagName.substring(1)),
};
});
return {
headingsTree: buildTree(flat),
contentWithIds: doc.body.innerHTML,
};
}, [htmlContent]);
if (!headingsTree.length) return null;
return (
<>
<div className="archor-text-group">
<div className="toc_title flex items-center justify-between gap-2">
<b className="text-fint-toc flex items-center text-base font-bold">
<span>Nội dung chính</span>
</b>
</div>
<div id="js-outp">{renderTree(headingsTree)}</div>
</div>
<div
className="box-article-detail-ct nd js_find"
dangerouslySetInnerHTML={{ __html: contentWithIds }}
/>
</>
);
}

View File

@@ -0,0 +1,117 @@
'use client';
import Link from 'next/link';
import Image from 'next/image';
import type { TypeArticleDetailPage } from '@/types/article/TypeArticleDetailPage';
import { ErrorLink } from '@components/Common/Error';
import { Breadcrumb } from '@components/Common/Breadcrumb';
import TocBox from './TocBox';
import PreLoader from '@/components/Common/PreLoader';
import { getArticleCategories, getArticleDetail } from '@/lib/api/article';
import { useApiData } from '@/hooks/useApiData';
import type { TypeArticleCategory } from '@/types/article/ListCategoryArticle';
interface DetailPageProps {
slug: string;
}
const ArticleDetailPage: React.FC<DetailPageProps> = ({ slug }) => {
const { data: page, isLoading } = useApiData(
() => getArticleDetail(slug),
[slug],
{ initialData: null as TypeArticleDetailPage | null },
);
const { data: categories } = useApiData(
() => getArticleCategories(),
[],
{ initialData: [] as TypeArticleCategory[] },
);
if (isLoading) {
return <PreLoader />;
}
if (!page) {
return <ErrorLink />;
}
const breadcrumbItems = [
{ name: 'Tin tức', url: '/tin-tuc' },
{ name: page.article_detail.title, url: page.article_detail.url },
];
const listRelayNew = Object.values(page.article_other_same_category.new);
const listRelayOld = Object.values(page.article_other_same_category.old);
const combinedList = [...listRelayNew.slice(0, 6), ...listRelayOld.slice(0, 6)];
return (
<>
<div className="container">
<Breadcrumb items={breadcrumbItems} />
</div>
<section className="page-article box-article-detail container">
<div className="tabs-category-article flex items-center">
{categories.map((item, index) => (
<Link
href={item.url}
key={`${item.id}-${index}`}
className={`item-tab-article ${page.article_detail.categoryInfo[0].id === item.id ? 'active' : ''}`}
>
<h2 className="title-cate-article font-[400]">{item.title}</h2>
</Link>
))}
</div>
<div className="row article-detail-page mt-5">
<div className="col-md-8">
<div className="box-article-detail-title">
<h1 className="font-weight-700">{page.article_detail.title}</h1>
<div className="post__user border-bottom my-5 flex items-center gap-2">
<span className="author-name">{page.article_detail.author}</span>
<span className="post-time">{page.article_detail.createDate}</span>
</div>
<TocBox htmlContent={page.article_detail.content} />
</div>
</div>
{page.article_other_same_category && (
<div className="col-md-4">
<div className="box-article-relay">
<p className="title-ar">
Bài viết <span>liên quan</span>
</p>
<div className="article-list list-article-relative flex flex-wrap gap-3">
{combinedList.map((item) => (
<div className="item-article d-flex flex-column gap-12" key={item.id}>
<Link href={item.url} className="img-article boder-radius-10">
<Image
className="boder-radius-10"
src={item.image.original}
fill
alt={item.title}
/>
</Link>
<div className="content-article flex-1">
<a href={item.url} className="title-article">
<h3 className="font-weight-400 line-clamp-2">{item.title}</h3>
</a>
<p className="time-article d-flex align-items-center gap-4">
<i className="sprite sprite-clock-item-article"></i>
<span>{item.createDate}</span>
</p>
<p className="descreption-article line-clamp-2">{item.summary}</p>
</div>
</div>
))}
</div>
</div>
</div>
)}
</div>
</section>
</>
);
};
export default ArticleDetailPage;

View File

@@ -0,0 +1,67 @@
'use client';
import ItemArticle from '@/components/Common/ItemArticle';
import Link from 'next/link';
import Image from 'next/image';
import { getArticles } from '@/lib/api/article';
import { useApiData } from '@/hooks/useApiData';
import type { ListArticle } from '@/types/article/TypeListArticle';
export const BoxArticleMid = () => {
const { data: articles } = useApiData(
() => getArticles(),
[],
{ initialData: [] as ListArticle },
);
return (
<div className="box-article-home-middle grid grid-cols-3 gap-2">
<div className="box-article-tech col-left-article boder-radius-10 border-box-article col-span-2">
<p className="title-box-article font-[600]">Tin công nghệ</p>
<div className="list-article-tech">
{articles.slice(0, 9).map((item) => (
<ItemArticle item={item} key={item.id} />
))}
</div>
<Link
href="/tin-cong-nghe"
className="btn-article-col flex items-center justify-center gap-2 font-[500]"
>
Xem tất cả
</Link>
</div>
<div className="col-right-article flex-1">
<div className="box-article-hot border-box-article boder-radius-10">
<p className="title-box-article font-bold">Tin nổi bật</p>
<div className="list-article-hot">
{articles.slice(0, 5).map((item) => (
<div className="item-article flex gap-4" key={item.id}>
<Link href={item.url} className="img-article boder-radius-10 relative">
<Image
className="boder-radius-10"
src={item.image.original}
fill
alt={item.title}
/>
<i className="sprite sprite-icon-play-video-detail icon-video-feature incon-play-youtube"></i>
<i className="sprite sprite-play-youtube incon-play-youtube"></i>
</Link>
<div className="content-article content-article-item flex flex-1 flex-col">
<Link href={item.url} className="title-article">
<h3 className="line-clamp-2 font-[400]">{item.title}</h3>
</Link>
<p className="time-article flex items-center gap-2">
<i className="sprite sprite-clock-item-article"></i>
<span>{item.createDate}</span>
</p>
<p className="descreption-article line-clamp-2">{item.summary}</p>
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,55 @@
'use client';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay, Navigation, Pagination, Thumbs } from 'swiper/modules';
import Image from 'next/image';
import Link from 'next/link';
import { getArticles } from '@/lib/api/article';
import { useApiData } from '@/hooks/useApiData';
import type { ListArticle } from '@/types/article/TypeListArticle';
export const BoxArticleReview = () => {
const { data: articles } = useApiData(
() => getArticles(),
[],
{ initialData: [] as ListArticle },
);
return (
<div className="box-article-category page-hompage">
<div className="box-article-global box-artice-review">
<div className="title-box-product-home mb-5 flex flex-col items-center">
<p className="title font-[500]">Review sản phẩm</p>
<p className="border-title"></p>
</div>
<div className="list-article-global">
<Swiper
modules={[Autoplay, Navigation, Pagination, Thumbs]}
spaceBetween={15}
slidesPerView={3}
loop={true}
>
{articles.map((item) => (
<SwiperSlide key={item.id}>
<div className="item-article">
<Link href={item.url} className="img-article">
<Image src={item.image.original} fill alt={item.title} />
</Link>
<div className="content-article-item">
<Link href={item.url} className="title font-weight-500 line-clamp-2">
{item.title}
</Link>
<div className="time-aricle-item flex items-center">
<i className="sprite sprite-clock-item-article"></i>
<span>{item.createDate}</span>
</div>
</div>
</div>
</SwiperSlide>
))}
</Swiper>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,130 @@
'use client';
import Link from 'next/link';
import { FaYoutube } from 'react-icons/fa6';
import Image from 'next/image';
import useFancybox from '@/hooks/useFancybox';
import { getArticleVideos } from '@/lib/api/article';
import { useApiData } from '@/hooks/useApiData';
import type { ListArticle } from '@/types/article/TypeListArticle';
export const BoxVideoArticle = () => {
const { data: videos } = useApiData(
() => getArticleVideos(),
[],
{ initialData: [] as ListArticle },
);
const getYoutubeEmbedUrl = (url: string): string => {
try {
const urlObj = new URL(url);
if (urlObj.hostname.includes('youtube.com')) {
const videoId = urlObj.searchParams.get('v');
if (videoId) {
return `https://www.youtube.com/embed/${videoId}?autoplay=1`;
}
}
if (urlObj.hostname.includes('youtu.be')) {
const videoId = urlObj.pathname.replace('/', '');
if (videoId) {
return `https://www.youtube.com/embed/${videoId}?autoplay=1`;
}
}
return url;
} catch {
return url;
}
};
const [fancyboxRef] = useFancybox({
closeButton: 'auto',
dragToClose: true,
});
return (
<div className="box-video-article boder-radius-10">
<div className="title-video-article flex justify-between">
<p className="title font-bold">Youtube channel</p>
<Link
href="https://www.youtube.com/c/NGUYENCONGPC"
className="follow-youtube flex items-center gap-2"
>
<FaYoutube />
<span className="font-bold">Theo dõi trên YouTube</span>
</Link>
</div>
<div className="list-video-article flex justify-between gap-2">
<div className="box-left" ref={fancyboxRef}>
{videos.slice(0, 1).map((item) => (
<div className="item-article-video d-flex w-50 gap-10" key={item.id}>
<Link
href={getYoutubeEmbedUrl(item.external_url)}
className="img-article img-article-video boder-radius-10 relative"
data-fancybox
>
<Image
src={item.image.original}
width={430}
height={310}
className="boder-radius-10"
alt={item.title}
/>
<i className="sprite sprite-big-play-video-article icon-play"></i>
<Image
className="icon-play-small"
src="https://nguyencongpc.vn/static/assets/nguyencong_2023/images/small-play-youtube.png"
alt="play"
width={58}
height={41}
/>
</Link>
<Link
href={getYoutubeEmbedUrl(item.external_url)}
className="title-article-video flex-1"
data-fancybox
>
{item.title}
</Link>
</div>
))}
</div>
<div className="box-right grid grid-cols-2 gap-2">
{videos.slice(1, 7).map((item) => (
<div className="item-article-video flex w-50 gap-2" key={item.id}>
<Link
href={getYoutubeEmbedUrl(item.external_url)}
className="img-article img-article-video boder-radius-10 relative"
data-fancybox
>
<Image
src={item.image.original}
width={430}
height={310}
className="boder-radius-10"
alt={item.title}
/>
<i className="sprite sprite-big-play-video-article icon-play"></i>
<Image
className="icon-play-small"
src="https://nguyencongpc.vn/static/assets/nguyencong_2023/images/small-play-youtube.png"
alt="play"
width={58}
height={41}
/>
</Link>
<Link
href={getYoutubeEmbedUrl(item.external_url)}
className="title-article-video flex-1"
data-fancybox
>
{item.title}
</Link>
</div>
))}
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,52 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { Breadcrumb } from '@components/Common/Breadcrumb';
import { ArticleTopLeft } from '../ArticleTopLeft';
import { ArticleTopRight } from '../ArticleTopRight';
import { BoxVideoArticle } from './BoxVideoArticle';
import { BoxArticleMid } from './BoxArticleMid';
import { BoxArticleReview } from './BoxArticleReview';
import { getArticleCategories } from '@/lib/api/article';
import { useApiData } from '@/hooks/useApiData';
import type { TypeArticleCategory } from '@/types/article/ListCategoryArticle';
const ArticleHome = () => {
const breadcrumbItems = [{ name: 'Tin tức', url: '/tin-tuc' }];
const { data: categories } = useApiData(
() => getArticleCategories(),
[],
{ initialData: [] as TypeArticleCategory[] },
);
return (
<section className="page-article pb-10">
<div className="container">
<Breadcrumb items={breadcrumbItems} />
<div className="tabs-category-article flex items-center">
{categories.map((item, index) => (
<Link href={item.url} key={`${item.id}-${index}`} className="item-tab-article">
<h2 className="title-cate-article font-[400]">{item.title}</h2>
</Link>
))}
</div>
<div className="box-article-home-top grid grid-cols-3 gap-3">
<div className="col-left-article border-box-article box-new-article boder-radius-10 col-span-2">
<ArticleTopLeft />
</div>
<ArticleTopRight />
</div>
<BoxVideoArticle />
<BoxArticleMid />
<BoxArticleReview />
</div>
</section>
);
};
export default ArticleHome;

View File

@@ -0,0 +1,123 @@
'use client';
import React, { useState } from 'react';
import { Article } from '@/types';
import Link from 'next/link';
import Image from 'next/image';
import { FaTimes } from 'react-icons/fa';
type ItemArticleProps = {
item: Article;
};
const ItemArticleVideo: React.FC<ItemArticleProps> = ({ item }) => {
const [open, setOpen] = useState(false);
// chọn link: nếu có external_url thì dùng, ngược lại dùng url
const linkHref = item.external_url && item.external_url !== '' ? item.external_url : item.url;
// chọn ảnh: nếu có original thì dùng, ngược lại ảnh mặc định
const imageSrc =
item.image?.original && item.image.original !== ''
? item.image.original
: '/static/assets/nguyencong_2023/images/not-image.png';
// chọn thời gian: ưu tiên article_time, fallback createDate
const timeDisplay =
item.article_time && item.article_time !== '' ? item.article_time : item.createDate;
const getYoutubeEmbedUrl = (url: string): string => {
try {
const urlObj = new URL(url);
// nếu là link youtube dạng watch?v=...
if (urlObj.hostname.includes('youtube.com')) {
const videoId = urlObj.searchParams.get('v');
if (videoId) {
return `https://www.youtube.com/embed/${videoId}?autoplay=1`;
}
}
// nếu là link youtu.be/xxxx
if (urlObj.hostname.includes('youtu.be')) {
const videoId = urlObj.pathname.replace('/', '');
if (videoId) {
return `https://www.youtube.com/embed/${videoId}?autoplay=1`;
}
}
// fallback: trả về chính url
return url;
} catch {
return url;
}
};
return (
<>
<div className="item-article flex gap-3">
<Link
onClick={(e) => {
e.preventDefault();
setOpen(true);
}}
href="javascript:void(0)"
className="img-article boder-radius-10 relative"
>
<Image
className="boder-radius-10"
src={imageSrc}
width={265}
height={180}
alt={item.title}
/>
{/* icon video nếu cần */}
<i className="sprite sprite-icon-play-video-detail icon-video-feature incon-play-youtube"></i>
<i className="sprite sprite-play-youtube incon-play-youtube"></i>
</Link>
<div className="content-article content-article-item flex flex-1 flex-col">
<Link href={linkHref} className="title-article">
<h3 className="line-clamp-2 font-[400]">{item.title}</h3>
</Link>
<p className="time-article flex items-center gap-4">
<i className="sprite sprite-clock-item-article"></i>
<span>{timeDisplay}</span>
</p>
<p className="descreption-article line-clamp-2">{item.summary}</p>
</div>
</div>
{open && (
<dialog id="video_modal" className="modal modal-open">
<div className="modal-box w-11/12 max-w-3xl">
<div className="flex items-center justify-between">
<h3 className="text-lg font-bold">{item.title}</h3>
<form method="dialog">
<button
className="btn btn-sm btn-circle btn-ghost absolute top-2 right-2"
onClick={() => setOpen(false)}
>
<FaTimes size={16} />
</button>
</form>
</div>
<div className="video-wrapper mt-4">
<iframe
width="100%"
height="400"
src={getYoutubeEmbedUrl(item.external_url)}
title={item.title}
frameBorder="0"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
></iframe>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={() => setOpen(false)}>close</button>
</form>
</dialog>
)}
</>
);
};
export default ItemArticleVideo;

View File

@@ -0,0 +1,42 @@
'use client';
import { FaCaretRight } from 'react-icons/fa';
import Link from 'next/link';
import ItemArticleVideo from './ItemArticleVideo';
import { getArticleVideos } from '@/lib/api/article';
import { useApiData } from '@/hooks/useApiData';
import type { Article } from '@/types';
const BoxArticleVideo: React.FC = () => {
const { data: videos } = useApiData(
() => getArticleVideos(),
[],
{ initialData: [] as Article[] },
);
return (
<div className="box-videos-group box-article-group boder-radius-10 relative">
<div className="flex items-center justify-between">
<div className="title-box">
<h2 className="title-box font-[600]">Youtube Channel</h2>
</div>
<Link
href="https://www.youtube.com/NguyenCongPC"
target="_blank"
rel="nofollow"
className="btn-article-group flex items-center gap-2"
>
<span>Xem tất cả</span>
<FaCaretRight size={16} />
</Link>
</div>
<div className="list-videos-group list-article-group flex items-center gap-10">
{videos.slice(0, 4).map((item) => (
<ItemArticleVideo item={item} key={item.id} />
))}
</div>
</div>
);
};
export default BoxArticleVideo;

View File

@@ -0,0 +1,56 @@
'use client';
import React from 'react';
import { Article } from '@/types';
import Link from 'next/link';
import Image from 'next/image';
type ItemArticleProps = {
item: Article;
};
const ItemArticle: React.FC<ItemArticleProps> = ({ item }) => {
// chọn link: nếu có external_url thì dùng, ngược lại dùng url
const linkHref = item.external_url && item.external_url !== '' ? item.external_url : item.url;
// chọn ảnh: nếu có original thì dùng, ngược lại ảnh mặc định
const imageSrc =
item.image?.original && item.image.original !== ''
? item.image.original
: '/static/assets/nguyencong_2023/images/not-image.png';
// chọn thời gian: ưu tiên article_time, fallback createDate
const timeDisplay =
item.article_time && item.article_time !== '' ? item.article_time : item.createDate;
return (
<div className="item-article flex gap-3">
<Link href={linkHref} className="img-article boder-radius-10 position-relative">
<Image
className="boder-radius-10"
src={imageSrc}
width={265}
height={180}
alt={item.title}
/>
{/* icon video nếu cần */}
<i className="sprite sprite-icon-play-video-detail icon-video-feature incon-play-youtube"></i>
<i className="sprite sprite-play-youtube incon-play-youtube"></i>
</Link>
<div className="content-article content-article-item flex flex-1 flex-col">
<Link href={linkHref} className="title-article">
<h3 className="line-clamp-2 font-[400]">{item.title}</h3>
</Link>
<p className="time-article flex items-center gap-4">
<i className="sprite sprite-clock-item-article"></i>
<span>{timeDisplay}</span>
</p>
<p className="descreption-article line-clamp-2">{item.summary}</p>
</div>
</div>
);
};
export default ItemArticle;

View File

@@ -0,0 +1,37 @@
'use client';
import { FaCaretRight } from 'react-icons/fa';
import Link from 'next/link';
import ItemArticle from './ItemArticle';
import { getArticles } from '@/lib/api/article';
import { useApiData } from '@/hooks/useApiData';
import type { Article } from '@/types';
const BoxArticle: React.FC = () => {
const { data: articles } = useApiData(
() => getArticles(),
[],
{ initialData: [] as Article[] },
);
return (
<div className="box-article-group boder-radius-10">
<div className="flex items-center justify-between">
<div className="title-box">
<h2 className="title-box font-[600]">Tin tức công nghệ</h2>
</div>
<Link href="/tin-cong-nghe" className="btn-article-group flex items-center gap-1">
<span>Xem tất cả</span>
<FaCaretRight size={16} />
</Link>
</div>
<div className="list-article-group flex items-center gap-10">
{articles.slice(0, 4).map((item) => (
<ItemArticle item={item} key={item.id} />
))}
</div>
</div>
);
};
export default BoxArticle;

View File

@@ -0,0 +1,62 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { FaCaretDown } from 'react-icons/fa';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
import ItemProduct from '@/components/Common/ItemProduct';
import { menuData } from '@/components/Other/Header/menuData';
import { getProductHot } from '@/lib/api/product';
import { useApiData } from '@/hooks/useApiData';
import type { TypeListProduct } from '@/types/global/TypeListProduct';
const BoxListCategory: React.FC = () => {
const { data: products } = useApiData(() => getProductHot(), [], {
initialData: [] as TypeListProduct,
});
return (
<>
{menuData[0].product.all_category.map((item, index) => (
<div className="box-product-category boder-radius-10" key={index}>
<div className="flex items-center justify-between">
<div className="title">
<h2 className="title-box font-[600]">{item.title}</h2>
</div>
<div className="list-category-child flex flex-1 items-center justify-end">
{item.children.slice(0, 4).map((item2, index2) => (
<Link key={index2} href={item2.url} className="title-category">
{item2.title}
</Link>
))}
<Link href={item.url} className="title-all-category flex items-center gap-2">
<span>Xem tất cả </span>
<FaCaretDown size={16} />
</Link>
</div>
</div>
<div className="box-list-item-category swiper-product-category">
<Swiper
modules={[Autoplay, Navigation, Pagination]}
spaceBetween={12}
slidesPerView={5}
loop={true}
navigation={true}
>
{products.map((product) => (
<SwiperSlide key={product.id}>
<ItemProduct item={product} />
</SwiperSlide>
))}
</Swiper>
</div>
</div>
))}
</>
);
};
export default BoxListCategory;

View File

@@ -0,0 +1,34 @@
'use client';
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import type { InfoCategory } from '@/types/global/Menu';
const ItemCategory: React.FC<{ item: InfoCategory }> = ({ item }) => {
return (
<Link href={item.url} className="item-category flex flex-col items-center">
<p className="item-category-img">
{item.thumnail ? (
<Image
src={item.thumnail}
width={50}
height={50}
alt={item.title}
className="swiper-lazy lazy"
/>
) : (
<Image
src="https://nguyencongpc.vn/static/assets/nguyencong_2023/images/not-image.png"
width={50}
height={50}
alt={item.title}
className="border-radius-10"
/>
)}
</p>
<p className="title line-clamp-2">{item.title}</p>
</Link>
);
};
export default ItemCategory;

View File

@@ -0,0 +1,29 @@
'use client';
import React from 'react';
import { menuData } from '@components/Other/Header/menuData';
import ItemCategory from './ItemCategory';
import { InfoCategory } from '@/types';
const renderFeaturedCategories = (categories: InfoCategory[]) => {
return categories.map((cat, idx) => (
<React.Fragment key={idx}>
{cat.is_featured == '1' && <ItemCategory item={cat} />}
{cat.children && renderFeaturedCategories(cat.children)}
</React.Fragment>
));
};
const CategoryFeature = () => {
return (
<div className="box-category-outstanding boder-radius-10">
<div className="title-box">
<h2 className="title title-box font-[600]">Danh mục nổi bật</h2>
</div>
<div className="list-category-outstanding grid grid-cols-10 gap-3">
{renderFeaturedCategories(menuData[0].product.all_category)}
</div>
</div>
);
};
export default CategoryFeature;

View File

@@ -0,0 +1,78 @@
import React from 'react';
import { DealType } from '@/types';
import { formatCurrency } from '@/lib/formatPrice';
import Image from 'next/image';
type ProductItemProps = {
item: DealType;
};
const ProductItem: React.FC<ProductItemProps> = ({ item }) => {
const { product_info } = item;
const offers = product_info.specialOffer?.all ?? [];
return (
<div className="product-item">
<a href={product_info.productUrl} className="product-image relative">
{product_info.productImage.large ? (
<Image
src={product_info.productImage.large}
width="164"
height="164"
alt={product_info.productName}
className="lazy"
/>
) : (
<Image
src="/static/assets/nguyencong_2023/images/not-image.png"
width="164"
height="164"
alt={product_info.productName}
className="lazy"
/>
)}
<span className="p-type-holder">
{product_info.productType.isHot === 1 && <i className="p-icon-type p-icon-hot"></i>}
{product_info.productType.isNew === 1 && <i className="p-icon-type p-icon-new"></i>}
</span>
</a>
<div className="product-info">
<a href={product_info.productUrl}>
<h3 className="product-title line-clamp-3">{product_info.productName}</h3>
</a>
<div className="product-martket-main flex items-center">
{product_info.marketPrice > 0 ? (
<p className="product-market-price">{product_info.marketPrice.toLocaleString()} </p>
) : (
<p className="product-market-price">
{product_info.sale_rules.normal_price.toLocaleString()}
</p>
)}
<div className="product-percent-price">-{product_info.price_off || 0}%</div>
</div>
<div className="product-price-main font-bold">
{item.price > '0' ? `${formatCurrency(product_info.price)}đ` : 'Liên hệ'}
</div>
<div className="p-quantity-sale">
<i className="sprite sprite-fire-deal"></i>
<div className="bg-gradient"></div>
<p className="js-line-deal-left"></p>
<span>
Còn {Number(item.quantity) - Number(item.sale_quantity)}/{Number(item.quantity)} sản
phẩm
</span>
</div>
{offers.length > 0 && (
<div
className="product-offer line-clamp-2"
dangerouslySetInnerHTML={{
__html: product_info.specialOffer!.all![0].title,
}}
/>
)}
</div>
</div>
);
};
export default ProductItem;

View File

@@ -0,0 +1,64 @@
'use client';
import React, { useEffect, useState } from 'react';
import Link from 'next/link';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
import { FaCaretRight } from 'react-icons/fa';
import { TypeListProductDeal } from '@/types';
import { getDeals } from '@/lib/api/deal';
import CountDown from '@/components/Common/CountDown';
import ProductItem from './ProductItem';
const BoxProductDeal: React.FC = () => {
const [deals, setDeals] = useState<TypeListProductDeal>([]);
const [expired, setExpired] = useState(false);
useEffect(() => {
getDeals().then(setDeals).catch(console.error);
}, []);
if (expired) return null;
const deadline = deals[0]?.to_time ?? '31-01-2026, 9:30 am';
return (
<div className="box-product-deal boder-radius-10">
<div className="box-title-deal flex items-center justify-between">
<div className="title-deal flex items-center justify-center gap-10">
<i className="sprite sprite-icon-deal-home"></i>
<h2 className="title font-bold">Giá tốt mỗi ngày</h2>
<span className="text-time-deal-home color-white fz-16 font-bold">Kết thúc sau</span>
<div className="global-time-deal flex items-center gap-2">
<CountDown deadline={deadline} onExpire={() => setExpired(true)} />
</div>
</div>
<Link href="/deal" className="button-deal color-white mb-10 flex items-center">
Xem thêm khuyến mãi <FaCaretRight size={16} />
</Link>
</div>
<div className="box-list-item-deal swiper-box-deal">
<Swiper
modules={[Autoplay, Navigation, Pagination]}
spaceBetween={12}
loop={true}
navigation={true}
breakpoints={{
320: { slidesPerView: 2 },
640: { slidesPerView: 3 },
768: { slidesPerView: 4 },
1024: { slidesPerView: 5 },
1280: { slidesPerView: 6 },
}}
>
{deals.map((item) => (
<SwiperSlide key={item.id}>
<ProductItem item={item} />
</SwiperSlide>
))}
</Swiper>
</div>
</div>
);
};
export default BoxProductDeal;

View File

@@ -0,0 +1,35 @@
'use client';
import Image from 'next/image';
interface TypeReview {
avatar: string;
text: string;
author: string;
}
type ItemReviewProps = {
item: TypeReview;
};
const ItemReview: React.FC<ItemReviewProps> = ({ item }) => {
return (
<div className="item-review-customer-hompage relative flex items-center justify-between">
<div className="left-review relative">
<Image src={item.avatar} width={88} height={88} className="lazy" alt="avatar" />
</div>
<div className="right-reivew">
<p className="text-reivew line-clamp-2">{item.text}</p>
<b className="author-review font-[500]">{item.author}</b>
<Image
src="https://nguyencongpc.vn/static/assets/nguyencong_2023/images/5star-customer.png"
width={80}
height={15}
alt="rating"
/>
</div>
</div>
);
};
export default ItemReview;

View File

@@ -0,0 +1,41 @@
'use client';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
import ItemReview from './ItemReview';
import { getHomeReviews } from '@/lib/api/home';
import { useApiData } from '@/hooks/useApiData';
import type { HomeReview } from '@/types';
const BoxReviewCustomer: React.FC = () => {
const { data: reviews } = useApiData(
() => getHomeReviews(),
[],
{ initialData: [] as HomeReview[] },
);
return (
<div className="box-review-from-customer boder-radius-10">
<div className="title-box">
<h2 className="title-box font-[600]">Đánh giá từ khách hàng về Nguyễn Công PC</h2>
</div>
<div className="list-review-customer-homepage">
<Swiper
modules={[Autoplay, Navigation, Pagination]}
spaceBetween={15}
slidesPerView={3}
loop={true}
pagination={{ clickable: true }}
>
{reviews.map((item, index) => (
<SwiperSlide key={`${item.author}-${index}`} className="item">
<ItemReview item={item} />
</SwiperSlide>
))}
</Swiper>
</div>
</div>
);
};
export default BoxReviewCustomer;

View File

@@ -0,0 +1,71 @@
'use client';
import React, { useEffect, useState } from 'react';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
import Image from 'next/image';
import Link from 'next/link';
import { getBanners } from '@/lib/api/banner';
import { TemplateBanner } from '@/types';
const SliderHome: React.FC = () => {
const [banners, setBanners] = useState<TemplateBanner | null>(null);
useEffect(() => {
getBanners().then(setBanners).catch(console.error);
}, []);
const dataSlider = banners?.homepage;
return (
<>
<div className="box-banner-homepage relative">
<Swiper
modules={[Autoplay, Navigation, Pagination]}
spaceBetween={12}
slidesPerView={1}
loop={true}
>
{dataSlider?.banner_slider_homepage_main?.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>
<div className="box-banner-under-slider">
<Swiper
modules={[Autoplay, Navigation, Pagination]}
spaceBetween={12}
slidesPerView={2}
loop={true}
navigation={true}
>
{dataSlider?.banner_under_slider_trangchu?.map((banner, index) => (
<SwiperSlide key={index} className="item">
<Link href={banner.desUrl} className="item-banner boder-radius-10">
<Image
src={banner.fileUrl}
width={banner.width}
height={banner.height}
alt={banner.title}
className="lazy boder-radius-10"
/>
</Link>
</SwiperSlide>
))}
</Swiper>
</div>
</>
);
};
export default SliderHome;

View File

@@ -0,0 +1,37 @@
import React from 'react';
import SliderHome from './SliderHome';
import BoxProductDeal from './Deal';
import CategoryFeature from './CategoryFeature';
import BoxListCategory from './Category';
import BoxArticle from './BoxArticle';
import BoxArticleVideo from './ArticleVideo';
import BoxReviewCustomer from './ReviewCustomer';
const Home = () => {
return (
<div className="page-hompage mt-4">
<div className="container">
{/* slider */}
<SliderHome />
{/* deal */}
<BoxProductDeal />
{/* box danh mục nổi bật */}
<CategoryFeature />
{/* DANH SÁCH DANH MỤC */}
<BoxListCategory />
{/* tin tức công nghệ */}
<BoxArticle />
{/* tin tức video */}
<BoxArticleVideo />
{/* đánh giá từa khách hàng */}
<BoxReviewCustomer />
</div>
</div>
);
};
export default Home;

View File

@@ -0,0 +1,55 @@
'use client';
import Link from 'next/link';
import { motion } from 'framer-motion';
const NotFound = () => {
return (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 via-white to-blue-100 px-4">
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4 }}
className="w-full max-w-md rounded-3xl bg-white p-8 text-center shadow-xl"
>
{/* Icon lỗi link */}
<motion.div
animate={{ y: [0, -4, 0] }}
transition={{ repeat: Infinity, duration: 1.8 }}
className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-orange-100"
>
<svg
className="h-10 w-10 text-orange-500"
fill="none"
stroke="currentColor"
strokeWidth="2"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M13.828 10.172a4 4 0 00-5.656 5.656M7 7a7 7 0 019.9 9.9M12 12l.01.01"
/>
</svg>
</motion.div>
<h1 className="text-2xl font-bold text-gray-800">Đưng dẫn không hợp lệ</h1>
<p className="mt-3 text-sm text-gray-600">
Bạn truy cập không tồn tại hoặc đưng dẫn đã bị thay đi.
</p>
{/* CTA */}
<div className="mt-8 flex flex-col gap-3">
<Link
href="/"
className="rounded-xl bg-blue-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-blue-700"
>
Về trang chủ
</Link>
</div>
</motion.div>
</div>
);
};
export default NotFound;

View File

@@ -0,0 +1,47 @@
'use client';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
import Image from 'next/image';
import Link from 'next/link';
import { getBanners } from '@/lib/api/banner';
import { useApiData } from '@/hooks/useApiData';
import type { TemplateBanner } from '@/types';
const BannerCategory = () => {
const { data: banners } = useApiData(
() => getBanners(),
[],
{ initialData: null as TemplateBanner | null },
);
const dataSlider = banners?.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;

View File

@@ -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
: 'https://nguyencongpc.vn/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;

View File

@@ -0,0 +1,131 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import type { CategoryData } from '@/types';
import { Breadcrumb } from '@/components/Common/Breadcrumb';
import BannerCategory from './BannerCategory';
import ItemCategoryChild from './ItemCategoryChild';
import BoxFilter from '@components/Product/BoxFilter';
import BoxSort from '@components/Product/BoxSort';
import ItemProduct from '@/components/Common/ItemProduct';
import PreLoader from '@/components/Common/PreLoader';
import { getProductCategory } from '@/lib/api/product';
import { useApiData } from '@/hooks/useApiData';
interface CategoryPageProps {
slug: string;
}
const CategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
const searchParams = useSearchParams();
const search = searchParams.toString();
const productDisplayType =
searchParams.get('display') === 'list' ? 'list' : searchParams.get('display') === 'detail' ? 'list' : 'grid';
const {
data: currentCategory,
isLoading,
} = useApiData(() => getProductCategory(slug, search), [slug, search], {
initialData: null as CategoryData | null,
});
if (isLoading) {
return <PreLoader />;
}
const breadcrumbItems = currentCategory?.current_category?.path?.path?.map((p) => ({
name: p.name,
url: p.url,
})) ?? [
{ name: 'Trang chủ', url: '/' },
{ name: currentCategory?.current_category.name ?? 'Danh mục', url: slug },
];
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">
Không thấy link <code className="rounded bg-gray-100 px-2 py-0.5 text-sm">{slug}</code>{' '}
không tồn tại hoặc đã bị xóa.
</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>
);
}
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) => (
<ItemCategoryChild item={item} key={item.id} />
))}
</ul>
<BoxFilter filters={currentCategory} />
<div className="box-list-product-category boder-radius-10">
<BoxSort
sort_by_collection={currentCategory.sort_by_collection}
display_by_collection={currentCategory.display_by_collection}
product_display_type={productDisplayType}
/>
</div>
<div className="list-product-category grid grid-cols-5 gap-3">
{currentCategory.product_list.map((item) => (
<ItemProduct key={item.id} 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;

View File

@@ -0,0 +1,51 @@
import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay } from 'swiper/modules';
const BOUGHT_DATA = [
{ name: 'Anh Tuấn', phone: '036 856 xxxx', time: '2 giờ trước' },
{ name: 'Quốc Trung', phone: '035 348 xxxx', time: '1 giờ trước' },
{ name: 'Quang Ngọc', phone: '097 478 xxxx', time: '30 phút trước' },
{ name: 'Mạnh Lực', phone: '037 204 xxxx', time: '25 phút trước' },
{ name: 'Hiếu', phone: '096 859 xxxx', time: '20 phút trước' },
];
export const BoxBought = () => {
return (
<div className="pro-customer-bought">
<svg
className="pcb-icon"
viewBox="0 0 438.533 438.533"
width={16}
height={16}
fill="red"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<g>
<path d="M409.133,109.203c-19.608-33.592-46.205-60.189-79.798-79.796C295.736,9.801,259.058,0,219.273,0c-39.781,0-76.47,9.801-110.063,29.407c-33.595,19.604-60.192,46.201-79.8,79.796C9.801,142.8,0,179.489,0,219.267c0,39.78,9.804,76.463,29.407,110.062c19.607,33.592,46.204,60.189,79.799,79.798c33.597,19.605,70.283,29.407,110.063,29.407s76.47-9.802,110.065-29.407c33.593-19.602,60.189-46.206,79.795-79.798c19.603-33.596,29.403-70.284,29.403-110.062C438.533,179.485,428.732,142.795,409.133,109.203z M334.332,232.111L204.71,361.736c-3.617,3.613-7.896,5.428-12.847,5.428c-4.952,0-9.235-1.814-12.85-5.428l-29.121-29.13c-3.617-3.613-5.426-7.898-5.426-12.847c0-4.941,1.809-9.232,5.426-12.847l87.653-87.646l-87.657-87.65c-3.617-3.612-5.426-7.898-5.426-12.845c0-4.949,1.809-9.231,5.426-12.847l29.121-29.13c3.619-3.615,7.898-5.424,12.85-5.424c4.95,0,9.233,1.809,12.85,5.424l129.622,129.621c3.613,3.614,5.42,7.898,5.42,12.847C339.752,224.213,337.945,228.498,334.332,232.111z" />
</g>
</svg>
<div className="pcb-slider swiper-customer-bought">
<Swiper
modules={[Autoplay]}
spaceBetween={12}
slidesPerView={1}
loop={true}
autoplay={{ delay: 3000, disableOnInteraction: false }}
>
{BOUGHT_DATA.map((customer, idx) => (
<SwiperSlide key={idx}>
<div>
<p>
<b>Khách hàng {customer.name} ({customer.phone})</b>
</p>
<p>Đã mua hàng {customer.time}</p>
</div>
</SwiperSlide>
))}
</Swiper>
</div>
</div>
);
};

View File

@@ -0,0 +1,68 @@
import { useState } from 'react';
import type { ProductDetailData } from '@/types';
import CountDown from '@/components/Common/CountDown';
import { formatCurrency } from '@/lib/formatPrice';
export const BoxPrice = (item: ProductDetailData) => {
const [now] = useState(() => Date.now());
const { sale_rules, deal_list, marketPrice, price } = item.product_info;
const isFlashSale = sale_rules.type === 'deal' && Number(sale_rules.to_time) > now;
const deal = deal_list[0];
const remaining = deal ? Number(deal.quantity) - Number(deal.sale_order) : 0;
const total = deal ? Number(deal.quantity) : 0;
const percentRemaining = total > 0 ? (remaining / total) * 100 : 0;
const hasMarketPrice = Number(marketPrice) > 0;
const savings = hasMarketPrice ? Number(marketPrice) - Number(price) : 0;
return (
<>
{isFlashSale && (
<div className="box-flash-sale boder-radius-10 flex items-center">
<div className="box-left relative flex items-center">
<i className="sprite sprite-flashsale-detail"></i>
<p className="title-deal font-weight-800">flash sale</p>
</div>
<div className="box-middle product-time-holder global-time-deal flex gap-2">
<CountDown deadline={Number(sale_rules.to_time)} />
</div>
<div className="box-right">
<div className="box-product-deal">
<p className="text-deal-detail">
Còn {remaining}/{total} sản phẩm
</p>
<div
className="p-quantity-sale"
data-quantity-left={remaining}
data-quantity-sale-total={total}
>
<i className="sprite sprite-fire-deal"></i>
<div className="bg-gradient"></div>
<p className="js-line-deal-left" style={{ width: `${percentRemaining}%` }}></p>
</div>
</div>
</div>
</div>
)}
{hasMarketPrice && isFlashSale && (
<div
className="box-price-detail boder-radius-10 flex flex-wrap items-center"
style={{ rowGap: '8px' }}
>
<p className="price-detail font-bold">
{Number(price) > 0 ? `${formatCurrency(price)}đ` : 'Liên hệ'}
</p>
<span className="market-price-detail font-weight-500">
{formatCurrency(marketPrice)}đ
</span>
<div className="save-price-detail flex items-center gap-1">
<span>Tiết kiệm</span>
{formatCurrency(savings)}
<span>đ</span>
</div>
</div>
)}
</>
);
};

View File

@@ -0,0 +1,171 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { ProductDetailData } from '@/types';
import { BoxPrice } from './BoxPrice';
import { BoxBought } from './BoxBought';
import { addToCart } from '@/lib/ButtonCart';
import { SanitizedHtml } from '@/components/Common/SanitizedHtml';
export const BoxInfoRight = (item: ProductDetailData) => {
const router = useRouter();
const [quantity, setQuantity] = useState(1);
const [cartMessage, setCartMessage] = useState('');
const inStock = Number(item.product_info.quantity) > 0;
const hasPrice = Number(item.product_info.price) > 0;
const changeQty = (delta: number) => setQuantity((prev) => Math.max(1, prev + delta));
const handleAddToCart = () => {
addToCart(item.product_info, quantity);
setCartMessage('Đã thêm vào giỏ hàng!');
setTimeout(() => setCartMessage(''), 2500);
};
const handleBuyNow = () => {
addToCart(item.product_info, quantity);
router.push('/cart');
};
return (
<>
<h1 className="product-name color-black line-clamp-3 font-bold">
{item.product_info.productName}
</h1>
<div className="list-basic-product-info flex flex-wrap items-center">
<div className="item-basic">
SP: <span className="color-primary">{item.product_info.productSKU}</span>
</div>
<div className="item-basic">
Đánh giá: <span className="color-primary">{item.product_info.review.summary.total}</span>
</div>
<div className="item-basic">
Bình luận:{' '}
<span className="color-primary">{item.product_info.comment.summary.total}</span>
</div>
<div className="item-basic">
Lượt xem: <span className="color-primary">{item.product_info.visit}</span>
</div>
{item.product_info.extend.buy_count?.length > 0 && (
<div className="item-basic last-item-basic position-relative">
Đã bán: <span className="color-primary">{item.product_info.extend.buy_count}</span>
</div>
)}
</div>
{/* tình trạng */}
<div className="list-basic-product-info flex flex-wrap items-center gap-6">
<div className="item-basic">
Bảo hành: <span className="color-red">{item.product_info.warranty}</span>
</div>
{inStock && (
<div className="item-basic last-item-basic position-relative">
Tình trạng: <span className="color-green">Còn hàng</span>
</div>
)}
</div>
{/* giá */}
<BoxPrice {...item} />
{item.product_info.specialOffer.all.length > 0 && (
<div className="box-offer-detail border-radius-10">
<div className="title-offer-detail flex items-center">
<i className="sprite sprite-gift-detail"></i>
<p className="font-weight-600">Khuyến mãi</p>
</div>
<div className="list-info-offter">
{item.product_info.specialOffer.all.map((_item, idx) => (
<div key={idx} className="item-offer">
<i className="icon"></i>
<SanitizedHtml html={_item.title} />
</div>
))}
</div>
</div>
)}
{/* mua hàng */}
{(inStock || hasPrice) && (
<>
<div className="product-buy-quantity flex items-center">
<p className="title-quantity">Số lượng:</p>
<div className="cart-quantity-select flex items-center justify-center">
<p
className="js-quantity-change cursor-pointer select-none"
onClick={() => changeQty(-1)}
>
</p>
<input
type="text"
className="js-buy-quantity bk-product-qty font-bold"
value={quantity}
onChange={(e) => {
const val = parseInt(e.target.value);
if (!isNaN(val) && val > 0) setQuantity(val);
}}
/>
<p
className="js-quantity-change cursor-pointer select-none"
onClick={() => changeQty(1)}
>
+
</p>
</div>
<button
className="addCart flex cursor-pointer flex-wrap items-center justify-center gap-3"
onClick={handleAddToCart}
>
<i className="sprite sprite-cart-detail"></i>
<p className="title-cart">Thêm vào giỏ hàng</p>
</button>
</div>
{cartMessage && <p className="mt-1 text-sm font-medium text-green-600">{cartMessage}</p>}
<div id="detail-buy-ads" className="detail-buy grid grid-cols-2 gap-2">
<button className="detail-buy-now col-span-2 cursor-pointer" onClick={handleBuyNow}>
<span>ĐT MUA NGAY</span>
Giao hàng tận nơi nhanh chóng
</button>
<button className="detail-add-cart">
<span>TRẢ GÓP QUA HỒ </span>
Chỉ từ 2.665.000/ tháng
</button>
<button className="detail-add-cart">
<span>TRẢ GÓP QUA THẺ</span>
Chỉ từ 1.332.500/ tháng
</button>
</div>
</>
)}
{/* yên tâm mua hàng */}
<div className="box-product-policy-detal boder-radius-10" style={{ marginTop: '24px' }}>
<h2 className="title font-semibold">Yên tâm mua hàng</h2>
<div className="list-showroom-detail flex flex-wrap justify-between">
<div className="item flex items-center gap-2">
<i className="sprite sprite-camket-detail"></i>
<p>Cam kết giá tốt nhất thị trường.</p>
</div>
<div className="item flex items-center gap-2">
<i className="sprite sprite-sanphammoi-detail"></i>
<p>Sản phẩm mới 100%.</p>
</div>
<div className="item flex items-center gap-2">
<i className="sprite sprite-1doi1-detail"></i>
<p>Lỗi 1 đi 1 ngay lập tức.</p>
</div>
<div className="item flex items-center gap-2">
<i className="sprite sprite-hotrotragop-detail"></i>
<p>Hỗ trợ trả góp - Thủ tục nhanh gọn.</p>
</div>
</div>
</div>
<BoxBought />
</>
);
};

View File

@@ -0,0 +1,83 @@
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { ComboProduct } from '@/types';
import { formatCurrency } from '@/lib/formatPrice';
interface ChangePopupProps {
titleGroup: string;
products: ComboProduct[];
open: boolean; // nhận trạng thái mở
onClose: () => void; // hàm đóng popup
onSelect: (product: ComboProduct) => void;
}
export const ChangeProductPopup: React.FC<ChangePopupProps> = ({
titleGroup,
products,
open,
onClose,
onSelect,
}) => {
if (!open) return null; // chỉ render khi open = true
return (
<dialog
open
className="modal"
onKeyDown={(e) => e.key === 'Escape' && onClose()}
aria-modal="true"
aria-label={`Chọn ${titleGroup} khác`}
>
<div className="modal-box max-w-5xl bg-white">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-bold">Chọn {titleGroup} khác</h3>
<button className="btn btn-sm btn-circle btn-ghost" onClick={onClose}>
</button>
</div>
{/* Danh sách sản phẩm */}
<div className="grid grid-cols-4 gap-3">
{products.map((p, idx) => (
<div key={`${p.id}-${idx}`} className="product-item c-pro-item">
<Link href={p.url} className="product-image">
<Image
src={p.images.large || '/static/assets/not-image.png'}
alt={p.title}
className="mb-2 object-cover"
height={170}
width={170}
/>
</Link>
<div className="product-info">
<Link href={p.url}>
<h3 className="product-title line-clamp-2">{p.title}</h3>
</Link>
<div className="product-price-main flex items-center justify-between">
<div className="product-price">
<b className="price font-semibold">
{Number(p.price) > 0 ? `${formatCurrency(p.price)} đ` : 'Liên hệ'}
</b>
</div>
</div>
<button
type="button"
className="c-btn js-c-btn"
onClick={() => onSelect(p)}
>
Chọn mua
</button>
</div>
</div>
))}
</div>
</div>
{/* Overlay */}
<form method="dialog" className="modal-backdrop">
<button onClick={onClose}>close</button>
</form>
</dialog>
);
};

View File

@@ -0,0 +1,99 @@
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { ComboProduct } from '@/types';
import { formatCurrency } from '@/lib/formatPrice';
const FALLBACK_IMAGE = '/static/assets/nguyencong_2023/images/not-image.png';
interface ItemComboProps {
item: ComboProduct;
keyGroup: string;
titleGroup: string;
setId: string;
products: ComboProduct[];
checked: boolean;
onToggle: (groupKey: string, checked: boolean) => void;
onOpenPopup: (groupKey: string, titleGroup: string, products: ComboProduct[]) => void;
}
export const ItemComboSet: React.FC<ItemComboProps> = ({
item,
keyGroup,
titleGroup,
setId,
products,
checked,
onToggle,
onOpenPopup,
}) => {
const hasDiscount = Number(item.normal_price) > Number(item.price) && Number(item.price) > 0;
return (
<div className={`product-item c-pro-item js-pro-${item.id} ${item.is_free === 'yes' ? 'w-select' : ''}`}>
<Link href={item.url} className="product-image">
<Image
src={item.images?.large || FALLBACK_IMAGE}
alt={item.title}
width={175}
height={175}
/>
</Link>
<div className="product-info">
<Link href={item.url}>
<h3 className="product-title line-clamp-2">{item.title}</h3>
</Link>
<div className="product-price-main flex items-center justify-between">
<div className="product-price">
<b className="price font-semibold">
{Number(item.price) > 0 ? `${formatCurrency(item.price)} đ` : 'Liên hệ'}
</b>
</div>
</div>
{hasDiscount ? (
<div className="product-martket-main flex flex-wrap items-center gap-4">
<p className="product-market-price">{item.normal_price} đ</p>
{item.discount.includes('%') ? (
<div className="product-percent-price text-[10px]">
-{item.discount}
</div>
) : (
<p className="text-[10px] text-[#BE1F2D]">(-{item.discount} đ)</p>
)}
</div>
) : (
<div className="product-martket-main" />
)}
<button
type="button"
className="c-pro-change js-chagne-pro"
data-id={item.id}
onClick={() => onOpenPopup(keyGroup, titleGroup, products)}
>
Chọn {titleGroup} khác
</button>
<div className="check-box-comboset">
<input
type="checkbox"
className={`js-price js-check-select js-combo-set js-combo-set-select-product relative cursor-pointer ${
item.is_free === 'yes' ? 'product_free' : ''
}`}
checked={checked}
onChange={(e) => onToggle(keyGroup, e.target.checked)}
data-price={item.price}
data-unprice={item.normal_price}
data-idpk={item.id}
data-set-id={setId}
data-group-key={keyGroup}
data-product-id={item.id}
/>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,145 @@
import React, { useCallback, useMemo, useState } from 'react';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
import 'swiper/css';
import { ComboSet, ComboProduct, ComboGroup } from '@/types';
import { ItemComboSet } from './ItemComboset';
import { ChangeProductPopup } from './ChangeProductPopup';
import { formatCurrency } from '@/lib/formatPrice';
interface ComboProps {
combo_set: ComboSet[];
}
interface PopupState {
open: boolean;
groupKey: string;
title: string;
products: ComboProduct[];
}
export const ComboSetBox: React.FC<ComboProps> = ({ combo_set }) => {
const [popup, setPopup] = useState<PopupState>({
open: false,
groupKey: '',
title: '',
products: [],
});
const [selectedByGroup, setSelectedByGroup] = useState<Record<string, ComboProduct>>({});
const [checkedGroups, setCheckedGroups] = useState<Record<string, boolean>>({});
const setInfo = combo_set?.[0];
const getDisplayedProduct = useCallback(
(group: ComboGroup): ComboProduct =>
selectedByGroup[group.key] ||
group.product_list.find((p) => p.is_first === 'yes') ||
group.product_list[0],
[selectedByGroup],
);
const isGroupChecked = useCallback(
(groupKey: string) => checkedGroups[groupKey] ?? true,
[checkedGroups],
);
const { totalPrice, totalSavings, checkedCount } = useMemo(() => {
if (!setInfo) return { totalPrice: 0, totalSavings: 0, checkedCount: 0 };
let total = 0;
let savings = 0;
let count = 0;
for (const group of setInfo.group_list) {
if (!isGroupChecked(group.key)) continue;
count++;
const product = getDisplayedProduct(group);
const price = Number(product.price);
const normalPrice = Number(product.normal_price);
if (price > 0) {
total += price;
if (normalPrice > price) savings += normalPrice - price;
}
}
return { totalPrice: total, totalSavings: savings, checkedCount: count };
}, [setInfo, getDisplayedProduct, isGroupChecked]);
if (!combo_set || combo_set.length === 0) return null;
const handleOpenPopup = (groupKey: string, titleGroup: string, products: ComboProduct[]) => {
setPopup({ open: true, groupKey, title: titleGroup, products });
};
const handleReplaceProduct = (newProduct: ComboProduct) => {
setSelectedByGroup((prev) => ({ ...prev, [popup.groupKey]: newProduct }));
setPopup((prev) => ({ ...prev, open: false }));
};
const handleToggleGroup = (groupKey: string, checked: boolean) => {
setCheckedGroups((prev) => ({ ...prev, [groupKey]: checked }));
};
return (
<div className="box-comboset mb-8">
<p className="title-comboset font-semibold">Mua theo combo</p>
<div id="comboset">
<Swiper
className="list-product-comboset swiper-comboset"
modules={[Autoplay, Navigation, Pagination]}
spaceBetween={16}
navigation
breakpoints={{
320: { slidesPerView: 1 },
640: { slidesPerView: 2 },
1024: { slidesPerView: 3 },
}}
>
{setInfo.group_list.map((group) => (
<SwiperSlide key={group.key}>
<ItemComboSet
item={getDisplayedProduct(group)}
keyGroup={group.key}
titleGroup={group.title}
setId={setInfo.id}
products={group.product_list}
checked={isGroupChecked(group.key)}
onToggle={handleToggleGroup}
onOpenPopup={handleOpenPopup}
/>
</SwiperSlide>
))}
</Swiper>
<div className="comboset-info mt-4 flex items-center justify-between rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
<div className="box-left flex flex-col gap-0.5">
<div className="total-comboset flex items-center gap-2">
<span className="text-sm text-gray-500">Tạm tính:</span>
<span className="js-pass-price text-lg font-bold text-red-600">
{formatCurrency(totalPrice)} đ
</span>
</div>
{totalSavings > 0 && (
<p className="text-xs text-green-600">
Tiết kiệm thêm{' '}
<span className="save-price font-semibold">{formatCurrency(totalSavings)} đ</span>
</p>
)}
</div>
<div className="box-right">
<button
className="js-combo-set js-combo-set-checkout buy_combo rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white transition-all hover:bg-red-700 active:scale-95"
data-set-id={setInfo.id}
>
Mua <span>{checkedCount}</span> sản phẩm combo
</button>
</div>
</div>
</div>
<ChangeProductPopup
titleGroup={popup.title}
products={popup.products}
open={popup.open}
onClose={() => setPopup((prev) => ({ ...prev, open: false }))}
onSelect={handleReplaceProduct}
/>
</div>
);
};

View File

@@ -0,0 +1,60 @@
import { useState } from 'react';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay, Navigation, Pagination, Thumbs } from 'swiper/modules';
import Image from 'next/image';
import Link from 'next/link';
import { ProductImageGallery } from '@/types';
import type { Swiper as SwiperType } from 'swiper';
import useFancybox from '@/hooks/useFancybox';
interface ImageProps {
images: ProductImageGallery[];
}
export const ImageProduct: React.FC<ImageProps> = ({ images }) => {
const [thumbsSwiper, setThumbsSwiper] = useState<SwiperType | null>(null);
const [fancyboxRef] = useFancybox({
closeButton: 'auto',
dragToClose: true,
});
return (
<div className="product-images-show">
<div className="gallery-top product-info-image" ref={fancyboxRef}>
<Swiper
modules={[Autoplay, Navigation, Pagination, Thumbs]}
spaceBetween={12}
slidesPerView={1}
loop={true}
thumbs={{ swiper: thumbsSwiper }}
>
{images?.map((item, index) => (
<SwiperSlide key={index}>
<Link href={item.size.original} className="bigImage" data-fancybox>
<Image src={item.size.original} alt={''} width="595" height="595" />
</Link>
</SwiperSlide>
))}
</Swiper>
</div>
<div className="gallery-thumbs product-images-slider mt-2">
<Swiper
modules={[Autoplay, Navigation, Pagination, Thumbs]}
spaceBetween={12}
slidesPerView={6}
loop={true}
onSwiper={setThumbsSwiper}
>
{images?.map((item, index) => (
<SwiperSlide key={index}>
<div className="smallImage">
<Image src={item.size.original} alt={''} width="90" height="60" />
</div>
</SwiperSlide>
))}
</Swiper>
</div>
</div>
);
};

View File

@@ -0,0 +1,40 @@
import React from 'react';
interface FormCommentProps {
open: boolean;
onClose: () => void;
}
export const FormComment: React.FC<FormCommentProps> = ({ open, onClose }) => {
return (
<dialog id="commentDialog" className={`modal ${open ? 'modal-open' : ''}`}>
{' '}
<div className="modal-box">
<h3 className="mb-4 font-semibold">Nhập thông tin</h3>
<div className="space-y-4">
<div className="flex gap-4">
<label className="label cursor-pointer">
<input type="radio" name="sex" value="Anh" className="radio radio-xs" />
<span className="ml-2">Anh</span>
</label>
<label className="label cursor-pointer">
<input type="radio" name="sex" value="Chị" className="radio radio-xs" />
<span className="ml-2">Chị</span>
</label>
</div>
<input type="text" className="input input-md w-[95%]" placeholder="Họ tên (bắt buộc)" />
<input
type="email"
className="input input-md w-[95%]"
placeholder="Email (để nhận phản hồi qua mail)"
/>
<button className="btn btn-active w-[93.5%] bg-red-500 text-white">Cập nhật</button>
</div>
</div>
<form method="dialog" className="modal-backdrop">
<button onClick={onClose}>Đóng</button>
</form>
</dialog>
);
};

View File

@@ -0,0 +1,103 @@
'use client';
import React, { useState } from 'react';
import Image from 'next/image';
import { getProductComments } from '@/lib/api/product';
import { useApiData } from '@/hooks/useApiData';
import type { ProductCommentData } from '@/types/Comment';
const INITIAL_SHOW = 3;
interface ListCommentProps {
slug: string;
}
export const ListComment: React.FC<ListCommentProps> = ({ slug }) => {
const [showAll, setShowAll] = useState(false);
const { data: comments } = useApiData(
() => getProductComments(slug),
[slug],
{ initialData: [] as ProductCommentData[] },
);
const visibleComments = showAll ? comments : comments.slice(0, INITIAL_SHOW);
return (
<div className="comment-list">
{visibleComments.map((item) => (
<div className="item-comment" id={`comment_${item.id}`} key={item.id}>
<div className="form-reply-comment">
<div className="comment-name flex justify-between">
<div className="comment-form-left flex items-center gap-2">
{item.user_avatar ? (
<b className="avatar-user">
<Image src={item.user_avatar} alt={item.user_name} width={40} height={40} />
</b>
) : (
<b className="avatar-user flex items-center justify-center">
{item.user_name.charAt(0)}
</b>
)}
<b className="user-name">{item.user_name}</b>
</div>
<div className="comment-form-right flex items-center gap-2 text-sm text-gray-500">
<i className="fa-regular fa-clock"></i>
<span>{new Date(Number(item.post_time) * 1000).toLocaleDateString('vi-VN')}</span>
</div>
</div>
<div className="comment-content relative mt-3 rounded p-2">
<p>{item.content}</p>
<div className="info_feeback mt-2 flex items-center gap-2">
<i className="sprite sprite-icon-reply-detail"></i>
<button className="btn-reply font-medium">Trả lời</button>
</div>
</div>
<div className="reply-list-container mt-4">
{item.new_replies?.map((reply) => (
<div key={reply.id} className="item_reply mt-3">
<div className="flex justify-between">
<div className="flex items-center gap-2">
{reply.user_avatar !== '0' ? (
<b className="avatar-user flex items-center justify-center">
<Image
src={reply.user_avatar}
alt={reply.user_name}
width={32}
height={32}
/>
</b>
) : (
<b className="avatar-user flex items-center justify-center">
{reply.user_name.charAt(0)}
</b>
)}
<div className="comment-name">
<b className="user-name">{reply.user_name}</b>
{reply.is_user_admin === '1' && <i className="note font-medium">QTV</i>}
</div>
</div>
<div className="text-sm text-gray-500">
{new Date(Number(reply.post_time) * 1000).toLocaleDateString('vi-VN')}
</div>
</div>
<div className="comment-content mt-2 rounded p-2">{reply.content}</div>
</div>
))}
</div>
</div>
</div>
))}
{!showAll && comments.length > INITIAL_SHOW && (
<button className="btn-more cursor-pointer" onClick={() => setShowAll(true)}>
Xem thêm bình luận
</button>
)}
{showAll && (
<button className="btn-more cursor-pointer" onClick={() => setShowAll(false)}>
Thu gọn
</button>
)}
</div>
);
};

View File

@@ -0,0 +1,38 @@
'use client';
import React, { useState } from 'react';
import { FormComment } from './FormComment';
import { ListComment } from './ListComment';
interface ProductCommentProps {
slug: string;
}
export const ProductComment: React.FC<ProductCommentProps> = ({ slug }) => {
const [open, setOpen] = useState(false);
return (
<div className="box-comment">
<p className="title-comment font-[600]">Hỏi đáp</p>
<div className="comment-detail">
<div className="form-comment flex justify-between gap-2">
<textarea
className="comment_reply_content boder-radius-10"
id="content0"
placeholder="Xin mời để lại câu hỏi, Nguyencong sẽ trả lời ngay trong 1h, các câu hỏi sau 22h - 8h sẽ được trả lời vào sáng hôm sau."
name="user_post[content]"
></textarea>
<button
className="btn-send-form-comment send-comment-pc flex items-center justify-center gap-2"
onClick={() => setOpen(true)}
>
<i className="sprite sprite-icon-send-detail"></i>Gửi
</button>
</div>
<FormComment open={open} onClose={() => setOpen(false)} />
</div>
<ListComment slug={slug} />
</div>
);
};

View File

@@ -0,0 +1,29 @@
import { useState } from 'react';
import { FaAngleDown, FaAngleUp } from 'react-icons/fa6';
import type { ProductDetailData } from '@/types';
import { SanitizedHtml } from '@/components/Common/SanitizedHtml';
export const ProductDescription = (item: ProductDetailData) => {
const [expanded, setExpanded] = useState(false);
if (!item.product_info.productDescription) return null;
return (
<div className="box-descreption-detail">
<h2 className="titlle-descreption font-[500]">Giới thiệu {item.product_info.productName}</h2>
<SanitizedHtml
html={item.product_info.productDescription}
className={`content-descreption-detail static-html relative ${
expanded ? 'max-h-none' : 'max-h-[467px] overflow-hidden'
}`}
/>
<div
onClick={() => setExpanded(!expanded)}
className="btn-article-col js-viewmore-content flex items-center justify-center gap-2 font-[500]"
>
<span>{expanded ? 'Thu gọn' : 'Xem tất cả'}</span>
{expanded ? <FaAngleUp /> : <FaAngleDown />}
</div>
</div>
);
};

View File

@@ -0,0 +1,140 @@
'use client';
import React, { useState } from 'react';
const STAR_OPTIONS = [5, 4, 3, 2, 1] as const;
interface ReviewFormError {
content?: string;
name?: string;
}
export const FormReview: React.FC = () => {
const [rate, setRate] = useState<number>(5);
const [content, setContent] = useState('');
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [errors, setErrors] = useState<ReviewFormError>({});
const [submitted, setSubmitted] = useState(false);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newErrors: ReviewFormError = {};
if (!content.trim() || content.trim().length < 10) {
newErrors.content = 'Nội dung đánh giá tối thiểu 10 ký tự';
}
if (!name.trim()) {
newErrors.name = 'Vui lòng nhập tên của bạn';
}
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
// TODO: gọi API gửi đánh giá
console.log({ rate, content, name, email });
setSubmitted(true);
};
if (submitted) {
return (
<div className="box-form-review">
<p className="font-medium text-green-600">
Cảm ơn bạn đã gửi đánh giá! Đánh giá của bạn đang chờ duyệt.
</p>
</div>
);
}
return (
<form className="box-form-review" onSubmit={handleSubmit}>
<div>
<textarea
className="review_reply_content"
placeholder="Mời bạn để lại đánh giá..."
name="user_post[content]"
value={content}
onChange={(e) => {
setContent(e.target.value);
setErrors((prev) => ({ ...prev, content: undefined }));
}}
/>
{errors.content && <p className="mt-1 text-xs text-red-500">{errors.content}</p>}
</div>
<div className="actions-comment">
<div className="infomation-customer">
<table>
<tbody>
<tr className="flex items-center">
<td>
<label>Đánh giá:</label>
</td>
<td>
<div className="rating">
<div className="rating-selection">
{STAR_OPTIONS.map((star) => (
<React.Fragment key={star}>
<input
type="radio"
className="rating-input"
id={`rating-star-${star}`}
value={star}
name="user_post[rate]"
checked={rate === star}
onChange={() => setRate(star)}
/>
<label
htmlFor={`rating-star-${star}`}
className="sprite-1star rating-star"
/>
</React.Fragment>
))}
</div>
</div>
</td>
</tr>
<tr className="flex items-center">
<td>
<label htmlFor="review-name">Tên bạn</label>
</td>
<td>
<input
type="text"
id="review-name"
name="user_post[user_name]"
className="form-control"
value={name}
onChange={(e) => {
setName(e.target.value);
setErrors((prev) => ({ ...prev, name: undefined }));
}}
/>
{errors.name && <p className="mt-1 text-xs text-red-500">{errors.name}</p>}
</td>
</tr>
<tr className="flex items-center">
<td>
<label htmlFor="review-email">Email</label>
</td>
<td>
<input
type="email"
id="review-email"
name="user_post[user_email]"
className="form-control"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
</td>
</tr>
</tbody>
</table>
</div>
<button type="submit" className="btn-review send_form mb-10 mt-12">
Gửi đánh giá
</button>
</div>
</form>
);
};

View File

@@ -0,0 +1,143 @@
'use client';
import React, { useState } from 'react';
import Image from 'next/image';
import { getProductReviews } from '@/lib/api/product';
import { useApiData } from '@/hooks/useApiData';
import type { ProductReviewData } from '@/types/Review';
interface ListReviewProps {
slug: string;
}
export const ListReview: React.FC<ListReviewProps> = ({ slug }) => {
const [showAll, setShowAll] = useState(false);
const { data: reviews } = useApiData(
() => getProductReviews(slug),
[slug],
{ initialData: [] as ProductReviewData[] },
);
const visibleReviews = showAll ? reviews : reviews.slice(0, 3);
return (
<div className="list-review">
{visibleReviews.map((review) => {
const avatarLetter = review.user_name.charAt(0).toUpperCase();
const date = new Date(Number(review.post_time) * 1000).toLocaleDateString('vi-VN');
return (
<div key={review.id} className="item-comment">
<div className="form-reply-comment">
<div className="comment-name flex items-center justify-between">
<div className="comment-form-left flex items-center gap-2">
{review.user_avatar ? (
<b className="avatar-user js-avatar-user flex items-center justify-center">
<Image
src={review.user_avatar}
alt={review.user_name}
width={40}
height={40}
/>
</b>
) : (
<b className="avatar-user js-avatar-user flex items-center justify-center">
{avatarLetter}
</b>
)}
<b className="user-name flex items-center gap-2">{review.user_name}</b>
</div>
<div className="comment-form-right flex items-center gap-2">
<i className="fa-regular fa-clock"></i>
<span style={{ color: '#787878', fontSize: 12, marginRight: 4 }}>{date}</span>
</div>
</div>
<div className="comment-content boder-radius-10 relative mt-3">
<div className="text-review flex flex-col gap-2">
<p className="flex items-center">
<b>Đánh giá:</b> <i className={`sprite-star-5 star${review.rate}`}></i>
</p>
<p className="flex items-center">
<b>Nhận xét:</b>
<span style={{ width: '80%' }}>{review.content}</span>
</p>
</div>
<div className="info_feeback flex items-center gap-2">
<i className="sprite sprite-icon-reply-detail"></i>
<button className="write_reply btn-reply font-weight-500">Trả lời</button>
</div>
<div className="jd-img-review flex flex-col gap-2">
{review.files.map((file) => (
<Image
key={file.id}
src={file.file_path}
alt={file.title}
width={100}
height={60}
/>
))}
</div>
</div>
<div className="reply-holder reply-list-container">
{review.new_replies.map((reply) => (
<div key={reply.id} className="item_reply relative mt-3">
<div className="flex items-center justify-between">
<div className="comment-left-form item-center flex gap-2">
<b className="avatar-user avatar-admin">
{reply.user_avatar !== '0' ? (
<Image
src={reply.user_avatar}
alt={reply.user_name}
width={32}
height={32}
/>
) : (
reply.user_name.charAt(0)
)}
</b>
<div className="comment-name mb-10">
<b className="user-name">{reply.user_name}</b>
{reply.is_user_admin === '1' && <i className="note font-medium">QTV</i>}
</div>
</div>
<div className="info_feeback comment-right-form">
<span style={{ color: '#787878', fontSize: 12 }}>
({new Date(Number(reply.post_time) * 1000).toLocaleDateString('vi-VN')})
</span>
</div>
</div>
<div className="comment-content boder-radius-10">{reply.content}</div>
</div>
))}
</div>
</div>
</div>
);
})}
{!showAll && reviews.length > 3 && (
<button
id="first-review"
className="btn-more cursor-pointer"
onClick={() => setShowAll(true)}
>
Xem thêm đánh giá
</button>
)}
{showAll && (
<button
id="hide-review"
className="btn-more cursor-pointer"
onClick={() => setShowAll(false)}
>
Thu gọn
</button>
)}
</div>
);
};

View File

@@ -0,0 +1,94 @@
'use client';
import React, { useState } from 'react';
import { Review } from '@/types';
import { FaStar } from 'react-icons/fa6';
import { FormReview } from './FormReview';
import { ListReview } from './ListReview';
interface Props {
review: Review;
slug: string;
}
export const ProductReview: React.FC<Props> = ({ review, slug }) => {
const [showForm, setShowForm] = useState(false);
const { summary } = review;
const totalRate = summary.list_rate.reduce((acc, item) => acc + Number(item.total), 0);
const rates = {
rate1: Number(summary.list_rate.find((r) => r.rate === '1')?.total || 0),
rate2: Number(summary.list_rate.find((r) => r.rate === '2')?.total || 0),
rate3: Number(summary.list_rate.find((r) => r.rate === '3')?.total || 0),
rate4: Number(summary.list_rate.find((r) => r.rate === '4')?.total || 0),
rate5: Number(summary.list_rate.find((r) => r.rate === '5')?.total || 0),
};
const percents = {
percent1: totalRate > 0 ? (rates.rate1 / totalRate) * 100 : 0,
percent2: totalRate > 0 ? (rates.rate2 / totalRate) * 100 : 0,
percent3: totalRate > 0 ? (rates.rate3 / totalRate) * 100 : 0,
percent4: totalRate > 0 ? (rates.rate4 / totalRate) * 100 : 0,
percent5: totalRate > 0 ? (rates.rate5 / totalRate) * 100 : 0,
};
return (
<div className="box-review">
<p className="title-review font-[600]">Bình luận đánh giá</p>
<div className="review-customer-detail">
<div className="form-post">
<div className="review-info boder-radius-10 flex">
<div className="avgRate flex flex-col items-center justify-center">
<span className="font-bold">{summary.avgRate}/5</span>
<i className={`sprite-star-5 star${summary.avgRate} icon-star-detail`}></i>
<p className="mt-3">{summary.total} đánh giá nhận xét</p>
</div>
<div className="box-avg-rate-count">
<div className="avg-rate-count">
{[5, 4, 3, 2, 1].map((rate) => {
const percent = percents[`percent${rate}` as keyof typeof percents];
const total = rates[`rate${rate}` as keyof typeof rates];
return (
<div key={rate} className="avg-rate-item mt-2 flex items-center justify-center">
<span className="rate-number flex items-center gap-1">
{rate} <FaStar className="text-yellow-500" />
</span>
<div className="nhan-xet-bar">
<div
className={`percent percent${rate}`}
style={{ width: `${percent}%` }}
></div>
</div>
<span className="total-avg-rate">
<strong>{total}</strong> đánh giá
</span>
</div>
);
})}
</div>
</div>
</div>
<p className="text-danh-gia mb-5">Bạn đánh giá sao sản phẩm này</p>
{!showForm ? (
<div
className="button-review mx-auto flex cursor-pointer items-center justify-center"
onClick={() => setShowForm(true)}
>
Đánh giá ngay
</div>
) : (
<div
className="button-review mx-auto flex cursor-pointer items-center justify-center"
onClick={() => setShowForm(false)}
>
Đóng lại
</div>
)}
{showForm && <FormReview />}
</div>
</div>
<ListReview slug={slug} />
</div>
);
};

View File

@@ -0,0 +1,30 @@
import { useState } from 'react';
import { FaAngleDown, FaAngleUp } from 'react-icons/fa6';
import { SanitizedHtml } from '@/components/Common/SanitizedHtml';
interface Props {
spec: string;
}
export const ProductSpec: React.FC<Props> = ({ spec }) => {
const [expanded, setExpanded] = useState(false);
if (!spec) return null;
return (
<div className="box-spec">
<h2 className="title font-semibold">Thông số kỹ thuật</h2>
<SanitizedHtml
html={spec}
className={`content-spec relative ${expanded ? '' : 'max-h-100 overflow-hidden'}`}
/>
<button
onClick={() => setExpanded(!expanded)}
className="btn-article-col font-weight-500 flex items-center justify-center gap-2"
>
{expanded ? 'Thu gọn' : 'Xem đầy đủ thông số kỹ thuật'}
{expanded ? <FaAngleUp /> : <FaAngleDown />}
</button>
</div>
);
};

View File

@@ -0,0 +1,32 @@
import React, { useState } from 'react';
import { FaAngleDown, FaAngleUp } from 'react-icons/fa6';
interface SummaryProps {
summary: string;
}
export const ProductSummary: React.FC<SummaryProps> = ({ summary }) => {
const summaryArray = summary.split('\r\n');
const [expanded, setExpanded] = useState(false);
const visibleItems = expanded ? summaryArray : summaryArray.slice(0, 3);
return (
<div className="box-product-summary boder-radius-10">
<p className="title font-weight-600">Thông số sản phẩm</p>
<ul className="list-product-summary">
{visibleItems.map((item, index) => (
<li key={index}>{item}</li>
))}
</ul>
{summaryArray.length > 3 && (
<div
className="button-showmore flex cursor-pointer items-center gap-2 font-[500]"
onClick={() => setExpanded(!expanded)}
>
<span>{expanded ? 'Thu gọn' : 'Xem thêm'}</span>
{expanded ? <FaAngleUp /> : <FaAngleDown />}
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,131 @@
'use client';
import React from 'react';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
import type { ProductDetailData } from '@/types';
import type { TypeListProduct } from '@/types/global/TypeListProduct';
import { ErrorLink } from '@/components/Common/Error';
import { Breadcrumb } from '@/components/Common/Breadcrumb';
import { ImageProduct } from './ImageProduct';
import { ProductSummary } from './ProductSummary';
import { ComboSetBox } from './ComboSet';
import { BoxInfoRight } from './BoxInfoRight';
import ItemProduct from '@/components/Common/ItemProduct';
import { ProductDescription } from './ProductDescription';
import { ProductSpec } from './ProductSpec';
import { ProductReview } from './ProductReview';
import { ProductComment } from './ProductComment';
import PreLoader from '@/components/Common/PreLoader';
import { getProductDetail, getProductHot } from '@/lib/api/product';
import { useApiData } from '@/hooks/useApiData';
interface ProductDetailPageProps {
slug: string;
}
const SWIPER_BREAKPOINTS = {
320: { slidesPerView: 2 },
640: { slidesPerView: 3 },
768: { slidesPerView: 4 },
1024: { slidesPerView: 5 },
};
const ProductSwiperSection = ({
title,
items,
}: {
title: string;
items: TypeListProduct;
}) => (
<div className="box-product-category">
<div className="title-box">
<h2 className="title title-box font-semibold">{title}</h2>
</div>
<div className="box-list-history-product">
<Swiper
modules={[Autoplay, Navigation, Pagination]}
spaceBetween={12}
loop={true}
breakpoints={SWIPER_BREAKPOINTS}
>
{items.map((item) => (
<SwiperSlide key={item.id}>
<ItemProduct item={item} />
</SwiperSlide>
))}
</Swiper>
</div>
</div>
);
const ProductDetailPage: React.FC<ProductDetailPageProps> = ({ slug }) => {
const { data: product, isLoading } = useApiData(
() => getProductDetail(slug),
[slug],
{ initialData: null as ProductDetailData | null },
);
const { data: relatedProducts } = useApiData(
() => getProductHot(),
[],
{ initialData: [] as TypeListProduct },
);
if (isLoading) {
return <PreLoader />;
}
if (!product) {
return <ErrorLink />;
}
const breadcrumbItems = product.product_info.productPath?.[0]?.path.map((item) => ({
name: item.name,
url: item.url,
})) ?? [{ name: 'Trang chủ', url: '/' }];
return (
<>
<div className="container">
<Breadcrumb items={breadcrumbItems} />
</div>
<section className="page-product-detail mt-2 bg-white">
<div className="container">
<div className="box-content-product-detail flex justify-between gap-5">
<div className="box-left">
<ImageProduct images={product.product_info.productImageGallery} />
<ProductSummary summary={product.product_info.productSummary} />
<ComboSetBox combo_set={product.combo_set} />
</div>
<div className="box-right">
<BoxInfoRight {...product} />
</div>
</div>
<div className="box-relative-product box-history-product page-hompage">
<ProductSwiperSection title="Sản phẩm tương tự" items={relatedProducts} />
</div>
<div className="box-read-product-detail flex justify-between gap-3">
<div className="box-left">
<ProductDescription {...product} />
<ProductReview review={product.product_info.review} slug={slug} />
<ProductComment slug={slug} />
</div>
<div className="box-right">
<ProductSpec spec={product.product_info.productSpec} />
</div>
</div>
<div className="box-history-product page-hompage mt-5">
<ProductSwiperSection title="Sản phẩm đã xem" items={relatedProducts} />
</div>
</div>
</section>
</>
);
};
export default ProductDetailPage;

View File

@@ -0,0 +1,84 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { ErrorLink } from '@/components/Common/Error';
import type { TypeProductHot } from '@/types/producthot';
import { Breadcrumb } from '@/components/Common/Breadcrumb';
import BoxFilter from '@components/Product/BoxFilter';
import BoxSort from '@components/Product/BoxSort';
import ItemProduct from '@/components/Common/ItemProduct';
import PreLoader from '@/components/Common/PreLoader';
import { getProductHotPage } from '@/lib/api/product';
import { useApiData } from '@/hooks/useApiData';
interface ProductHotPageProps {
slug: string;
}
const ProductHotPage: React.FC<ProductHotPageProps> = ({ slug }) => {
const { data: page, isLoading } = useApiData(
() => getProductHotPage(slug),
[slug],
{ initialData: null as TypeProductHot | null },
);
if (isLoading) {
return <PreLoader />;
}
if (!page) {
return <ErrorLink />;
}
const breadcrumbItems = [
{ name: 'Trang chủ', url: '/' },
{ name: page.title, url: page.url },
];
const products = Object.values(page.product_list);
return (
<>
<div className="container">
<Breadcrumb items={breadcrumbItems} />
</div>
<section className="page-category page-search container">
<div className="current-cate-title">
<div className="mt-5 flex items-center gap-2">
<h1 className="current-cate-text font-bold">{page.title}</h1>
<span className="current-cate-total">(Tổng {page.product_count} sản phẩm)</span>
</div>
</div>
<div className="box-content-category">
<BoxFilter filters={page} />
<div className="box-list-product-category boder-radius-10">
<BoxSort sort_by_collection={page.sort_by_collection} product_display_type="grid" />
</div>
<div className="list-product-category grid grid-cols-5 gap-3">
{products.map((item) => (
<ItemProduct key={item.id} item={item} />
))}
</div>
<div className="paging flex items-center justify-center">
{page.paging_collection.map((item, index) => (
<Link
key={index}
href={item.url}
className={`item ${item.is_active === '1' ? 'current' : ''}`}
>
{item.name}
</Link>
))}
</div>
</div>
</section>
</>
);
};
export default ProductHotPage;

View File

@@ -0,0 +1,117 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { ErrorLink } from '@/components/Common/Error';
import type { TypeProductSearch } from '@/types/product/search';
import { Breadcrumb } from '@/components/Common/Breadcrumb';
import BoxFilter from '@components/Product/BoxFilter';
import BoxSort from '@components/Product/BoxSort';
import ItemProduct from '@/components/Common/ItemProduct';
import PreLoader from '@/components/Common/PreLoader';
import { getProductSearch } from '@/lib/api/product';
import { useApiData } from '@/hooks/useApiData';
const ProductSearchPage: React.FC = () => {
const searchParams = useSearchParams();
const keyword = searchParams.get('q') ?? '';
const { data: page, isLoading } = useApiData(
() => getProductSearch(keyword),
[keyword],
{
initialData: null as TypeProductSearch | null,
enabled: keyword.length > 0,
},
);
if (isLoading) {
return <PreLoader />;
}
if (!page) {
return <ErrorLink />;
}
const breadcrumbItems = [
{ name: 'Trang chủ', url: '/' },
{ name: `Tìm kiếm "${keyword}"`, url: `/tim?q=${page.keywords}` },
];
const products = Object.values(page.product_list);
return (
<>
<div className="container">
<Breadcrumb items={breadcrumbItems} />
</div>
<section className="page-category page-search container">
<div className="current-cate-title flex items-center gap-2">
<h1 className="current-cate-text font-bold">Tìm kiếm: {page.keywords}</h1>
<span className="current-cate-total">(Tổng {page.product_count} sản phẩm)</span>
</div>
{products.length > 0 ? (
<div className="box-content-category">
<BoxFilter filters={page} />
<div className="box-list-product-category boder-radius-10">
<BoxSort sort_by_collection={page.sort_by_collection} product_display_type="grid" />
</div>
<div className="list-product-category grid grid-cols-5 gap-3">
{products.map((item) => (
<ItemProduct key={item.id} item={item} />
))}
</div>
<div className="paging flex items-center justify-center">
{page.paging_collection.map((item, index) => (
<Link
key={index}
href={item.url}
className={`item ${item.is_active === '1' ? 'current' : ''}`}
>
{item.name}
</Link>
))}
</div>
</div>
) : (
<div className="text-center" style={{ padding: 20, fontSize: 15 }}>
<p style={{ fontSize: 24, margin: '15px 0 25px 0', fontWeight: 'bold' }}>
Rất tiếc, chúng tôi không tìm thấy kết quả của{' '}
<span>&quot;{page.keywords}&quot;</span>
</p>
<div
style={{
textAlign: 'left',
border: 'solid 1px #ccc',
maxWidth: '500px',
padding: '20px',
margin: '15px auto',
lineHeight: 2,
}}
>
<p style={{ textAlign: 'center', margin: '0 0 8px 0' }}>
<b>Đ tìm đưc kết quả chính xác hơn, xin vui lòng</b>
</p>
<ul>
<li>Kiểm tra lại chính tả của từ khóa đã nhập</li>
<li>Thử lại bằng từ khóa khác</li>
<li>Thử lại bằng các từ khóa tổng quát hơn</li>
<li>Thử lại bằng các từ khóa ngắn gọn hơn</li>
</ul>
</div>
<Link href="/">
<i className="fa fa-long-arrow-alt-left"></i> Quay lại trang chủ
</Link>
</div>
)}
</section>
</>
);
};
export default ProductSearchPage;