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

View File

@@ -0,0 +1,8 @@
{
"permissions": {
"allow": [
"Bash(grep -E \"\\\\.\\(tsx|ts|json\\)$\")",
"Bash(npm install:*)"
]
}
}

13
.vscode/mcp.json vendored Normal file
View File

@@ -0,0 +1,13 @@
{
"servers": {
"figma": {
"url": "https://mcp.figma.com/mcp",
"type": "http"
},
"my-mcp-server-cf2b4222": {
"url": "enter",
"type": "http"
}
},
"inputs": []
}

View File

@@ -1,18 +1,11 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
import { defineConfig, globalIgnores } from 'eslint/config';
import nextVitals from 'eslint-config-next/core-web-vitals';
import nextTs from 'eslint-config-next/typescript';
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
globalIgnores(['.next/**', 'out/**', 'build/**', 'next-env.d.ts']),
]);
export default eslintConfig;

View File

@@ -8,6 +8,11 @@ const nextConfig: NextConfig = {
hostname: 'nguyencongpc.vn',
pathname: '/**',
},
{
protocol: 'https',
hostname: 'www.dmca.com',
pathname: '/**',
},
],
},
};

1261
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,10 +11,12 @@
"dependencies": {
"@fancyapps/ui": "^6.1.7",
"@tippyjs/react": "^4.2.6",
"@types/dompurify": "^3.0.5",
"date-fns": "^4.1.0",
"dompurify": "^3.3.3",
"framer-motion": "^12.23.26",
"lightgallery": "^2.9.0",
"next": "16.0.10",
"next": "^16.1.6",
"postcss": "^8.5.6",
"react": "19.2.1",
"react-dom": "19.2.1",
@@ -27,12 +29,18 @@
"@types/react": "^19",
"@types/react-dom": "^19",
"daisyui": "^5.5.14",
"eslint": "^9",
"eslint-config-next": "16.0.10",
"eslint": "^9.39.4",
"eslint-config-next": "^16.1.6",
"eslint-config-prettier": "^10.1.8",
"msw": "^2.12.7",
"prettier": "^3.7.4",
"prettier-plugin-tailwindcss": "^0.7.2",
"tailwindcss": "^4.1.18",
"typescript": "^5"
"typescript": "^5.9.3"
},
"msw": {
"workerDirectory": [
"public"
]
}
}

348
public/mockServiceWorker.js Normal file
View File

@@ -0,0 +1,348 @@
/* tslint:disable */
/**
* Mock Service Worker.
* @see https://github.com/mswjs/msw
* - Please do NOT modify this file.
*/
const PACKAGE_VERSION = '2.12.7'
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
const activeClientIds = new Set()
addEventListener('install', function () {
self.skipWaiting()
})
addEventListener('activate', function (event) {
event.waitUntil(self.clients.claim())
})
addEventListener('message', async function (event) {
const clientId = Reflect.get(event.source || {}, 'id')
if (!clientId || !self.clients) {
return
}
const client = await self.clients.get(clientId)
if (!client) {
return
}
const allClients = await self.clients.matchAll({
type: 'window',
})
switch (event.data) {
case 'KEEPALIVE_REQUEST': {
sendToClient(client, {
type: 'KEEPALIVE_RESPONSE',
})
break
}
case 'INTEGRITY_CHECK_REQUEST': {
sendToClient(client, {
type: 'INTEGRITY_CHECK_RESPONSE',
payload: {
packageVersion: PACKAGE_VERSION,
checksum: INTEGRITY_CHECKSUM,
},
})
break
}
case 'MOCK_ACTIVATE': {
activeClientIds.add(clientId)
sendToClient(client, {
type: 'MOCKING_ENABLED',
payload: {
client: {
id: client.id,
frameType: client.frameType,
},
},
})
break
}
case 'CLIENT_CLOSED': {
activeClientIds.delete(clientId)
const remainingClients = allClients.filter((client) => {
return client.id !== clientId
})
// Unregister itself when there are no more clients
if (remainingClients.length === 0) {
self.registration.unregister()
}
break
}
}
})
addEventListener('fetch', function (event) {
const requestInterceptedAt = Date.now()
// Bypass navigation requests.
if (event.request.mode === 'navigate') {
return
}
// Opening the DevTools triggers the "only-if-cached" request
// that cannot be handled by the worker. Bypass such requests.
if (
event.request.cache === 'only-if-cached' &&
event.request.mode !== 'same-origin'
) {
return
}
// Bypass all requests when there are no active clients.
// Prevents the self-unregistered worked from handling requests
// after it's been terminated (still remains active until the next reload).
if (activeClientIds.size === 0) {
return
}
const requestId = crypto.randomUUID()
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
})
/**
* @param {FetchEvent} event
* @param {string} requestId
* @param {number} requestInterceptedAt
*/
async function handleRequest(event, requestId, requestInterceptedAt) {
const client = await resolveMainClient(event)
const requestCloneForEvents = event.request.clone()
const response = await getResponse(
event,
client,
requestId,
requestInterceptedAt,
)
// Send back the response clone for the "response:*" life-cycle events.
// Ensure MSW is active and ready to handle the message, otherwise
// this message will pend indefinitely.
if (client && activeClientIds.has(client.id)) {
const serializedRequest = await serializeRequest(requestCloneForEvents)
// Clone the response so both the client and the library could consume it.
const responseClone = response.clone()
sendToClient(
client,
{
type: 'RESPONSE',
payload: {
isMockedResponse: IS_MOCKED_RESPONSE in response,
request: {
id: requestId,
...serializedRequest,
},
response: {
type: responseClone.type,
status: responseClone.status,
statusText: responseClone.statusText,
headers: Object.fromEntries(responseClone.headers.entries()),
body: responseClone.body,
},
},
},
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
)
}
return response
}
/**
* Resolve the main client for the given event.
* Client that issues a request doesn't necessarily equal the client
* that registered the worker. It's with the latter the worker should
* communicate with during the response resolving phase.
* @param {FetchEvent} event
* @returns {Promise<Client | undefined>}
*/
async function resolveMainClient(event) {
const client = await self.clients.get(event.clientId)
if (activeClientIds.has(event.clientId)) {
return client
}
if (client?.frameType === 'top-level') {
return client
}
const allClients = await self.clients.matchAll({
type: 'window',
})
return allClients
.filter((client) => {
// Get only those clients that are currently visible.
return client.visibilityState === 'visible'
})
.find((client) => {
// Find the client ID that's recorded in the
// set of clients that have registered the worker.
return activeClientIds.has(client.id)
})
}
/**
* @param {FetchEvent} event
* @param {Client | undefined} client
* @param {string} requestId
* @param {number} requestInterceptedAt
* @returns {Promise<Response>}
*/
async function getResponse(event, client, requestId, requestInterceptedAt) {
// Clone the request because it might've been already used
// (i.e. its body has been read and sent to the client).
const requestClone = event.request.clone()
function passthrough() {
// Cast the request headers to a new Headers instance
// so the headers can be manipulated with.
const headers = new Headers(requestClone.headers)
// Remove the "accept" header value that marked this request as passthrough.
// This prevents request alteration and also keeps it compliant with the
// user-defined CORS policies.
const acceptHeader = headers.get('accept')
if (acceptHeader) {
const values = acceptHeader.split(',').map((value) => value.trim())
const filteredValues = values.filter(
(value) => value !== 'msw/passthrough',
)
if (filteredValues.length > 0) {
headers.set('accept', filteredValues.join(', '))
} else {
headers.delete('accept')
}
}
return fetch(requestClone, { headers })
}
// Bypass mocking when the client is not active.
if (!client) {
return passthrough()
}
// Bypass initial page load requests (i.e. static assets).
// The absence of the immediate/parent client in the map of the active clients
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
// and is not ready to handle requests.
if (!activeClientIds.has(client.id)) {
return passthrough()
}
// Notify the client that a request has been intercepted.
const serializedRequest = await serializeRequest(event.request)
const clientMessage = await sendToClient(
client,
{
type: 'REQUEST',
payload: {
id: requestId,
interceptedAt: requestInterceptedAt,
...serializedRequest,
},
},
[serializedRequest.body],
)
switch (clientMessage.type) {
case 'MOCK_RESPONSE': {
return respondWithMock(clientMessage.data)
}
case 'PASSTHROUGH': {
return passthrough()
}
}
return passthrough()
}
/**
* @param {Client} client
* @param {any} message
* @param {Array<Transferable>} transferrables
* @returns {Promise<any>}
*/
function sendToClient(client, message, transferrables = []) {
return new Promise((resolve, reject) => {
const channel = new MessageChannel()
channel.port1.onmessage = (event) => {
if (event.data && event.data.error) {
return reject(event.data.error)
}
resolve(event.data)
}
client.postMessage(message, [
channel.port2,
...transferrables.filter(Boolean),
])
})
}
/**
* @param {Response} response
* @returns {Response}
*/
function respondWithMock(response) {
// Setting response status code to 0 is a no-op.
// However, when responding with a "Response.error()", the produced Response
// instance will have status code set to 0. Since it's not possible to create
// a Response instance with status code 0, handle that use-case separately.
if (response.status === 0) {
return Response.error()
}
const mockedResponse = new Response(response.body, response)
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
value: true,
enumerable: true,
})
return mockedResponse
}
/**
* @param {Request} request
*/
async function serializeRequest(request) {
return {
url: request.url,
mode: request.mode,
method: request.method,
headers: Object.fromEntries(request.headers.entries()),
cache: request.cache,
credentials: request.credentials,
destination: request.destination,
integrity: request.integrity,
redirect: request.redirect,
referrer: request.referrer,
referrerPolicy: request.referrerPolicy,
body: await request.arrayBuffer(),
keepalive: request.keepalive,
}
}

View File

