update
This commit is contained in:
8
.claude/settings.local.json
Normal file
8
.claude/settings.local.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(grep -E \"\\\\.\\(tsx|ts|json\\)$\")",
|
||||
"Bash(npm install:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
13
.vscode/mcp.json
vendored
Normal file
13
.vscode/mcp.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"servers": {
|
||||
"figma": {
|
||||
"url": "https://mcp.figma.com/mcp",
|
||||
"type": "http"
|
||||
},
|
||||
"my-mcp-server-cf2b4222": {
|
||||
"url": "enter",
|
||||
"type": "http"
|
||||
}
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -8,6 +8,11 @@ const nextConfig: NextConfig = {
|
||||
hostname: 'nguyencongpc.vn',
|
||||
pathname: '/**',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'www.dmca.com',
|
||||
pathname: '/**',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
1261
package-lock.json
generated
1261
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
16
package.json
16
package.json
@@ -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
348
public/mockServiceWorker.js
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' }}>
|
||||
|
||||
55
src/app/deal/DealPageClient.tsx
Normal file
55
src/app/deal/DealPageClient.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 khuyễn 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 />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
<MSWProvider>
|
||||
<main>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</main>
|
||||
</MSWProvider>
|
||||
<Footer />
|
||||
</>
|
||||
)}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
5
src/app/not-found.tsx
Normal file
5
src/app/not-found.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import NotFoundPage from '@/features/NotFoundPage';
|
||||
|
||||
export default function NotFound() {
|
||||
return <NotFoundPage />;
|
||||
}
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -4,110 +4,191 @@ 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>
|
||||
<p className="title-section-cart font-semibold">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="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>
|
||||
<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">
|
||||
<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>
|
||||
<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="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="">Hà Nội</option>
|
||||
<option value="hn">Hà Nội</option>
|
||||
</select>
|
||||
<select name="user_info[district]" id="js-district-holder">
|
||||
<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]" id="buyer_note"></textarea>
|
||||
|
||||
<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-[20px]"
|
||||
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"
|
||||
id="txtTaxName"
|
||||
placeholder="Tên công ty"
|
||||
className="form-control"
|
||||
name="user_info[tax_company]"
|
||||
value={fields.taxName}
|
||||
onChange={setField('taxName')}
|
||||
/>
|
||||
</div>
|
||||
</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]"
|
||||
value={fields.taxAddress}
|
||||
onChange={setField('taxAddress')}
|
||||
/>
|
||||
</div>
|
||||
</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]"
|
||||
value={fields.taxCode}
|
||||
onChange={setField('taxCode')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -115,7 +196,6 @@ export const FormCart = forwardRef<FormCartRef, object>((props, ref) => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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) {
|
||||
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);
|
||||
setCart(newCart);
|
||||
localStorage.setItem('cart', JSON.stringify(newCart));
|
||||
}
|
||||
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 có 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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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">Khoảng 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) => (
|
||||
{price_filter_list.map((itemPrice) => {
|
||||
const isActive = isFilterUrlActive(pathname, currentSearch, itemPrice.url);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`item item-cetner flex gap-4 ${ItemPrice.is_selected == '1' ? 'current' : ''}`}
|
||||
key={itemPrice.url}
|
||||
className={`item item-cetner flex gap-4 ${isActive ? 'current' : ''}`}
|
||||
>
|
||||
<Link href={ItemPrice.url}>{ItemPrice.name}</Link>
|
||||
<a href={ItemPrice.url}>
|
||||
({ItemPrice.is_selected == '1' ? 'Xóa' : ItemPrice.count})
|
||||
</a>
|
||||
<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 hiệu:</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) => (
|
||||
{brand_filter_list.map((itemBrand) => {
|
||||
const isActive = isFilterUrlActive(pathname, currentSearch, itemBrand.url);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`item item-cetner flex gap-4 ${ItemBrand.is_selected == '1' ? 'current' : ''}`}
|
||||
key={itemBrand.url}
|
||||
className={`item item-cetner flex gap-4 ${isActive ? 'current' : ''}`}
|
||||
>
|
||||
<Link href={ItemBrand.url}>{ItemBrand.name}</Link>
|
||||
<a href={ItemBrand.url}>
|
||||
({ItemBrand.is_selected == '1' ? 'Xóa' : ItemBrand.count})
|
||||
</a>
|
||||
<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">Chọn 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 hiệu</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">
|
||||
{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}>({item.is_selected === '1' ? 'Xóa' : item.count})</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>
|
||||
{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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
77
src/components/common/CountDown/index.tsx
Normal file
77
src/components/common/CountDown/index.tsx
Normal 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;
|
||||
46
src/components/common/ErrorBoundary.tsx
Normal file
46
src/components/common/ErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
34
src/components/common/MSWProvider.tsx
Normal file
34
src/components/common/MSWProvider.tsx
Normal 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;
|
||||
35
src/components/common/SanitizedHtml/index.tsx
Normal file
35
src/components/common/SanitizedHtml/index.tsx
Normal 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 }} />;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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">tư 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>Mã số thuế: 0107568451 do Sở Kế Hoạch và Đầu Tư TP.Hà 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>
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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>
|
||||
|
||||
29
src/features/Article/ArticleTopLeft/index.tsx
Normal file
29
src/features/Article/ArticleTopLeft/index.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
'use client';
|
||||
|
||||
import ItemArticle from '@/components/Common/ItemArticle';
|
||||
import { getArticles } from '@/lib/api/article';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
import type { ListArticle } from '@/types/article/TypeListArticle';
|
||||
|
||||
export const ArticleTopLeft = () => {
|
||||
const { data: articles } = useApiData(
|
||||
() => getArticles(),
|
||||
[],
|
||||
{ initialData: [] as ListArticle },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex gap-3">
|
||||
<div className="box-left">
|
||||
{articles.slice(0, 1).map((item) => (
|
||||
<ItemArticle item={item} key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
<div className="box-right flex flex-1 flex-col gap-3">
|
||||
{articles.slice(0, 4).map((item) => (
|
||||
<ItemArticle item={item} key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
@@ -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"
|
||||
@@ -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">
|
||||
@@ -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} />
|
||||
@@ -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"
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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">
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 (
|
||||
<>
|
||||
@@ -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">
|
||||
@@ -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>
|
||||
@@ -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ị xoá.
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay } from 'swiper/modules';
|
||||
|
||||
const BOUGHT_DATA = [
|
||||
{ name: 'Anh Tuấn', phone: '036 856 xxxx', time: '2 giờ trước' },
|
||||
{ name: 'Quốc Trung', phone: '035 348 xxxx', time: '1 giờ trước' },
|
||||
{ name: 'Quang Ngọc', phone: '097 478 xxxx', time: '30 phút trước' },
|
||||
{ name: 'Mạnh Lực', phone: '037 204 xxxx', time: '25 phút trước' },
|
||||
{ name: 'Hiếu', phone: '096 859 xxxx', time: '20 phút trước' },
|
||||
];
|
||||
|
||||
export const BoxBought = () => {
|
||||
return (
|
||||
<div className="pro-customer-bought">
|
||||
<svg
|
||||
className="pcb-icon"
|
||||
viewBox="0 0 438.533 438.533"
|
||||
width={16}
|
||||
height={16}
|
||||
fill="red"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g>
|
||||
<path d="M409.133,109.203c-19.608-33.592-46.205-60.189-79.798-79.796C295.736,9.801,259.058,0,219.273,0c-39.781,0-76.47,9.801-110.063,29.407c-33.595,19.604-60.192,46.201-79.8,79.796C9.801,142.8,0,179.489,0,219.267c0,39.78,9.804,76.463,29.407,110.062c19.607,33.592,46.204,60.189,79.799,79.798c33.597,19.605,70.283,29.407,110.063,29.407s76.47-9.802,110.065-29.407c33.593-19.602,60.189-46.206,79.795-79.798c19.603-33.596,29.403-70.284,29.403-110.062C438.533,179.485,428.732,142.795,409.133,109.203z M334.332,232.111L204.71,361.736c-3.617,3.613-7.896,5.428-12.847,5.428c-4.952,0-9.235-1.814-12.85-5.428l-29.121-29.13c-3.617-3.613-5.426-7.898-5.426-12.847c0-4.941,1.809-9.232,5.426-12.847l87.653-87.646l-87.657-87.65c-3.617-3.612-5.426-7.898-5.426-12.845c0-4.949,1.809-9.231,5.426-12.847l29.121-29.13c3.619-3.615,7.898-5.424,12.85-5.424c4.95,0,9.233,1.809,12.85,5.424l129.622,129.621c3.613,3.614,5.42,7.898,5.42,12.847C339.752,224.213,337.945,228.498,334.332,232.111z" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div className="pcb-slider swiper-customer-bought">
|
||||
<Swiper
|
||||
modules={[Autoplay]}
|
||||
spaceBetween={12}
|
||||
slidesPerView={1}
|
||||
loop={true}
|
||||
autoplay={{ delay: 3000, disableOnInteraction: false }}
|
||||
>
|
||||
{BOUGHT_DATA.map((customer, idx) => (
|
||||
<SwiperSlide key={idx}>
|
||||
<div>
|
||||
<p>
|
||||
<b>Khách hàng {customer.name} ({customer.phone})</b>
|
||||
</p>
|
||||
<p>Đã mua hàng {customer.time}</p>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from 'react';
|
||||
import type { ProductDetailData } from '@/types';
|
||||
import CountDown from '@/components/Common/CountDown';
|
||||
import { formatCurrency } from '@/lib/formatPrice';
|
||||
|
||||
export const BoxPrice = (item: ProductDetailData) => {
|
||||
const [now] = useState(() => Date.now());
|
||||
const { sale_rules, deal_list, marketPrice, price } = item.product_info;
|
||||
const isFlashSale = sale_rules.type === 'deal' && Number(sale_rules.to_time) > now;
|
||||
|
||||
const deal = deal_list[0];
|
||||
const remaining = deal ? Number(deal.quantity) - Number(deal.sale_order) : 0;
|
||||
const total = deal ? Number(deal.quantity) : 0;
|
||||
const percentRemaining = total > 0 ? (remaining / total) * 100 : 0;
|
||||
const hasMarketPrice = Number(marketPrice) > 0;
|
||||
const savings = hasMarketPrice ? Number(marketPrice) - Number(price) : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFlashSale && (
|
||||
<div className="box-flash-sale boder-radius-10 flex items-center">
|
||||
<div className="box-left relative flex items-center">
|
||||
<i className="sprite sprite-flashsale-detail"></i>
|
||||
<p className="title-deal font-weight-800">flash sale</p>
|
||||
</div>
|
||||
<div className="box-middle product-time-holder global-time-deal flex gap-2">
|
||||
<CountDown deadline={Number(sale_rules.to_time)} />
|
||||
</div>
|
||||
<div className="box-right">
|
||||
<div className="box-product-deal">
|
||||
<p className="text-deal-detail">
|
||||
Còn {remaining}/{total} sản phẩm
|
||||
</p>
|
||||
<div
|
||||
className="p-quantity-sale"
|
||||
data-quantity-left={remaining}
|
||||
data-quantity-sale-total={total}
|
||||
>
|
||||
<i className="sprite sprite-fire-deal"></i>
|
||||
<div className="bg-gradient"></div>
|
||||
<p className="js-line-deal-left" style={{ width: `${percentRemaining}%` }}></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMarketPrice && isFlashSale && (
|
||||
<div
|
||||
className="box-price-detail boder-radius-10 flex flex-wrap items-center"
|
||||
style={{ rowGap: '8px' }}
|
||||
>
|
||||
<p className="price-detail font-bold">
|
||||
{Number(price) > 0 ? `${formatCurrency(price)}đ` : 'Liên hệ'}
|
||||
</p>
|
||||
<span className="market-price-detail font-weight-500">
|
||||
{formatCurrency(marketPrice)}đ
|
||||
</span>
|
||||
<div className="save-price-detail flex items-center gap-1">
|
||||
<span>Tiết kiệm</span>
|
||||
{formatCurrency(savings)}
|
||||
<span>đ</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 */}
|
||||
<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') && (
|
||||
{(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Ồ SƠ</span>
|
||||
Chỉ từ 2.665.000₫/ tháng
|
||||
</button>
|
||||
|
||||
<button className="detail-add-cart">
|
||||
<span>TRẢ GÓP QUA THẺ</span>
|
||||
Chỉ từ 1.332.500₫/ tháng
|
||||
@@ -128,9 +142,10 @@ export const BoxInfoRight = (item: ProductDetailData) => {
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
@@ -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>
|
||||
))}
|
||||
@@ -0,0 +1,99 @@
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { ComboProduct } from '@/types';
|
||||
import { formatCurrency } from '@/lib/formatPrice';
|
||||
|
||||
const FALLBACK_IMAGE = '/static/assets/nguyencong_2023/images/not-image.png';
|
||||
|
||||
interface ItemComboProps {
|
||||
item: ComboProduct;
|
||||
keyGroup: string;
|
||||
titleGroup: string;
|
||||
setId: string;
|
||||
products: ComboProduct[];
|
||||
checked: boolean;
|
||||
onToggle: (groupKey: string, checked: boolean) => void;
|
||||
onOpenPopup: (groupKey: string, titleGroup: string, products: ComboProduct[]) => void;
|
||||
}
|
||||
|
||||
export const ItemComboSet: React.FC<ItemComboProps> = ({
|
||||
item,
|
||||
keyGroup,
|
||||
titleGroup,
|
||||
setId,
|
||||
products,
|
||||
checked,
|
||||
onToggle,
|
||||
onOpenPopup,
|
||||
}) => {
|
||||
const hasDiscount = Number(item.normal_price) > Number(item.price) && Number(item.price) > 0;
|
||||
|
||||
return (
|
||||
<div className={`product-item c-pro-item js-pro-${item.id} ${item.is_free === 'yes' ? 'w-select' : ''}`}>
|
||||
<Link href={item.url} className="product-image">
|
||||
<Image
|
||||
src={item.images?.large || FALLBACK_IMAGE}
|
||||
alt={item.title}
|
||||
width={175}
|
||||
height={175}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<div className="product-info">
|
||||
<Link href={item.url}>
|
||||
<h3 className="product-title line-clamp-2">{item.title}</h3>
|
||||
</Link>
|
||||
|
||||
<div className="product-price-main flex items-center justify-between">
|
||||
<div className="product-price">
|
||||
<b className="price font-semibold">
|
||||
{Number(item.price) > 0 ? `${formatCurrency(item.price)} đ` : 'Liên hệ'}
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasDiscount ? (
|
||||
<div className="product-martket-main flex flex-wrap items-center gap-4">
|
||||
<p className="product-market-price">{item.normal_price} đ</p>
|
||||
{item.discount.includes('%') ? (
|
||||
<div className="product-percent-price text-[10px]">
|
||||
-{item.discount}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[10px] text-[#BE1F2D]">(-{item.discount} đ)</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="product-martket-main" />
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="c-pro-change js-chagne-pro"
|
||||
data-id={item.id}
|
||||
onClick={() => onOpenPopup(keyGroup, titleGroup, products)}
|
||||
>
|
||||
Chọn {titleGroup} khác
|
||||
</button>
|
||||
|
||||
<div className="check-box-comboset">
|
||||
<input
|
||||
type="checkbox"
|
||||
className={`js-price js-check-select js-combo-set js-combo-set-select-product relative cursor-pointer ${
|
||||
item.is_free === 'yes' ? 'product_free' : ''
|
||||
}`}
|
||||
checked={checked}
|
||||
onChange={(e) => onToggle(keyGroup, e.target.checked)}
|
||||
data-price={item.price}
|
||||
data-unprice={item.normal_price}
|
||||
data-idpk={item.id}
|
||||
data-set-id={setId}
|
||||
data-group-key={keyGroup}
|
||||
data-product-id={item.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
145
src/features/Product/ProductDetail/ComboSet/index.tsx
Normal file
145
src/features/Product/ProductDetail/ComboSet/index.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
||||
import 'swiper/css';
|
||||
import { ComboSet, ComboProduct, ComboGroup } from '@/types';
|
||||
import { ItemComboSet } from './ItemComboset';
|
||||
import { ChangeProductPopup } from './ChangeProductPopup';
|
||||
import { formatCurrency } from '@/lib/formatPrice';
|
||||
|
||||
interface ComboProps {
|
||||
combo_set: ComboSet[];
|
||||
}
|
||||
|
||||
interface PopupState {
|
||||
open: boolean;
|
||||
groupKey: string;
|
||||
title: string;
|
||||
products: ComboProduct[];
|
||||
}
|
||||
|
||||
export const ComboSetBox: React.FC<ComboProps> = ({ combo_set }) => {
|
||||
const [popup, setPopup] = useState<PopupState>({
|
||||
open: false,
|
||||
groupKey: '',
|
||||
title: '',
|
||||
products: [],
|
||||
});
|
||||
const [selectedByGroup, setSelectedByGroup] = useState<Record<string, ComboProduct>>({});
|
||||
const [checkedGroups, setCheckedGroups] = useState<Record<string, boolean>>({});
|
||||
|
||||
const setInfo = combo_set?.[0];
|
||||
|
||||
const getDisplayedProduct = useCallback(
|
||||
(group: ComboGroup): ComboProduct =>
|
||||
selectedByGroup[group.key] ||
|
||||
group.product_list.find((p) => p.is_first === 'yes') ||
|
||||
group.product_list[0],
|
||||
[selectedByGroup],
|
||||
);
|
||||
|
||||
const isGroupChecked = useCallback(
|
||||
(groupKey: string) => checkedGroups[groupKey] ?? true,
|
||||
[checkedGroups],
|
||||
);
|
||||
|
||||
const { totalPrice, totalSavings, checkedCount } = useMemo(() => {
|
||||
if (!setInfo) return { totalPrice: 0, totalSavings: 0, checkedCount: 0 };
|
||||
let total = 0;
|
||||
let savings = 0;
|
||||
let count = 0;
|
||||
for (const group of setInfo.group_list) {
|
||||
if (!isGroupChecked(group.key)) continue;
|
||||
count++;
|
||||
const product = getDisplayedProduct(group);
|
||||
const price = Number(product.price);
|
||||
const normalPrice = Number(product.normal_price);
|
||||
if (price > 0) {
|
||||
total += price;
|
||||
if (normalPrice > price) savings += normalPrice - price;
|
||||
}
|
||||
}
|
||||
return { totalPrice: total, totalSavings: savings, checkedCount: count };
|
||||
}, [setInfo, getDisplayedProduct, isGroupChecked]);
|
||||
|
||||
if (!combo_set || combo_set.length === 0) return null;
|
||||
|
||||
const handleOpenPopup = (groupKey: string, titleGroup: string, products: ComboProduct[]) => {
|
||||
setPopup({ open: true, groupKey, title: titleGroup, products });
|
||||
};
|
||||
|
||||
const handleReplaceProduct = (newProduct: ComboProduct) => {
|
||||
setSelectedByGroup((prev) => ({ ...prev, [popup.groupKey]: newProduct }));
|
||||
setPopup((prev) => ({ ...prev, open: false }));
|
||||
};
|
||||
|
||||
const handleToggleGroup = (groupKey: string, checked: boolean) => {
|
||||
setCheckedGroups((prev) => ({ ...prev, [groupKey]: checked }));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="box-comboset mb-8">
|
||||
<p className="title-comboset font-semibold">Mua theo combo</p>
|
||||
<div id="comboset">
|
||||
<Swiper
|
||||
className="list-product-comboset swiper-comboset"
|
||||
modules={[Autoplay, Navigation, Pagination]}
|
||||
spaceBetween={16}
|
||||
navigation
|
||||
breakpoints={{
|
||||
320: { slidesPerView: 1 },
|
||||
640: { slidesPerView: 2 },
|
||||
1024: { slidesPerView: 3 },
|
||||
}}
|
||||
>
|
||||
{setInfo.group_list.map((group) => (
|
||||
<SwiperSlide key={group.key}>
|
||||
<ItemComboSet
|
||||
item={getDisplayedProduct(group)}
|
||||
keyGroup={group.key}
|
||||
titleGroup={group.title}
|
||||
setId={setInfo.id}
|
||||
products={group.product_list}
|
||||
checked={isGroupChecked(group.key)}
|
||||
onToggle={handleToggleGroup}
|
||||
onOpenPopup={handleOpenPopup}
|
||||
/>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
|
||||
<div className="comboset-info mt-4 flex items-center justify-between rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
|
||||
<div className="box-left flex flex-col gap-0.5">
|
||||
<div className="total-comboset flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">Tạm tính:</span>
|
||||
<span className="js-pass-price text-lg font-bold text-red-600">
|
||||
{formatCurrency(totalPrice)} đ
|
||||
</span>
|
||||
</div>
|
||||
{totalSavings > 0 && (
|
||||
<p className="text-xs text-green-600">
|
||||
Tiết kiệm thêm{' '}
|
||||
<span className="save-price font-semibold">{formatCurrency(totalSavings)} đ</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="box-right">
|
||||
<button
|
||||
className="js-combo-set js-combo-set-checkout buy_combo rounded-lg bg-red-600 px-4 py-2 text-sm font-semibold text-white transition-all hover:bg-red-700 active:scale-95"
|
||||
data-set-id={setInfo.id}
|
||||
>
|
||||
Mua <span>{checkedCount}</span> sản phẩm combo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<ChangeProductPopup
|
||||
titleGroup={popup.title}
|
||||
products={popup.products}
|
||||
open={popup.open}
|
||||
onClose={() => setPopup((prev) => ({ ...prev, open: false }))}
|
||||
onSelect={handleReplaceProduct}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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" />
|
||||
@@ -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 className="comment-content mt-2 rounded p-2">{reply.content} </div>{' '}
|
||||
</div>
|
||||
<div className="comment-content mt-2 rounded p-2">{reply.content}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{!showAll && comments.length > INITIAL_SHOW && (
|
||||
<button className="btn-more cursor-pointer" onClick={() => setShowAll(true)}>
|
||||
Xem thêm bình luận
|
||||
</button>
|
||||
)}
|
||||
{showAll && (
|
||||
<button className="btn-more cursor-pointer" onClick={() => setShowAll(false)}>
|
||||
Thu gọn
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 và đá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>
|
||||
);
|
||||
};
|
||||
@@ -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)}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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"
|
||||
@@ -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 và đá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>
|
||||
<ListReview />
|
||||
</div>
|
||||
<ListReview slug={slug} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
30
src/features/Product/ProductDetail/ProductSpec/index.tsx
Normal file
30
src/features/Product/ProductDetail/ProductSpec/index.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useState } from 'react';
|
||||
import { FaAngleDown, FaAngleUp } from 'react-icons/fa6';
|
||||
import { SanitizedHtml } from '@/components/Common/SanitizedHtml';
|
||||
|
||||
interface Props {
|
||||
spec: string;
|
||||
}
|
||||
|
||||
export const ProductSpec: React.FC<Props> = ({ spec }) => {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
if (!spec) return null;
|
||||
|
||||
return (
|
||||
<div className="box-spec">
|
||||
<h2 className="title font-semibold">Thông số kỹ thuật</h2>
|
||||
<SanitizedHtml
|
||||
html={spec}
|
||||
className={`content-spec relative ${expanded ? '' : 'max-h-100 overflow-hidden'}`}
|
||||
/>
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="btn-article-col font-weight-500 flex items-center justify-center gap-2"
|
||||
>
|
||||
{expanded ? 'Thu gọn' : 'Xem đầy đủ thông số kỹ thuật'}
|
||||
{expanded ? <FaAngleUp /> : <FaAngleDown />}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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 (
|
||||
131
src/features/Product/ProductDetail/index.tsx
Normal file
131
src/features/Product/ProductDetail/index.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
||||
|
||||
import type { ProductDetailData } from '@/types';
|
||||
import type { TypeListProduct } from '@/types/global/TypeListProduct';
|
||||
|
||||
import { ErrorLink } from '@/components/Common/Error';
|
||||
import { Breadcrumb } from '@/components/Common/Breadcrumb';
|
||||
import { ImageProduct } from './ImageProduct';
|
||||
import { ProductSummary } from './ProductSummary';
|
||||
import { ComboSetBox } from './ComboSet';
|
||||
import { BoxInfoRight } from './BoxInfoRight';
|
||||
import ItemProduct from '@/components/Common/ItemProduct';
|
||||
import { ProductDescription } from './ProductDescription';
|
||||
import { ProductSpec } from './ProductSpec';
|
||||
import { ProductReview } from './ProductReview';
|
||||
import { ProductComment } from './ProductComment';
|
||||
import PreLoader from '@/components/Common/PreLoader';
|
||||
import { getProductDetail, getProductHot } from '@/lib/api/product';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
|
||||
interface ProductDetailPageProps {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
const SWIPER_BREAKPOINTS = {
|
||||
320: { slidesPerView: 2 },
|
||||
640: { slidesPerView: 3 },
|
||||
768: { slidesPerView: 4 },
|
||||
1024: { slidesPerView: 5 },
|
||||
};
|
||||
|
||||
const ProductSwiperSection = ({
|
||||
title,
|
||||
items,
|
||||
}: {
|
||||
title: string;
|
||||
items: TypeListProduct;
|
||||
}) => (
|
||||
<div className="box-product-category">
|
||||
<div className="title-box">
|
||||
<h2 className="title title-box font-semibold">{title}</h2>
|
||||
</div>
|
||||
<div className="box-list-history-product">
|
||||
<Swiper
|
||||
modules={[Autoplay, Navigation, Pagination]}
|
||||
spaceBetween={12}
|
||||
loop={true}
|
||||
breakpoints={SWIPER_BREAKPOINTS}
|
||||
>
|
||||
{items.map((item) => (
|
||||
<SwiperSlide key={item.id}>
|
||||
<ItemProduct item={item} />
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const ProductDetailPage: React.FC<ProductDetailPageProps> = ({ slug }) => {
|
||||
const { data: product, isLoading } = useApiData(
|
||||
() => getProductDetail(slug),
|
||||
[slug],
|
||||
{ initialData: null as ProductDetailData | null },
|
||||
);
|
||||
const { data: relatedProducts } = useApiData(
|
||||
() => getProductHot(),
|
||||
[],
|
||||
{ initialData: [] as TypeListProduct },
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <PreLoader />;
|
||||
}
|
||||
|
||||
if (!product) {
|
||||
return <ErrorLink />;
|
||||
}
|
||||
|
||||
const breadcrumbItems = product.product_info.productPath?.[0]?.path.map((item) => ({
|
||||
name: item.name,
|
||||
url: item.url,
|
||||
})) ?? [{ name: 'Trang chủ', url: '/' }];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="container">
|
||||
<Breadcrumb items={breadcrumbItems} />
|
||||
</div>
|
||||
<section className="page-product-detail mt-2 bg-white">
|
||||
<div className="container">
|
||||
<div className="box-content-product-detail flex justify-between gap-5">
|
||||
<div className="box-left">
|
||||
<ImageProduct images={product.product_info.productImageGallery} />
|
||||
<ProductSummary summary={product.product_info.productSummary} />
|
||||
<ComboSetBox combo_set={product.combo_set} />
|
||||
</div>
|
||||
<div className="box-right">
|
||||
<BoxInfoRight {...product} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="box-relative-product box-history-product page-hompage">
|
||||
<ProductSwiperSection title="Sản phẩm tương tự" items={relatedProducts} />
|
||||
</div>
|
||||
|
||||
<div className="box-read-product-detail flex justify-between gap-3">
|
||||
<div className="box-left">
|
||||
<ProductDescription {...product} />
|
||||
<ProductReview review={product.product_info.review} slug={slug} />
|
||||
<ProductComment slug={slug} />
|
||||
</div>
|
||||
<div className="box-right">
|
||||
<ProductSpec spec={product.product_info.productSpec} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="box-history-product page-hompage mt-5">
|
||||
<ProductSwiperSection title="Sản phẩm đã xem" items={relatedProducts} />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetailPage;
|
||||
@@ -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}
|
||||
@@ -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>"{page.keywords}"</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
68
src/hooks/useApiData.ts
Normal 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
6
src/instrumentation.ts
Normal 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' });
|
||||
}
|
||||
}
|
||||
@@ -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
29
src/lib/api/article.ts
Normal 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
6
src/lib/api/banner.ts
Normal 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
10
src/lib/api/buildpc.ts
Normal 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
0
src/lib/api/category.ts
Normal file
43
src/lib/api/client.ts
Normal file
43
src/lib/api/client.ts
Normal 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
6
src/lib/api/deal.ts
Normal 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
6
src/lib/api/home.ts
Normal 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
Reference in New Issue
Block a user