update
This commit is contained in:
29
src/features/Article/ArticleTopLeft/index.tsx
Normal file
29
src/features/Article/ArticleTopLeft/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
42
src/features/Article/ArticleTopRight/index.tsx
Normal file
42
src/features/Article/ArticleTopRight/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
132
src/features/Article/CategoryPage/index.tsx
Normal file
132
src/features/Article/CategoryPage/index.tsx
Normal 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;
|
||||
113
src/features/Article/DetailPage/TocBox/index.tsx
Normal file
113
src/features/Article/DetailPage/TocBox/index.tsx
Normal 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 }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
117
src/features/Article/DetailPage/index.tsx
Normal file
117
src/features/Article/DetailPage/index.tsx
Normal 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;
|
||||
67
src/features/Article/HomeArticlePage/BoxArticleMid/index.tsx
Normal file
67
src/features/Article/HomeArticlePage/BoxArticleMid/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
130
src/features/Article/HomeArticlePage/BoxVideoArticle/index.tsx
Normal file
130
src/features/Article/HomeArticlePage/BoxVideoArticle/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
52
src/features/Article/HomeArticlePage/index.tsx
Normal file
52
src/features/Article/HomeArticlePage/index.tsx
Normal 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;
|
||||
123
src/features/Home/ArticleVideo/ItemArticleVideo/index.tsx
Normal file
123
src/features/Home/ArticleVideo/ItemArticleVideo/index.tsx
Normal 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;
|
||||
42
src/features/Home/ArticleVideo/index.tsx
Normal file
42
src/features/Home/ArticleVideo/index.tsx
Normal 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;
|
||||
56
src/features/Home/BoxArticle/ItemArticle/index.tsx
Normal file
56
src/features/Home/BoxArticle/ItemArticle/index.tsx
Normal 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;
|
||||
37
src/features/Home/BoxArticle/index.tsx
Normal file
37
src/features/Home/BoxArticle/index.tsx
Normal 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;
|
||||
62
src/features/Home/Category/index.tsx
Normal file
62
src/features/Home/Category/index.tsx
Normal 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;
|
||||
34
src/features/Home/CategoryFeature/ItemCategory/index.tsx
Normal file
34
src/features/Home/CategoryFeature/ItemCategory/index.tsx
Normal 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;
|
||||
29
src/features/Home/CategoryFeature/index.tsx
Normal file
29
src/features/Home/CategoryFeature/index.tsx
Normal 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;
|
||||
78
src/features/Home/Deal/ProductItem/index.tsx
Normal file
78
src/features/Home/Deal/ProductItem/index.tsx
Normal 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;
|
||||
64
src/features/Home/Deal/index.tsx
Normal file
64
src/features/Home/Deal/index.tsx
Normal 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;
|
||||
35
src/features/Home/ReviewCustomer/ItemReview/index.tsx
Normal file
35
src/features/Home/ReviewCustomer/ItemReview/index.tsx
Normal 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;
|
||||
41
src/features/Home/ReviewCustomer/index.tsx
Normal file
41
src/features/Home/ReviewCustomer/index.tsx
Normal 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;
|
||||
71
src/features/Home/SliderHome/index.tsx
Normal file
71
src/features/Home/SliderHome/index.tsx
Normal 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;
|
||||
37
src/features/Home/index.tsx
Normal file
37
src/features/Home/index.tsx
Normal 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;
|
||||
55
src/features/NotFoundPage/index.tsx
Normal file
55
src/features/NotFoundPage/index.tsx
Normal 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;
|
||||
47
src/features/Product/Category/BannerCategory/index.tsx
Normal file
47
src/features/Product/Category/BannerCategory/index.tsx
Normal 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;
|
||||
30
src/features/Product/Category/ItemCategoryChild/index.tsx
Normal file
30
src/features/Product/Category/ItemCategoryChild/index.tsx
Normal 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;
|
||||
131
src/features/Product/Category/index.tsx
Normal file
131
src/features/Product/Category/index.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
171
src/features/Product/ProductDetail/BoxInfoRight/index.tsx
Normal file
171
src/features/Product/ProductDetail/BoxInfoRight/index.tsx
Normal 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">
|
||||
Mã 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Ồ SƠ</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 />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
145
src/features/Product/ProductDetail/ComboSet/index.tsx
Normal file
145
src/features/Product/ProductDetail/ComboSet/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
60
src/features/Product/ProductDetail/ImageProduct/index.tsx
Normal file
60
src/features/Product/ProductDetail/ImageProduct/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
38
src/features/Product/ProductDetail/ProductComment/index.tsx
Normal file
38
src/features/Product/ProductDetail/ProductComment/index.tsx
Normal 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 và đá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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
94
src/features/Product/ProductDetail/ProductReview/index.tsx
Normal file
94
src/features/Product/ProductDetail/ProductReview/index.tsx
Normal 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 và đá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á và 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>
|
||||
);
|
||||
};
|
||||
30
src/features/Product/ProductDetail/ProductSpec/index.tsx
Normal file
30
src/features/Product/ProductDetail/ProductSpec/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
32
src/features/Product/ProductDetail/ProductSummary/index.tsx
Normal file
32
src/features/Product/ProductDetail/ProductSummary/index.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
131
src/features/Product/ProductDetail/index.tsx
Normal file
131
src/features/Product/ProductDetail/index.tsx
Normal 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;
|
||||
84
src/features/Product/ProductHot/index.tsx
Normal file
84
src/features/Product/ProductHot/index.tsx
Normal 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;
|
||||
117
src/features/Product/ProductSearch/index.tsx
Normal file
117
src/features/Product/ProductSearch/index.tsx
Normal 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>"{page.keywords}"</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;
|
||||
Reference in New Issue
Block a user