@@ -1,27 +1,40 @@
'use client';
import { useParams } from 'next/navigation';
import NotFound from '../pages/404';
import { resolvePageType } from '@/lib/resolvePageType';
import NotFound from '@/features/NotFoundPage';
import CategoryPage from '@/app/pages/Product/Category';
import ProductSearchPage from '@/app/pages/Product/ProductSearch';
import ProductDetailPage from '@/app/pages/Product/ProductDetail';
import ProductHotPage from '@/app/pages/Product/ProductHot';
import ArticlePage from '@/app/pages/Article/HomeArticlePage';
import ArticleCategoryPage from '@/app/pages/Article/CategoryPage';
import ArticleDetailPage from '@/app/pages/Article/DetailPage';
import CategoryPage from '@/features/Product/Category';
import ProductSearchPage from '@/features/Product/ProductSearch';
import ProductDetailPage from '@/features/Product/ProductDetail';
import ProductHotPage from '@/features/Product/ProductHot';
import ArticlePage from '@/features/Article/HomeArticlePage';
import ArticleCategoryPage from '@/features/Article/CategoryPage';
import ArticleDetailPage from '@/features/Article/DetailPage';
import PreLoader from '@/components/Common/PreLoader';
import { getResolvedPageType } from '@/lib/api/page';
import { useApiData } from '@/hooks/useApiData';
export default function DynamicPage() {
const { slug } = useParams();
const fullSlug = '/' + slug;
const fullSlug = `/${slug}`;
const pageType = resolvePageType(fullSlug);
const { data: pageType, isLoading } = useApiData(
() => getResolvedPageType(fullSlug),
[fullSlug],
{
initialData: '404',
enabled: typeof slug === 'string' && slug.length > 0,
},
);
if (isLoading) {
return <PreLoader />;
}
switch (pageType) {
case 'category':
return <CategoryPage slug={fullSlug} />;
case 'product-search':
return <ProductSearchPage slug={fullSlug} />;
return <ProductSearchPage />;
case 'product-detail':
return <ProductDetailPage slug={fullSlug} />;
case 'product-hot':

View File

@@ -1,11 +1,22 @@
'use client';
import React from 'react';
import { category_config } from '@/data/buildpc/category';
import { FaPlus } from 'react-icons/fa';
import { getBuildPcCategories } from '@/lib/api/buildpc';
import { useApiData } from '@/hooks/useApiData';
interface BuildPcCategory {
id: string | number;
name: string;
}
export const BoxListAccessory = () => {
const { data: categories } = useApiData(() => getBuildPcCategories(), [], {
initialData: [] as BuildPcCategory[],
});
return (
<div className="list-drive" id="js-buildpc-layout" style={{ border: 'solid 1px #e1e1e1' }}>
{category_config.map((category, index) => (
{categories.map((category, index) => (
<div key={category.id} className="item-drive flex">
<div className="name-item-drive">
<h3

View File

@@ -1,12 +1,21 @@
'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 { bannerData } from '@/data/banner';
import { getBanners } from '@/lib/api/banner';
import { useApiData } from '@/hooks/useApiData';
import type { TemplateBanner } from '@/types';
const Slider = () => {
const dataSlider = bannerData[0].header;
const { data: banners } = useApiData(
() => getBanners(),
[],
{ initialData: null as TemplateBanner | null },
);
const dataSlider = banners?.header;
return (
<div className="banner-buildpc" style={{ marginBottom: '40px' }}>

View File

@@ -0,0 +1,55 @@
'use client';
import React from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { Breadcrumb } from '@components/Common/Breadcrumb';
import ItemDeal from '@components/Deal/ItemDeal';
import { getBanners } from '@/lib/api/banner';
import { getDeals } from '@/lib/api/deal';
import { useApiData } from '@/hooks/useApiData';
import type { TemplateBanner, TypeListProductDeal } from '@/types';
export default function DealPageClient() {
const breadcrumbItems = [{ name: 'Danh sách deal', url: '/deal' }];
const { data: banners } = useApiData(() => getBanners(), [], {
initialData: null as TemplateBanner | null,
});
const { data: deals } = useApiData(() => getDeals(), [], {
initialData: [] as TypeListProductDeal,
});
return (
<>
<div className="container">
<Breadcrumb items={breadcrumbItems} />
</div>
<section className="page-deal container">
<div className="box-product-deal">
{banners?.header.banner_page_deal_2023 && (
<div className="banner-deal-page mb-5">
{banners.header.banner_page_deal_2023.map((item, index) => (
<Link href={item.desUrl} className="item-banner" key={index}>
<Image
src={item.fileUrl}
width={1200}
height={325}
alt={item.title}
style={{ display: 'block' }}
/>
</Link>
))}
</div>
)}
<div className="box-list-item-deal grid grid-cols-4 gap-3 pb-10" id="js-deal-page">
{deals.map((item) => (
<ItemDeal key={item.id} item={item} />
))}
</div>
</div>
</section>
</>
);
}

View File

@@ -1,51 +1,11 @@
import React from 'react';
import Link from 'next/link';
import Image from 'next/image';
import { Metadata } from 'next';
import { Breadcrumb } from '@components/Common/Breadcrumb';
import { bannerData } from '@/data/banner';
import { ListDealData } from '@/data/deal';
import ItemDeal from '@components/Deal/ItemDeal';
import DealPageClient from './DealPageClient';
export const metadata: Metadata = {
title: 'Danh sách deal',
description: 'Sản phẩm khuyn mãi giá ưu đãi',
description: 'Sản phẩm khuyến mãi giá ưu đãi',
};
export default function DealPage() {
const breadcrumbItems = [{ name: 'Danh sách deal', url: '/deal' }];
return (
<>
<div className="container">
<Breadcrumb items={breadcrumbItems} />
</div>
<section className="page-deal container">
<div className="box-product-deal">
{bannerData[0].header.banner_page_deal_2023 && (
<div className="banner-deal-page mb-5">
{bannerData[0].header.banner_page_deal_2023.map((item, index) => (
<Link href={item.desUrl} className="item-banner" key={index}>
<Image
src={item.fileUrl}
width={1200}
height={325}
alt={item.title}
style={{ display: 'block' }}
/>
</Link>
))}
</div>
)}
<div className="box-list-item-deal grid grid-cols-4 gap-3 pb-10" id="js-deal-page">
{ListDealData.map((Item, index) => (
<ItemDeal key={index} Item={Item} />
))}
</div>
</div>
</section>
</>
);
return <DealPageClient />;
}

View File

@@ -1,5 +1,4 @@
'use client';
import { useState, useEffect } from 'react';
import type { Metadata } from 'next';
import '@styles/sf-pro-display.css';
import 'swiper/css';
import 'swiper/css/navigation';
@@ -7,32 +6,44 @@ import 'swiper/css/pagination';
import '@styles/globals.css';
import Header from '@/components/Other/Header';
import Footer from '@/components/Other/Footer';
import MSWProvider from '@/components/Common/MSWProvider';
import { ErrorBoundary } from '@/components/Common/ErrorBoundary';
import PreLoader from '@/components/Common/PreLoader';
export const metadata: Metadata = {
title: {
default: 'Nguyễn Công PC - Máy tính, Laptop, Linh kiện chính hãng',
template: '%s | Nguyễn Công PC',
},
description:
'Nguyễn Công PC - Chuyên cung cấp máy tính, laptop, linh kiện, phụ kiện chính hãng với giá tốt nhất thị trường. Bảo hành uy tín, giao hàng nhanh toàn quốc.',
keywords: ['máy tính', 'laptop', 'linh kiện máy tính', 'nguyễn công pc', 'pc gaming'],
authors: [{ name: 'Nguyễn Công PC' }],
openGraph: {
type: 'website',
locale: 'vi_VN',
siteName: 'Nguyễn Công PC',
title: 'Nguyễn Công PC - Máy tính, Laptop, Linh kiện chính hãng',
description:
'Chuyên cung cấp máy tính, laptop, linh kiện, phụ kiện chính hãng với giá tốt nhất.',
},
robots: { index: true, follow: true },
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const [loading, setLoading] = useState<boolean>(true);
useEffect(() => {
setTimeout(() => setLoading(false), 1000);
}, []);
return (
<html suppressHydrationWarning>
<body>
{loading ? (
<PreLoader />
) : (
<>
<Header />
<main>{children}</main>
<Footer />
</>
)}
<Header />
<MSWProvider>
<main>
<ErrorBoundary>{children}</ErrorBoundary>
</main>
</MSWProvider>
<Footer />
</body>
</html>
);

5
src/app/not-found.tsx Normal file
View File

@@ -0,0 +1,5 @@
import NotFoundPage from '@/features/NotFoundPage';
export default function NotFound() {
return <NotFoundPage />;
}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import Home from '@/app/pages/Home';
import Home from '@/features/Home';
import { Metadata } from 'next';
export const metadata: Metadata = {

View File

@@ -1,19 +0,0 @@
import ItemArticle from '@/components/Common/ItemArticle';
import { DataListArticleNews } from '@/data/article/ListArticleNews';
export const ArticleTopLeft = () => {
return (
<div className="flex gap-3">
<div className="box-left">
{DataListArticleNews.slice(0, 1).map((item, index) => (
<ItemArticle item={item} key={index} />
))}
</div>
<div className="box-right flex flex-1 flex-col gap-3">
{DataListArticleNews.slice(0, 4).map((item, index) => (
<ItemArticle item={item} key={index} />
))}
</div>
</div>
);
};

View File

@@ -1,265 +0,0 @@
import { ListArticle } from '@/types';
export const dataArticle: ListArticle = [
{
id: 4185,
title: 'Chuyện RAM ĐẮT - Góc nhìn mà anh em chưa thấy',
extend: {
pixel_code: '',
},
summary: '',
createDate: '28-11-2025, 11:51 am',
createBy: '53',
lastUpdate: '28-11-2025, 11:51 am',
lastUpdateBy: '53',
visit: 8,
is_featured: 0,
article_time: '',
review_rate: 0,
review_count: 0,
video_code: '',
external_url: 'https://www.youtube.com/watch?v=rH9Aq_2yZEc',
author: 'Trần Mạnh',
counter: 1,
url: '/chuyen-ram-dat-goc-nhin-ma-anh-em-chua-thay',
image: {
thum: 'https://nguyencongpc.vn/media/news/120-4185---efwegweg.jpg',
original: 'https://nguyencongpc.vn/media/news/4185---efwegweg.jpg',
},
},
{
id: 4184,
title: 'Build PC GAMING tầm giá 20 Triệu trong mùa BÃO RAM - Cũng KHOAI phết',
extend: {
pixel_code: '',
},
summary: '',
createDate: '28-11-2025, 11:49 am',
createBy: '53',
lastUpdate: '28-11-2025, 11:49 am',
lastUpdateBy: '53',
visit: 7,
is_featured: 0,
article_time: '',
review_rate: 0,
review_count: 0,
video_code: '',
external_url: 'https://www.youtube.com/watch?v=c-JQPclPXmg',
author: 'Trần Mạnh',
counter: 2,
url: '/build-pc-gaming-tam-gia-20-trieu-trong-mua-bao-ram-cung-khoai-phet',
image: {
thum: 'https://nguyencongpc.vn/media/news/120-4184-maxresdefault.jpg',
original: 'https://nguyencongpc.vn/media/news/4184-maxresdefault.jpg',
},
},
{
id: 4171,
title: 'Điểm dừng cho PC GAMING - Nhiều tiền thì cũng PHÍ',
extend: {
pixel_code: '',
},
summary: '',
createDate: '10-11-2025, 2:41 pm',
createBy: '53',
lastUpdate: '10-11-2025, 2:41 pm',
lastUpdateBy: '53',
visit: 8,
is_featured: 0,
article_time: '',
review_rate: 0,
review_count: 0,
video_code: '',
external_url: 'https://www.youtube.com/watch?v=xUpMSpaa_H0',
author: 'Trần Mạnh',
counter: 3,
url: '/diem-dung-cho-pc-gaming-nhieu-tien-thi-cung-phi',
image: {
thum: 'https://nguyencongpc.vn/media/news/120-4171-dvsdfgrsdf.jpg',
original: 'https://nguyencongpc.vn/media/news/4171-dvsdfgrsdf.jpg',
},
},
{
id: 3683,
title: 'Bộ PC KHỦNG BỐ tới đâu mà đích thân Chủ Tịch MaxHome phải tự đi build ???',
extend: {
pixel_code: '',
},
summary: '',
createDate: '12-03-2025, 9:59 am',
createBy: '53',
lastUpdate: '12-03-2025, 9:59 am',
lastUpdateBy: '53',
visit: 64,
is_featured: 0,
article_time: '',
review_rate: 0,
review_count: 0,
video_code: '',
external_url: 'https://www.youtube.com/watch?v=Ir9zlznA9ms',
author: 'Trần Mạnh',
counter: 4,
url: '/bo-pc-khung-bo-toi-dau-ma-dich-than-chu-tich-maxhome-phai-tu-di-build',
image: {
thum: 'https://nguyencongpc.vn/media/news/120-3683-tymyumyj.jpg',
original: 'https://nguyencongpc.vn/media/news/3683-tymyumyj.jpg',
},
},
{
id: 4107,
title: 'Intel ĐẮT quá nên BUILD PC với AMD chỉ 17 TRIỆU mà chiến ALL GAME',
extend: {
pixel_code: '',
},
summary: '',
createDate: '04-10-2025, 5:39 pm',
createBy: '53',
lastUpdate: '04-10-2025, 5:40 pm',
lastUpdateBy: '53',
visit: 7,
is_featured: 0,
article_time: '',
review_rate: 0,
review_count: 0,
video_code: '',
external_url: 'https://www.youtube.com/watch?v=DBuud_Lwt6w',
author: 'Trần Mạnh',
counter: 5,
url: '/intel-dat-qua-nen-build-pc-voi-amd-chi-17-trieu-ma-chien-all-game',
image: {
thum: 'https://nguyencongpc.vn/media/news/120-4107---gherthert.jpg',
original: 'https://nguyencongpc.vn/media/news/4107---gherthert.jpg',
},
},
{
id: 4079,
title: 'Tôi thấy chán PC HIỆU NĂNG/GIÁ THÀNH sau khi thấy bộ PC này',
extend: {
pixel_code: '',
},
summary: '',
createDate: '20-09-2025, 10:42 am',
createBy: '53',
lastUpdate: '20-09-2025, 10:42 am',
lastUpdateBy: '53',
visit: 28,
is_featured: 0,
article_time: '',
review_rate: 0,
review_count: 0,
video_code: '',
external_url: 'https://www.youtube.com/watch?v=ceT_nSB1JCA',
author: 'Trần Mạnh',
counter: 6,
url: '/toi-thay-chan-pc-hieu-nang-gia-thanh-sau-khi-thay-bo-pc-nay',
image: {
thum: 'https://nguyencongpc.vn/media/news/120-4079-ewgergherth.jpg',
original: 'https://nguyencongpc.vn/media/news/4079-ewgergherth.jpg',
},
},
{
id: 4004,
title: 'Sinh Viên ĐỒ HOẠ lên cấu hình PC nào dưới 20 TRIỆU trong 2025',
extend: {
pixel_code: '',
},
summary: '',
createDate: '15-08-2025, 2:04 pm',
createBy: '53',
lastUpdate: '15-08-2025, 2:04 pm',
lastUpdateBy: '53',
visit: 44,
is_featured: 0,
article_time: '',
review_rate: 0,
review_count: 0,
video_code: '',
external_url: 'https://www.youtube.com/watch?v=k6rIzVmU9bA',
author: 'Trần Mạnh',
counter: 7,
url: '/sinh-vien-do-hoa-len-cau-hinh-pc-nao-duoi-20-trieu-trong-2025',
image: {
thum: 'https://nguyencongpc.vn/media/news/120-4004-dhtrhj.jpg',
original: 'https://nguyencongpc.vn/media/news/4004-dhtrhj.jpg',
},
},
{
id: 3951,
title: 'Cấu hình PC 10 Triệu cả Màn hình - Test GAME AAA vẫn OK',
extend: {
pixel_code: '',
},
summary: '',
createDate: '19-07-2025, 4:57 pm',
createBy: '53',
lastUpdate: '19-07-2025, 4:57 pm',
lastUpdateBy: '53',
visit: 43,
is_featured: 0,
article_time: '',
review_rate: 0,
review_count: 0,
video_code: '',
external_url: 'https://www.youtube.com/watch?v=QCQwdLcosQc',
author: 'Trần Mạnh',
counter: 8,
url: '/cau-hinh-pc-10-trieu-ca-man-hinh-test-game-aaa-van-ok',
image: {
thum: 'https://nguyencongpc.vn/media/news/120-3951-dfbeadbeat.jpg',
original: 'https://nguyencongpc.vn/media/news/3951-dfbeadbeat.jpg',
},
},
{
id: 3950,
title:
'Tại sao mình ít làm video CORE ULTRA - Có đáng không 40 Triệu cho Ultra 7 265K + RTX 5070',
extend: {
pixel_code: '',
},
summary: '',
createDate: '19-07-2025, 4:56 pm',
createBy: '53',
lastUpdate: '19-07-2025, 5:00 pm',
lastUpdateBy: '53',
visit: 51,
is_featured: 0,
article_time: '',
review_rate: 0,
review_count: 0,
video_code: '',
external_url: 'https://www.youtube.com/watch?v=Y6PBwYe5My0',
author: 'Trần Mạnh',
counter: 9,
url: '/tai-sao-minh-it-lam-video-core-ultra-co-dang-khong-40-trieu-cho-ultra-7-265k-rtx-5070',
image: {
thum: 'https://nguyencongpc.vn/media/news/120-3950-maxresdefault.jpg',
original: 'https://nguyencongpc.vn/media/news/3950-maxresdefault.jpg',
},
},
{
id: 3949,
title: 'Cấu hình PC PHỔ BIẾN nhất THẾ GIỚI gaming - Cũng rẻ phết',
extend: {
pixel_code: '',
},
summary: '',
createDate: '19-07-2025, 4:54 pm',
createBy: '53',
lastUpdate: '19-07-2025, 4:54 pm',
lastUpdateBy: '53',
visit: 28,
is_featured: 0,
article_time: '',
review_rate: 0,
review_count: 0,
video_code: '',
external_url: 'https://www.youtube.com/watch?v=KXfA10koGDk',
author: 'Trần Mạnh',
counter: 10,
url: '/cau-hinh-pc-pho-bien-nhat-the-gioi-gaming-cung-re-phet',
image: {
thum: 'https://nguyencongpc.vn/media/news/120-3949----herthrtn.jpg',
original: 'https://nguyencongpc.vn/media/news/3949----herthrtn.jpg',
},
},
];

View File

@@ -1,282 +0,0 @@
import { ListArticle } from '@/types';
export const dataArticle: ListArticle = [
{
id: 4200,
title: 'Top PC 15 triệu tối ưu hiệu năng nhất trong mùa bão giá RAM',
extend: {
pixel_code: '',
},
summary:
'Chỉ với 15 triệu đồng, người dùng đã có thể sở hữu một bộ máy tính tối ưu hiệu năng cho nhu cầu học tập, làm việc và giải trí. Nguyễn Công PC mang đến nhiều cấu hình cân bằng giữa sức mạnh và giá trị, đảm bảo hoạt động mượt mà trong mọi tác vụ. Đây là lựa chọn lý tưởng cho những ai muốn đầu tư một hệ thống mạnh mẽ với chi phí hợp lý.',
createDate: '10-12-2025, 5:44 pm',
createBy: '75',
lastUpdate: '22-12-2025, 5:03 pm',
lastUpdateBy: '75',
visit: 157,
is_featured: 0,
article_time: '',
review_rate: 0,
review_count: 0,
video_code: '',
external_url: '',
author: 'Diệu Linh',
counter: 1,
url: '/top-pc-15-trieu-toi-uu-hieu-nang-cho-gaming-va-lam-viec',
image: {
thum: 'https://nguyencongpc.vn/media/news/120-4200-chi-voi-15-trieu-ban-da-co-ngay-mot-bo-pc-chat-luong-dam-bao-hieu-nang1.jpg',
original:
'https://nguyencongpc.vn/media/news/4200-chi-voi-15-trieu-ban-da-co-ngay-mot-bo-pc-chat-luong-dam-bao-hieu-nang1.jpg',
},
},
{
id: 4195,
title: 'Cách nhận chứng chỉ Google Gemini Educator làm đẹp CV của bạn ngay hôm nay!',
extend: {
pixel_code: '',
},
summary:
'Chứng chỉ Google Gemini Educator giúp bạn khẳng định kỹ năng sử dụng AI trong giáo dục và công nghệ. Việc sở hữu chứng chỉ này không chỉ tăng tính chuyên nghiệp cho CV mà còn mở ra nhiều cơ hội nghề nghiệp mới. Bài viết sẽ hướng dẫn bạn cách đăng ký, học và nhận chứng chỉ nhanh chóng nhất.',
createDate: '08-12-2025, 11:26 am',
createBy: '75',
lastUpdate: '08-12-2025, 12:07 pm',
lastUpdateBy: '75',
visit: 3067,
is_featured: 0,
article_time: '',
review_rate: 0,
review_count: 0,
video_code: '',
external_url: '',
author: 'Diệu Linh',
counter: 2,
url: '/cach-nhan-chung-chi-google-gemini-educator-mien-phi-nam-2025',
image: {
thum: 'https://nguyencongpc.vn/media/news/120-4195-cach-nhan-chung-chi-google-gemini-educator-mien-phi-nam-20251.jpg',
original:
'https://nguyencongpc.vn/media/news/4195-cach-nhan-chung-chi-google-gemini-educator-mien-phi-nam-20251.jpg',
},
},
{
id: 2722,
title: 'Top 100+ cấu hình PC Gaming giá tốt nhất năm 2025',
extend: {
pixel_code: '',
},
summary:
'Trong bài viết, Nguyễn Công PC đã tổng hợp hơn 100 cấu hình PC gaming tối ưu nhất năm 2025, phù hợp với nhiều mức ngân sách từ phổ thông đến cao cấp. Mỗi cấu hình cân bằng giữa hiệu năng và giá thành, đáp ứng nhu cầu chơi game mượt mà, đồ họa sắc nét và khả năng nâng cấp linh hoạt trong tương lai.\r\n\r\n\r\n',
createDate: '16-01-2024, 10:52 am',
createBy: '50',
lastUpdate: '06-12-2025, 4:30 pm',
lastUpdateBy: '53',
visit: 36705,
is_featured: 0,
article_time: '07-11-2025, 9:00 am',
review_rate: 0,
review_count: 0,
video_code: '',
external_url: '',
author: 'Trần Mạnh',
counter: 3,
url: '/top-100-cau-hinh-pc-gaming-gia-tot-nhat',
image: {
thum: 'https://nguyencongpc.vn/media/news/120-2722-pc-gaming.jpg',
original: 'https://nguyencongpc.vn/media/news/2722-pc-gaming.jpg',
},
},
{
id: 2718,
title: 'Top 50 cấu hình PC đồ họa giá tốt nhất hiện nay',
extend: {
pixel_code: '',
},
summary:
'Với đà phát triển của truyền thông, công nghệ số, kỹ thuật số,... Cần rất nhiều công cụ để hỗ trợ cho công việc, làm việc của bạn. Sức mạnh ngành chuyền thông nói riêng cũng như công nghệ nói chung càng ngày càng phát triển mạnh mẽ, vượt trội, chính vì để hỗ trợ cho việc xây dựng các bộ (PC Render) làm việc cũng như giải trí đang là nhu cầu lơn trên thị trường hiện nay.\r\n\r\n',
createDate: '15-01-2024, 1:39 pm',
createBy: '50',
lastUpdate: '24-11-2025, 10:23 am',
lastUpdateBy: '74',
visit: 24390,
is_featured: 0,
article_time: '05-11-2025, 10:00 am',
review_rate: 0,
review_count: 0,
video_code: '',
external_url: '',
author: 'Anh Tuấn',
counter: 4,
url: '/top-cau-hinh-do-hoa-gia-tot-nhat',
image: {
thum: 'https://nguyencongpc.vn/media/news/120-2718-pc-do-hoa.jpg',
original: 'https://nguyencongpc.vn/media/news/2718-pc-do-hoa.jpg',
},
},
{
id: 4203,
title:
'NGUYỄN CÔNG PC - NHÀ TÀI TRỢ KIM CƯƠNG CHÀO TÂN SINH VIÊN ĐẠI HỌC KIẾN TRÚC HÀ NỘI 2025',
extend: {
pixel_code: '',
},
summary:
'Máy tính Nguyễn Công tiếp tục khẳng định vị thế là đối tác tin cậy hàng đầu khi vinh dự trở thành Nhà Tài Trợ Kim Cương liên tục trong 7 năm (2019 2025) cho chương trình Chào Tân Sinh Viên của Đại học Kiến Trúc Hà Nội (HAU).',
createDate: '15-12-2025, 10:09 am',
createBy: '74',
lastUpdate: '15-12-2025, 4:00 pm',
lastUpdateBy: '53',
visit: 51,
is_featured: 0,
article_time: '',
review_rate: 0,
review_count: 0,
video_code: '',
external_url: '',
author: 'Trần Mạnh',
counter: 5,
url: '/nguyen-cong-pc-nha-tai-tro-kim-cuong-chao-tan-sinh-vien-dai-hoc-kien-truc-ha-noi-2025',
image: {
thum: 'https://nguyencongpc.vn/media/news/120-4203-nguyen-cong-pc-nha-tai-tro-kim-cuong-chao-tan-sinh-vien-dai-hoc-kien-truc-ha-noi-2025-06.jpg',
original:
'https://nguyencongpc.vn/media/news/4203-nguyen-cong-pc-nha-tai-tro-kim-cuong-chao-tan-sinh-vien-dai-hoc-kien-truc-ha-noi-2025-06.jpg',
},
},
{
id: 4199,
title: 'Đại chiến đồ họa: Canva và Photoshop: Ai là "Vua" thiết kế hiện nay',
extend: {
pixel_code: '',
},
summary:
'Canva và Photoshop đang là hai nền tảng thiết kế phổ biến nhất, mỗi công cụ sở hữu những ưu nhược điểm riêng. Trong khi Canva mang đến sự tiện lợi và tốc độ, Photoshop lại vượt trội về sức mạnh xử lý và khả năng sáng tạo chuyên sâu. Cuộc đối đầu này giúp người dùng lựa chọn đúng công cụ phù hợp với nhu cầu thiết kế của mình.',
createDate: '09-12-2025, 6:56 pm',
createBy: '75',
lastUpdate: '11-12-2025, 2:21 pm',
lastUpdateBy: '75',
visit: 72,
is_featured: 0,
article_time: '',
review_rate: 0,
review_count: 0,
video_code: '',
external_url: '',
author: 'Diệu Linh',
counter: 6,
url: '/dai-chien-do-hoa-canva-va-photoshop-ai-la-vua-thiet-ke-hien-nay',
image: {
thum: 'https://nguyencongpc.vn/media/news/120-4199-dai-chien-do-hoa-canva-va-photoshop-ai-la-vua-thiet-ke-hien-nay5.jpg',
original:
'https://nguyencongpc.vn/media/news/4199-dai-chien-do-hoa-canva-va-photoshop-ai-la-vua-thiet-ke-hien-nay5.jpg',
},
},
{
id: 4197,
title: 'Người dùng nên nâng cấp Windows 11 hiện đại hay tiếp tục sử dụng Windows 10 ổn định? ',
extend: {
pixel_code: '',
},
summary: '',
createDate: '09-12-2025, 11:03 am',
createBy: '75',
lastUpdate: '09-12-2025, 5:23 pm',
lastUpdateBy: '75',
visit: 92,
is_featured: 0,
article_time: '',
review_rate: 0,
review_count: 0,
video_code: '',
external_url: '',
author: 'Diệu Linh',
counter: 7,
url: '/nguoi-dung-nen-nang-cap-windows-11-hien-dai-hay-tiep-tuc-su-dung-windows-10-on-dinh',
image: {
thum: 'https://nguyencongpc.vn/media/news/120-4197-nguoi-dung-nen-nang-cap-windows-11-hien-dai-hay-tiep-tuc-su-dung-windows-10-on-dinh2.jpg',
original:
'https://nguyencongpc.vn/media/news/4197-nguoi-dung-nen-nang-cap-windows-11-hien-dai-hay-tiep-tuc-su-dung-windows-10-on-dinh2.jpg',
},
},
{
id: 3954,
title: 'Hướng Dẫn Các Bước Cài Đặt Plugin Sketch Up Nhanh Chóng, Đơn Giản Nhất',
extend: {
pixel_code: '',
},
summary:
'Theo dõi các hướng dẫn chi tiết cách cài đặt plugin cho phần mềm SketchUp cùng Nguyễn Công PC để giúp người dùng mở rộng chức năng, tiết kiệm thời gian thiết kế và nâng cao hiệu suất làm việc. ',
createDate: '21-07-2025, 10:39 am',
createBy: '75',
lastUpdate: '22-07-2025, 9:06 am',
lastUpdateBy: '75',
visit: 6532,
is_featured: 0,
article_time: '',
review_rate: 0,
review_count: 0,
video_code: '',
external_url: '',
author: 'Diệu Linh',
counter: 8,
url: '/huong-dan-cac-buoc-cai-dat-plugin-sketch-up-nhanh-chong-don-gian-nhat',
image: {
thum: 'https://nguyencongpc.vn/media/news/120-3954-huong-dan-cac-buoc-cai-dat-plugin-sketch-up-nhanh-chong-don-gian-nhat10.jpg',
original:
'https://nguyencongpc.vn/media/news/3954-huong-dan-cac-buoc-cai-dat-plugin-sketch-up-nhanh-chong-don-gian-nhat10.jpg',
},
},
{
id: 4198,
title: 'Bạn đã biết cách tạo ảnh AI cực hot với công cụ Nano Banana từ Gemini chưa?',
extend: {
pixel_code: '',
},
summary:
'Nano Banana là công cụ AI mới giúp người dùng tạo ảnh nhanh, đẹp và chuẩn ý tưởng chỉ từ vài dòng mô tả. Tại Nguyễn Công PC, bạn có thể dễ dàng trải nghiệm Nano Banana với giao diện đơn giản, tốc độ xử lý mạnh mẽ. Công cụ này phù hợp cho designer, marketer, người làm nội dung và bất kỳ ai muốn tạo hình ảnh chuyên nghiệp.',
createDate: '09-12-2025, 4:59 pm',
createBy: '75',
lastUpdate: '09-12-2025, 6:48 pm',
lastUpdateBy: '75',
visit: 137,
is_featured: 0,
article_time: '',
review_rate: 0,
review_count: 0,
video_code: '',
external_url: '',
author: 'Diệu Linh',
counter: 9,
url: '/ban-da-biet-cach-tao-anh-ai-cuc-hot-voi-cong-cu-nano-banana-tu-gemini-chua',
image: {
thum: 'https://nguyencongpc.vn/media/news/120-4198-ban-da-biet-cach-tao-anh-ai-cuc-hot-voi-cong-cu-nano-banana-tu-gemini-chua7.jpg',
original:
'https://nguyencongpc.vn/media/news/4198-ban-da-biet-cach-tao-anh-ai-cuc-hot-voi-cong-cu-nano-banana-tu-gemini-chua7.jpg',
},
},
{
id: 4118,
title: 'Windows 10 chính thức ngừng hoạt động, người dùng cần làm gì để giữ máy tính an toàn?',
extend: {
pixel_code: '',
},
summary:
'Microsoft đã chính thức ngừng hỗ trợ Windows 10, đồng nghĩa với việc hệ điều hành này không còn nhận được các bản cập nhật bảo mật. Người dùng tiếp tục sử dụng có nguy cơ cao bị tấn công mạng hoặc gặp lỗi nghiêm trọng. Vì vậy, việc nâng cấp hoặc áp dụng các biện pháp bảo vệ bổ sung là điều cấp thiết để giữ máy tính an toàn.',
createDate: '14-10-2025, 5:37 pm',
createBy: '75',
lastUpdate: '15-10-2025, 10:14 am',
lastUpdateBy: '75',
visit: 325,
is_featured: 0,
article_time: '',
review_rate: 0,
review_count: 0,
video_code: '',
external_url: '',
author: 'Diệu Linh',
counter: 10,
url: '/windows-10-chinh-thuc-ngung-hoat-dong-nguoi-dung-can-lam-gi-de-giu-may-tinh-an-toan',
image: {
thum: 'https://nguyencongpc.vn/media/news/120-4118-huong-dan-reset-windows-10-ve-trang-thai-moi-cai-dat14.jpg',
original:
'https://nguyencongpc.vn/media/news/4118-huong-dan-reset-windows-10-ve-trang-thai-moi-cai-dat14.jpg',
},
},
];

File diff suppressed because it is too large Load Diff

View File

@@ -1,86 +0,0 @@
import { FaCheckSquare } from 'react-icons/fa';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay, Navigation, Pagination, Thumbs } from 'swiper/modules';
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"
>
<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,0
c-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.267
c0,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.407
s76.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.062
C438.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.428
c-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.847
l87.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.13
c3.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.847
C339.752,224.213,337.945,228.498,334.332,232.111z"
/>
</g>
</svg>
<div className="pcb-slider swiper-customer-bought">
<Swiper
modules={[Autoplay, Navigation, Pagination, Thumbs]}
spaceBetween={12}
slidesPerView={1}
loop={true}
autoplay={{
delay: 3000,
disableOnInteraction: false,
}}
>
<SwiperSlide>
<div>
<p>
<b>Khách hàng Anh Tuấn (036 856 xxxx)</b>
</p>
<p>Đã mua hàng 2 giờ trước</p>
</div>
</SwiperSlide>
<SwiperSlide>
<div>
<p>
<b>Khách hàng Quốc Trung (035 348 xxxx)</b>
</p>
<p>Đã mua hàng 1 giờ trước</p>
</div>
</SwiperSlide>
<SwiperSlide>
<div>
<p>
<b>Khách hàng Quang Ngọc (097 478 xxxx)</b>
</p>
<p>Đã mua hàng 30 phút trước</p>
</div>
</SwiperSlide>
<SwiperSlide>
<div>
<p>
<b>Khách hàng Mạnh Lực (037 204 xxxx)</b>
</p>
<p>Đã mua hàng 25 phút trước</p>
</div>
</SwiperSlide>
<SwiperSlide>
<div>
<p>
<b>Khách hàng Hiếu (096 859 xxxx)</b>
</p>
<p>Đã mua hàng 20 phút trước</p>
</div>
</SwiperSlide>
</Swiper>
</div>
</div>
);
};

View File

@@ -1,91 +0,0 @@
import { useEffect, useState } from 'react';
import type { ProductDetailData } from '@/types';
import CounDown from '@/components/Common/CounDown';
import { formatCurrency } from '@/lib/formatPrice';
export const BoxPrice = (item: ProductDetailData) => {
const [now, setNow] = useState(() => Date.now());
return (
<>
{item.product_info.sale_rules.type == 'deal' &&
Number(item.product_info.sale_rules.to_time) > now && (
<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">
<CounDown deadline={Number(item.product_info.sale_rules.to_time)} />
</div>
<div className="box-right">
<div className="box-product-deal">
<p className="text-deal-detail">
Còn{' '}
{(() => {
const deal = item.product_info.deal_list[0];
return Number(deal.quantity) - deal.sale_order;
})()}
/{item.product_info.deal_list[0].quantity} sản phẩm
</p>
<div
className="p-quantity-sale"
data-quantity-left="3"
data-quantity-sale-total="5"
>
<i className="sprite sprite-fire-deal"></i>
<div className="bg-gradient"></div>
{(() => {
const deal = item.product_info.deal_list[0];
const percentRemaining =
((Number(deal.quantity) - deal.sale_order) / Number(deal.quantity)) * 100;
return (
<>
<p
className="js-line-deal-left"
style={{ width: `${percentRemaining}%` }}
></p>
</>
);
})()}
</div>
</div>
</div>
</div>
)}
{/* giá */}
{item.product_info.marketPrice > '0' && item.product_info.sale_rules.type == 'deal' && (
<div
className="box-price-detail boder-radius-10 flex flex-wrap items-center"
style={{ rowGap: '8px' }}
>
<p className="price-detail font-bold">
{item.product_info.price !== '0'
? `${formatCurrency(item.product_info.price)}đ`
: 'Liên hệ'}
</p>
{item.product_info.marketPrice > '0' && (
<>
<span className="market-price-detail font-weight-500">
{formatCurrency(item.product_info.marketPrice)}
</span>
<div className="save-price-detail flex items-center gap-1">
<span>Tiết kiệm</span>
{(() => {
return formatCurrency(
Number(item.product_info.marketPrice) - Number(item.product_info.price),
);
})()}
<span>đ</span>
</div>
</>
)}
</div>
)}
</>
);
};

View File

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

View File

@@ -1,114 +0,0 @@
import React, { 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';
interface ComboProps {
combo_set: ComboSet[];
}
interface PopupGroup {
title: string;
products: ComboProduct[];
}
export const ComboSetBox: React.FC<ComboProps> = ({ combo_set }) => {
const [openPopup, setOpenPopup] = useState(false);
const [popupGroup, setPopupGroup] = useState<PopupGroup>({
title: '',
products: [],
});
const [selectedProduct, setSelectedProduct] = useState<ComboProduct | null>(null);
const handleOpenPopup = (
titleGroup: string,
products: ComboProduct[],
currentItem: ComboProduct,
) => {
setPopupGroup({ title: titleGroup, products });
setSelectedProduct(currentItem); // lưu sản phẩm đang hiển thị
setOpenPopup(true);
};
const handleReplaceProduct = (newProduct: ComboProduct) => {
// cập nhật selectedProduct bằng sản phẩm mới
setSelectedProduct(newProduct);
setOpenPopup(false);
};
const getDisplayedProduct = (group: ComboGroup) => {
// Nếu selectedProduct thuộc group này thì hiển thị nó
if (selectedProduct && group.product_list.some((p) => p.id === selectedProduct.id)) {
return selectedProduct;
}
// Ngược lại lấy sản phẩm mặc định
return group.product_list.find((p) => p.is_first === 'yes') || group.product_list[0];
};
if (!combo_set || combo_set.length === 0) return null;
const setInfo = combo_set[0];
return (
<div className="box-comboset mb-8">
<p className="title-comboset font-weight-600">Mua theo combo</p>
<div id="comboset">
<Swiper
className="list-product-comboset swiper-comboset"
modules={[Autoplay, Navigation, Pagination]}
spaceBetween={16}
slidesPerView={3}
navigation
>
{setInfo.group_list.map((group, index) => {
// lấy sản phẩm đầu tiên theo logic "is_first" hoặc mặc định
const firstProduct =
group.product_list.find((p) => p.is_first === 'yes') || group.product_list[0];
return (
<SwiperSlide key={index}>
<ItemComboSet
item={getDisplayedProduct(group)}
keyGroup={group.key}
titleGroup={group.title}
setId={setInfo.id}
products={group.product_list}
onOpenPopup={handleOpenPopup}
/>
</SwiperSlide>
);
})}
</Swiper>
<div className="comboset-info mt-4 flex justify-between">
<div className="box-left">
<div className="total-comboset flex items-center gap-2">
<p>Tạm tính:</p>
<p className="js-pass-price price text-red font-weight-600">
{/* giả sử lấy giá từ product_info */}
3.050.000 đ
</p>
</div>
<p className="save-price-combo">
Tiết kiệm thêm <span className="save-price">215.000đ</span>
</p>
</div>
<div className="box-right flex items-center justify-end gap-2">
<p className="js-combo-set js-combo-set-checkout buy_combo" data-set-id={setInfo.id}>
Mua thêm <span id="count-pro-selected">0</span> sản phẩm
</p>
</div>
</div>
</div>
<ChangeProductPopup
titleGroup={popupGroup.title}
products={popupGroup.products}
open={openPopup}
onClose={() => setOpenPopup(false)}
onSelect={handleReplaceProduct}
/>
</div>
);
};

View File

@@ -1,137 +0,0 @@
import React from 'react';
export const FormReview: React.FC = () => {
return (
<div className="box-form-review" id="js-box-review">
<textarea
className="review_reply_content"
id="rating-content"
placeholder="Mời bạn để lại đánh giá..."
name="user_post[content]"
></textarea>
<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" id="select-rate-pro">
<div className="rating-selection" id="rating-review0">
<input
type="radio"
className="rating-input"
id="rating-input-review-0-5"
value="5"
name="user_post[rate]"
defaultChecked
/>
<label
htmlFor="rating-input-review-0-5"
className="sprite-1star rating-star"
></label>
<input
type="radio"
className="rating-input"
id="rating-input-review-0-4"
value="4"
name="user_post[rate]"
/>
<label
htmlFor="rating-input-review-0-4"
className="sprite-1star rating-star"
></label>
<input
type="radio"
className="rating-input"
id="rating-input-review-0-3"
value="3"
name="user_post[rate]"
/>
<label
htmlFor="rating-input-review-0-3"
className="sprite-1star rating-star"
></label>
<input
type="radio"
className="rating-input"
id="rating-input-review-0-2"
value="2"
name="user_post[rate]"
/>
<label
htmlFor="rating-input-review-0-2"
className="sprite-1star rating-star"
></label>
<input
type="radio"
className="rating-input"
id="rating-input-review-0-1"
value="1"
name="user_post[rate]"
/>
<label
htmlFor="rating-input-review-0-1"
className="sprite-1star rating-star"
></label>
</div>
</div>
</td>
</tr>
<tr className="flex items-center">
<td>Tên bạn</td>
<td>
<input
type="text"
id="rating-name"
name="user_post[user_name]"
className="form-control"
defaultValue=""
/>
</td>
</tr>
<tr className="flex items-center">
<td>Email</td>
<td>
<input
type="text"
id="rating-email"
name="user_post[user_email]"
className="form-control"
defaultValue=""
/>
</td>
</tr>
</tbody>
</table>
</div>
<p
id="js-review-note"
className="font-weight-700 flex"
style={{ color: 'red', maxWidth: '100%' }}
></p>
<button
type="button"
className="btn-review send_form mt-12 mb-10"
onClick={() => {
// TODO: viết hàm send_vote() trong React
console.log('Send vote clicked');
}}
>
Gửi đánh giá
</button>
</div>
</div>
);
};

View File

@@ -1,45 +0,0 @@
import { useState } from 'react';
import { FaAngleDown, FaAngleUp } from 'react-icons/fa6';
import useFancybox from '@/hooks/useFancybox';
interface Props {
ItemSpec: string;
}
export const ProductSpec: React.FC<Props> = ({ ItemSpec }) => {
const [fancyboxRef] = useFancybox({
closeButton: 'auto',
dragToClose: true,
});
return (
<div className="box-spec">
<h2 className="title font-[600]">Thông số kỹ thuật</h2>
<div className="content-spec relative" dangerouslySetInnerHTML={{ __html: ItemSpec }} />
<div id="product-spec" style={{ display: 'none' }} ref={fancyboxRef}>
<div className="box-top-centent-spec d-flex justify-content-between hide">
<h2 className="font-weight-600">Thông số kỹ thuật</h2>
<p
className="delelte-content-spec d-flex justify-content-center align-items-center"
data-fancybox-close
>
<i className="fa-solid fa-xmark"></i>
</p>
</div>
<div className="content-spec">
{/* thay vì {{ page.product_info.productSpec }} bạn truyền từ props */}
{ItemSpec}
</div>
</div>
<a
data-fancybox
data-options='{"src": "#product-spec", "touch": false, "smallBtn": false}'
href="javascript:;"
className="btn-article-col font-weight-500 flex items-center justify-center gap-2"
>
Xem đy đ thông số kỹ thuật
<FaAngleDown />
</a>
</div>
);
};

View File

@@ -1,131 +0,0 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
// type
import type { ProductDetailData } from '@/types';
// data
import { productDetailData } from '@/data/product/detail';
import { productData } from '@/data/ListProduct';
import { findProductDetailBySlug } from '@/lib/product/productdetail';
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';
interface ProductDetailPageProps {
slug: string;
}
const ProductDetailPage: React.FC<ProductDetailPageProps> = ({ slug }) => {
const productDetails = productDetailData as unknown as ProductDetailData[];
const Products = findProductDetailBySlug(slug, productDetails);
const breadcrumbItems = Products?.product_info?.productPath?.[0]?.path.map((item) => ({
name: item.name,
url: item.url,
})) ?? [{ name: 'Trang chủ', url: '/' }];
// Trường hợp không tìm thấy chi tiết sản phẩm
// Không tìm thấy chi tiết sản phẩm
if (!Products) {
return <ErrorLink />;
}
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">
{/* image product */}
<ImageProduct ItemImage={Products.product_info.productImageGallery} />
<ProductSummary ItemSummary={Products.product_info.productSummary} />
<ComboSetBox combo_set={Products.combo_set} />
</div>
<div className="box-right">
<BoxInfoRight {...Products} />
</div>
</div>
{/* sản phẩm tương tự */}
<div className="box-relative-product box-history-product page-hompage">
<div className="box-product-category">
<div className="title-box">
<h2 className="title title-box font-[600]">Sản phẩm tương tự</h2>
</div>
<div className="box-list-history-product">
<Swiper
modules={[Autoplay, Navigation, Pagination]}
spaceBetween={12}
slidesPerView={5}
loop={true}
>
{productData.map((item, index) => (
<SwiperSlide key={index}>
<ItemProduct item={item} />
</SwiperSlide>
))}
</Swiper>
</div>
</div>
</div>
{/* nội dung chi tiết sản phẩm */}
<div className="box-read-product-detail flex justify-between gap-3">
<div className="box-left">
{/* mô tả chi tiết sản phẩm */}
<ProductDescription {...Products} />
{/* đánh giá sản phẩm */}
<ProductReview ItemReview={Products.product_info.review} />
{/* bình luận sản phẩm */}
<ProductComment />
</div>
<div className="box-right">
<ProductSpec ItemSpec={Products.product_info.productSpec} />
</div>
</div>
{/* sản phẩm đã xem */}
<div className="box-history-product page-hompage mt-5">
<div className="box-product-category">
<div className="title-box">
<h2 className="title title-box font-[600]">Sản phẩm đã xem</h2>
</div>
<div className="box-list-history-product">
<Swiper
modules={[Autoplay, Navigation, Pagination]}
spaceBetween={12}
slidesPerView={5}
loop={true}
>
{productData.map((item, index) => (
<SwiperSlide key={index}>
<ItemProduct item={item} />
</SwiperSlide>
))}
</Swiper>
</div>
</div>
</div>
</div>
</section>
</>
);
};
export default ProductDetailPage;

View File

@@ -18,8 +18,8 @@ export default function SendCartPage() {
ĐƠN HÀNG ĐÃ ĐƯC TIẾP NHẬN
</p>
<div className="send-cart-title-descreption leading-[150%]">
Cảm ơn quý khách đã đt hàng tại Đơn hàng đã đưc tiếp nhận. Đ kiểm tra đơn hàng hoặc
thay đi thông tin, vui lòng
Cảm ơn quý khách đã đt hàng tại Nguyễn Công PC. Đơn hàng đã đưc tiếp nhận. Đ kiểm
tra đơn hàng hoặc thay đi thông tin, vui lòng
<Link href="/dang-nhap" className="red-text px-2">
Đăng nhập
</Link>

View File

@@ -4,118 +4,198 @@ import { useState, forwardRef, useImperativeHandle } from 'react';
export interface FormCartRef {
validateForm: () => boolean;
}
export const FormCart = forwardRef<FormCartRef, object>((props, ref) => {
interface FormFields {
name: string;
tel: string;
email: string;
address: string;
province: string;
district: string;
note: string;
taxName: string;
taxAddress: string;
taxCode: string;
}
interface FormErrors {
name?: string;
tel?: string;
address?: string;
}
const REGEX_NO_SPECIAL = /^[\p{L}\p{N}\s]+$/u;
const REGEX_PHONE = /^0\d{9}$/;
export const FormCart = forwardRef<FormCartRef, object>((_, ref) => {
const [showTax, setShowTax] = useState(false);
const [errors, setErrors] = useState<FormErrors>({});
const [fields, setFields] = useState<FormFields>({
name: '',
tel: '',
email: '',
address: '',
province: '0',
district: '0',
note: '',
taxName: '',
taxAddress: '',
taxCode: '',
});
const validateForm = () => {
const name = (document.getElementById('buyer_name') as HTMLInputElement)?.value.trim();
const tel = (document.getElementById('buyer_tel') as HTMLInputElement)?.value.trim();
const address = (document.getElementById('buyer_address') as HTMLInputElement)?.value.trim();
const setField =
(key: keyof FormFields) =>
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
setFields((prev) => ({ ...prev, [key]: e.target.value }));
setErrors((prev) => ({ ...prev, [key]: undefined }));
};
// Regex kiểm tra ký tự đặc biệt (chỉ cho phép chữ cái, số, khoảng trắng)
const regexNoSpecial = /^[\p{L}\p{N}\s]+$/u;
// Regex số điện thoại Việt Nam (10 số, bắt đầu bằng 0)
const regexPhone = /^0\d{9}$/;
const validateForm = (): boolean => {
const newErrors: FormErrors = {};
const name = fields.name.trim();
const tel = fields.tel.trim();
const address = fields.address.trim();
// Kiểm tra tên
if (!name || name.length <= 4 || !regexNoSpecial.test(name)) {
alert('Bạn nhập tên chưa đúng định dạng!');
return false;
if (!name || name.length <= 4 || !REGEX_NO_SPECIAL.test(name)) {
newErrors.name = 'Họ tên không hợp lệ (tối thiểu 5 ký tự, không chứa ký tự đặc biệt)';
}
if (!tel || !REGEX_PHONE.test(tel)) {
newErrors.tel = 'Số điện thoại không hợp lệ (Ví dụ: 0912345678)';
}
if (!address || address.length <= 4 || !REGEX_NO_SPECIAL.test(address)) {
newErrors.address = 'Địa chỉ không hợp lệ (tối thiểu 5 ký tự)';
}
// Kiểm tra số điện thoại
if (!tel || !regexPhone.test(tel)) {
alert('Số điện thoại không hợp lệ! (Ví dụ: 0912345678)');
return false;
}
// Kiểm tra địa chỉ
if (!address || address.length <= 4 || !regexNoSpecial.test(address)) {
alert('Địa chỉ chưa hợp lệ!');
return false;
}
// Nếu hợp lệ thì xử lý đặt hàng
return true;
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
useImperativeHandle(ref, () => ({ validateForm }));
return (
<>
<div className="box-cart-info-customer">
<p className="title-section-cart font-[600]">Thông tin khách hàng</p>
<div className="list-info-customer">
<input type="text" placeholder="Họ tên*" name="user_info[name]" id="buyer_name" />
<div className="flex justify-between gap-2">
<input type="text" placeholder="Số điện thoại*" name="user_info[tel]" id="buyer_tel" />
<input type="text" name="user_info[email]" id="buyer_email" placeholder="Email" />
</div>
<input type="text" placeholder="Địa chỉ*" id="buyer_address" name="user_info[address]" />
<div className="flex justify-between gap-2">
<select name="user_info[province]" className="text-black" id="buyer_province">
<option value="0">Tỉnh/Thành phố</option>
<option value=""> Nội</option>
</select>
<select name="user_info[district]" id="js-district-holder">
<option value="0">Quận/Huyện</option>
</select>
</div>
<textarea placeholder="Ghi chú" name="user_info[note]" id="buyer_note"></textarea>
<div className="box-cart-info-customer">
<p className="title-section-cart font-semibold">Thông tin khách hàng</p>
<div className="list-info-customer">
<div>
<input
type="text"
placeholder="Họ tên*"
name="user_info[name]"
value={fields.name}
onChange={setField('name')}
/>
{errors.name && <p className="mt-1 text-xs text-red-500">{errors.name}</p>}
</div>
<div className="form-group-taxt">
<label className="tax-title label flex items-center gap-2">
<input
type="checkbox"
className="w-[20px]"
checked={showTax}
onChange={(e) => setShowTax(e.target.checked)}
/>
Yêu cầu xuất hóa đơn công ty
</label>
</div>
{showTax && (
<div className="js-tax-group">
<div className="form-group row">
<div className="input-taxt">
<input
type="text"
id="txtTaxName"
placeholder="Tên công ty"
className="form-control"
name="user_info[tax_company]"
/>
</div>
</div>
<div className="flex justify-between gap-2">
<input
type="text"
placeholder="Số điện thoại*"
name="user_info[tel]"
value={fields.tel}
onChange={setField('tel')}
/>
{errors.tel && <p className="mt-1 text-xs text-red-500">{errors.tel}</p>}
<input
type="email"
name="user_info[email]"
placeholder="Email"
value={fields.email}
onChange={setField('email')}
/>
</div>
<div className="form-group row">
<div className="input-taxt">
<input
type="text"
id="txtTaxAddress"
placeholder="Địa chỉ công ty"
className="form-control"
name="user_info[tax_address]"
/>
</div>
</div>
<div>
<input
type="text"
placeholder="Địa chỉ*"
name="user_info[address]"
value={fields.address}
onChange={setField('address')}
/>
{errors.address && <p className="mt-1 text-xs text-red-500">{errors.address}</p>}
</div>
<div className="form-group row">
<div className="input-taxt">
<input
type="text"
id="txtTaxCode"
placeholder="Mã số thuế"
className="form-control"
name="user_info[tax_code]"
/>
</div>
<div className="flex justify-between gap-2">
<select
name="user_info[province]"
className="text-black"
value={fields.province}
onChange={setField('province')}
>
<option value="0">Tỉnh/Thành phố</option>
<option value="hn"> Nội</option>
</select>
<select
name="user_info[district]"
value={fields.district}
onChange={setField('district')}
>
<option value="0">Quận/Huyện</option>
</select>
</div>
<textarea
placeholder="Ghi chú"
name="user_info[note]"
value={fields.note}
onChange={setField('note')}
/>
<div className="form-group-taxt">
<label className="tax-title label flex items-center gap-2">
<input
type="checkbox"
className="w-5"
checked={showTax}
onChange={(e) => setShowTax(e.target.checked)}
/>
Yêu cầu xuất hóa đơn công ty
</label>
</div>
{showTax && (
<div className="js-tax-group">
<div className="form-group row">
<div className="input-taxt">
<input
type="text"
placeholder="Tên công ty"
className="form-control"
name="user_info[tax_company]"
value={fields.taxName}
onChange={setField('taxName')}
/>
</div>
</div>
)}
</div>
<div className="form-group row">
<div className="input-taxt">
<input
type="text"
placeholder="Địa chỉ công ty"
className="form-control"
name="user_info[tax_address]"
value={fields.taxAddress}
onChange={setField('taxAddress')}
/>
</div>
</div>
<div className="form-group row">
<div className="input-taxt">
<input
type="text"
placeholder="Mã số thuế"
className="form-control"
name="user_info[tax_code]"
value={fields.taxCode}
onChange={setField('taxCode')}
/>
</div>
</div>
</div>
)}
</div>
</>
</div>
);
});

View File

@@ -3,6 +3,7 @@ import Link from 'next/link';
import { TypeCartItem } from '@/types/cart';
import { FaSortDown, FaTrashCan } from 'react-icons/fa6';
import { formatCurrency } from '@/lib/formatPrice';
import { SanitizedHtml } from '@/components/Common/SanitizedHtml';
interface PropsCart {
item: TypeCartItem;
@@ -11,11 +12,20 @@ interface PropsCart {
}
export const ItemCart: React.FC<PropsCart> = ({ item, onUpdate, onDelete }) => {
const currentQty = parseInt(item.in_cart.quantity) || 1;
const handleChangeQuantity = (delta: number) => {
const newQuantity = Math.max(1, parseInt(item.in_cart.quantity) + delta);
const newQuantity = Math.max(1, currentQty + delta);
onUpdate(item._id, newQuantity);
};
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = parseInt(e.target.value);
if (!isNaN(val) && val >= 1) {
onUpdate(item._id, val);
}
};
return (
<div className="cart-item-info js-item-row flex justify-between">
<div className="cart-item-left flex">
@@ -57,7 +67,7 @@ export const ItemCart: React.FC<PropsCart> = ({ item, onUpdate, onDelete }) => {
<div className="item-offer-content">
{item.item_info.specialOffer.all.map((_item, idx) => (
<div key={idx} dangerouslySetInnerHTML={{ __html: _item.title }} />
<SanitizedHtml key={idx} html={_item.title} />
))}
</div>
</div>
@@ -72,10 +82,11 @@ export const ItemCart: React.FC<PropsCart> = ({ item, onUpdate, onDelete }) => {
-
</button>
<input
type="text"
className="js-buy-quantity js-quantity-change bk-product-qty font-bold"
value={item.in_cart.quantity}
onChange={() => handleChangeQuantity(1)}
type="number"
min={1}
className="js-buy-quantity bk-product-qty font-bold"
value={currentQty}
onChange={handleInputChange}
/>
<button
onClick={() => handleChangeQuantity(1)}

View File

@@ -1,23 +1,30 @@
'use client';
import { useState, useRef } from 'react';
import { useRef, useState, useSyncExternalStore } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { FaChevronLeft } from 'react-icons/fa6';
import { Breadcrumb } from '@/components/Common/Breadcrumb';
import { TypeCartItem } from '@/types/cart';
import { ItemCart } from './ItemCart';
import { FormCart, FormCartRef } from './FormCart';
import { formatCurrency } from '@/lib/formatPrice';
import {
clearCartStorage,
getServerCartSnapshot,
readCartFromStorage,
subscribeCartStorage,
writeCartToStorage,
} from '@/lib/cartStorage';
const HomeCart = () => {
const router = useRouter();
const breadcrumbItems = [{ name: 'Giỏ hàng', url: '/cart' }];
const [cart, setCart] = useState<TypeCartItem[]>(() => {
const storedCart = localStorage.getItem('cart');
return storedCart ? JSON.parse(storedCart) : [];
});
const cart = useSyncExternalStore(
subscribeCartStorage,
readCartFromStorage,
getServerCartSnapshot,
);
const [payMethod, setPayMethod] = useState('2');
const formRef = useRef<FormCartRef>(null);
@@ -35,28 +42,20 @@ const HomeCart = () => {
}
: item,
);
setCart(newCart);
localStorage.setItem('cart', JSON.stringify(newCart));
writeCartToStorage(newCart);
};
const deleteCartItem = (id: string) => {
const isConfirm = confirm('Bạn có chắc chắn xóa sản phẩm này không ?');
if (isConfirm) {
const newCart = cart.filter((item) => item._id !== id);
setCart(newCart);
localStorage.setItem('cart', JSON.stringify(newCart));
}
if (!window.confirm('Bạn có chắc chắn xóa sản phẩm này không?')) return;
const newCart = cart.filter((item) => item._id !== id);
writeCartToStorage(newCart);
};
const deleteCart = () => {
const isConfirm = confirm('Bạn có chắc chắn xóa sản phẩm này không ?');
if (isConfirm) {
setCart([]);
localStorage.removeItem('cart');
}
if (!window.confirm('Bạn có chắc chắn xóa toàn bộ giỏ hàng không?')) return;
clearCartStorage();
};
// tính tổng tiền
const getTotalPrice = () => {
return formatCurrency(cart.reduce((sum, item) => sum + Number(item.in_cart.total_price), 0));
};
@@ -84,7 +83,7 @@ const HomeCart = () => {
/>
<p>Không sản phẩm nào trong giỏ hàng của bạn.</p>
<Link href="/" className="back-cart">
Tiết tục mua sắm
Tiếp tục mua sắm
</Link>
</div>
) : (
@@ -100,22 +99,20 @@ const HomeCart = () => {
<div className="box-info-cart container-cart">
<div className="box-delete-all flex justify-end">
<button className="delete-cart-all" onClick={() => deleteCart()}>
{' '}
Xóa giỏ hàng{' '}
Xóa giỏ hàng
</button>
</div>
<div className="box-cart-item-list">
{cart.map((item, index) => (
{cart.map((item) => (
<ItemCart
item={item}
key={index}
key={item._id}
onUpdate={updateCartItem}
onDelete={deleteCartItem}
/>
))}
</div>
{/* form mua hàng */}
<FormCart ref={formRef} />
<div className="box-payment">
<p className="title-section-cart font-bold">Phương thức thanh toán</p>
@@ -137,7 +134,7 @@ const HomeCart = () => {
<p className="price-total1 flex items-center justify-between">
<b className="txt">Tổng cộng</b>
<b className="price js-total-before-fee-cart-price" id="total-cart-price">
{getTotalPrice()}
{getTotalPrice()} đ
</b>
</p>
<p className="price-total2 flex items-center justify-between">
@@ -174,7 +171,7 @@ const HomeCart = () => {
target="_blank"
className="print-cart font-bold"
>
in báo giá
In báo giá
</Link>
</div>
</div>

View File

@@ -1,23 +1,21 @@
'use client';
import React, { useState } from 'react';
import { parse } from 'date-fns';
import Link from 'next/link';
import Image from 'next/image';
import CounDown from '@/components/Common/CounDown';
import CountDown from '@/components/Common/CountDown';
import { DealType } from '@/types';
import { formatCurrency } from '@/lib/formatPrice';
type ItemDealProps = {
Item: DealType;
item: DealType;
};
const ItemDeal: React.FC<ItemDealProps> = ({ Item }) => {
const ItemDeal: React.FC<ItemDealProps> = ({ item }) => {
const [now] = useState(() => Date.now());
const deadline = parse(item.to_time, 'dd-MM-yyyy, h:mm a', new Date()).getTime();
// ép kiểu to_time sang số (timestamp) hoặc Date
const deadline = parse(Item.to_time, 'dd-MM-yyyy, h:mm a', new Date()).getTime();
// chỉ hiển thị nếu deadline còn lớn hơn thời gian hiện tại
if (deadline <= now) {
return null;
}
@@ -25,31 +23,31 @@ const ItemDeal: React.FC<ItemDealProps> = ({ Item }) => {
return (
<div className="product-item">
<div className="item-deal">
<Link href={Item.product_info.productUrl} className="product-image position-relative">
<Link href={item.product_info.productUrl} className="product-image position-relative">
<Image
src={Item.product_info.productImage.large}
src={item.product_info.productImage.large}
width={250}
height={250}
alt={Item.product_info.productName}
alt={item.product_info.productName}
/>
</Link>
<div className="product-info flex-1">
<Link href={Item.product_info.productUrl}>
<h3 className="product-title line-clamp-3">{Item.product_info.productName}</h3>
<Link href={item.product_info.productUrl}>
<h3 className="product-title line-clamp-3">{item.product_info.productName}</h3>
</Link>
<div className="product-martket-main flex items-center">
{Item.product_info.marketPrice > 0 && (
{Number(item.product_info.marketPrice) > 0 && (
<>
<p className="product-market-price">
{Item.product_info.marketPrice.toLocaleString()}
{formatCurrency(item.product_info.marketPrice)} đ
</p>
<div className="product-percent-price">-{Item.product_info.price_off || 0}%</div>
<div className="product-percent-price">-{item.product_info.price_off || 0}%</div>
</>
)}
</div>
<div className="product-price-main font-bold">
{Item.product_info.price > '0'
? `${formatCurrency(Item.product_info.price)}đ`
{item.product_info.price > '0'
? `${formatCurrency(item.product_info.price)}đ`
: 'Liên hệ'}
</div>
<div className="p-quantity-sale">
@@ -57,24 +55,20 @@ const ItemDeal: React.FC<ItemDealProps> = ({ Item }) => {
<div className="bg-gradient"></div>
{(() => {
const percentRemaining =
((Number(Item.quantity) - Number(Item.sale_quantity)) / Number(Item.quantity)) *
((Number(item.quantity) - Number(item.sale_quantity)) / Number(item.quantity)) *
100;
return (
<>
<p className="js-line-deal-left" style={{ width: `${percentRemaining}%` }}></p>
</>
);
return <p className="js-line-deal-left" style={{ width: `${percentRemaining}%` }}></p>;
})()}
<span>
Còn {Number(Item.quantity) - Number(Item.sale_quantity)}/{Number(Item.quantity)} sản
Còn {Number(item.quantity) - Number(item.sale_quantity)}/{Number(item.quantity)} sản
phẩm
</span>
</div>
<div className="js-item-deal-time js-item-time-25404">
<div className="time-deal-page flex items-center justify-center gap-2">
<div>Kết thúc sau: </div>
<CounDown deadline={Item.to_time} />
<div>Kết thúc sau:</div>
<CountDown deadline={item.to_time} />
</div>
</div>
<a href="javascript:buyNow(25404)" className="buy-now-deal">

View File

@@ -12,23 +12,30 @@ interface Filters {
current_category?: { url: string };
}
function isFilterUrlActive(pathname: string, currentSearch: string, targetUrl: string) {
const current = new URL(`${pathname}${currentSearch ? `?${currentSearch}` : ''}`, 'http://local');
const target = new URL(targetUrl, 'http://local');
return current.pathname === target.pathname && current.search === target.search;
}
const ActiveFilters: React.FC<{ filters: Filters }> = ({ filters }) => {
const pathname = usePathname();
const searchParams = useSearchParams();
const fullUrl = `${pathname}?${searchParams.toString()}`;
const currentSearch = searchParams.toString();
const selectedPrice = filters.price_filter_list?.filter((f) => fullUrl.includes(f.url)) ?? [];
const selectedBrand = filters.brand_filter_list?.filter((f) => fullUrl.includes(f.url)) ?? [];
const selectedPrice =
filters.price_filter_list?.filter((f) => isFilterUrlActive(pathname, currentSearch, f.url)) ?? [];
const selectedBrand =
filters.brand_filter_list?.filter((f) => isFilterUrlActive(pathname, currentSearch, f.url)) ?? [];
const selectedAttr =
filters.attribute_filter_list?.flatMap((attr) =>
attr.value_list.filter((v) => pathname.includes(v.url)),
attr.value_list.filter((v) => isFilterUrlActive(pathname, currentSearch, v.url)),
) ?? [];
const allSelected = [...selectedPrice, ...selectedBrand, ...selectedAttr];
const isFiltered = allSelected.length;
console.log(isFiltered);
if (isFiltered === 0) return null;
return (

View File

@@ -1,120 +1,144 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { PriceFilter, AttributeFilterList, BrandFilter } from '@/types';
import { usePathname, useSearchParams } from 'next/navigation';
import { FaSortDown } from 'react-icons/fa6';
import { AttributeFilterList, BrandFilter, PriceFilter } from '@/types';
import ActiveFilters from './ActiveFilters';
interface Filters {
price_filter_list?: PriceFilter[];
attribute_filter_list?: AttributeFilterList[];
brand_filter_list?: BrandFilter[];
current_category?: { url: string };
}
interface BoxFilterProps {
filters: Filters;
}
function isFilterUrlActive(pathname: string, currentSearch: string, targetUrl: string) {
const current = new URL(`${pathname}${currentSearch ? `?${currentSearch}` : ''}`, 'http://local');
const target = new URL(targetUrl, 'http://local');
return current.pathname === target.pathname && current.search === target.search;
}
const BoxFilter: React.FC<BoxFilterProps> = ({ filters }) => {
const pathname = usePathname();
const searchParams = useSearchParams();
const currentSearch = searchParams.toString();
const { price_filter_list, attribute_filter_list, brand_filter_list } = filters;
const primaryBrandFilter = brand_filter_list?.[0];
return (
<div className="box-filter-category boder-radius-10">
{/* khoảng giá */}
{price_filter_list && (
<div className="info-filter-category flex gap-10">
<p className="title">Khong giá:</p>
<p className="title">Khoang gia:</p>
<div className="list-filter-category flex flex-1 flex-wrap items-center gap-2">
{price_filter_list.map((ItemPrice, index) => (
<div
key={index}
className={`item item-cetner flex gap-4 ${ItemPrice.is_selected == '1' ? 'current' : ''}`}
>
<Link href={ItemPrice.url}>{ItemPrice.name}</Link>
<a href={ItemPrice.url}>
({ItemPrice.is_selected == '1' ? 'Xóa' : ItemPrice.count})
</a>
</div>
))}
{price_filter_list.map((itemPrice) => {
const isActive = isFilterUrlActive(pathname, currentSearch, itemPrice.url);
return (
<div
key={itemPrice.url}
className={`item item-cetner flex gap-4 ${isActive ? 'current' : ''}`}
>
<Link href={itemPrice.url}>{itemPrice.name}</Link>
<Link href={itemPrice.url}>({isActive ? 'Xoa' : itemPrice.count})</Link>
</div>
);
})}
</div>
</div>
)}
{/* Thương hiệu */}
{brand_filter_list && (
<div className="info-filter-category flex gap-10">
<p className="title">Thương hiu:</p>
<p className="title">Thuong hieu:</p>
<div className="list-filter-category flex flex-1 flex-wrap items-center gap-2">
{brand_filter_list.map((ItemBrand, index) => (
<div
key={index}
className={`item item-cetner flex gap-4 ${ItemBrand.is_selected == '1' ? 'current' : ''}`}
>
<Link href={ItemBrand.url}>{ItemBrand.name}</Link>
<a href={ItemBrand.url}>
({ItemBrand.is_selected == '1' ? 'Xóa' : ItemBrand.count})
</a>
</div>
))}
{brand_filter_list.map((itemBrand) => {
const isActive = isFilterUrlActive(pathname, currentSearch, itemBrand.url);
return (
<div
key={itemBrand.url}
className={`item item-cetner flex gap-4 ${isActive ? 'current' : ''}`}
>
<Link href={itemBrand.url}>{itemBrand.name}</Link>
<Link href={itemBrand.url}>({isActive ? 'Xoa' : itemBrand.count})</Link>
</div>
);
})}
</div>
</div>
)}
{/* chọn thiêu tiêu trí */}
{attribute_filter_list && (
<div className="info-filter-category flex gap-10">
<p className="title">Chn theo tiêu chí:</p>
<p className="title">Chon theo tieu chi:</p>
<div className="list-filter-category flex flex-1 flex-wrap items-center gap-3">
{/* thương hiệu */}
{brand_filter_list && (
<div className={`item ${brand_filter_list[0].is_selected === '1' ? 'current' : ''}`}>
{primaryBrandFilter && (
<div
className={`item ${
isFilterUrlActive(pathname, currentSearch, primaryBrandFilter.url) ? 'current' : ''
}`}
>
<div className="flex items-center">
{brand_filter_list[0].is_selected === '1' ? (
<span>{brand_filter_list[0].name}</span>
{isFilterUrlActive(pathname, currentSearch, primaryBrandFilter.url) ? (
<span>{primaryBrandFilter.name}</span>
) : (
<span>Thương hiu</span>
<span>Thuong hieu</span>
)}
<FaSortDown size={16} style={{ marginBottom: 8 }} />
</div>
<ul>
{brand_filter_list.map((item, idx) => (
<li key={idx} className="flex items-center gap-3">
<Link href={item.url}>{item.name}</Link>
<Link href={item.url}>({item.is_selected === '1' ? 'Xóa' : item.count})</Link>
</li>
))}
{brand_filter_list.map((item) => {
const isActive = isFilterUrlActive(pathname, currentSearch, item.url);
return (
<li key={item.url} className="flex items-center gap-3">
<Link href={item.url}>{item.name}</Link>
<Link href={item.url}>({isActive ? 'Xoa' : item.count})</Link>
</li>
);
})}
</ul>
</div>
)}
{/* Attribute filter */}
{attribute_filter_list && attribute_filter_list.length > 0 && (
<>
{attribute_filter_list.map((attr, idx) => (
{attribute_filter_list.length > 0 &&
attribute_filter_list.map((attribute) => {
const selectedValue = attribute.value_list.find((value) =>
isFilterUrlActive(pathname, currentSearch, value.url),
);
return (
<div
key={idx}
className={`item ${attr.value_list[0]?.is_selected === '1' ? 'current' : ''}`}
key={attribute.filter_code}
className={`item ${selectedValue ? 'current' : ''}`}
>
<a href="javascript:void(0)" className="flex items-center">
{attr.value_list[0]?.is_selected === '1' ? (
<span>{attr.value_list[0].name}</span>
) : (
<span>{attr.name}</span>
)}
<button type="button" className="flex items-center">
<span>{selectedValue?.name ?? attribute.name}</span>
<FaSortDown size={16} style={{ marginBottom: 8 }} />
</a>
</button>
<ul>
{attr.value_list.map((val) => (
<li key={val.id} className="flex items-center gap-3">
<Link href={val.url}>{val.name}</Link>
<Link href={val.url}>{val.is_selected === '1' ? 'Xóa' : val.count}</Link>
</li>
))}
{attribute.value_list.map((value) => {
const isActive = isFilterUrlActive(pathname, currentSearch, value.url);
return (
<li key={value.id} className="flex items-center gap-3">
<Link href={value.url}>{value.name}</Link>
<Link href={value.url}>{isActive ? 'Xoa' : value.count}</Link>
</li>
);
})}
</ul>
</div>
))}
</>
)}
);
})}
</div>
</div>
)}
@@ -123,4 +147,5 @@ const BoxFilter: React.FC<BoxFilterProps> = ({ filters }) => {
</div>
);
};
export default BoxFilter;

View File

@@ -1,7 +1,7 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useSearchParams } from 'next/navigation';
import { FaGrip, FaList } from 'react-icons/fa6';
interface SortItem {
@@ -11,11 +11,22 @@ interface SortItem {
interface SortProps {
sort_by_collection: SortItem[];
display_by_collection?: SortItem[];
product_display_type?: 'grid' | 'list';
}
const BoxSort: React.FC<SortProps> = ({ sort_by_collection, product_display_type }) => {
const pathname = usePathname();
const BoxSort: React.FC<SortProps> = ({
sort_by_collection,
display_by_collection,
product_display_type,
}) => {
const searchParams = useSearchParams();
const selectedSortKey = searchParams.get('sort') ?? 'new';
const selectedDisplay = searchParams.get('display') ?? product_display_type ?? 'grid';
const gridUrl = display_by_collection?.find((item) => item.key === 'grid')?.url;
const listUrl =
display_by_collection?.find((item) => item.key === 'list')?.url ??
display_by_collection?.find((item) => item.key === 'detail')?.url;
return (
<div className="box-sort-category flex items-center justify-between">
@@ -53,9 +64,7 @@ const BoxSort: React.FC<SortProps> = ({ sort_by_collection, product_display_type
<Link
key={item.key}
href={item.url}
className={`item flex items-center ${
pathname.includes(item.key) ? 'selected' : ''
}`}
className={`item flex items-center ${selectedSortKey === item.key ? 'selected' : ''}`}
>
{iconClass && <i className={iconClass}></i>}
<span>{label}</span>
@@ -64,27 +73,20 @@ const BoxSort: React.FC<SortProps> = ({ sort_by_collection, product_display_type
})}
</div>
<div className="sort-bar-select-category flex items-center gap-3">
<a
href="javascript:;"
<Link
href={gridUrl ?? '#'}
className={`item-sort-bar d-flex align-items-center ${
product_display_type === 'grid' ? 'active' : ''
selectedDisplay === 'grid' ? 'active' : ''
}`}
onClick={() => {
window.location.reload();
}}
>
<FaGrip />
</a>
<a
href="javascript:;"
className={`item-sort-bar ${product_display_type === 'list' ? 'active' : ''}`}
onClick={() => {
console.log('Set display to list');
window.location.reload();
}}
</Link>
<Link
href={listUrl ?? '#'}
className={`item-sort-bar ${selectedDisplay !== 'grid' ? 'active' : ''}`}
>
<FaList />
</a>
</Link>
</div>
</div>
);

View File

@@ -1,67 +0,0 @@
'use client';
import React, { useState, useEffect } from 'react';
import { parse } from 'date-fns';
interface CountDownProps {
deadline: number | string;
}
const CounDown: React.FC<CountDownProps> = ({ deadline }) => {
const [days, setDays] = useState(0);
const [hours, setHours] = useState(0);
const [minutes, setMinutes] = useState(0);
const [seconds, setSeconds] = useState(0);
const getTime = () => {
let time: number;
if (typeof deadline == 'string') {
const parsed = parse(deadline as string, 'dd-MM-yyyy, h:mm a', new Date());
time = parsed.getTime() - Date.now();
} else {
time = Number(deadline) * 1000 - Date.now();
}
setDays(Math.floor(time / (1000 * 60 * 60 * 24)));
setHours(Math.floor((time / (1000 * 60 * 60)) % 24));
setMinutes(Math.floor((time / 1000 / 60) % 60));
setSeconds(Math.floor((time / 1000) % 60));
};
useEffect(() => {
const interval = setInterval(() => getTime(), 1000);
return () => clearInterval(interval);
}, []);
return (
<>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<p> {days < 10 ? '0' + days : days} </p> <span>:</span>
</div>
<span className="blocl mt-1 text-sm">Ngày</span>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<p>{hours < 10 ? '0' + hours : hours} </p> <span>:</span>
</div>
<span className="blocl mt-1 text-sm">Giờ</span>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<p>{minutes < 10 ? '0' + minutes : minutes} </p> <span>:</span>
</div>
<span className="blocl mt-1 text-sm">Phút</span>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<p>{seconds < 10 ? '0' + seconds : seconds} </p>
</div>
<span className="blocl mt-1 text-sm">Giây</span>
</div>
</>
);
};
export default CounDown;

View File

@@ -0,0 +1,77 @@
'use client';
import React, { useState, useEffect, useCallback } from 'react';
import { parse } from 'date-fns';
interface CountDownProps {
deadline: number | string;
onExpire?: () => void;
}
const CountDown: React.FC<CountDownProps> = ({ deadline, onExpire }) => {
const [days, setDays] = useState(0);
const [hours, setHours] = useState(0);
const [minutes, setMinutes] = useState(0);
const [seconds, setSeconds] = useState(0);
const getDeadlineMs = useCallback((): number => {
if (typeof deadline === 'string') {
return parse(deadline, 'dd-MM-yyyy, h:mm a', new Date()).getTime();
}
return Number(deadline) * 1000;
}, [deadline]);
useEffect(() => {
const tick = () => {
const time = getDeadlineMs() - Date.now();
if (time <= 0) {
setDays(0);
setHours(0);
setMinutes(0);
setSeconds(0);
onExpire?.();
return;
}
setDays(Math.floor(time / (1000 * 60 * 60 * 24)));
setHours(Math.floor((time / (1000 * 60 * 60)) % 24));
setMinutes(Math.floor((time / 1000 / 60) % 60));
setSeconds(Math.floor((time / 1000) % 60));
};
tick();
const interval = setInterval(tick, 1000);
return () => clearInterval(interval);
}, [getDeadlineMs, onExpire]);
const pad = (n: number) => String(n).padStart(2, '0');
return (
<>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<p>{pad(days)}</p> <span>:</span>
</div>
<span className="mt-1 block text-sm">Ngày</span>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<p>{pad(hours)}</p> <span>:</span>
</div>
<span className="mt-1 block text-sm">Giờ</span>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<p>{pad(minutes)}</p> <span>:</span>
</div>
<span className="mt-1 block text-sm">Phút</span>
</div>
<div className="flex flex-col">
<div className="flex items-center gap-2">
<p>{pad(seconds)}</p>
</div>
<span className="mt-1 block text-sm">Giây</span>
</div>
</>
);
};
export default CountDown;

View File

@@ -0,0 +1,46 @@
'use client';
import React from 'react';
interface State {
hasError: boolean;
error: Error | null;
}
interface Props {
children: React.ReactNode;
fallback?: React.ReactNode;
}
export class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('[ErrorBoundary]', error, info.componentStack);
}
render() {
if (this.state.hasError) {
if (this.props.fallback) return this.props.fallback;
return (
<div className="flex min-h-[200px] flex-col items-center justify-center gap-3 rounded-xl bg-red-50 p-6 text-center">
<p className="font-semibold text-red-600">Đã xảy ra lỗi hiển thị.</p>
<p className="text-sm text-gray-500">{this.state.error?.message}</p>
<button
onClick={() => this.setState({ hasError: false, error: null })}
className="rounded-lg bg-red-100 px-4 py-2 text-sm font-medium text-red-700 hover:bg-red-200"
>
Thử lại
</button>
</div>
);
}
return this.props.children;
}
}

View File

@@ -1,27 +1,29 @@
'use client';
import React from 'react';
import Tippy from '@tippyjs/react';
import 'tippy.js/dist/tippy.css';
import { Product } from '@/types';
import Image from 'next/image';
import Link from 'next/link';
import { formatCurrency } from '@/lib/formatPrice';
import { SanitizedHtml } from '@/components/Common/SanitizedHtml';
type ProductItemProps = {
item: Product;
};
const ItemProduct: React.FC<ProductItemProps> = ({ item }) => {
const offers = item.specialOffer?.all ?? [];
const firstOffer = item.specialOffer?.all?.[0];
return (
<div className="product-item js-p-item">
<a href={item.productUrl} className="product-image relative">
{item.productImage.large ? (
<Image src={item.productImage.large} width="203" height="203" alt={item.productName} />
<Image src={item.productImage.large} width={203} height={203} alt={item.productName} />
) : (
<Image
src="https://nguyencongpc.vn/static/assets/nguyencong_2023/images/not-image.png"
alt={item.productName}
width={203}
height={203}
/>
)}
@@ -38,39 +40,35 @@ const ItemProduct: React.FC<ProductItemProps> = ({ item }) => {
<Link href={item.productUrl}>
<h3 className="product-title line-clamp-3">{item.productName}</h3>
</Link>
{item.marketPrice > 0 ? (
{Number(item.marketPrice) > 0 ? (
<div className="product-martket-main flex items-center">
<p className="product-market-price">
{item.marketPrice.toLocaleString()}
{formatCurrency(item.marketPrice)}
<u>đ</u>
</p>
<div className="product-percent-price">-{Math.round(Number(item.price_off))} %</div>
</div>
) : (
<div className="product-martket-main flex items-center"></div>
<div className="product-martket-main flex items-center" />
)}
<div className="product-price-main font-[600]">
{item.price > '0' ? `${formatCurrency(item.price)}đ` : 'Liên hệ'}
<div className="product-price-main font-semibold">
{Number(item.price) > 0 ? `${formatCurrency(item.price)}đ` : 'Liên hệ'}
</div>
{item.specialOffer?.all?.length ? (
<div
className="product-offer line-clamp-2"
dangerouslySetInnerHTML={{
__html: item.specialOffer!.all![0].title,
}}
/>
{firstOffer ? (
<SanitizedHtml html={firstOffer.title} className="product-offer line-clamp-2" />
) : (
<div className="product-offer line-clamp-2"></div>
<div className="product-offer line-clamp-2" />
)}
{item.extend?.buy_count ? (
<div style={{ height: 18 }}>
{' '}
<b>Đã bán: </b> <span>{item.extend.buy_count}</span>{' '}
<div className="h-4.5">
<b>Đã bán: </b>
<span>{item.extend.buy_count}</span>
</div>
) : (
<div style={{ height: 18, display: 'block' }}> </div>
<div className="h-4.5" />
)}
</div>
</div>

View File

@@ -0,0 +1,34 @@
'use client';
import { useEffect, useState } from 'react';
/**
* Khởi động MSW browser worker trong development.
* Block render cho đến khi worker ready để tránh race condition
* (requests gửi trước khi MSW sẵn sàng sẽ bị passthrough → "Failed to fetch").
*/
const MSWProvider = ({ children }: { children: React.ReactNode }) => {
// Production: render ngay (NODE_ENV !== 'development' → ready = true)
// Development: chờ worker.start() xong rồi mới render
const [ready, setReady] = useState(process.env.NODE_ENV !== 'development');
useEffect(() => {
if (process.env.NODE_ENV !== 'development') return;
import('@/mocks/browser')
.then(({ worker }) =>
worker.start({
onUnhandledRequest: 'bypass',
quiet: true,
}),
)
.then(() => setReady(true))
.catch((err) => {
console.error('[MSW] Failed to start worker:', err);
setReady(true); // vẫn render dù worker fail
});
}, []);
if (!ready) return null;
return <>{children}</>;
};
export default MSWProvider;

View File

@@ -0,0 +1,35 @@
'use client';
import { useMemo } from 'react';
import { normalizeHtmlAssetUrls } from '@/lib/normalizeAssetUrl';
const PURIFY_CONFIG = {
USE_PROFILES: { html: true },
FORBID_TAGS: ['script', 'iframe', 'object', 'embed'],
FORBID_ATTR: ['onerror', 'onload', 'onclick', 'onmouseover'],
};
interface Props {
html: string;
className?: string;
}
/**
* Render HTML an toan.
* Tren server se giu nguyen HTML, tren client se sanitize truoc khi render.
*/
export function SanitizedHtml({ html, className }: Props) {
const sanitized = useMemo(() => {
const normalizedHtml = normalizeHtmlAssetUrls(html);
if (typeof window === 'undefined') {
return normalizedHtml;
}
// eslint-disable-next-line @typescript-eslint/no-require-imports
const DOMPurify = require('dompurify');
return DOMPurify.sanitize(normalizedHtml, PURIFY_CONFIG);
}, [html]);
return <div className={className} dangerouslySetInnerHTML={{ __html: sanitized }} />;
}

View File

@@ -1,163 +0,0 @@
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { TypeListProductDeal } from '@types/TypeListProductDeal';
const formatCurrency = (price: number | string) => {
return Number(price).toLocaleString('vi-VN');
};
const DealProductItem = ({ item }: { item: TypeListProductDeal }) => {
const product = item.product_info;
const quantityLeft = item.quantity - item.sale_quantity;
return (
<div
className="product-item"
data-id={product.id}
data-time={item.deal_time_left}
data-type={product.sale_rules.type}
>
<Link href={product.productUrl || '#'} className="product-image relative">
<Image
src={product.productImage?.large || '/static/assets/nguyencong_2023/images/not-image.png'}
width={164}
height={164}
alt={product.productName}
className="lazy"
unoptimized // Thêm nếu dùng ảnh từ domain bên ngoài chưa config
/>
<span className="p-type-holder">
{product.productType?.isHot === 1 && <i className="p-icon-type p-icon-hot"></i>}
{product.productType?.isNew === 1 && <i className="p-icon-type p-icon-new"></i>}
</span>
</Link>
<div className="product-info">
<Link href={product.productUrl || '#'}>
<h3 className="product-title line-clamp-3">{product.productName}</h3>
</Link>
<div className="product-martket-main flex items-center">
{product.marketPrice > 0 ? (
<>
<p className="product-market-price">{product.marketPrice.toLocaleString()} </p>
<div className="product-percent-price">-{parseInt(product.price_off || '0')}%</div>
</>
) : product.sale_rules?.type === 'deal' ? (
<>
<p className="product-market-price">
{product.sale_rules.normal_price.toLocaleString()}
</p>
<div className="product-percent-price">0%</div>
</>
) : null}
</div>
<div className="product-price-main font-semibold">
{Number(item.price) > 0 ? `${formatCurrency(item.price)}đ` : 'Liên hệ'}
</div>
<div
className="p-quantity-sale"
data-quantity-left={quantityLeft}
data-quantity-sale-total={item.quantity}
>
<i className="sprite sprite-fire-deal"></i>
<div className="bg-gradient"></div>
<p className="js-line-deal-left"></p>
<span>
Còn {quantityLeft}/ {item.quantity} sản phẩm
</span>
</div>
{product.specialOffer?.all?.length > 0 ? (
<div
className="product-offer line-clamp-2"
dangerouslySetInnerHTML={{ __html: product.specialOffer.all[0].title }}
/>
) : (
<div className="product-offer line-clamp-2"></div>
)}
</div>
{/* TOOLTIP */}
<div className="tooltip p-tooltip tippy-box">
<div className="tooltip-name">{product.productName}</div>
<div className="tooltip-descreption">
<div className="tooltip-descreption-price">
{product.marketPrice > 0 ? (
<p>Giá niêm yết</p>
) : (
product.sale_rules?.type === 'deal' && <p>Giá gốc</p>
)}
<p>Giá bán</p>
{product.warranty !== '' && <p>Bảo hành</p>}
<p>Tình trạng</p>
</div>
<div className="tooltip-descreption-info">
{product.marketPrice > 0 ? (
<div className="d-flex align-items-center">
<p className="card-price-origin color-black" style={{ position: 'relative' }}>
{product.marketPrice.toLocaleString()}
<span className="card-price-origin-line-through"></span>
</p>
<span className="color-red" style={{ marginLeft: '4px' }}>
-{product.price_off}%
</span>
</div>
) : product.sale_rules?.type === 'deal' ? (
<div className="d-flex align-items-center">
<p className="card-price-origin color-black" style={{ position: 'relative' }}>
{product.sale_rules.normal_price.toLocaleString()}
<span className="card-price-origin-line-through"></span>
</p>
<span className="color-red" style={{ marginLeft: '4px' }}>
-
{Math.floor(
100 -
(Number(product.sale_rules.price) / product.sale_rules.normal_price) * 100,
)}
%
</span>
</div>
) : null}
<p>{Number(product.price) > 0 ? `${formatCurrency(product.price)}đ` : 'Liên hệ'}</p>
<p className="color-primary">{product.warranty}</p>
<p className="color-secondary">{quantityLeft > 0 ? 'Còn DEAL' : 'Hết DEAL'}</p>
</div>
</div>
{product.productSummary && (
<>
<div className="tooltip-input">
<i className="fa-solid fa-database icon-database"></i>
<span>Thông số sản phẩm</span>
</div>
<div className="tooltip-list">
<span dangerouslySetInnerHTML={{ __html: product.productSummary }} />
</div>
</>
)}
{product.specialOffer?.all?.length > 0 && (
<div className="box-tooltip-gift">
<div className="tooltip-input tooltip-gift">
<p className="icon-gift">
<i className="fa-solid fa-gift"></i> Khuyến mãi
</p>
</div>
<div className="tooltip-list tooltip-list-gift">
<ul dangerouslySetInnerHTML={{ __html: product.specialOffer.all[0].title }} />
</div>
</div>
)}
</div>
</div>
);
};
export default DealProductItem;

View File

@@ -1,8 +1,8 @@
'use client';
import Link from 'next/link';
import Image from 'next/image';
import { FaFacebookF, FaYoutube, FaAngleUp } from 'react-icons/fa';
import { FaFacebookMessenger } from 'react-icons/fa';
import { SiZalo } from 'react-icons/si';
const IconFixRight: React.FC = () => {
return (
@@ -25,14 +25,15 @@ const IconFixRight: React.FC = () => {
<FaYoutube size={22} />
</Link>
<Link
href="javascript:window.scrollTo({ top: 0, behavior: 'smooth' });"
<button
type="button"
className="scroll-top-btn items-center justify-center"
title="Di chuyển lên đầu trang!"
style={{ display: 'none' }}
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
>
<FaAngleUp size={20} />
</Link>
</button>
<Link
href="https://m.me/nguyencongpc.vn"
@@ -41,8 +42,8 @@ const IconFixRight: React.FC = () => {
>
<Image
src="https://nguyencongpc.vn/static/assets/nguyencong_2023/images/facebook_messenger.png"
width="40"
height="40"
width={40}
height={40}
alt="mes"
className="lazy"
/>
@@ -69,8 +70,8 @@ const IconFixRight: React.FC = () => {
>
<Image
src="https://nguyencongpc.vn/media/lib/24-01-2024/zalo.png"
width="40"
height="40"
width={40}
height={40}
alt="mes"
className="lazy"
style={{ marginRight: '10px' }}
@@ -83,4 +84,5 @@ const IconFixRight: React.FC = () => {
</div>
);
};
export default IconFixRight;

View File

@@ -1,11 +1,11 @@
import Link from 'next/link';
import Image from 'next/image';
import IconFixRight from './IconFixRight';
const Footer: React.FC = () => {
return (
<>
<footer className="footer-main">
{/* Chính sách */}
<div className="footer-policy">
<div className="container flex items-center justify-between gap-12">
<div className="item flex items-center justify-center">
@@ -18,32 +18,31 @@ const Footer: React.FC = () => {
<div className="item flex items-center justify-center">
<i className="sprite sprite-doitra-footer"></i>
<p className="text box-title-policy m-0">
<b className="block">đi trả dễ dàng</b>
<b className="block">Đi trả dễ dàng</b>
<span className="grey block">1 đi 1 trong 15 ngày</span>
</p>
</div>
<div className="item flex items-center justify-center">
<i className="sprite sprite-thanhtoan-footer"></i>
<p className="text box-title-policy m-0">
<b className="block">thanh toán tiện lợi</b>
<b className="block">Thanh toán tiện lợi</b>
<span className="grey block">tiền mặt, CK, trả góp 0%</span>
</p>
</div>
<div className="item flex items-center justify-center">
<i className="sprite sprite-hotro-footer"></i>
<p className="text box-title-policy m-0">
<b className="block">h trợ nhiệt tình</b>
<b className="block">H trợ nhiệt tình</b>
<span className="grey block"> vấn, giải đáp mọi thắc mắc</span>
</p>
</div>
</div>
</div>
{/* Box info */}
<div className="box-info-main">
<div className="justify-content-between footer-list-info-main container flex">
<div className="item-info-main">
<p className="title font-weight-700">Giới thiệu nguyễn công</p>
<p className="title font-weight-700">Giới thiệu Nguyễn Công</p>
<Link href="https://nguyencongpc.vn/pages/profile.html" className="text">
Giới thiệu công ty
</Link>
@@ -75,12 +74,12 @@ const Footer: React.FC = () => {
>
<i className="sprite sprite-youtube-fotoer"></i>
</Link>
<a href="javascript:;" className="item-social" aria-label="Instagram">
<Link href="#" className="item-social" aria-label="Instagram">
<i className="sprite sprite-instagram-footer"></i>
</a>
<a href="javascript:;" className="item-social" aria-label="Tiktok">
</Link>
<Link href="#" className="item-social" aria-label="Tiktok">
<i className="sprite sprite-tiktok-footer"></i>
</a>
</Link>
</div>
<div className="bct-footer flex gap-3">
<Link
@@ -90,10 +89,10 @@ const Footer: React.FC = () => {
>
<Image
src="https://nguyencongpc.vn/static/assets/nguyencong_2023/images/footer-bct.png"
alt="bộ công thương"
alt="Bộ công thương"
className="lazy"
width={132}
height="1"
height={40}
/>
</Link>
@@ -103,7 +102,7 @@ const Footer: React.FC = () => {
target="_blank"
rel="nofollow"
>
<img
<Image
src="https://www.dmca.com/img/dmca-compliant-grayscale.png"
alt="DMCA compliant"
width={115}
@@ -128,7 +127,7 @@ const Footer: React.FC = () => {
Gửi yêu cầu bảo hành
</Link>
<Link href="/lien-he" className="text">
Góp ý, Khiếu Nại
Góp ý, Khiếu nại
</Link>
</div>
@@ -174,7 +173,6 @@ const Footer: React.FC = () => {
</div>
</div>
{/* Footer bottom */}
<div className="footer-bottom">
<div className="container">
<div className="copyright">
@@ -184,12 +182,12 @@ const Footer: React.FC = () => {
<p> số thuế: 0107568451 do Sở Kế Hoạch Đu TP. Nội (17/09/2016)</p>
<p>
Mua hàng: <Link href="tel:0866666166">089.9999.191</Link> -{' '}
<a href="tel:0812666665">0812.666.665</a>
<Link href="tel:0812666665">0812.666.665</Link>
</p>
<p className="list-contact-footer flex items-center">
<span>
GÓP Ý : <Link href="tel:0979999191">097.9999.191</Link> -{' '}
<a href="tel:0983333388">098.33333.88</a>.
GÓP Ý: <Link href="tel:0979999191">097.9999.191</Link> -{' '}
<Link href="tel:0983333388">098.33333.88</Link>.
</span>
<span>
Email: <Link href="mailto:info@nguyencongpc.vn">info@nguyencongpc.vn</Link>.
@@ -199,9 +197,9 @@ const Footer: React.FC = () => {
</span>
<span>
Fanpage:{' '}
<a href="https://www.facebook.com/MAY.TINH.NGUYEN.CONG">
<Link href="https://www.facebook.com/MAY.TINH.NGUYEN.CONG">
facebook.com/MAY.TINH.NGUYEN.CONG
</a>
</Link>
.
</span>
</p>

View File

@@ -1,5 +1,6 @@
'use client';
import React, { useState } from 'react';
import React from 'react';
import { FaBars } from 'react-icons/fa';
import { menuData } from '../menuData';
import Image from 'next/image';
@@ -30,7 +31,6 @@ const HeaderBottom: React.FC = () => {
<span className="cat-title line-clamp-1">{item.title}</span>
</Link>
{/* Cấp 2 & Cấp 3 */}
{item.children && item.children.length > 0 && (
<div className="sub-menu-list">
{item.children.map((_children2) => (
@@ -38,7 +38,6 @@ const HeaderBottom: React.FC = () => {
<Link href={_children2.url} className="cat-2">
{_children2.title}
</Link>
{/* Cấp 3 */}
{_children2.children && _children2.children.length > 0 && (
<>
{_children2.children.map((_children3) => (

View File

@@ -1,56 +1,39 @@
'use client';
import React, { useState, useEffect } from 'react';
import React, { useEffect, useState, useSyncExternalStore } from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { FaMapMarkerAlt, FaBars } from 'react-icons/fa';
import BoxShowroom from '@/components/Common/BoxShowroom';
import BoxHotLine from '../../BoxHotline';
import { TypeCartItem } from '@/types/cart';
import { formatCurrency } from '@/lib/formatPrice';
import {
getServerCartSnapshot,
readCartFromStorage,
subscribeCartStorage,
} from '@/lib/cartStorage';
const HeaderMid: React.FC = () => {
const [cartCount, setCartCount] = useState(() => {
const storedCart = localStorage.getItem('cart');
return storedCart ? JSON.parse(storedCart).length : 0;
});
const [cart, setCart] = useState<TypeCartItem[]>(() => {
const storedCart = localStorage.getItem('cart');
return storedCart ? JSON.parse(storedCart) : [];
});
const [cartQuantity, setCartQuantity] = useState(() => {
return cart.reduce((sum: number, item) => sum + Number(item.in_cart.quantity), 0);
});
const [cartTotal, setCartTotal] = useState(() => {
return cart.reduce((sum: number, item) => sum + Number(item.in_cart.total_price), 0);
});
const cart = useSyncExternalStore(
subscribeCartStorage,
readCartFromStorage,
getServerCartSnapshot,
);
const [isFixed, setIsFixed] = useState(false);
useEffect(() => {
const handleScroll = () => {
const distanceFromTop = window.scrollY;
if (distanceFromTop > 680) {
setIsFixed(true);
} else {
setIsFixed(false);
}
};
const handleScroll = () => setIsFixed(window.scrollY > 680);
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const PopupAddress = () => {
const modal = document.getElementById('boxShowroom') as HTMLDialogElement;
modal?.showModal();
};
const cartCount = cart.length;
const cartQuantity = cart.reduce((sum, item) => sum + Number(item.in_cart.quantity), 0);
const cartTotal = cart.reduce((sum, item) => sum + Number(item.in_cart.total_price), 0);
const PopupHotLine = () => {
const modal = document.getElementById('boxHotline') as HTMLDialogElement;
modal?.showModal();
const openModal = (id: string) => {
(document.getElementById(id) as HTMLDialogElement)?.showModal();
};
return (
@@ -66,7 +49,10 @@ const HeaderMid: React.FC = () => {
className="logo-header"
/>
</Link>
<button className="icon-showroom flex items-center justify-center" onClick={PopupAddress}>
<button
className="icon-showroom flex items-center justify-center"
onClick={() => openModal('boxShowroom')}
>
<FaMapMarkerAlt size={16} />
</button>
</div>
@@ -74,7 +60,7 @@ const HeaderMid: React.FC = () => {
<div className="header-menu-category">
<div className="box-title flex items-center justify-center gap-8">
<FaBars size={16} />
<p className="title-menu font-weight-500">Danh mục sản phẩm</p>
<p className="title-menu font-medium">Danh mục sản phẩm</p>
</div>
<div className="cau-noi"></div>
</div>
@@ -109,24 +95,24 @@ const HeaderMid: React.FC = () => {
<p className="icon-item-tab flex items-center justify-center">
<i className="sprite sprite-buildpc-header"></i>
</p>
<span className="font-500">Xây dựng cấu hình</span>
<span className="font-medium">Xây dựng cấu hình</span>
</Link>
<button
onClick={PopupHotLine}
onClick={() => openModal('boxHotline')}
className="item-tab-header flex flex-col items-center gap-4"
>
<p className="icon-item-tab flex items-center justify-center">
<i className="sprite sprite-lienhe-header"></i>
</p>
<span className="font-500">Khách hàng liên hệ</span>
<span className="font-medium">Khách hàng liên hệ</span>
</button>
<Link href="/tin-tuc" className="item-tab-header flex flex-col items-center gap-4">
<p className="icon-item-tab flex items-center justify-center">
<i className="sprite sprite-article-header"></i>
</p>
<span className="font-weight-500">Tin tức công nghệ</span>
<span className="font-medium">Tin tức công nghệ</span>
</Link>
<div id="js-header-cart" className="relative">
@@ -135,15 +121,15 @@ const HeaderMid: React.FC = () => {
<i className="sprite sprite-cart-header"></i>
<u className="cart-count header-features-cart-amount">{cartCount}</u>
</p>
<span className="font-weight-500">Giỏ hàng</span>
<span className="font-medium">Giỏ hàng</span>
</Link>
<div className="cau-noi"></div>
<div className="cart-ttip" id="js-cart-tooltip">
<div className="cart-ttip-item-container">
{cart.map((item, index) => (
{cart.map((item) => (
<div
className="compare-item js-compare-item flex items-center gap-2"
key={index}
key={item._id}
>
<Link className="img-compare" href={item.item_info.productUrl}>
<Image
@@ -164,29 +150,24 @@ const HeaderMid: React.FC = () => {
<b>x {item.in_cart.quantity}</b>
<b className="price-compare">
{item.in_cart.price == '0'
? 'Liên Hệ'
? 'Liên hệ'
: `${formatCurrency(item.in_cart.total_price)} đ`}
</b>
</div>
</div>
</div>
))}
{/* end item */}
</div>
<div className="cart-ttip-price flex items-center justify-end gap-2">
<p>Tổng tiền hàng</p>
<p id="js-header-cart-quantity" className="font-[500]">
({cartQuantity} sản phẩm)
</p>
<p id="js-header-cart-total-price" className="font-bold">
{formatCurrency(cartTotal)}đ
</p>
<p className="font-medium">({cartQuantity} sản phẩm)</p>
<p className="font-bold">{formatCurrency(cartTotal)}đ</p>
</div>
<Link
href="/cart"
className="cart-ttip-price-button flex items-center justify-center"
>
<p className="font-bold">THANH TOÁN NGAY </p>
<p className="font-bold">THANH TOÁN NGAY</p>
</Link>
</div>
</div>
@@ -198,7 +179,7 @@ const HeaderMid: React.FC = () => {
<p className="icon-item-tab flex items-center justify-center">
<i className="sprite sprite-account-header"></i>
</p>
<span className="font-[500]">Tài khoản</span>
<span className="font-medium">Tài khoản</span>
</Link>
</div>
</div>

View File

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

View File

@@ -1,7 +1,17 @@
import { DataListArticleNews } from '@/data/article/ListArticleNews';
'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
@@ -17,9 +27,9 @@ export const ArticleTopRight = () => {
<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">
{DataListArticleNews.slice(0, 6).map((item, index) => (
<li className="item-most-view-article flex items-center gap-2" key={index}>
<span className="number flex items-center justify-center font-[600]"></span>
{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>

View File

@@ -1,39 +1,55 @@
'use client';
import Link from 'next/link';
import Image from 'next/image';
import type { TypeArticleCatePage } from '@/types/article/TypeArticleCatePage';
import { ArticleCateDetailPageData } from '@/data/article/ArticleCateDetailPageData';
import { DataArticleCategory } from '@/data/article/ListCategory';
import { DataListArticleNews } from '@/data/article/ListArticleNews';
import { findCategoryBySlug } from '@/lib/article/category';
import { Breadcrumb } from '@components/Common/Breadcrumb';
import { ErrorLink } from '@components/Common/error';
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; // khai báo prop slug
slug: string;
}
const ArticleCategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
// Ép kiểu dữ liệu từ index.ts về CategoryData[]
const categories = ArticleCateDetailPageData as TypeArticleCatePage[];
const currentCategory = findCategoryBySlug(slug, categories);
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 },
);
const breadcrumbItems = [
{ name: 'Tin tức', url: '/tin-tuc' },
{ name: currentCategory?.category_info.name, url: currentCategory?.category_info.request_path },
];
if (isLoading) {
return <PreLoader />;
}
// Trường hợp không tìm thấy danh mục
if (!currentCategory) {
return <ErrorLink />;
}
// lấy danh sách tin tức
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 (
@@ -43,10 +59,10 @@ const ArticleCategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
</div>
<section className="page-article page-article-category container">
<div className="tabs-category-article flex items-center">
{DataArticleCategory.map((item, index) => (
{categories.map((item, index) => (
<Link
href={item.url}
key={index}
key={`${item.id}-${index}`}
className={`item-tab-article ${currentCategory.title === item.title ? 'active' : ''}`}
>
<h2 className="title-cate-article font-[400]">{item.title}</h2>
@@ -64,8 +80,8 @@ const ArticleCategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
<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, index) => (
<ItemArticle item={item} key={index} />
{articleList.slice(0, 9).map((item) => (
<ItemArticle item={item} key={item.id} />
))}
</div>
<Link
@@ -79,8 +95,8 @@ const ArticleCategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
<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">
{DataListArticleNews.slice(0, 5).map((item, index) => (
<div className="item-article flex gap-4" key={index}>
{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"
@@ -93,7 +109,7 @@ const ArticleCategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
<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="/tuyen-dung-nhan-vien-ky-thuat-1-2" className="title-article">
<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">

View File

@@ -1,41 +1,50 @@
'use client';
import Link from 'next/link';
import Image from 'next/image';
import type { TypeArticleDetailPage } from '@/types/article/TypeArticleDetailPage';
import { ArticleDetailPageData } from '@/data/article/ArticleDetailPageData';
import { DataArticleCategory } from '@/data/article/ListCategory';
import { ErrorLink } from '@components/Common/error';
import { ErrorLink } from '@components/Common/Error';
import { findDetailBySlug } from '@/lib/article/detail';
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; // khai báo prop slug
slug: string;
}
const ArticleDetailPage: React.FC<DetailPageProps> = ({ slug }) => {
// Ép kiểu dữ liệu từ index.ts về CategoryData[]
const details = ArticleDetailPageData as TypeArticleDetailPage[];
const page = findDetailBySlug(slug, details);
const { data: page, isLoading } = useApiData(
() => getArticleDetail(slug),
[slug],
{ initialData: null as TypeArticleDetailPage | null },
);
const { data: categories } = useApiData(
() => getArticleCategories(),
[],
{ initialData: [] as TypeArticleCategory[] },
);
const breadcrumbItems = [
{ name: 'Tin tức', url: '/tin-tuc' },
{ name: page?.article_detail.title, url: page?.article_detail.url },
];
if (isLoading) {
return <PreLoader />;
}
// Trường hợp không tìm thấy danh mục
if (!page) {
return <ErrorLink />;
}
// lấy danh sách tin tức liên quan mới
const ListRelayNew = Object.values(page.article_other_same_category.new);
// lấy danh sách tin tức liên quan cũ
const ListRelayOld = Object.values(page.article_other_same_category.old);
const breadcrumbItems = [
{ name: 'Tin tức', url: '/tin-tuc' },
{ name: page.article_detail.title, url: page.article_detail.url },
];
const combinedList = [...ListRelayNew.slice(0, 6), ...ListRelayOld.slice(0, 6)];
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 (
<>
@@ -44,11 +53,11 @@ const ArticleDetailPage: React.FC<DetailPageProps> = ({ slug }) => {
</div>
<section className="page-article box-article-detail container">
<div className="tabs-category-article flex items-center">
{DataArticleCategory.map((item, index) => (
{categories.map((item, index) => (
<Link
href={item.url}
key={index}
className={`item-tab-article ${page?.article_detail.categoryInfo[0].id === item.id ? 'active' : ''}`}
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>
@@ -62,7 +71,6 @@ const ArticleDetailPage: React.FC<DetailPageProps> = ({ slug }) => {
<span className="author-name">{page.article_detail.author}</span>
<span className="post-time">{page.article_detail.createDate}</span>
</div>
{/* nội dung */}
<TocBox htmlContent={page.article_detail.content} />
</div>
</div>
@@ -74,8 +82,8 @@ const ArticleDetailPage: React.FC<DetailPageProps> = ({ slug }) => {
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, index) => (
<div className="item-article d-flex flex-column gap-12" key={index}>
{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"

View File

@@ -1,16 +1,26 @@
'use client';
import ItemArticle from '@/components/Common/ItemArticle';
import { DataListArticleNews } from '@/data/article/ListArticleNews';
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">
{DataListArticleNews.slice(0, 9).map((item, index) => (
<ItemArticle item={item} key={index} />
{articles.slice(0, 9).map((item) => (
<ItemArticle item={item} key={item.id} />
))}
</div>
<Link
@@ -24,8 +34,8 @@ export const BoxArticleMid = () => {
<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">
{DataListArticleNews.slice(0, 5).map((item, index) => (
<div className="item-article flex gap-4" key={index}>
{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"
@@ -38,7 +48,7 @@ export const BoxArticleMid = () => {
<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="/tuyen-dung-nhan-vien-ky-thuat-1-2" className="title-article">
<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">

View File

@@ -1,10 +1,20 @@
'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 { DataListArticleNews } from '@/data/article/ListArticleNews';
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">
@@ -19,8 +29,8 @@ export const BoxArticleReview = () => {
slidesPerView={3}
loop={true}
>
{DataListArticleNews.map((item, index) => (
<SwiperSlide key={index}>
{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} />

View File

@@ -1,28 +1,35 @@
'use client';
import Link from 'next/link';
import { FaYoutube } from 'react-icons/fa6';
import { DataListArticleVideo } from '@/data/article/ListAricleVideo';
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);
// 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;
@@ -48,8 +55,8 @@ export const BoxVideoArticle = () => {
</div>
<div className="list-video-article flex justify-between gap-2">
<div className="box-left" ref={fancyboxRef}>
{DataListArticleVideo.slice(0, 1).map((item, index) => (
<div className="item-article-video d-flex w-50 gap-10" key={index}>
{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"
@@ -83,8 +90,8 @@ export const BoxVideoArticle = () => {
))}
</div>
<div className="box-right grid grid-cols-2 gap-2">
{DataListArticleVideo.slice(1, 7).map((item, index) => (
<div className="item-article-video flex w-50 gap-2" key={index}>
{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"

View File

@@ -1,17 +1,25 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { Breadcrumb } from '@components/Common/Breadcrumb';
import { DataArticleCategory } from '@/data/article/ListCategory';
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">
@@ -19,8 +27,8 @@ const ArticleHome = () => {
<Breadcrumb items={breadcrumbItems} />
<div className="tabs-category-article flex items-center">
{DataArticleCategory.map((item, index) => (
<Link href={item.url} key={index} className="item-tab-article">
{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>
))}
@@ -33,13 +41,8 @@ const ArticleHome = () => {
<ArticleTopRight />
</div>
{/* box video */}
<BoxVideoArticle />
{/* box mid */}
<BoxArticleMid />
{/* review */}
<BoxArticleReview />
</div>
</section>

View File

@@ -1,8 +1,19 @@
'use client';
import { FaCaretRight } from 'react-icons/fa';
import Link from 'next/link';
import { dataArticle } from './dataArticle';
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">
@@ -15,13 +26,13 @@ const BoxArticleVideo: React.FC = () => {
rel="nofollow"
className="btn-article-group flex items-center gap-2"
>
<span>Xem tất cả </span>
<span>Xem tất cả</span>
<FaCaretRight size={16} />
</Link>
</div>
<div className="list-videos-group list-article-group flex items-center gap-10">
{dataArticle.slice(0, 4).map((item, index) => (
<ItemArticleVideo item={item} key={index} />
{videos.slice(0, 4).map((item) => (
<ItemArticleVideo item={item} key={item.id} />
))}
</div>
</div>

View File

@@ -1,22 +1,33 @@
'use client';
import { FaCaretRight } from 'react-icons/fa';
import { dataArticle } from './dataArticle';
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>
<a href="/tin-cong-nghe" className="btn-article-group flex items-center gap-1">
<Link href="/tin-cong-nghe" className="btn-article-group flex items-center gap-1">
<span>Xem tất cả</span>
<FaCaretRight size={16} />
</a>
</Link>
</div>
<div className="list-article-group flex items-center gap-10">
{dataArticle.slice(0, 4).map((item, index) => (
<ItemArticle item={item} key={index} />
{articles.slice(0, 4).map((item) => (
<ItemArticle item={item} key={item.id} />
))}
</div>
</div>

View File

@@ -1,4 +1,5 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { FaCaretDown } from 'react-icons/fa';
@@ -6,12 +7,16 @@ import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
import ItemProduct from '@/components/Common/ItemProduct';
import { InfoCategory } from '@/types';
import { menuData } from '@/components/Other/Header/menuData';
import { productData } from './productData';
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) => (
@@ -41,9 +46,9 @@ const BoxListCategory: React.FC = () => {
loop={true}
navigation={true}
>
{productData.map((item, index) => (
<SwiperSlide key={index}>
<ItemProduct item={item} />
{products.map((product) => (
<SwiperSlide key={product.id}>
<ItemProduct item={product} />
</SwiperSlide>
))}
</Swiper>

View File

@@ -2,9 +2,9 @@
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { Category } from '@/types/global/Menu';
import type { InfoCategory } from '@/types/global/Menu';
const ItemCategory: React.FC<{ item: Category }> = ({ item }) => {
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">

View File

@@ -1,6 +1,6 @@
'use client';
import React from 'react';
import { menuData } from '../../../../components/Other/Header/menuData';
import { menuData } from '@components/Other/Header/menuData';
import ItemCategory from './ItemCategory';
import { InfoCategory } from '@/types';

View File

@@ -1,16 +1,26 @@
'use client';
import React from 'react';
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 { ListDealData } from '@/data/deal';
import CounDown from '@components/Common/CounDown';
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">
@@ -19,7 +29,7 @@ const BoxProductDeal: React.FC = () => {
<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">
<CounDown deadline={'31-01-2026, 9:30 am'} />
<CountDown deadline={deadline} onExpire={() => setExpired(true)} />
</div>
</div>
<Link href="/deal" className="button-deal color-white mb-10 flex items-center">
@@ -30,13 +40,19 @@ const BoxProductDeal: React.FC = () => {
<Swiper
modules={[Autoplay, Navigation, Pagination]}
spaceBetween={12}
slidesPerView={6}
loop={true}
navigation={true}
breakpoints={{
320: { slidesPerView: 2 },
640: { slidesPerView: 3 },
768: { slidesPerView: 4 },
1024: { slidesPerView: 5 },
1280: { slidesPerView: 6 },
}}
>
{ListDealData.map((Item, index) => (
<SwiperSlide key={index}>
<ProductItem item={Item} />
{deals.map((item) => (
<SwiperSlide key={item.id}>
<ProductItem item={item} />
</SwiperSlide>
))}
</Swiper>

View File

@@ -1,7 +1,6 @@
'use client';
import { Swiper, SwiperSlide } from 'swiper/react';
import Image from 'next/image';
import Link from 'next/link';
interface TypeReview {
avatar: string;
@@ -32,4 +31,5 @@ const ItemReview: React.FC<ItemReviewProps> = ({ item }) => {
</div>
);
};
export default ItemReview;

View File

@@ -1,9 +1,19 @@
'use client';
import { dataReview } from './dataReview';
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">
@@ -17,8 +27,8 @@ const BoxReviewCustomer: React.FC = () => {
loop={true}
pagination={{ clickable: true }}
>
{dataReview.map((item, index) => (
<SwiperSlide key={index} className="item">
{reviews.map((item, index) => (
<SwiperSlide key={`${item.author}-${index}`} className="item">
<ItemReview item={item} />
</SwiperSlide>
))}
@@ -27,4 +37,5 @@ const BoxReviewCustomer: React.FC = () => {
</div>
);
};
export default BoxReviewCustomer;

View File

@@ -1,14 +1,20 @@
'use client';
import React from 'react';
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 { bannerData } from '@/data/banner';
import { getBanners } from '@/lib/api/banner';
import { TemplateBanner } from '@/types';
const SliderHome: React.FC = () => {
// data banner slider
const dataSlider = bannerData[0].homepage;
const [banners, setBanners] = useState<TemplateBanner | null>(null);
useEffect(() => {
getBanners().then(setBanners).catch(console.error);
}, []);
const dataSlider = banners?.homepage;
return (
<>

View File

@@ -1,11 +1,21 @@
'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 { bannerData } from '@/data/banner';
import { getBanners } from '@/lib/api/banner';
import { useApiData } from '@/hooks/useApiData';
import type { TemplateBanner } from '@/types';
const BannerCategory = () => {
const dataSlider = bannerData[0].product_list;
const { data: banners } = useApiData(
() => getBanners(),
[],
{ initialData: null as TemplateBanner | null },
);
const dataSlider = banners?.product_list;
return (
<div className="box-banner-category">

View File

@@ -9,7 +9,7 @@ interface BoxCategoryChildProps {
}
const ItemCategoryChild: React.FC<BoxCategoryChildProps> = ({ item }) => {
const ItemImage = item.big_image
const itemImage = item.big_image
? item.big_image
: item.thumnail
? item.thumnail
@@ -19,7 +19,7 @@ const ItemCategoryChild: React.FC<BoxCategoryChildProps> = ({ item }) => {
<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} />
<Image src={itemImage} width={60} height={60} alt={item.title} />
</div>
<p className="txt font-weight-500">{item.title}</p>
</Link>

View File

@@ -1,35 +1,48 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import type { CategoryData } from '@/types';
import { productCategoryData } from '@/data/product/category';
import { findCategoryBySlug } from '@/lib/product/category';
// box
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; // khai báo prop slug
slug: string;
}
const CategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
// Ép kiểu dữ liệu từ index.ts về CategoryData[]
const categories = productCategoryData as unknown as CategoryData[];
const currentCategory = findCategoryBySlug(slug, categories);
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, url: currentCategory?.current_category.url },
{ name: currentCategory?.current_category.name ?? 'Danh mục', url: slug },
];
// Trường hợp không tìm thấy danh mục
if (!currentCategory) {
return (
<div className="flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 py-50">
@@ -53,24 +66,21 @@ const CategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
<h1 className="text-2xl font-bold text-gray-800">Không tìm thấy danh mục</h1>
<p className="mt-3 text-gray-600">
Đưng dẫn <code className="rounded bg-gray-100 px-2 py-0.5 text-sm">{slug}</code> không
tồn tại hoặc đã bị x.
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ủ
Về trang chủ
</Link>
</div>
</div>
);
}
// lấy sản phẩm
const products = Object.values(currentCategory.product_list);
return (
<div className="page-category">
<div className="container">
@@ -81,26 +91,23 @@ const CategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
<h1 className="name-category font-bold">{currentCategory.current_category.name}</h1>
<div className="box-content-category">
<ul className="category-child boder-radius-10 flex flex-wrap justify-center">
{currentCategory.current_category.children?.map((item, index) => (
<ItemCategoryChild item={item} key={index} />
{currentCategory.current_category.children?.map((item) => (
<ItemCategoryChild item={item} key={item.id} />
))}
</ul>
{/* filter */}
<BoxFilter filters={currentCategory} />
<div className="box-list-product-category boder-radius-10">
{/* filter sort */}
<BoxSort
sort_by_collection={currentCategory.sort_by_collection}
product_display_type="grid"
display_by_collection={currentCategory.display_by_collection}
product_display_type={productDisplayType}
/>
</div>
{/* list product */}
<div className="list-product-category grid grid-cols-5 gap-3">
{products.map((item, index) => (
<ItemProduct key={index} item={item} />
{currentCategory.product_list.map((item) => (
<ItemProduct key={item.id} item={item} />
))}
</div>

View File

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

View File

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

View File

@@ -1,20 +1,32 @@
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import type { ProductDetailData } from '@/types';
import Link from 'next/link';
import { BoxPrice } from './BoxPrice';
import { BoxBought } from './BoxBought';
// thêm giỏ hàng
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');
addToCart(item.product_info.productId);
};
return (
@@ -36,27 +48,28 @@ export const BoxInfoRight = (item: ProductDetailData) => {
<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 */}
{/* 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>
{item.product_info.quantity > '0' && (
{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">
@@ -67,60 +80,61 @@ export const BoxInfoRight = (item: ProductDetailData) => {
{item.product_info.specialOffer.all.map((_item, idx) => (
<div key={idx} className="item-offer">
<i className="icon"></i>
<div dangerouslySetInnerHTML={{ __html: _item.title }} />
<SanitizedHtml html={_item.title} />
</div>
))}
</div>
</div>
)}
{/* mua hàng */}
{(item.product_info.quantity > '0' || item.product_info.price > '0') && (
{/* 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" data-value="-1">
{' '}
{' '}
<p
className="js-quantity-change cursor-pointer select-none"
onClick={() => changeQty(-1)}
>
</p>
<input
type="text"
className="js-buy-quantity js-quantity-change bk-product-qty font-bold"
defaultValue={1}
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" data-value="1">
{' '}
+{' '}
<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={() => {
addToCart(item.product_info.productId);
alert('Sản phẩm đã được thêm vào giỏ hàng!');
}}
onClick={handleAddToCart}
>
<i className="sprite sprite-cart-detail"></i>
<p className="title-cart">Thêm vào giỏ hàng</p>
</button>
<input type="hidden" className="js-buy-quantity-temp" value="1" />
</div>
<div
id="detail-buy-ads"
className="detail-buy grid grid-cols-2 gap-2"
onClick={() => handleBuyNow()}
>
<button className="detail-buy-now col-span-2 cursor-pointer">
{cartMessage && <p className="mt-1 text-sm font-medium text-green-600">{cartMessage}</p>}
<div id="detail-buy-ads" className="detail-buy grid grid-cols-2 gap-2">
<button className="detail-buy-now col-span-2 cursor-pointer" onClick={handleBuyNow}>
<span>ĐT MUA NGAY</span>
Giao hàng tận nơi nhanh chóng
</button>
<button className="detail-add-cart">
<span>TRẢ GÓP QUA HỒ </span>
Chỉ từ 2.665.000/ tháng
</button>
<button className="detail-add-cart">
<span>TRẢ GÓP QUA THẺ</span>
Chỉ từ 1.332.500/ tháng
@@ -128,9 +142,10 @@ export const BoxInfoRight = (item: ProductDetailData) => {
</div>
</>
)}
{/* yên tâm mua hàng */}
{/* yên tâm mua hàng */}
<div className="box-product-policy-detal boder-radius-10" style={{ marginTop: '24px' }}>
<h2 className="title font-[600]">Yên tâm mua hàng</h2>
<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>

View File

@@ -1,4 +1,4 @@
import React, { useState } from 'react';
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { ComboProduct } from '@/types';
@@ -22,7 +22,13 @@ export const ChangeProductPopup: React.FC<ChangePopupProps> = ({
if (!open) return null; // chỉ render khi open = true
return (
<dialog open className="modal">
<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>
@@ -33,8 +39,8 @@ export const ChangeProductPopup: React.FC<ChangePopupProps> = ({
{/* Danh sách sản phẩm */}
<div className="grid grid-cols-4 gap-3">
{products.map((p) => (
<div key={p.id} className="product-item c-pro-item">
{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'}
@@ -49,20 +55,19 @@ export const ChangeProductPopup: React.FC<ChangePopupProps> = ({
<h3 className="product-title line-clamp-2">{p.title}</h3>
</Link>
<div className="product-price-main flex items-center justify-between">
<div className='class="product-price"'>
<b className="price font-[600]">
<div className="product-price">
<b className="price font-semibold">
{Number(p.price) > 0 ? `${formatCurrency(p.price)} đ` : 'Liên hệ'}
</b>
</div>
</div>
<span
<button
type="button"
className="c-btn js-c-btn"
onClick={() => {
onSelect(p);
}}
onClick={() => onSelect(p)}
>
Chọn mua
</span>
</button>
</div>
</div>
))}

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay, Navigation, Pagination, Thumbs } from 'swiper/modules';
import Image from 'next/image';
@@ -8,10 +8,10 @@ import type { Swiper as SwiperType } from 'swiper';
import useFancybox from '@/hooks/useFancybox';
interface ImageProps {
ItemImage: ProductImageGallery[];
images: ProductImageGallery[];
}
export const ImageProduct: React.FC<ImageProps> = ({ ItemImage }) => {
export const ImageProduct: React.FC<ImageProps> = ({ images }) => {
const [thumbsSwiper, setThumbsSwiper] = useState<SwiperType | null>(null);
const [fancyboxRef] = useFancybox({
@@ -29,7 +29,7 @@ export const ImageProduct: React.FC<ImageProps> = ({ ItemImage }) => {
loop={true}
thumbs={{ swiper: thumbsSwiper }}
>
{ItemImage?.map((item, index) => (
{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" />
@@ -46,7 +46,7 @@ export const ImageProduct: React.FC<ImageProps> = ({ ItemImage }) => {
loop={true}
onSwiper={setThumbsSwiper}
>
{ItemImage?.map((item, index) => (
{images?.map((item, index) => (
<SwiperSlide key={index}>
<div className="smallImage">
<Image src={item.size.original} alt={''} width="90" height="60" />

View File

@@ -1,51 +1,70 @@
import React, { useState } from 'react';
import { ListCommentData } from '@/data/ListComment';
import Image from 'next/image';
'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);
export const ListComment = () => {
return (
<div className="comment-list">
{ListCommentData.slice(0.3).map((item, index) => (
<div className="item-comment" id={`comment_${item.id}`} key={index}>
{visibleComments.map((item) => (
<div className="item-comment" id={`comment_${item.id}`} key={item.id}>
<div className="form-reply-comment">
{/* header */}
<div className="comment-name flex justify-between">
<div className="comment-form-left flex items-center gap-2">
{item.user_avatar ? (
<b className="avatar-user">
<img src={item.user_avatar} alt={item.user_name} />
<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)}{' '}
{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>{' '}
<i className="fa-regular fa-clock"></i>
<span>{new Date(Number(item.post_time) * 1000).toLocaleDateString('vi-VN')}</span>
</div>
</div>{' '}
{/* content */}
</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>{' '}
{/* reply list */}
<button className="btn-reply font-medium">Trả lời</button>
</div>
</div>
<div className="reply-list-container mt-4">
{item.new_replies.map((reply) => (
{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">
{' '}
<img src={reply.user_avatar} alt={reply.user_name} />{' '}
<Image
src={reply.user_avatar}
alt={reply.user_name}
width={32}
height={32}
/>
</b>
) : (
<b className="avatar-user flex items-center justify-center">
@@ -55,19 +74,30 @@ export const ListComment = () => {
<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>
<div className="text-sm text-gray-500">
{new Date(Number(reply.post_time) * 1000).toLocaleDateString('vi-VN')}
</div>{' '}
</div>
</div>
<div className="comment-content mt-2 rounded p-2">{reply.content} </div>{' '}
<div className="comment-content mt-2 rounded p-2">{reply.content}</div>
</div>
))}
</div>
</div>
</div>
))}
{!showAll && comments.length > INITIAL_SHOW && (
<button className="btn-more cursor-pointer" onClick={() => setShowAll(true)}>
Xem thêm bình luận
</button>
)}
{showAll && (
<button className="btn-more cursor-pointer" onClick={() => setShowAll(false)}>
Thu gọn
</button>
)}
</div>
);
};

View File

@@ -1,9 +1,16 @@
'use client';
import React, { useState } from 'react';
import { FormComment } from './FormComment';
import { ListComment } from './ListComment';
export const ProductComment: React.FC = () => {
interface ProductCommentProps {
slug: string;
}
export const ProductComment: React.FC<ProductCommentProps> = ({ slug }) => {
const [open, setOpen] = useState(false);
return (
<div className="box-comment">
<p className="title-comment font-[600]">Hỏi đáp</p>
@@ -25,8 +32,7 @@ export const ProductComment: React.FC = () => {
<FormComment open={open} onClose={() => setOpen(false)} />
</div>
{/* list comment */}
<ListComment />
<ListComment slug={slug} />
</div>
);
};

View File

@@ -1,7 +1,7 @@
import { useState } from 'react';
import { FaAngleDown, FaAngleUp } from 'react-icons/fa6';
import type { ProductDetailData } from '@/types';
import Link from 'next/link';
import { SanitizedHtml } from '@/components/Common/SanitizedHtml';
export const ProductDescription = (item: ProductDetailData) => {
const [expanded, setExpanded] = useState(false);
@@ -11,11 +11,11 @@ export const ProductDescription = (item: ProductDetailData) => {
return (
<div className="box-descreption-detail">
<h2 className="titlle-descreption font-[500]">Giới thiệu {item.product_info.productName}</h2>
<div
<SanitizedHtml
html={item.product_info.productDescription}
className={`content-descreption-detail static-html relative ${
expanded ? 'max-h-none' : 'max-h-[467px] overflow-hidden'
}`}
dangerouslySetInnerHTML={{ __html: item.product_info.productDescription }}
/>
<div
onClick={() => setExpanded(!expanded)}

View File

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

View File

@@ -1,10 +1,24 @@
import React, { useState } from 'react';
import { ListReviewData } from '@/data/ListReview';
import Image from 'next/image';
'use client';
export const ListReview = () => {
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 visibleReviews = showAll ? ListReviewData : ListReviewData.slice(0, 3);
const { data: reviews } = useApiData(
() => getProductReviews(slug),
[slug],
{ initialData: [] as ProductReviewData[] },
);
const visibleReviews = showAll ? reviews : reviews.slice(0, 3);
return (
<div className="list-review">
@@ -15,12 +29,16 @@ export const ListReview = () => {
return (
<div key={review.id} className="item-comment">
<div className="form-reply-comment">
{/* header */}
<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} />
<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">
@@ -35,7 +53,6 @@ export const ListReview = () => {
</div>
</div>
{/* content */}
<div className="comment-content boder-radius-10 relative mt-3">
<div className="text-review flex flex-col gap-2">
<p className="flex items-center">
@@ -47,13 +64,11 @@ export const ListReview = () => {
</p>
</div>
{/* feedback actions */}
<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>
{/* images nếu có */}
<div className="jd-img-review flex flex-col gap-2">
{review.files.map((file) => (
<Image
@@ -67,7 +82,6 @@ export const ListReview = () => {
</div>
</div>
{/* reply list */}
<div className="reply-holder reply-list-container">
{review.new_replies.map((reply) => (
<div key={reply.id} className="item_reply relative mt-3">
@@ -75,19 +89,24 @@ export const ListReview = () => {
<div className="comment-left-form item-center flex gap-2">
<b className="avatar-user avatar-admin">
{reply.user_avatar !== '0' ? (
<img src={reply.user_avatar} alt={reply.user_name} />
<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-[500]">QTV</i>}
{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()})
({new Date(Number(reply.post_time) * 1000).toLocaleDateString('vi-VN')})
</span>
</div>
</div>
@@ -100,7 +119,7 @@ export const ListReview = () => {
);
})}
{!showAll && ListReviewData.length > 3 && (
{!showAll && reviews.length > 3 && (
<button
id="first-review"
className="btn-more cursor-pointer"

View File

@@ -1,3 +1,5 @@
'use client';
import React, { useState } from 'react';
import { Review } from '@/types';
import { FaStar } from 'react-icons/fa6';
@@ -5,16 +7,16 @@ import { FormReview } from './FormReview';
import { ListReview } from './ListReview';
interface Props {
ItemReview: Review;
review: Review;
slug: string;
}
export const ProductReview: React.FC<Props> = ({ ItemReview }) => {
export const ProductReview: React.FC<Props> = ({ review, slug }) => {
const [showForm, setShowForm] = useState(false);
const { summary } = ItemReview;
const { summary } = review;
const totalRate = summary.list_rate.reduce((acc, item) => acc + Number(item.total), 0);
// Tạo object chứa số lượng và phần trăm cho từng sao const
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),
@@ -34,12 +36,7 @@ export const ProductReview: React.FC<Props> = ({ ItemReview }) => {
<div className="box-review">
<p className="title-review font-[600]">Bình luận đánh giá</p>
<div className="review-customer-detail">
<form
action="/ajax/post_comment.php"
method="post"
encType="multipart/form-data"
className="form-post"
>
<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>
@@ -77,24 +74,21 @@ export const ProductReview: React.FC<Props> = ({ ItemReview }) => {
className="button-review mx-auto flex cursor-pointer items-center justify-center"
onClick={() => setShowForm(true)}
>
{' '}
Đánh giá ngay{' '}
Đánh giá ngay
</div>
) : (
<div
className="button-review mx-auto flex cursor-pointer items-center justify-center"
onClick={() => setShowForm(false)}
>
{' '}
Đóng lại{' '}
Đóng lại
</div>
)}
{/* form */}
{showForm && <FormReview />}
</form>
</div>
</div>
<ListReview />
<ListReview slug={slug} />
</div>
);
};

View File

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

View File

@@ -2,13 +2,12 @@ import React, { useState } from 'react';
import { FaAngleDown, FaAngleUp } from 'react-icons/fa6';
interface SummaryProps {
ItemSummary: string;
summary: string;
}
export const ProductSummary: React.FC<SummaryProps> = ({ ItemSummary }) => {
const summaryArray = ItemSummary.split('\r\n');
export const ProductSummary: React.FC<SummaryProps> = ({ summary }) => {
const summaryArray = summary.split('\r\n');
const [expanded, setExpanded] = useState(false);
// Nếu chưa expanded thì chỉ hiển thị 3 dòng đầu
const visibleItems = expanded ? summaryArray : summaryArray.slice(0, 3);
return (

View File

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

View File

@@ -1,36 +1,44 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { ErrorLink } from '@/components/Common/error';
import { ErrorLink } from '@/components/Common/Error';
import type { TypeProductHot } from '@/types/producthot';
import { ProductHotPageData } from '@/data/producthot';
import { findProductHotBySlug } from '@/lib/product/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; // khai báo prop slug
slug: string;
}
const ProductHotPage: React.FC<ProductHotPageProps> = ({ slug }) => {
const ProductHot = ProductHotPageData as unknown as TypeProductHot[];
const Pages = findProductHotBySlug(slug, ProductHot);
const { data: page, isLoading } = useApiData(
() => getProductHotPage(slug),
[slug],
{ initialData: null as TypeProductHot | null },
);
const breadcrumbItems = [
{ name: 'Trang chủ', url: '/' },
{ name: Pages?.title, url: Pages?.url },
];
if (isLoading) {
return <PreLoader />;
}
if (!Pages) {
if (!page) {
return <ErrorLink />;
}
// lấy sản phẩm
const products = Object.values(Pages.product_list);
const breadcrumbItems = [
{ name: 'Trang chủ', url: '/' },
{ name: page.title, url: page.url },
];
const products = Object.values(page.product_list);
return (
<>
@@ -40,28 +48,24 @@ const ProductHotPage: React.FC<ProductHotPageProps> = ({ slug }) => {
<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"> {Pages.title} </h1>
<span className="current-cate-total">(Tổng {Pages.product_count} sản phẩm)</span>
<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">
{/* filter */}
<BoxFilter filters={Pages} />
<BoxFilter filters={page} />
<div className="box-list-product-category boder-radius-10">
{/* filter sort */}
<BoxSort sort_by_collection={Pages.sort_by_collection} product_display_type="grid" />
<BoxSort sort_by_collection={page.sort_by_collection} product_display_type="grid" />
</div>
{/* list product */}
<div className="list-product-category grid grid-cols-5 gap-3">
{products.map((item, index) => (
<ItemProduct key={index} item={item} />
{products.map((item) => (
<ItemProduct key={item.id} item={item} />
))}
</div>
<div className="paging flex items-center justify-center">
{Pages.paging_collection.map((item, index) => (
{page.paging_collection.map((item, index) => (
<Link
key={index}
href={item.url}

View File

@@ -1,40 +1,47 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { ErrorLink } from '@/components/Common/error';
import { ErrorLink } from '@/components/Common/Error';
import type { TypeProductSearch } from '@/types/product/search';
import { ProductSearchData } from '@/data/product/search';
import { findSearchBySlug } from '@/lib/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';
interface ProductSearchPageProps {
slug: string; // khai báo prop slug
}
const ProductSearchPage: React.FC<ProductSearchPageProps> = ({ slug }) => {
const ProductSearchPage: React.FC = () => {
const searchParams = useSearchParams();
const keys = searchParams.get('q');
const keyword = searchParams.get('q') ?? '';
const Searchs = ProductSearchData as unknown as TypeProductSearch[];
const Pages = findSearchBySlug(keys, Searchs);
const { data: page, isLoading } = useApiData(
() => getProductSearch(keyword),
[keyword],
{
initialData: null as TypeProductSearch | null,
enabled: keyword.length > 0,
},
);
const breadcrumbItems = [
{ name: 'Trang chủ', url: '/' },
{ name: `Tìm kiếm "${keys}"`, url: `/tim?q=${Pages?.keywords}` },
];
if (isLoading) {
return <PreLoader />;
}
if (!Pages) {
if (!page) {
return <ErrorLink />;
}
// lấy sản phẩm
const products = Object.values(Pages.product_list);
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 (
<>
@@ -43,28 +50,24 @@ const ProductSearchPage: React.FC<ProductSearchPageProps> = ({ slug }) => {
</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 : {Pages.keywords} </h1>
<span className="current-cate-total">(Tổng {Pages.product_count} sản phẩm)</span>
<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>
{Pages.product_list ? (
{products.length > 0 ? (
<div className="box-content-category">
{/* filter */}
<BoxFilter filters={Pages} />
<BoxFilter filters={page} />
<div className="box-list-product-category boder-radius-10">
{/* filter sort */}
<BoxSort sort_by_collection={Pages.sort_by_collection} product_display_type="grid" />
<BoxSort sort_by_collection={page.sort_by_collection} product_display_type="grid" />
</div>
{/* list product */}
<div className="list-product-category grid grid-cols-5 gap-3">
{products.map((item, index) => (
<ItemProduct key={index} item={item} />
{products.map((item) => (
<ItemProduct key={item.id} item={item} />
))}
</div>
<div className="paging flex items-center justify-center">
{Pages.paging_collection.map((item, index) => (
{page.paging_collection.map((item, index) => (
<Link
key={index}
href={item.url}
@@ -78,7 +81,8 @@ const ProductSearchPage: React.FC<ProductSearchPageProps> = ({ slug }) => {
) : (
<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 ${Pages.keywords}
Rất tiếc, chúng tôi không tìm thấy kết quả của{' '}
<span>&quot;{page.keywords}&quot;</span>
</p>
<div
style={{
@@ -101,7 +105,7 @@ const ProductSearchPage: React.FC<ProductSearchPageProps> = ({ slug }) => {
</ul>
</div>
<Link href="/">
<i className="fa fa-long-arrow-alt-left"></i> Quay lại trang chủ{' '}
<i className="fa fa-long-arrow-alt-left"></i> Quay lại trang chủ
</Link>
</div>
)}

68
src/hooks/useApiData.ts Normal file
View File

@@ -0,0 +1,68 @@
'use client';
import { DependencyList, useEffect, useRef, useState } from 'react';
interface UseApiDataOptions<T> {
initialData: T;
enabled?: boolean;
}
interface UseApiDataState<T> {
data: T;
isLoading: boolean;
error: Error | null;
}
export function useApiData<T>(
loader: () => Promise<T>,
deps: DependencyList,
options: UseApiDataOptions<T>,
): UseApiDataState<T> {
const { initialData, enabled = true } = options;
const [data, setData] = useState<T>(initialData);
const [isLoading, setIsLoading] = useState(enabled);
const [error, setError] = useState<Error | null>(null);
const loaderRef = useRef(loader);
useEffect(() => {
loaderRef.current = loader;
}, [loader]);
useEffect(() => {
if (!enabled) {
return;
}
let isMounted = true;
setIsLoading(true);
setError(null);
loaderRef.current()
.then((result) => {
if (!isMounted) return;
setData(result);
})
.catch((err: unknown) => {
if (!isMounted) return;
setError(err instanceof Error ? err : new Error('Unknown API error'));
setData(initialData);
})
.finally(() => {
if (!isMounted) return;
setIsLoading(false);
});
return () => {
isMounted = false;
};
// This hook accepts a caller-provided dependency list by design.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, initialData, ...deps]);
if (!enabled) {
return { data: initialData, isLoading: false, error: null };
}
return { data, isLoading, error };
}

6
src/instrumentation.ts Normal file
View File

@@ -0,0 +1,6 @@
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs' && process.env.NODE_ENV === 'development') {
const { server } = await import('./mocks/server');
server.listen({ onUnhandledRequest: 'bypass' });
}
}

View File

@@ -1,33 +1,54 @@
import { TypeCartItem } from '@/types/cart';
import { readCartFromStorage, writeCartToStorage } from '@/lib/cartStorage';
// data
import { productData } from '@/data/ListProduct';
type CartSourceProduct = {
id: string | number;
productId?: string | number;
priceUnit?: string;
marketPrice?: string | number;
hasVAT?: string | number;
weight?: string | number;
price: string | number;
currency?: string;
bulk_price?: [];
configurable?: string | number;
productName: string;
productImage: {
small: string;
large: string;
original: string;
};
productUrl: string;
brand: unknown;
quantity: string | number;
addon?: [];
warranty: string;
variants?: [];
variant_option?: [];
extend?: unknown;
categories?: unknown[];
specialOffer?: { other?: unknown[]; all?: unknown[] };
specialOfferGroup?: [];
sale_rules?: unknown;
};
export const addToCart = (productId: string | number) => {
// Lấy giỏ hàng hiện tại từ localStorage
const cart: TypeCartItem[] = JSON.parse(localStorage.getItem('cart') || '[]');
export const addToCart = (product: CartSourceProduct, quantity = 1) => {
const cart: TypeCartItem[] = readCartFromStorage();
const productId = product.productId ?? product.id;
console.log('chay tiếp');
const product = productData.find((p) => p.productId == productId);
if (!product) return;
// Kiểm tra sản phẩm đã có trong giỏ chưa
const existing = cart.find((item) => item.item_info.id == productId);
if (existing) {
// Nếu có rồi thì tăng số lượng
existing.in_cart.quantity = (parseInt(existing.in_cart.quantity) + 1).toString();
existing.in_cart.quantity = (parseInt(existing.in_cart.quantity) + quantity).toString();
existing.in_cart.total_price =
Number(existing.in_cart.quantity) * Number(existing.in_cart.price);
} else {
// Nếu chưa có thì thêm mới
const cartItem = {
_id: `product-${product.id}-0`,
item_type: 'product',
item_id: `${product.id}-0`,
item_info: {
id: product.productId,
id: productId,
priceUnit: product.priceUnit,
marketPrice: product.marketPrice,
hasVAT: product.hasVAT,
@@ -39,31 +60,29 @@ export const addToCart = (productId: string | number) => {
productName: product.productName,
productImage: product.productImage,
productUrl: product.productUrl,
brand: product.brand,
productSKU: product.productSKU,
brand: product.brand as TypeCartItem['item_info']['brand'],
quantity: product.quantity,
addon: product.addon,
warranty: product.warranty,
variants: product.variants,
variant_option: product.variant_option,
extend: product.extend,
categories: product.categories,
specialOffer: product.specialOffer,
extend: product.extend as TypeCartItem['item_info']['extend'],
categories: product.categories as TypeCartItem['item_info']['categories'],
specialOffer: product.specialOffer as TypeCartItem['item_info']['specialOffer'],
specialOfferGroup: product.specialOfferGroup,
sale_rules: product.sale_rules,
sale_rules: product.sale_rules as TypeCartItem['item_info']['sale_rules'],
},
in_cart: {
quantity: '1',
quantity: quantity.toString(),
buyer_note: '',
price: product.price,
total_price: Number(product.quantity) * Number(product.price),
total_price: quantity * Number(product.price),
weight: '0',
total_weight: '0',
},
};
} as TypeCartItem;
cart.push(cartItem);
}
// Lưu lại vào localStorage
localStorage.setItem('cart', JSON.stringify(cart));
writeCartToStorage(cart);
};

29
src/lib/api/article.ts Normal file
View File

@@ -0,0 +1,29 @@
import { apiFetch } from './client';
import type { ListArticle } from '@/types/article/TypeListArticle';
import type { TypeArticleCatePage } from '@/types/article/TypeArticleCatePage';
import type { TypeArticleDetailPage } from '@/types/article/TypeArticleDetailPage';
import type { TypeArticleCategory } from '@/types/article/ListCategoryArticle';
function normalizeSlug(slug: string) {
return slug.replace(/^\/+/, '');
}
export function getArticles() {
return apiFetch<ListArticle>('/articles');
}
export function getArticleVideos() {
return apiFetch<ListArticle>('/articles/videos');
}
export function getArticleCategories() {
return apiFetch<TypeArticleCategory[]>('/articles/categories');
}
export function getArticleCategoryDetail(slug: string) {
return apiFetch<TypeArticleCatePage>(`/articles/categories/${normalizeSlug(slug)}`);
}
export function getArticleDetail(slug: string) {
return apiFetch<TypeArticleDetailPage>(`/articles/${normalizeSlug(slug)}`);
}

6
src/lib/api/banner.ts Normal file
View File

@@ -0,0 +1,6 @@
import { apiFetch } from './client';
import { TemplateBanner } from '@/types';
export function getBanners() {
return apiFetch<TemplateBanner>('/banners');
}

10
src/lib/api/buildpc.ts Normal file
View File

@@ -0,0 +1,10 @@
import { apiFetch } from './client';
export interface BuildPcCategory {
id: number;
name: string;
}
export function getBuildPcCategories() {
return apiFetch<BuildPcCategory[]>('/buildpc/categories');
}

0
src/lib/api/category.ts Normal file
View File

43
src/lib/api/client.ts Normal file
View File

@@ -0,0 +1,43 @@
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? '';
const DEFAULT_TIMEOUT_MS = 10_000;
export class ApiError extends Error {
constructor(
public status: number,
message: string,
) {
super(message);
this.name = 'ApiError';
}
}
export async function apiFetch<T>(
path: string,
options?: RequestInit & { timeoutMs?: number },
): Promise<T> {
const { timeoutMs = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options ?? {};
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const res = await fetch(`${API_URL}${path}`, {
...fetchOptions,
cache: 'no-store',
signal: controller.signal,
});
if (!res.ok) {
throw new ApiError(res.status, `API ${res.status}: ${res.statusText}${path}`);
}
return res.json() as Promise<T>;
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new ApiError(408, `Request timeout after ${timeoutMs}ms — ${path}`);
}
throw err;
} finally {
clearTimeout(timeoutId);
}
}

6
src/lib/api/deal.ts Normal file
View File

@@ -0,0 +1,6 @@
import { apiFetch } from './client';
import { TypeListProductDeal } from '@/types';
export function getDeals() {
return apiFetch<TypeListProductDeal>('/deals');
}

6
src/lib/api/home.ts Normal file
View File

@@ -0,0 +1,6 @@
import { apiFetch } from './client';
import type { HomeReviewList } from '@/types';
export function getHomeReviews() {
return apiFetch<HomeReviewList>('/home/reviews');
}

Some files were not shown because too many files have changed in this diff Show More