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 { defineConfig, globalIgnores } from 'eslint/config';
|
||||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
import nextVitals from 'eslint-config-next/core-web-vitals';
|
||||||
import nextTs from "eslint-config-next/typescript";
|
import nextTs from 'eslint-config-next/typescript';
|
||||||
|
|
||||||
const eslintConfig = defineConfig([
|
const eslintConfig = defineConfig([
|
||||||
...nextVitals,
|
...nextVitals,
|
||||||
...nextTs,
|
...nextTs,
|
||||||
// Override default ignores of eslint-config-next.
|
globalIgnores(['.next/**', 'out/**', 'build/**', 'next-env.d.ts']),
|
||||||
globalIgnores([
|
|
||||||
// Default ignores of eslint-config-next:
|
|
||||||
".next/**",
|
|
||||||
"out/**",
|
|
||||||
"build/**",
|
|
||||||
"next-env.d.ts",
|
|
||||||
]),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export default eslintConfig;
|
export default eslintConfig;
|
||||||
|
|||||||
@@ -8,6 +8,11 @@ const nextConfig: NextConfig = {
|
|||||||
hostname: 'nguyencongpc.vn',
|
hostname: 'nguyencongpc.vn',
|
||||||
pathname: '/**',
|
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": {
|
"dependencies": {
|
||||||
"@fancyapps/ui": "^6.1.7",
|
"@fancyapps/ui": "^6.1.7",
|
||||||
"@tippyjs/react": "^4.2.6",
|
"@tippyjs/react": "^4.2.6",
|
||||||
|
"@types/dompurify": "^3.0.5",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"dompurify": "^3.3.3",
|
||||||
"framer-motion": "^12.23.26",
|
"framer-motion": "^12.23.26",
|
||||||
"lightgallery": "^2.9.0",
|
"lightgallery": "^2.9.0",
|
||||||
"next": "16.0.10",
|
"next": "^16.1.6",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.1",
|
||||||
@@ -27,12 +29,18 @@
|
|||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"daisyui": "^5.5.14",
|
"daisyui": "^5.5.14",
|
||||||
"eslint": "^9",
|
"eslint": "^9.39.4",
|
||||||
"eslint-config-next": "16.0.10",
|
"eslint-config-next": "^16.1.6",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"msw": "^2.12.7",
|
||||||
"prettier": "^3.7.4",
|
"prettier": "^3.7.4",
|
||||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||||
"tailwindcss": "^4.1.18",
|
"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';
|
'use client';
|
||||||
import { useParams } from 'next/navigation';
|
import { useParams } from 'next/navigation';
|
||||||
import NotFound from '../pages/404';
|
import NotFound from '@/features/NotFoundPage';
|
||||||
import { resolvePageType } from '@/lib/resolvePageType';
|
|
||||||
|
|
||||||
import CategoryPage from '@/app/pages/Product/Category';
|
import CategoryPage from '@/features/Product/Category';
|
||||||
import ProductSearchPage from '@/app/pages/Product/ProductSearch';
|
import ProductSearchPage from '@/features/Product/ProductSearch';
|
||||||
import ProductDetailPage from '@/app/pages/Product/ProductDetail';
|
import ProductDetailPage from '@/features/Product/ProductDetail';
|
||||||
import ProductHotPage from '@/app/pages/Product/ProductHot';
|
import ProductHotPage from '@/features/Product/ProductHot';
|
||||||
import ArticlePage from '@/app/pages/Article/HomeArticlePage';
|
import ArticlePage from '@/features/Article/HomeArticlePage';
|
||||||
import ArticleCategoryPage from '@/app/pages/Article/CategoryPage';
|
import ArticleCategoryPage from '@/features/Article/CategoryPage';
|
||||||
import ArticleDetailPage from '@/app/pages/Article/DetailPage';
|
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() {
|
export default function DynamicPage() {
|
||||||
const { slug } = useParams();
|
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) {
|
switch (pageType) {
|
||||||
case 'category':
|
case 'category':
|
||||||
return <CategoryPage slug={fullSlug} />;
|
return <CategoryPage slug={fullSlug} />;
|
||||||
case 'product-search':
|
case 'product-search':
|
||||||
return <ProductSearchPage slug={fullSlug} />;
|
return <ProductSearchPage />;
|
||||||
case 'product-detail':
|
case 'product-detail':
|
||||||
return <ProductDetailPage slug={fullSlug} />;
|
return <ProductDetailPage slug={fullSlug} />;
|
||||||
case 'product-hot':
|
case 'product-hot':
|
||||||
|
|||||||
@@ -1,11 +1,22 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { category_config } from '@/data/buildpc/category';
|
import { getBuildPcCategories } from '@/lib/api/buildpc';
|
||||||
import { FaPlus } from 'react-icons/fa';
|
import { useApiData } from '@/hooks/useApiData';
|
||||||
|
|
||||||
|
interface BuildPcCategory {
|
||||||
|
id: string | number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
export const BoxListAccessory = () => {
|
export const BoxListAccessory = () => {
|
||||||
|
const { data: categories } = useApiData(() => getBuildPcCategories(), [], {
|
||||||
|
initialData: [] as BuildPcCategory[],
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="list-drive" id="js-buildpc-layout" style={{ border: 'solid 1px #e1e1e1' }}>
|
<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 key={category.id} className="item-drive flex">
|
||||||
<div className="name-item-drive">
|
<div className="name-item-drive">
|
||||||
<h3
|
<h3
|
||||||
|
|||||||
@@ -1,12 +1,21 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||||
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
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 Slider = () => {
|
||||||
const dataSlider = bannerData[0].header;
|
const { data: banners } = useApiData(
|
||||||
|
() => getBanners(),
|
||||||
|
[],
|
||||||
|
{ initialData: null as TemplateBanner | null },
|
||||||
|
);
|
||||||
|
|
||||||
|
const dataSlider = banners?.header;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="banner-buildpc" style={{ marginBottom: '40px' }}>
|
<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 { Metadata } from 'next';
|
||||||
|
import DealPageClient from './DealPageClient';
|
||||||
import { Breadcrumb } from '@components/Common/Breadcrumb';
|
|
||||||
import { bannerData } from '@/data/banner';
|
|
||||||
import { ListDealData } from '@/data/deal';
|
|
||||||
import ItemDeal from '@components/Deal/ItemDeal';
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: 'Danh sách deal',
|
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() {
|
export default function DealPage() {
|
||||||
const breadcrumbItems = [{ name: 'Danh sách deal', url: '/deal' }];
|
return <DealPageClient />;
|
||||||
|
|
||||||
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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
'use client';
|
import type { Metadata } from 'next';
|
||||||
import { useState, useEffect } from 'react';
|
|
||||||
import '@styles/sf-pro-display.css';
|
import '@styles/sf-pro-display.css';
|
||||||
import 'swiper/css';
|
import 'swiper/css';
|
||||||
import 'swiper/css/navigation';
|
import 'swiper/css/navigation';
|
||||||
@@ -7,32 +6,44 @@ import 'swiper/css/pagination';
|
|||||||
import '@styles/globals.css';
|
import '@styles/globals.css';
|
||||||
import Header from '@/components/Other/Header';
|
import Header from '@/components/Other/Header';
|
||||||
import Footer from '@/components/Other/Footer';
|
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({
|
export default function RootLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{
|
}: Readonly<{
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
const [loading, setLoading] = useState<boolean>(true);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setTimeout(() => setLoading(false), 1000);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<html suppressHydrationWarning>
|
<html suppressHydrationWarning>
|
||||||
<body>
|
<body>
|
||||||
{loading ? (
|
|
||||||
<PreLoader />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Header />
|
<Header />
|
||||||
<main>{children}</main>
|
<MSWProvider>
|
||||||
|
<main>
|
||||||
|
<ErrorBoundary>{children}</ErrorBoundary>
|
||||||
|
</main>
|
||||||
|
</MSWProvider>
|
||||||
<Footer />
|
<Footer />
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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 React from 'react';
|
||||||
import Home from '@/app/pages/Home';
|
import Home from '@/features/Home';
|
||||||
import { Metadata } from 'next';
|
import { Metadata } from 'next';
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
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
|
ĐƠN HÀNG ĐÃ ĐƯỢC TIẾP NHẬN
|
||||||
</p>
|
</p>
|
||||||
<div className="send-cart-title-descreption leading-[150%]">
|
<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
|
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
|
||||||
thay đổi thông tin, vui lòng
|
tra đơn hàng hoặc thay đổi thông tin, vui lòng
|
||||||
<Link href="/dang-nhap" className="red-text px-2">
|
<Link href="/dang-nhap" className="red-text px-2">
|
||||||
Đăng nhập
|
Đăng nhập
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -4,110 +4,191 @@ import { useState, forwardRef, useImperativeHandle } from 'react';
|
|||||||
export interface FormCartRef {
|
export interface FormCartRef {
|
||||||
validateForm: () => boolean;
|
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 [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 setField =
|
||||||
const name = (document.getElementById('buyer_name') as HTMLInputElement)?.value.trim();
|
(key: keyof FormFields) =>
|
||||||
const tel = (document.getElementById('buyer_tel') as HTMLInputElement)?.value.trim();
|
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||||
const address = (document.getElementById('buyer_address') as HTMLInputElement)?.value.trim();
|
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 validateForm = (): boolean => {
|
||||||
const regexNoSpecial = /^[\p{L}\p{N}\s]+$/u;
|
const newErrors: FormErrors = {};
|
||||||
// Regex số điện thoại Việt Nam (10 số, bắt đầu bằng 0)
|
const name = fields.name.trim();
|
||||||
const regexPhone = /^0\d{9}$/;
|
const tel = fields.tel.trim();
|
||||||
|
const address = fields.address.trim();
|
||||||
|
|
||||||
// Kiểm tra tên
|
if (!name || name.length <= 4 || !REGEX_NO_SPECIAL.test(name)) {
|
||||||
if (!name || name.length <= 4 || !regexNoSpecial.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)';
|
||||||
alert('Bạn nhập tên chưa đúng định dạng!');
|
}
|
||||||
return false;
|
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
|
setErrors(newErrors);
|
||||||
if (!tel || !regexPhone.test(tel)) {
|
return Object.keys(newErrors).length === 0;
|
||||||
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;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
useImperativeHandle(ref, () => ({ validateForm }));
|
useImperativeHandle(ref, () => ({ validateForm }));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<div className="box-cart-info-customer">
|
<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">
|
<div className="list-info-customer">
|
||||||
<input type="text" placeholder="Họ tên*" name="user_info[name]" id="buyer_name" />
|
<div>
|
||||||
<div className="flex justify-between gap-2">
|
<input
|
||||||
<input type="text" placeholder="Số điện thoại*" name="user_info[tel]" id="buyer_tel" />
|
type="text"
|
||||||
<input type="text" name="user_info[email]" id="buyer_email" placeholder="Email" />
|
placeholder="Họ tên*"
|
||||||
|
name="user_info[name]"
|
||||||
|
value={fields.name}
|
||||||
|
onChange={setField('name')}
|
||||||
|
/>
|
||||||
|
{errors.name && <p className="mt-1 text-xs text-red-500">{errors.name}</p>}
|
||||||
</div>
|
</div>
|
||||||
<input type="text" placeholder="Địa chỉ*" id="buyer_address" name="user_info[address]" />
|
|
||||||
<div className="flex justify-between gap-2">
|
<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="0">Tỉnh/Thành phố</option>
|
||||||
<option value="">Hà Nội</option>
|
<option value="hn">Hà Nội</option>
|
||||||
</select>
|
</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>
|
<option value="0">Quận/Huyện</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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">
|
<div className="form-group-taxt">
|
||||||
<label className="tax-title label flex items-center gap-2">
|
<label className="tax-title label flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="w-[20px]"
|
className="w-5"
|
||||||
checked={showTax}
|
checked={showTax}
|
||||||
onChange={(e) => setShowTax(e.target.checked)}
|
onChange={(e) => setShowTax(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
Yêu cầu xuất hóa đơn công ty
|
Yêu cầu xuất hóa đơn công ty
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{showTax && (
|
{showTax && (
|
||||||
<div className="js-tax-group">
|
<div className="js-tax-group">
|
||||||
<div className="form-group row">
|
<div className="form-group row">
|
||||||
<div className="input-taxt">
|
<div className="input-taxt">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="txtTaxName"
|
|
||||||
placeholder="Tên công ty"
|
placeholder="Tên công ty"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
name="user_info[tax_company]"
|
name="user_info[tax_company]"
|
||||||
|
value={fields.taxName}
|
||||||
|
onChange={setField('taxName')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group row">
|
<div className="form-group row">
|
||||||
<div className="input-taxt">
|
<div className="input-taxt">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="txtTaxAddress"
|
|
||||||
placeholder="Địa chỉ công ty"
|
placeholder="Địa chỉ công ty"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
name="user_info[tax_address]"
|
name="user_info[tax_address]"
|
||||||
|
value={fields.taxAddress}
|
||||||
|
onChange={setField('taxAddress')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group row">
|
<div className="form-group row">
|
||||||
<div className="input-taxt">
|
<div className="input-taxt">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
id="txtTaxCode"
|
|
||||||
placeholder="Mã số thuế"
|
placeholder="Mã số thuế"
|
||||||
className="form-control"
|
className="form-control"
|
||||||
name="user_info[tax_code]"
|
name="user_info[tax_code]"
|
||||||
|
value={fields.taxCode}
|
||||||
|
onChange={setField('taxCode')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -115,7 +196,6 @@ export const FormCart = forwardRef<FormCartRef, object>((props, ref) => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import Link from 'next/link';
|
|||||||
import { TypeCartItem } from '@/types/cart';
|
import { TypeCartItem } from '@/types/cart';
|
||||||
import { FaSortDown, FaTrashCan } from 'react-icons/fa6';
|
import { FaSortDown, FaTrashCan } from 'react-icons/fa6';
|
||||||
import { formatCurrency } from '@/lib/formatPrice';
|
import { formatCurrency } from '@/lib/formatPrice';
|
||||||
|
import { SanitizedHtml } from '@/components/Common/SanitizedHtml';
|
||||||
|
|
||||||
interface PropsCart {
|
interface PropsCart {
|
||||||
item: TypeCartItem;
|
item: TypeCartItem;
|
||||||
@@ -11,11 +12,20 @@ interface PropsCart {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const ItemCart: React.FC<PropsCart> = ({ item, onUpdate, onDelete }) => {
|
export const ItemCart: React.FC<PropsCart> = ({ item, onUpdate, onDelete }) => {
|
||||||
|
const currentQty = parseInt(item.in_cart.quantity) || 1;
|
||||||
|
|
||||||
const handleChangeQuantity = (delta: number) => {
|
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);
|
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 (
|
return (
|
||||||
<div className="cart-item-info js-item-row flex justify-between">
|
<div className="cart-item-info js-item-row flex justify-between">
|
||||||
<div className="cart-item-left flex">
|
<div className="cart-item-left flex">
|
||||||
@@ -57,7 +67,7 @@ export const ItemCart: React.FC<PropsCart> = ({ item, onUpdate, onDelete }) => {
|
|||||||
|
|
||||||
<div className="item-offer-content">
|
<div className="item-offer-content">
|
||||||
{item.item_info.specialOffer.all.map((_item, idx) => (
|
{item.item_info.specialOffer.all.map((_item, idx) => (
|
||||||
<div key={idx} dangerouslySetInnerHTML={{ __html: _item.title }} />
|
<SanitizedHtml key={idx} html={_item.title} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -72,10 +82,11 @@ export const ItemCart: React.FC<PropsCart> = ({ item, onUpdate, onDelete }) => {
|
|||||||
-
|
-
|
||||||
</button>
|
</button>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="number"
|
||||||
className="js-buy-quantity js-quantity-change bk-product-qty font-bold"
|
min={1}
|
||||||
value={item.in_cart.quantity}
|
className="js-buy-quantity bk-product-qty font-bold"
|
||||||
onChange={() => handleChangeQuantity(1)}
|
value={currentQty}
|
||||||
|
onChange={handleInputChange}
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleChangeQuantity(1)}
|
onClick={() => handleChangeQuantity(1)}
|
||||||
|
|||||||
@@ -1,23 +1,30 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState, useRef } from 'react';
|
|
||||||
|
import { useRef, useState, useSyncExternalStore } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { FaChevronLeft } from 'react-icons/fa6';
|
import { FaChevronLeft } from 'react-icons/fa6';
|
||||||
import { Breadcrumb } from '@/components/Common/Breadcrumb';
|
import { Breadcrumb } from '@/components/Common/Breadcrumb';
|
||||||
import { TypeCartItem } from '@/types/cart';
|
|
||||||
import { ItemCart } from './ItemCart';
|
import { ItemCart } from './ItemCart';
|
||||||
import { FormCart, FormCartRef } from './FormCart';
|
import { FormCart, FormCartRef } from './FormCart';
|
||||||
import { formatCurrency } from '@/lib/formatPrice';
|
import { formatCurrency } from '@/lib/formatPrice';
|
||||||
|
import {
|
||||||
|
clearCartStorage,
|
||||||
|
getServerCartSnapshot,
|
||||||
|
readCartFromStorage,
|
||||||
|
subscribeCartStorage,
|
||||||
|
writeCartToStorage,
|
||||||
|
} from '@/lib/cartStorage';
|
||||||
|
|
||||||
const HomeCart = () => {
|
const HomeCart = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const breadcrumbItems = [{ name: 'Giỏ hàng', url: '/cart' }];
|
const breadcrumbItems = [{ name: 'Giỏ hàng', url: '/cart' }];
|
||||||
const [cart, setCart] = useState<TypeCartItem[]>(() => {
|
const cart = useSyncExternalStore(
|
||||||
const storedCart = localStorage.getItem('cart');
|
subscribeCartStorage,
|
||||||
return storedCart ? JSON.parse(storedCart) : [];
|
readCartFromStorage,
|
||||||
});
|
getServerCartSnapshot,
|
||||||
|
);
|
||||||
const [payMethod, setPayMethod] = useState('2');
|
const [payMethod, setPayMethod] = useState('2');
|
||||||
|
|
||||||
const formRef = useRef<FormCartRef>(null);
|
const formRef = useRef<FormCartRef>(null);
|
||||||
@@ -35,28 +42,20 @@ const HomeCart = () => {
|
|||||||
}
|
}
|
||||||
: item,
|
: item,
|
||||||
);
|
);
|
||||||
setCart(newCart);
|
writeCartToStorage(newCart);
|
||||||
localStorage.setItem('cart', JSON.stringify(newCart));
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteCartItem = (id: string) => {
|
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 (!window.confirm('Bạn có chắc chắn xóa sản phẩm này không?')) return;
|
||||||
if (isConfirm) {
|
|
||||||
const newCart = cart.filter((item) => item._id !== id);
|
const newCart = cart.filter((item) => item._id !== id);
|
||||||
setCart(newCart);
|
writeCartToStorage(newCart);
|
||||||
localStorage.setItem('cart', JSON.stringify(newCart));
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteCart = () => {
|
const deleteCart = () => {
|
||||||
const isConfirm = confirm('Bạn có chắc chắn xóa sản phẩm này không ?');
|
if (!window.confirm('Bạn có chắc chắn xóa toàn bộ giỏ hàng không?')) return;
|
||||||
if (isConfirm) {
|
clearCartStorage();
|
||||||
setCart([]);
|
|
||||||
localStorage.removeItem('cart');
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// tính tổng tiền
|
|
||||||
const getTotalPrice = () => {
|
const getTotalPrice = () => {
|
||||||
return formatCurrency(cart.reduce((sum, item) => sum + Number(item.in_cart.total_price), 0));
|
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>
|
<p>Không có sản phẩm nào trong giỏ hàng của bạn.</p>
|
||||||
<Link href="/" className="back-cart">
|
<Link href="/" className="back-cart">
|
||||||
Tiết tục mua sắm
|
Tiếp tục mua sắm
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@@ -100,22 +99,20 @@ const HomeCart = () => {
|
|||||||
<div className="box-info-cart container-cart">
|
<div className="box-info-cart container-cart">
|
||||||
<div className="box-delete-all flex justify-end">
|
<div className="box-delete-all flex justify-end">
|
||||||
<button className="delete-cart-all" onClick={() => deleteCart()}>
|
<button className="delete-cart-all" onClick={() => deleteCart()}>
|
||||||
{' '}
|
Xóa giỏ hàng
|
||||||
Xóa giỏ hàng{' '}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="box-cart-item-list">
|
<div className="box-cart-item-list">
|
||||||
{cart.map((item, index) => (
|
{cart.map((item) => (
|
||||||
<ItemCart
|
<ItemCart
|
||||||
item={item}
|
item={item}
|
||||||
key={index}
|
key={item._id}
|
||||||
onUpdate={updateCartItem}
|
onUpdate={updateCartItem}
|
||||||
onDelete={deleteCartItem}
|
onDelete={deleteCartItem}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* form mua hàng */}
|
|
||||||
<FormCart ref={formRef} />
|
<FormCart ref={formRef} />
|
||||||
<div className="box-payment">
|
<div className="box-payment">
|
||||||
<p className="title-section-cart font-bold">Phương thức thanh toán</p>
|
<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">
|
<p className="price-total1 flex items-center justify-between">
|
||||||
<b className="txt">Tổng cộng</b>
|
<b className="txt">Tổng cộng</b>
|
||||||
<b className="price js-total-before-fee-cart-price" id="total-cart-price">
|
<b className="price js-total-before-fee-cart-price" id="total-cart-price">
|
||||||
{getTotalPrice()} ₫
|
{getTotalPrice()} đ
|
||||||
</b>
|
</b>
|
||||||
</p>
|
</p>
|
||||||
<p className="price-total2 flex items-center justify-between">
|
<p className="price-total2 flex items-center justify-between">
|
||||||
@@ -174,7 +171,7 @@ const HomeCart = () => {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
className="print-cart font-bold"
|
className="print-cart font-bold"
|
||||||
>
|
>
|
||||||
in báo giá
|
In báo giá
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,23 +1,21 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { parse } from 'date-fns';
|
import { parse } from 'date-fns';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import CounDown from '@/components/Common/CounDown';
|
import CountDown from '@/components/Common/CountDown';
|
||||||
import { DealType } from '@/types';
|
import { DealType } from '@/types';
|
||||||
import { formatCurrency } from '@/lib/formatPrice';
|
import { formatCurrency } from '@/lib/formatPrice';
|
||||||
|
|
||||||
type ItemDealProps = {
|
type ItemDealProps = {
|
||||||
Item: DealType;
|
item: DealType;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ItemDeal: React.FC<ItemDealProps> = ({ Item }) => {
|
const ItemDeal: React.FC<ItemDealProps> = ({ item }) => {
|
||||||
const [now] = useState(() => Date.now());
|
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) {
|
if (deadline <= now) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -25,31 +23,31 @@ const ItemDeal: React.FC<ItemDealProps> = ({ Item }) => {
|
|||||||
return (
|
return (
|
||||||
<div className="product-item">
|
<div className="product-item">
|
||||||
<div className="item-deal">
|
<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
|
<Image
|
||||||
src={Item.product_info.productImage.large}
|
src={item.product_info.productImage.large}
|
||||||
width={250}
|
width={250}
|
||||||
height={250}
|
height={250}
|
||||||
alt={Item.product_info.productName}
|
alt={item.product_info.productName}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="product-info flex-1">
|
<div className="product-info flex-1">
|
||||||
<Link href={Item.product_info.productUrl}>
|
<Link href={item.product_info.productUrl}>
|
||||||
<h3 className="product-title line-clamp-3">{Item.product_info.productName}</h3>
|
<h3 className="product-title line-clamp-3">{item.product_info.productName}</h3>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="product-martket-main flex items-center">
|
<div className="product-martket-main flex items-center">
|
||||||
{Item.product_info.marketPrice > 0 && (
|
{Number(item.product_info.marketPrice) > 0 && (
|
||||||
<>
|
<>
|
||||||
<p className="product-market-price">
|
<p className="product-market-price">
|
||||||
{Item.product_info.marketPrice.toLocaleString()} ₫
|
{formatCurrency(item.product_info.marketPrice)} đ
|
||||||
</p>
|
</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>
|
||||||
<div className="product-price-main font-bold">
|
<div className="product-price-main font-bold">
|
||||||
{Item.product_info.price > '0'
|
{item.product_info.price > '0'
|
||||||
? `${formatCurrency(Item.product_info.price)}đ`
|
? `${formatCurrency(item.product_info.price)}đ`
|
||||||
: 'Liên hệ'}
|
: 'Liên hệ'}
|
||||||
</div>
|
</div>
|
||||||
<div className="p-quantity-sale">
|
<div className="p-quantity-sale">
|
||||||
@@ -57,24 +55,20 @@ const ItemDeal: React.FC<ItemDealProps> = ({ Item }) => {
|
|||||||
<div className="bg-gradient"></div>
|
<div className="bg-gradient"></div>
|
||||||
{(() => {
|
{(() => {
|
||||||
const percentRemaining =
|
const percentRemaining =
|
||||||
((Number(Item.quantity) - Number(Item.sale_quantity)) / Number(Item.quantity)) *
|
((Number(item.quantity) - Number(item.sale_quantity)) / Number(item.quantity)) *
|
||||||
100;
|
100;
|
||||||
|
|
||||||
return (
|
return <p className="js-line-deal-left" style={{ width: `${percentRemaining}%` }}></p>;
|
||||||
<>
|
|
||||||
<p className="js-line-deal-left" style={{ width: `${percentRemaining}%` }}></p>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})()}
|
})()}
|
||||||
<span>
|
<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
|
phẩm
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="js-item-deal-time js-item-time-25404">
|
<div className="js-item-deal-time js-item-time-25404">
|
||||||
<div className="time-deal-page flex items-center justify-center gap-2">
|
<div className="time-deal-page flex items-center justify-center gap-2">
|
||||||
<div>Kết thúc sau:</div>
|
<div>Kết thúc sau:</div>
|
||||||
<CounDown deadline={Item.to_time} />
|
<CountDown deadline={item.to_time} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<a href="javascript:buyNow(25404)" className="buy-now-deal">
|
<a href="javascript:buyNow(25404)" className="buy-now-deal">
|
||||||
|
|||||||
@@ -12,23 +12,30 @@ interface Filters {
|
|||||||
current_category?: { url: string };
|
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 ActiveFilters: React.FC<{ filters: Filters }> = ({ filters }) => {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const searchParams = useSearchParams();
|
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 selectedPrice =
|
||||||
const selectedBrand = filters.brand_filter_list?.filter((f) => fullUrl.includes(f.url)) ?? [];
|
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 =
|
const selectedAttr =
|
||||||
filters.attribute_filter_list?.flatMap((attr) =>
|
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 allSelected = [...selectedPrice, ...selectedBrand, ...selectedAttr];
|
||||||
const isFiltered = allSelected.length;
|
const isFiltered = allSelected.length;
|
||||||
|
|
||||||
console.log(isFiltered);
|
|
||||||
|
|
||||||
if (isFiltered === 0) return null;
|
if (isFiltered === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,120 +1,144 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { PriceFilter, AttributeFilterList, BrandFilter } from '@/types';
|
import { usePathname, useSearchParams } from 'next/navigation';
|
||||||
import { FaSortDown } from 'react-icons/fa6';
|
import { FaSortDown } from 'react-icons/fa6';
|
||||||
|
import { AttributeFilterList, BrandFilter, PriceFilter } from '@/types';
|
||||||
import ActiveFilters from './ActiveFilters';
|
import ActiveFilters from './ActiveFilters';
|
||||||
|
|
||||||
interface Filters {
|
interface Filters {
|
||||||
price_filter_list?: PriceFilter[];
|
price_filter_list?: PriceFilter[];
|
||||||
attribute_filter_list?: AttributeFilterList[];
|
attribute_filter_list?: AttributeFilterList[];
|
||||||
brand_filter_list?: BrandFilter[];
|
brand_filter_list?: BrandFilter[];
|
||||||
|
current_category?: { url: string };
|
||||||
}
|
}
|
||||||
|
|
||||||
interface BoxFilterProps {
|
interface BoxFilterProps {
|
||||||
filters: Filters;
|
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 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 { price_filter_list, attribute_filter_list, brand_filter_list } = filters;
|
||||||
|
const primaryBrandFilter = brand_filter_list?.[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="box-filter-category boder-radius-10">
|
<div className="box-filter-category boder-radius-10">
|
||||||
{/* khoảng giá */}
|
|
||||||
{price_filter_list && (
|
{price_filter_list && (
|
||||||
<div className="info-filter-category flex gap-10">
|
<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">
|
<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
|
<div
|
||||||
key={index}
|
key={itemPrice.url}
|
||||||
className={`item item-cetner flex gap-4 ${ItemPrice.is_selected == '1' ? 'current' : ''}`}
|
className={`item item-cetner flex gap-4 ${isActive ? 'current' : ''}`}
|
||||||
>
|
>
|
||||||
<Link href={ItemPrice.url}>{ItemPrice.name}</Link>
|
<Link href={itemPrice.url}>{itemPrice.name}</Link>
|
||||||
<a href={ItemPrice.url}>
|
<Link href={itemPrice.url}>({isActive ? 'Xoa' : itemPrice.count})</Link>
|
||||||
({ItemPrice.is_selected == '1' ? 'Xóa' : ItemPrice.count})
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Thương hiệu */}
|
|
||||||
{brand_filter_list && (
|
{brand_filter_list && (
|
||||||
<div className="info-filter-category flex gap-10">
|
<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">
|
<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
|
<div
|
||||||
key={index}
|
key={itemBrand.url}
|
||||||
className={`item item-cetner flex gap-4 ${ItemBrand.is_selected == '1' ? 'current' : ''}`}
|
className={`item item-cetner flex gap-4 ${isActive ? 'current' : ''}`}
|
||||||
>
|
>
|
||||||
<Link href={ItemBrand.url}>{ItemBrand.name}</Link>
|
<Link href={itemBrand.url}>{itemBrand.name}</Link>
|
||||||
<a href={ItemBrand.url}>
|
<Link href={itemBrand.url}>({isActive ? 'Xoa' : itemBrand.count})</Link>
|
||||||
({ItemBrand.is_selected == '1' ? 'Xóa' : ItemBrand.count})
|
|
||||||
</a>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* chọn thiêu tiêu trí */}
|
|
||||||
{attribute_filter_list && (
|
{attribute_filter_list && (
|
||||||
<div className="info-filter-category flex gap-10">
|
<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">
|
<div className="list-filter-category flex flex-1 flex-wrap items-center gap-3">
|
||||||
{/* thương hiệu */}
|
{primaryBrandFilter && (
|
||||||
{brand_filter_list && (
|
<div
|
||||||
<div className={`item ${brand_filter_list[0].is_selected === '1' ? 'current' : ''}`}>
|
className={`item ${
|
||||||
|
isFilterUrlActive(pathname, currentSearch, primaryBrandFilter.url) ? 'current' : ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
{brand_filter_list[0].is_selected === '1' ? (
|
{isFilterUrlActive(pathname, currentSearch, primaryBrandFilter.url) ? (
|
||||||
<span>{brand_filter_list[0].name}</span>
|
<span>{primaryBrandFilter.name}</span>
|
||||||
) : (
|
) : (
|
||||||
<span>Thương hiệu</span>
|
<span>Thuong hieu</span>
|
||||||
)}
|
)}
|
||||||
<FaSortDown size={16} style={{ marginBottom: 8 }} />
|
<FaSortDown size={16} style={{ marginBottom: 8 }} />
|
||||||
</div>
|
</div>
|
||||||
<ul>
|
<ul>
|
||||||
{brand_filter_list.map((item, idx) => (
|
{brand_filter_list.map((item) => {
|
||||||
<li key={idx} className="flex items-center gap-3">
|
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.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>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Attribute filter */}
|
{attribute_filter_list.length > 0 &&
|
||||||
{attribute_filter_list && attribute_filter_list.length > 0 && (
|
attribute_filter_list.map((attribute) => {
|
||||||
<>
|
const selectedValue = attribute.value_list.find((value) =>
|
||||||
{attribute_filter_list.map((attr, idx) => (
|
isFilterUrlActive(pathname, currentSearch, value.url),
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
<div
|
<div
|
||||||
key={idx}
|
key={attribute.filter_code}
|
||||||
className={`item ${attr.value_list[0]?.is_selected === '1' ? 'current' : ''}`}
|
className={`item ${selectedValue ? 'current' : ''}`}
|
||||||
>
|
>
|
||||||
<a href="javascript:void(0)" className="flex items-center">
|
<button type="button" className="flex items-center">
|
||||||
{attr.value_list[0]?.is_selected === '1' ? (
|
<span>{selectedValue?.name ?? attribute.name}</span>
|
||||||
<span>{attr.value_list[0].name}</span>
|
|
||||||
) : (
|
|
||||||
<span>{attr.name}</span>
|
|
||||||
)}
|
|
||||||
<FaSortDown size={16} style={{ marginBottom: 8 }} />
|
<FaSortDown size={16} style={{ marginBottom: 8 }} />
|
||||||
</a>
|
</button>
|
||||||
<ul>
|
<ul>
|
||||||
{attr.value_list.map((val) => (
|
{attribute.value_list.map((value) => {
|
||||||
<li key={val.id} className="flex items-center gap-3">
|
const isActive = isFilterUrlActive(pathname, currentSearch, value.url);
|
||||||
<Link href={val.url}>{val.name}</Link>
|
|
||||||
<Link href={val.url}>{val.is_selected === '1' ? 'Xóa' : val.count}</Link>
|
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>
|
</li>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
))}
|
);
|
||||||
</>
|
})}
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -123,4 +147,5 @@ const BoxFilter: React.FC<BoxFilterProps> = ({ filters }) => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default BoxFilter;
|
export default BoxFilter;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { useSearchParams } from 'next/navigation';
|
||||||
import { FaGrip, FaList } from 'react-icons/fa6';
|
import { FaGrip, FaList } from 'react-icons/fa6';
|
||||||
|
|
||||||
interface SortItem {
|
interface SortItem {
|
||||||
@@ -11,11 +11,22 @@ interface SortItem {
|
|||||||
|
|
||||||
interface SortProps {
|
interface SortProps {
|
||||||
sort_by_collection: SortItem[];
|
sort_by_collection: SortItem[];
|
||||||
|
display_by_collection?: SortItem[];
|
||||||
product_display_type?: 'grid' | 'list';
|
product_display_type?: 'grid' | 'list';
|
||||||
}
|
}
|
||||||
|
|
||||||
const BoxSort: React.FC<SortProps> = ({ sort_by_collection, product_display_type }) => {
|
const BoxSort: React.FC<SortProps> = ({
|
||||||
const pathname = usePathname();
|
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 (
|
return (
|
||||||
<div className="box-sort-category flex items-center justify-between">
|
<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
|
<Link
|
||||||
key={item.key}
|
key={item.key}
|
||||||
href={item.url}
|
href={item.url}
|
||||||
className={`item flex items-center ${
|
className={`item flex items-center ${selectedSortKey === item.key ? 'selected' : ''}`}
|
||||||
pathname.includes(item.key) ? 'selected' : ''
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{iconClass && <i className={iconClass}></i>}
|
{iconClass && <i className={iconClass}></i>}
|
||||||
<span>{label}</span>
|
<span>{label}</span>
|
||||||
@@ -64,27 +73,20 @@ const BoxSort: React.FC<SortProps> = ({ sort_by_collection, product_display_type
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="sort-bar-select-category flex items-center gap-3">
|
<div className="sort-bar-select-category flex items-center gap-3">
|
||||||
<a
|
<Link
|
||||||
href="javascript:;"
|
href={gridUrl ?? '#'}
|
||||||
className={`item-sort-bar d-flex align-items-center ${
|
className={`item-sort-bar d-flex align-items-center ${
|
||||||
product_display_type === 'grid' ? 'active' : ''
|
selectedDisplay === 'grid' ? 'active' : ''
|
||||||
}`}
|
}`}
|
||||||
onClick={() => {
|
|
||||||
window.location.reload();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<FaGrip />
|
<FaGrip />
|
||||||
</a>
|
</Link>
|
||||||
<a
|
<Link
|
||||||
href="javascript:;"
|
href={listUrl ?? '#'}
|
||||||
className={`item-sort-bar ${product_display_type === 'list' ? 'active' : ''}`}
|
className={`item-sort-bar ${selectedDisplay !== 'grid' ? 'active' : ''}`}
|
||||||
onClick={() => {
|
|
||||||
console.log('Set display to list');
|
|
||||||
window.location.reload();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<FaList />
|
<FaList />
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</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 React from 'react';
|
||||||
import Tippy from '@tippyjs/react';
|
|
||||||
import 'tippy.js/dist/tippy.css';
|
|
||||||
import { Product } from '@/types';
|
import { Product } from '@/types';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { formatCurrency } from '@/lib/formatPrice';
|
import { formatCurrency } from '@/lib/formatPrice';
|
||||||
|
import { SanitizedHtml } from '@/components/Common/SanitizedHtml';
|
||||||
|
|
||||||
type ProductItemProps = {
|
type ProductItemProps = {
|
||||||
item: Product;
|
item: Product;
|
||||||
};
|
};
|
||||||
|
|
||||||
const ItemProduct: React.FC<ProductItemProps> = ({ item }) => {
|
const ItemProduct: React.FC<ProductItemProps> = ({ item }) => {
|
||||||
const offers = item.specialOffer?.all ?? [];
|
const firstOffer = item.specialOffer?.all?.[0];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="product-item js-p-item">
|
<div className="product-item js-p-item">
|
||||||
<a href={item.productUrl} className="product-image relative">
|
<a href={item.productUrl} className="product-image relative">
|
||||||
{item.productImage.large ? (
|
{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
|
<Image
|
||||||
src="https://nguyencongpc.vn/static/assets/nguyencong_2023/images/not-image.png"
|
src="https://nguyencongpc.vn/static/assets/nguyencong_2023/images/not-image.png"
|
||||||
alt={item.productName}
|
alt={item.productName}
|
||||||
|
width={203}
|
||||||
|
height={203}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -38,39 +40,35 @@ const ItemProduct: React.FC<ProductItemProps> = ({ item }) => {
|
|||||||
<Link href={item.productUrl}>
|
<Link href={item.productUrl}>
|
||||||
<h3 className="product-title line-clamp-3">{item.productName}</h3>
|
<h3 className="product-title line-clamp-3">{item.productName}</h3>
|
||||||
</Link>
|
</Link>
|
||||||
{item.marketPrice > 0 ? (
|
{Number(item.marketPrice) > 0 ? (
|
||||||
<div className="product-martket-main flex items-center">
|
<div className="product-martket-main flex items-center">
|
||||||
<p className="product-market-price">
|
<p className="product-market-price">
|
||||||
{item.marketPrice.toLocaleString()}
|
{formatCurrency(item.marketPrice)}
|
||||||
<u>đ</u>
|
<u>đ</u>
|
||||||
</p>
|
</p>
|
||||||
<div className="product-percent-price">-{Math.round(Number(item.price_off))} %</div>
|
<div className="product-percent-price">-{Math.round(Number(item.price_off))} %</div>
|
||||||
</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]">
|
<div className="product-price-main font-semibold">
|
||||||
{item.price > '0' ? `${formatCurrency(item.price)}đ` : 'Liên hệ'}
|
{Number(item.price) > 0 ? `${formatCurrency(item.price)}đ` : 'Liên hệ'}
|
||||||
</div>
|
</div>
|
||||||
{item.specialOffer?.all?.length ? (
|
|
||||||
<div
|
{firstOffer ? (
|
||||||
className="product-offer line-clamp-2"
|
<SanitizedHtml html={firstOffer.title} className="product-offer line-clamp-2" />
|
||||||
dangerouslySetInnerHTML={{
|
|
||||||
__html: item.specialOffer!.all![0].title,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="product-offer line-clamp-2"></div>
|
<div className="product-offer line-clamp-2" />
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{item.extend?.buy_count ? (
|
{item.extend?.buy_count ? (
|
||||||
<div style={{ height: 18 }}>
|
<div className="h-4.5">
|
||||||
{' '}
|
<b>Đã bán: </b>
|
||||||
<b>Đã bán: </b> <span>{item.extend.buy_count}</span>{' '}
|
<span>{item.extend.buy_count}</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ height: 18, display: 'block' }}> </div>
|
<div className="h-4.5" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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 Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import { FaFacebookF, FaYoutube, FaAngleUp } from 'react-icons/fa';
|
import { FaFacebookF, FaYoutube, FaAngleUp } from 'react-icons/fa';
|
||||||
import { FaFacebookMessenger } from 'react-icons/fa';
|
|
||||||
import { SiZalo } from 'react-icons/si';
|
|
||||||
|
|
||||||
const IconFixRight: React.FC = () => {
|
const IconFixRight: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
@@ -25,14 +25,15 @@ const IconFixRight: React.FC = () => {
|
|||||||
<FaYoutube size={22} />
|
<FaYoutube size={22} />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link
|
<button
|
||||||
href="javascript:window.scrollTo({ top: 0, behavior: 'smooth' });"
|
type="button"
|
||||||
className="scroll-top-btn items-center justify-center"
|
className="scroll-top-btn items-center justify-center"
|
||||||
title="Di chuyển lên đầu trang!"
|
title="Di chuyển lên đầu trang!"
|
||||||
style={{ display: 'none' }}
|
style={{ display: 'none' }}
|
||||||
|
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||||
>
|
>
|
||||||
<FaAngleUp size={20} />
|
<FaAngleUp size={20} />
|
||||||
</Link>
|
</button>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="https://m.me/nguyencongpc.vn"
|
href="https://m.me/nguyencongpc.vn"
|
||||||
@@ -41,8 +42,8 @@ const IconFixRight: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src="https://nguyencongpc.vn/static/assets/nguyencong_2023/images/facebook_messenger.png"
|
src="https://nguyencongpc.vn/static/assets/nguyencong_2023/images/facebook_messenger.png"
|
||||||
width="40"
|
width={40}
|
||||||
height="40"
|
height={40}
|
||||||
alt="mes"
|
alt="mes"
|
||||||
className="lazy"
|
className="lazy"
|
||||||
/>
|
/>
|
||||||
@@ -69,8 +70,8 @@ const IconFixRight: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src="https://nguyencongpc.vn/media/lib/24-01-2024/zalo.png"
|
src="https://nguyencongpc.vn/media/lib/24-01-2024/zalo.png"
|
||||||
width="40"
|
width={40}
|
||||||
height="40"
|
height={40}
|
||||||
alt="mes"
|
alt="mes"
|
||||||
className="lazy"
|
className="lazy"
|
||||||
style={{ marginRight: '10px' }}
|
style={{ marginRight: '10px' }}
|
||||||
@@ -83,4 +84,5 @@ const IconFixRight: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default IconFixRight;
|
export default IconFixRight;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import IconFixRight from './IconFixRight';
|
import IconFixRight from './IconFixRight';
|
||||||
|
|
||||||
const Footer: React.FC = () => {
|
const Footer: React.FC = () => {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<footer className="footer-main">
|
<footer className="footer-main">
|
||||||
{/* Chính sách */}
|
|
||||||
<div className="footer-policy">
|
<div className="footer-policy">
|
||||||
<div className="container flex items-center justify-between gap-12">
|
<div className="container flex items-center justify-between gap-12">
|
||||||
<div className="item flex items-center justify-center">
|
<div className="item flex items-center justify-center">
|
||||||
@@ -18,32 +18,31 @@ const Footer: React.FC = () => {
|
|||||||
<div className="item flex items-center justify-center">
|
<div className="item flex items-center justify-center">
|
||||||
<i className="sprite sprite-doitra-footer"></i>
|
<i className="sprite sprite-doitra-footer"></i>
|
||||||
<p className="text box-title-policy m-0">
|
<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>
|
<span className="grey block">1 đổi 1 trong 15 ngày</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="item flex items-center justify-center">
|
<div className="item flex items-center justify-center">
|
||||||
<i className="sprite sprite-thanhtoan-footer"></i>
|
<i className="sprite sprite-thanhtoan-footer"></i>
|
||||||
<p className="text box-title-policy m-0">
|
<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>
|
<span className="grey block">tiền mặt, CK, trả góp 0%</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="item flex items-center justify-center">
|
<div className="item flex items-center justify-center">
|
||||||
<i className="sprite sprite-hotro-footer"></i>
|
<i className="sprite sprite-hotro-footer"></i>
|
||||||
<p className="text box-title-policy m-0">
|
<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>
|
<span className="grey block">tư vấn, giải đáp mọi thắc mắc</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Box info */}
|
|
||||||
<div className="box-info-main">
|
<div className="box-info-main">
|
||||||
<div className="justify-content-between footer-list-info-main container flex">
|
<div className="justify-content-between footer-list-info-main container flex">
|
||||||
<div className="item-info-main">
|
<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">
|
<Link href="https://nguyencongpc.vn/pages/profile.html" className="text">
|
||||||
Giới thiệu công ty
|
Giới thiệu công ty
|
||||||
</Link>
|
</Link>
|
||||||
@@ -75,12 +74,12 @@ const Footer: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<i className="sprite sprite-youtube-fotoer"></i>
|
<i className="sprite sprite-youtube-fotoer"></i>
|
||||||
</Link>
|
</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>
|
<i className="sprite sprite-instagram-footer"></i>
|
||||||
</a>
|
</Link>
|
||||||
<a href="javascript:;" className="item-social" aria-label="Tiktok">
|
<Link href="#" className="item-social" aria-label="Tiktok">
|
||||||
<i className="sprite sprite-tiktok-footer"></i>
|
<i className="sprite sprite-tiktok-footer"></i>
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="bct-footer flex gap-3">
|
<div className="bct-footer flex gap-3">
|
||||||
<Link
|
<Link
|
||||||
@@ -90,10 +89,10 @@ const Footer: React.FC = () => {
|
|||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src="https://nguyencongpc.vn/static/assets/nguyencong_2023/images/footer-bct.png"
|
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"
|
className="lazy"
|
||||||
width={132}
|
width={132}
|
||||||
height="1"
|
height={40}
|
||||||
/>
|
/>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
@@ -103,7 +102,7 @@ const Footer: React.FC = () => {
|
|||||||
target="_blank"
|
target="_blank"
|
||||||
rel="nofollow"
|
rel="nofollow"
|
||||||
>
|
>
|
||||||
<img
|
<Image
|
||||||
src="https://www.dmca.com/img/dmca-compliant-grayscale.png"
|
src="https://www.dmca.com/img/dmca-compliant-grayscale.png"
|
||||||
alt="DMCA compliant"
|
alt="DMCA compliant"
|
||||||
width={115}
|
width={115}
|
||||||
@@ -128,7 +127,7 @@ const Footer: React.FC = () => {
|
|||||||
Gửi yêu cầu bảo hành
|
Gửi yêu cầu bảo hành
|
||||||
</Link>
|
</Link>
|
||||||
<Link href="/lien-he" className="text">
|
<Link href="/lien-he" className="text">
|
||||||
Góp ý, Khiếu Nại
|
Góp ý, Khiếu nại
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -174,7 +173,6 @@ const Footer: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer bottom */}
|
|
||||||
<div className="footer-bottom">
|
<div className="footer-bottom">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="copyright">
|
<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>Mã số thuế: 0107568451 do Sở Kế Hoạch và Đầu Tư TP.Hà Nội (17/09/2016)</p>
|
||||||
<p>
|
<p>
|
||||||
Mua hàng: <Link href="tel:0866666166">089.9999.191</Link> -{' '}
|
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>
|
||||||
<p className="list-contact-footer flex items-center">
|
<p className="list-contact-footer flex items-center">
|
||||||
<span>
|
<span>
|
||||||
GÓP Ý: <Link href="tel:0979999191">097.9999.191</Link> -{' '}
|
GÓP Ý: <Link href="tel:0979999191">097.9999.191</Link> -{' '}
|
||||||
<a href="tel:0983333388">098.33333.88</a>.
|
<Link href="tel:0983333388">098.33333.88</Link>.
|
||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Email: <Link href="mailto:info@nguyencongpc.vn">info@nguyencongpc.vn</Link>.
|
Email: <Link href="mailto:info@nguyencongpc.vn">info@nguyencongpc.vn</Link>.
|
||||||
@@ -199,9 +197,9 @@ const Footer: React.FC = () => {
|
|||||||
</span>
|
</span>
|
||||||
<span>
|
<span>
|
||||||
Fanpage:{' '}
|
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
|
facebook.com/MAY.TINH.NGUYEN.CONG
|
||||||
</a>
|
</Link>
|
||||||
.
|
.
|
||||||
</span>
|
</span>
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React, { useState } from 'react';
|
|
||||||
|
import React from 'react';
|
||||||
import { FaBars } from 'react-icons/fa';
|
import { FaBars } from 'react-icons/fa';
|
||||||
import { menuData } from '../menuData';
|
import { menuData } from '../menuData';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
@@ -30,7 +31,6 @@ const HeaderBottom: React.FC = () => {
|
|||||||
<span className="cat-title line-clamp-1">{item.title}</span>
|
<span className="cat-title line-clamp-1">{item.title}</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Cấp 2 & Cấp 3 */}
|
|
||||||
{item.children && item.children.length > 0 && (
|
{item.children && item.children.length > 0 && (
|
||||||
<div className="sub-menu-list">
|
<div className="sub-menu-list">
|
||||||
{item.children.map((_children2) => (
|
{item.children.map((_children2) => (
|
||||||
@@ -38,7 +38,6 @@ const HeaderBottom: React.FC = () => {
|
|||||||
<Link href={_children2.url} className="cat-2">
|
<Link href={_children2.url} className="cat-2">
|
||||||
{_children2.title}
|
{_children2.title}
|
||||||
</Link>
|
</Link>
|
||||||
{/* Cấp 3 */}
|
|
||||||
{_children2.children && _children2.children.length > 0 && (
|
{_children2.children && _children2.children.length > 0 && (
|
||||||
<>
|
<>
|
||||||
{_children2.children.map((_children3) => (
|
{_children2.children.map((_children3) => (
|
||||||
|
|||||||
@@ -1,56 +1,39 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React, { useState, useEffect } from 'react';
|
|
||||||
|
import React, { useEffect, useState, useSyncExternalStore } from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { FaMapMarkerAlt, FaBars } from 'react-icons/fa';
|
import { FaMapMarkerAlt, FaBars } from 'react-icons/fa';
|
||||||
import BoxShowroom from '@/components/Common/BoxShowroom';
|
import BoxShowroom from '@/components/Common/BoxShowroom';
|
||||||
import BoxHotLine from '../../BoxHotline';
|
import BoxHotLine from '../../BoxHotline';
|
||||||
|
|
||||||
import { TypeCartItem } from '@/types/cart';
|
|
||||||
import { formatCurrency } from '@/lib/formatPrice';
|
import { formatCurrency } from '@/lib/formatPrice';
|
||||||
|
import {
|
||||||
|
getServerCartSnapshot,
|
||||||
|
readCartFromStorage,
|
||||||
|
subscribeCartStorage,
|
||||||
|
} from '@/lib/cartStorage';
|
||||||
|
|
||||||
const HeaderMid: React.FC = () => {
|
const HeaderMid: React.FC = () => {
|
||||||
const [cartCount, setCartCount] = useState(() => {
|
const cart = useSyncExternalStore(
|
||||||
const storedCart = localStorage.getItem('cart');
|
subscribeCartStorage,
|
||||||
return storedCart ? JSON.parse(storedCart).length : 0;
|
readCartFromStorage,
|
||||||
});
|
getServerCartSnapshot,
|
||||||
|
);
|
||||||
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 [isFixed, setIsFixed] = useState(false);
|
const [isFixed, setIsFixed] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScroll = () => {
|
const handleScroll = () => setIsFixed(window.scrollY > 680);
|
||||||
const distanceFromTop = window.scrollY;
|
|
||||||
if (distanceFromTop > 680) {
|
|
||||||
setIsFixed(true);
|
|
||||||
} else {
|
|
||||||
setIsFixed(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener('scroll', handleScroll);
|
window.addEventListener('scroll', handleScroll);
|
||||||
return () => window.removeEventListener('scroll', handleScroll);
|
return () => window.removeEventListener('scroll', handleScroll);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const PopupAddress = () => {
|
const cartCount = cart.length;
|
||||||
const modal = document.getElementById('boxShowroom') as HTMLDialogElement;
|
const cartQuantity = cart.reduce((sum, item) => sum + Number(item.in_cart.quantity), 0);
|
||||||
modal?.showModal();
|
const cartTotal = cart.reduce((sum, item) => sum + Number(item.in_cart.total_price), 0);
|
||||||
};
|
|
||||||
|
|
||||||
const PopupHotLine = () => {
|
const openModal = (id: string) => {
|
||||||
const modal = document.getElementById('boxHotline') as HTMLDialogElement;
|
(document.getElementById(id) as HTMLDialogElement)?.showModal();
|
||||||
modal?.showModal();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -66,7 +49,10 @@ const HeaderMid: React.FC = () => {
|
|||||||
className="logo-header"
|
className="logo-header"
|
||||||
/>
|
/>
|
||||||
</Link>
|
</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} />
|
<FaMapMarkerAlt size={16} />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,7 +60,7 @@ const HeaderMid: React.FC = () => {
|
|||||||
<div className="header-menu-category">
|
<div className="header-menu-category">
|
||||||
<div className="box-title flex items-center justify-center gap-8">
|
<div className="box-title flex items-center justify-center gap-8">
|
||||||
<FaBars size={16} />
|
<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>
|
||||||
<div className="cau-noi"></div>
|
<div className="cau-noi"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -109,24 +95,24 @@ const HeaderMid: React.FC = () => {
|
|||||||
<p className="icon-item-tab flex items-center justify-center">
|
<p className="icon-item-tab flex items-center justify-center">
|
||||||
<i className="sprite sprite-buildpc-header"></i>
|
<i className="sprite sprite-buildpc-header"></i>
|
||||||
</p>
|
</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>
|
</Link>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={PopupHotLine}
|
onClick={() => openModal('boxHotline')}
|
||||||
className="item-tab-header flex flex-col items-center gap-4"
|
className="item-tab-header flex flex-col items-center gap-4"
|
||||||
>
|
>
|
||||||
<p className="icon-item-tab flex items-center justify-center">
|
<p className="icon-item-tab flex items-center justify-center">
|
||||||
<i className="sprite sprite-lienhe-header"></i>
|
<i className="sprite sprite-lienhe-header"></i>
|
||||||
</p>
|
</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>
|
</button>
|
||||||
|
|
||||||
<Link href="/tin-tuc" className="item-tab-header flex flex-col items-center gap-4">
|
<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">
|
<p className="icon-item-tab flex items-center justify-center">
|
||||||
<i className="sprite sprite-article-header"></i>
|
<i className="sprite sprite-article-header"></i>
|
||||||
</p>
|
</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>
|
</Link>
|
||||||
|
|
||||||
<div id="js-header-cart" className="relative">
|
<div id="js-header-cart" className="relative">
|
||||||
@@ -135,15 +121,15 @@ const HeaderMid: React.FC = () => {
|
|||||||
<i className="sprite sprite-cart-header"></i>
|
<i className="sprite sprite-cart-header"></i>
|
||||||
<u className="cart-count header-features-cart-amount">{cartCount}</u>
|
<u className="cart-count header-features-cart-amount">{cartCount}</u>
|
||||||
</p>
|
</p>
|
||||||
<span className="font-weight-500">Giỏ hàng</span>
|
<span className="font-medium">Giỏ hàng</span>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="cau-noi"></div>
|
<div className="cau-noi"></div>
|
||||||
<div className="cart-ttip" id="js-cart-tooltip">
|
<div className="cart-ttip" id="js-cart-tooltip">
|
||||||
<div className="cart-ttip-item-container">
|
<div className="cart-ttip-item-container">
|
||||||
{cart.map((item, index) => (
|
{cart.map((item) => (
|
||||||
<div
|
<div
|
||||||
className="compare-item js-compare-item flex items-center gap-2"
|
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}>
|
<Link className="img-compare" href={item.item_info.productUrl}>
|
||||||
<Image
|
<Image
|
||||||
@@ -164,23 +150,18 @@ const HeaderMid: React.FC = () => {
|
|||||||
<b>x {item.in_cart.quantity}</b>
|
<b>x {item.in_cart.quantity}</b>
|
||||||
<b className="price-compare">
|
<b className="price-compare">
|
||||||
{item.in_cart.price == '0'
|
{item.in_cart.price == '0'
|
||||||
? 'Liên Hệ'
|
? 'Liên hệ'
|
||||||
: `${formatCurrency(item.in_cart.total_price)} đ`}
|
: `${formatCurrency(item.in_cart.total_price)} đ`}
|
||||||
</b>
|
</b>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{/* end item */}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="cart-ttip-price flex items-center justify-end gap-2">
|
<div className="cart-ttip-price flex items-center justify-end gap-2">
|
||||||
<p>Tổng tiền hàng</p>
|
<p>Tổng tiền hàng</p>
|
||||||
<p id="js-header-cart-quantity" className="font-[500]">
|
<p className="font-medium">({cartQuantity} sản phẩm)</p>
|
||||||
({cartQuantity} sản phẩm)
|
<p className="font-bold">{formatCurrency(cartTotal)}đ</p>
|
||||||
</p>
|
|
||||||
<p id="js-header-cart-total-price" className="font-bold">
|
|
||||||
{formatCurrency(cartTotal)}đ
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
href="/cart"
|
href="/cart"
|
||||||
@@ -198,7 +179,7 @@ const HeaderMid: React.FC = () => {
|
|||||||
<p className="icon-item-tab flex items-center justify-center">
|
<p className="icon-item-tab flex items-center justify-center">
|
||||||
<i className="sprite sprite-account-header"></i>
|
<i className="sprite sprite-account-header"></i>
|
||||||
</p>
|
</p>
|
||||||
<span className="font-[500]">Tài khoản</span>
|
<span className="font-medium">Tài khoản</span>
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</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 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 = () => {
|
export const ArticleTopRight = () => {
|
||||||
|
const { data: articles } = useApiData(
|
||||||
|
() => getArticles(),
|
||||||
|
[],
|
||||||
|
{ initialData: [] as ListArticle },
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="col-right-article box-view-article flex-1">
|
<div className="col-right-article box-view-article flex-1">
|
||||||
<form
|
<form
|
||||||
@@ -17,9 +27,9 @@ export const ArticleTopRight = () => {
|
|||||||
<div className="boder-radius-10 border-box-article">
|
<div className="boder-radius-10 border-box-article">
|
||||||
<div className="title-box-article font-bold">Xem nhiều</div>
|
<div className="title-box-article font-bold">Xem nhiều</div>
|
||||||
<ul className="list-most-view-article flex flex-col gap-4">
|
<ul className="list-most-view-article flex flex-col gap-4">
|
||||||
{DataListArticleNews.slice(0, 6).map((item, index) => (
|
{articles.slice(0, 6).map((item, index) => (
|
||||||
<li className="item-most-view-article flex items-center gap-2" key={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]"></span>
|
<span className="number flex items-center justify-center font-[600]">{index + 1}</span>
|
||||||
<Link href={item.url} className="line-clamp-2 flex-1">
|
<Link href={item.url} className="line-clamp-2 flex-1">
|
||||||
{item.title}
|
{item.title}
|
||||||
</Link>
|
</Link>
|
||||||
@@ -1,39 +1,55 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
import type { TypeArticleCatePage } from '@/types/article/TypeArticleCatePage';
|
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 { Breadcrumb } from '@components/Common/Breadcrumb';
|
||||||
import { ErrorLink } from '@components/Common/error';
|
import { ErrorLink } from '@components/Common/Error';
|
||||||
import { ArticleTopLeft } from '../ArticleTopLeft';
|
import { ArticleTopLeft } from '../ArticleTopLeft';
|
||||||
import { ArticleTopRight } from '../ArticleTopRight';
|
import { ArticleTopRight } from '../ArticleTopRight';
|
||||||
import ItemArticle from '@/components/Common/ItemArticle';
|
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 {
|
interface CategoryPageProps {
|
||||||
slug: string; // khai báo prop slug
|
slug: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ArticleCategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
|
const ArticleCategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
|
||||||
// Ép kiểu dữ liệu từ index.ts về CategoryData[]
|
const { data: currentCategory, isLoading } = useApiData(
|
||||||
const categories = ArticleCateDetailPageData as TypeArticleCatePage[];
|
() => getArticleCategoryDetail(slug),
|
||||||
const currentCategory = findCategoryBySlug(slug, categories);
|
[slug],
|
||||||
|
{ initialData: null as TypeArticleCatePage | null },
|
||||||
|
);
|
||||||
|
const { data: categories } = useApiData(
|
||||||
|
() => getArticleCategories(),
|
||||||
|
[],
|
||||||
|
{ initialData: [] as TypeArticleCategory[] },
|
||||||
|
);
|
||||||
|
const { data: articles } = useApiData(
|
||||||
|
() => getArticles(),
|
||||||
|
[],
|
||||||
|
{ initialData: [] as ListArticle },
|
||||||
|
);
|
||||||
|
|
||||||
const breadcrumbItems = [
|
if (isLoading) {
|
||||||
{ name: 'Tin tức', url: '/tin-tuc' },
|
return <PreLoader />;
|
||||||
{ name: currentCategory?.category_info.name, url: currentCategory?.category_info.request_path },
|
}
|
||||||
];
|
|
||||||
|
|
||||||
// Trường hợp không tìm thấy danh mục
|
|
||||||
if (!currentCategory) {
|
if (!currentCategory) {
|
||||||
return <ErrorLink />;
|
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);
|
const articleList = Object.values(currentCategory.article_list);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -43,10 +59,10 @@ const ArticleCategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
|
|||||||
</div>
|
</div>
|
||||||
<section className="page-article page-article-category container">
|
<section className="page-article page-article-category container">
|
||||||
<div className="tabs-category-article flex items-center">
|
<div className="tabs-category-article flex items-center">
|
||||||
{DataArticleCategory.map((item, index) => (
|
{categories.map((item, index) => (
|
||||||
<Link
|
<Link
|
||||||
href={item.url}
|
href={item.url}
|
||||||
key={index}
|
key={`${item.id}-${index}`}
|
||||||
className={`item-tab-article ${currentCategory.title === item.title ? 'active' : ''}`}
|
className={`item-tab-article ${currentCategory.title === item.title ? 'active' : ''}`}
|
||||||
>
|
>
|
||||||
<h2 className="title-cate-article font-[400]">{item.title}</h2>
|
<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">
|
<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>
|
<p className="title-box-article font-[600]">{currentCategory.title}</p>
|
||||||
<div className="list-article-tech">
|
<div className="list-article-tech">
|
||||||
{articleList.slice(0, 9).map((item, index) => (
|
{articleList.slice(0, 9).map((item) => (
|
||||||
<ItemArticle item={item} key={index} />
|
<ItemArticle item={item} key={item.id} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
@@ -79,8 +95,8 @@ const ArticleCategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
|
|||||||
<div className="box-article-global border-box-article boder-radius-10">
|
<div className="box-article-global border-box-article boder-radius-10">
|
||||||
<p className="title-box-article font-bold">Tin nổi bật</p>
|
<p className="title-box-article font-bold">Tin nổi bật</p>
|
||||||
<div className="list-article-global flex flex-col gap-2">
|
<div className="list-article-global flex flex-col gap-2">
|
||||||
{DataListArticleNews.slice(0, 5).map((item, index) => (
|
{articles.slice(0, 5).map((item) => (
|
||||||
<div className="item-article flex gap-4" key={index}>
|
<div className="item-article flex gap-4" key={item.id}>
|
||||||
<Link href={item.url} className="img-article boder-radius-10 relative">
|
<Link href={item.url} className="img-article boder-radius-10 relative">
|
||||||
<Image
|
<Image
|
||||||
className="boder-radius-10"
|
className="boder-radius-10"
|
||||||
@@ -93,7 +109,7 @@ const ArticleCategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
|
|||||||
<i className="sprite sprite-play-youtube incon-play-youtube"></i>
|
<i className="sprite sprite-play-youtube incon-play-youtube"></i>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="content-article content-article-item flex flex-1 flex-col">
|
<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>
|
<h3 className="line-clamp-2 font-[400]">{item.title}</h3>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="time-article flex items-center gap-2">
|
<p className="time-article flex items-center gap-2">
|
||||||
@@ -1,41 +1,50 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
|
|
||||||
import type { TypeArticleDetailPage } from '@/types/article/TypeArticleDetailPage';
|
import type { TypeArticleDetailPage } from '@/types/article/TypeArticleDetailPage';
|
||||||
import { ArticleDetailPageData } from '@/data/article/ArticleDetailPageData';
|
import { ErrorLink } from '@components/Common/Error';
|
||||||
import { DataArticleCategory } from '@/data/article/ListCategory';
|
|
||||||
import { ErrorLink } from '@components/Common/error';
|
|
||||||
|
|
||||||
import { findDetailBySlug } from '@/lib/article/detail';
|
|
||||||
import { Breadcrumb } from '@components/Common/Breadcrumb';
|
import { Breadcrumb } from '@components/Common/Breadcrumb';
|
||||||
import TocBox from './TocBox';
|
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 {
|
interface DetailPageProps {
|
||||||
slug: string; // khai báo prop slug
|
slug: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ArticleDetailPage: React.FC<DetailPageProps> = ({ slug }) => {
|
const ArticleDetailPage: React.FC<DetailPageProps> = ({ slug }) => {
|
||||||
// Ép kiểu dữ liệu từ index.ts về CategoryData[]
|
const { data: page, isLoading } = useApiData(
|
||||||
const details = ArticleDetailPageData as TypeArticleDetailPage[];
|
() => getArticleDetail(slug),
|
||||||
const page = findDetailBySlug(slug, details);
|
[slug],
|
||||||
|
{ initialData: null as TypeArticleDetailPage | null },
|
||||||
|
);
|
||||||
|
const { data: categories } = useApiData(
|
||||||
|
() => getArticleCategories(),
|
||||||
|
[],
|
||||||
|
{ initialData: [] as TypeArticleCategory[] },
|
||||||
|
);
|
||||||
|
|
||||||
const breadcrumbItems = [
|
if (isLoading) {
|
||||||
{ name: 'Tin tức', url: '/tin-tuc' },
|
return <PreLoader />;
|
||||||
{ name: page?.article_detail.title, url: page?.article_detail.url },
|
}
|
||||||
];
|
|
||||||
|
|
||||||
// Trường hợp không tìm thấy danh mục
|
|
||||||
if (!page) {
|
if (!page) {
|
||||||
return <ErrorLink />;
|
return <ErrorLink />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// lấy danh sách tin tức liên quan mới
|
const breadcrumbItems = [
|
||||||
const ListRelayNew = Object.values(page.article_other_same_category.new);
|
{ name: 'Tin tức', url: '/tin-tuc' },
|
||||||
// lấy danh sách tin tức liên quan cũ
|
{ name: page.article_detail.title, url: page.article_detail.url },
|
||||||
const ListRelayOld = Object.values(page.article_other_same_category.old);
|
];
|
||||||
|
|
||||||
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -44,11 +53,11 @@ const ArticleDetailPage: React.FC<DetailPageProps> = ({ slug }) => {
|
|||||||
</div>
|
</div>
|
||||||
<section className="page-article box-article-detail container">
|
<section className="page-article box-article-detail container">
|
||||||
<div className="tabs-category-article flex items-center">
|
<div className="tabs-category-article flex items-center">
|
||||||
{DataArticleCategory.map((item, index) => (
|
{categories.map((item, index) => (
|
||||||
<Link
|
<Link
|
||||||
href={item.url}
|
href={item.url}
|
||||||
key={index}
|
key={`${item.id}-${index}`}
|
||||||
className={`item-tab-article ${page?.article_detail.categoryInfo[0].id === item.id ? 'active' : ''}`}
|
className={`item-tab-article ${page.article_detail.categoryInfo[0].id === item.id ? 'active' : ''}`}
|
||||||
>
|
>
|
||||||
<h2 className="title-cate-article font-[400]">{item.title}</h2>
|
<h2 className="title-cate-article font-[400]">{item.title}</h2>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -62,7 +71,6 @@ const ArticleDetailPage: React.FC<DetailPageProps> = ({ slug }) => {
|
|||||||
<span className="author-name">{page.article_detail.author}</span>
|
<span className="author-name">{page.article_detail.author}</span>
|
||||||
<span className="post-time">{page.article_detail.createDate}</span>
|
<span className="post-time">{page.article_detail.createDate}</span>
|
||||||
</div>
|
</div>
|
||||||
{/* nội dung */}
|
|
||||||
<TocBox htmlContent={page.article_detail.content} />
|
<TocBox htmlContent={page.article_detail.content} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -74,8 +82,8 @@ const ArticleDetailPage: React.FC<DetailPageProps> = ({ slug }) => {
|
|||||||
Bài viết <span>liên quan</span>
|
Bài viết <span>liên quan</span>
|
||||||
</p>
|
</p>
|
||||||
<div className="article-list list-article-relative flex flex-wrap gap-3">
|
<div className="article-list list-article-relative flex flex-wrap gap-3">
|
||||||
{combinedList.map((item, index) => (
|
{combinedList.map((item) => (
|
||||||
<div className="item-article d-flex flex-column gap-12" key={index}>
|
<div className="item-article d-flex flex-column gap-12" key={item.id}>
|
||||||
<Link href={item.url} className="img-article boder-radius-10">
|
<Link href={item.url} className="img-article boder-radius-10">
|
||||||
<Image
|
<Image
|
||||||
className="boder-radius-10"
|
className="boder-radius-10"
|
||||||
@@ -1,16 +1,26 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import ItemArticle from '@/components/Common/ItemArticle';
|
import ItemArticle from '@/components/Common/ItemArticle';
|
||||||
import { DataListArticleNews } from '@/data/article/ListArticleNews';
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import Image from 'next/image';
|
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 = () => {
|
export const BoxArticleMid = () => {
|
||||||
|
const { data: articles } = useApiData(
|
||||||
|
() => getArticles(),
|
||||||
|
[],
|
||||||
|
{ initialData: [] as ListArticle },
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="box-article-home-middle grid grid-cols-3 gap-2">
|
<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">
|
<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>
|
<p className="title-box-article font-[600]">Tin công nghệ</p>
|
||||||
<div className="list-article-tech">
|
<div className="list-article-tech">
|
||||||
{DataListArticleNews.slice(0, 9).map((item, index) => (
|
{articles.slice(0, 9).map((item) => (
|
||||||
<ItemArticle item={item} key={index} />
|
<ItemArticle item={item} key={item.id} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<Link
|
<Link
|
||||||
@@ -24,8 +34,8 @@ export const BoxArticleMid = () => {
|
|||||||
<div className="box-article-hot border-box-article boder-radius-10">
|
<div className="box-article-hot border-box-article boder-radius-10">
|
||||||
<p className="title-box-article font-bold">Tin nổi bật</p>
|
<p className="title-box-article font-bold">Tin nổi bật</p>
|
||||||
<div className="list-article-hot">
|
<div className="list-article-hot">
|
||||||
{DataListArticleNews.slice(0, 5).map((item, index) => (
|
{articles.slice(0, 5).map((item) => (
|
||||||
<div className="item-article flex gap-4" key={index}>
|
<div className="item-article flex gap-4" key={item.id}>
|
||||||
<Link href={item.url} className="img-article boder-radius-10 relative">
|
<Link href={item.url} className="img-article boder-radius-10 relative">
|
||||||
<Image
|
<Image
|
||||||
className="boder-radius-10"
|
className="boder-radius-10"
|
||||||
@@ -38,7 +48,7 @@ export const BoxArticleMid = () => {
|
|||||||
<i className="sprite sprite-play-youtube incon-play-youtube"></i>
|
<i className="sprite sprite-play-youtube incon-play-youtube"></i>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="content-article content-article-item flex flex-1 flex-col">
|
<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>
|
<h3 className="line-clamp-2 font-[400]">{item.title}</h3>
|
||||||
</Link>
|
</Link>
|
||||||
<p className="time-article flex items-center gap-2">
|
<p className="time-article flex items-center gap-2">
|
||||||
@@ -1,10 +1,20 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||||
import { Autoplay, Navigation, Pagination, Thumbs } from 'swiper/modules';
|
import { Autoplay, Navigation, Pagination, Thumbs } from 'swiper/modules';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
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 = () => {
|
export const BoxArticleReview = () => {
|
||||||
|
const { data: articles } = useApiData(
|
||||||
|
() => getArticles(),
|
||||||
|
[],
|
||||||
|
{ initialData: [] as ListArticle },
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="box-article-category page-hompage">
|
<div className="box-article-category page-hompage">
|
||||||
<div className="box-article-global box-artice-review">
|
<div className="box-article-global box-artice-review">
|
||||||
@@ -19,8 +29,8 @@ export const BoxArticleReview = () => {
|
|||||||
slidesPerView={3}
|
slidesPerView={3}
|
||||||
loop={true}
|
loop={true}
|
||||||
>
|
>
|
||||||
{DataListArticleNews.map((item, index) => (
|
{articles.map((item) => (
|
||||||
<SwiperSlide key={index}>
|
<SwiperSlide key={item.id}>
|
||||||
<div className="item-article">
|
<div className="item-article">
|
||||||
<Link href={item.url} className="img-article">
|
<Link href={item.url} className="img-article">
|
||||||
<Image src={item.image.original} fill alt={item.title} />
|
<Image src={item.image.original} fill alt={item.title} />
|
||||||
@@ -1,28 +1,35 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { FaYoutube } from 'react-icons/fa6';
|
import { FaYoutube } from 'react-icons/fa6';
|
||||||
import { DataListArticleVideo } from '@/data/article/ListAricleVideo';
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import useFancybox from '@/hooks/useFancybox';
|
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 = () => {
|
export const BoxVideoArticle = () => {
|
||||||
|
const { data: videos } = useApiData(
|
||||||
|
() => getArticleVideos(),
|
||||||
|
[],
|
||||||
|
{ initialData: [] as ListArticle },
|
||||||
|
);
|
||||||
|
|
||||||
const getYoutubeEmbedUrl = (url: string): string => {
|
const getYoutubeEmbedUrl = (url: string): string => {
|
||||||
try {
|
try {
|
||||||
const urlObj = new URL(url);
|
const urlObj = new URL(url);
|
||||||
// nếu là link youtube dạng watch?v=...
|
|
||||||
if (urlObj.hostname.includes('youtube.com')) {
|
if (urlObj.hostname.includes('youtube.com')) {
|
||||||
const videoId = urlObj.searchParams.get('v');
|
const videoId = urlObj.searchParams.get('v');
|
||||||
if (videoId) {
|
if (videoId) {
|
||||||
return `https://www.youtube.com/embed/${videoId}?autoplay=1`;
|
return `https://www.youtube.com/embed/${videoId}?autoplay=1`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// nếu là link youtu.be/xxxx
|
|
||||||
if (urlObj.hostname.includes('youtu.be')) {
|
if (urlObj.hostname.includes('youtu.be')) {
|
||||||
const videoId = urlObj.pathname.replace('/', '');
|
const videoId = urlObj.pathname.replace('/', '');
|
||||||
if (videoId) {
|
if (videoId) {
|
||||||
return `https://www.youtube.com/embed/${videoId}?autoplay=1`;
|
return `https://www.youtube.com/embed/${videoId}?autoplay=1`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// fallback: trả về chính url
|
|
||||||
return url;
|
return url;
|
||||||
} catch {
|
} catch {
|
||||||
return url;
|
return url;
|
||||||
@@ -48,8 +55,8 @@ export const BoxVideoArticle = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div className="list-video-article flex justify-between gap-2">
|
<div className="list-video-article flex justify-between gap-2">
|
||||||
<div className="box-left" ref={fancyboxRef}>
|
<div className="box-left" ref={fancyboxRef}>
|
||||||
{DataListArticleVideo.slice(0, 1).map((item, index) => (
|
{videos.slice(0, 1).map((item) => (
|
||||||
<div className="item-article-video d-flex w-50 gap-10" key={index}>
|
<div className="item-article-video d-flex w-50 gap-10" key={item.id}>
|
||||||
<Link
|
<Link
|
||||||
href={getYoutubeEmbedUrl(item.external_url)}
|
href={getYoutubeEmbedUrl(item.external_url)}
|
||||||
className="img-article img-article-video boder-radius-10 relative"
|
className="img-article img-article-video boder-radius-10 relative"
|
||||||
@@ -83,8 +90,8 @@ export const BoxVideoArticle = () => {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="box-right grid grid-cols-2 gap-2">
|
<div className="box-right grid grid-cols-2 gap-2">
|
||||||
{DataListArticleVideo.slice(1, 7).map((item, index) => (
|
{videos.slice(1, 7).map((item) => (
|
||||||
<div className="item-article-video flex w-50 gap-2" key={index}>
|
<div className="item-article-video flex w-50 gap-2" key={item.id}>
|
||||||
<Link
|
<Link
|
||||||
href={getYoutubeEmbedUrl(item.external_url)}
|
href={getYoutubeEmbedUrl(item.external_url)}
|
||||||
className="img-article img-article-video boder-radius-10 relative"
|
className="img-article img-article-video boder-radius-10 relative"
|
||||||
@@ -1,17 +1,25 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Breadcrumb } from '@components/Common/Breadcrumb';
|
import { Breadcrumb } from '@components/Common/Breadcrumb';
|
||||||
import { DataArticleCategory } from '@/data/article/ListCategory';
|
|
||||||
|
|
||||||
import { ArticleTopLeft } from '../ArticleTopLeft';
|
import { ArticleTopLeft } from '../ArticleTopLeft';
|
||||||
import { ArticleTopRight } from '../ArticleTopRight';
|
import { ArticleTopRight } from '../ArticleTopRight';
|
||||||
import { BoxVideoArticle } from './BoxVideoArticle';
|
import { BoxVideoArticle } from './BoxVideoArticle';
|
||||||
import { BoxArticleMid } from './BoxArticleMid';
|
import { BoxArticleMid } from './BoxArticleMid';
|
||||||
import { BoxArticleReview } from './BoxArticleReview';
|
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 ArticleHome = () => {
|
||||||
const breadcrumbItems = [{ name: 'Tin tức', url: '/tin-tuc' }];
|
const breadcrumbItems = [{ name: 'Tin tức', url: '/tin-tuc' }];
|
||||||
|
const { data: categories } = useApiData(
|
||||||
|
() => getArticleCategories(),
|
||||||
|
[],
|
||||||
|
{ initialData: [] as TypeArticleCategory[] },
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="page-article pb-10">
|
<section className="page-article pb-10">
|
||||||
@@ -19,8 +27,8 @@ const ArticleHome = () => {
|
|||||||
<Breadcrumb items={breadcrumbItems} />
|
<Breadcrumb items={breadcrumbItems} />
|
||||||
|
|
||||||
<div className="tabs-category-article flex items-center">
|
<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">
|
<Link href={item.url} key={`${item.id}-${index}`} className="item-tab-article">
|
||||||
<h2 className="title-cate-article font-[400]">{item.title}</h2>
|
<h2 className="title-cate-article font-[400]">{item.title}</h2>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
@@ -33,13 +41,8 @@ const ArticleHome = () => {
|
|||||||
<ArticleTopRight />
|
<ArticleTopRight />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* box video */}
|
|
||||||
<BoxVideoArticle />
|
<BoxVideoArticle />
|
||||||
|
|
||||||
{/* box mid */}
|
|
||||||
<BoxArticleMid />
|
<BoxArticleMid />
|
||||||
|
|
||||||
{/* review */}
|
|
||||||
<BoxArticleReview />
|
<BoxArticleReview />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1,8 +1,19 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import { FaCaretRight } from 'react-icons/fa';
|
import { FaCaretRight } from 'react-icons/fa';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { dataArticle } from './dataArticle';
|
|
||||||
import ItemArticleVideo from './ItemArticleVideo';
|
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 BoxArticleVideo: React.FC = () => {
|
||||||
|
const { data: videos } = useApiData(
|
||||||
|
() => getArticleVideos(),
|
||||||
|
[],
|
||||||
|
{ initialData: [] as Article[] },
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="box-videos-group box-article-group boder-radius-10 relative">
|
<div className="box-videos-group box-article-group boder-radius-10 relative">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -20,8 +31,8 @@ const BoxArticleVideo: React.FC = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="list-videos-group list-article-group flex items-center gap-10">
|
<div className="list-videos-group list-article-group flex items-center gap-10">
|
||||||
{dataArticle.slice(0, 4).map((item, index) => (
|
{videos.slice(0, 4).map((item) => (
|
||||||
<ItemArticleVideo item={item} key={index} />
|
<ItemArticleVideo item={item} key={item.id} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,22 +1,33 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import { FaCaretRight } from 'react-icons/fa';
|
import { FaCaretRight } from 'react-icons/fa';
|
||||||
import { dataArticle } from './dataArticle';
|
import Link from 'next/link';
|
||||||
import ItemArticle from './ItemArticle';
|
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 BoxArticle: React.FC = () => {
|
||||||
|
const { data: articles } = useApiData(
|
||||||
|
() => getArticles(),
|
||||||
|
[],
|
||||||
|
{ initialData: [] as Article[] },
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="box-article-group boder-radius-10">
|
<div className="box-article-group boder-radius-10">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="title-box">
|
<div className="title-box">
|
||||||
<h2 className="title-box font-[600]">Tin tức công nghệ</h2>
|
<h2 className="title-box font-[600]">Tin tức công nghệ</h2>
|
||||||
</div>
|
</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>
|
<span>Xem tất cả</span>
|
||||||
<FaCaretRight size={16} />
|
<FaCaretRight size={16} />
|
||||||
</a>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<div className="list-article-group flex items-center gap-10">
|
<div className="list-article-group flex items-center gap-10">
|
||||||
{dataArticle.slice(0, 4).map((item, index) => (
|
{articles.slice(0, 4).map((item) => (
|
||||||
<ItemArticle item={item} key={index} />
|
<ItemArticle item={item} key={item.id} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { FaCaretDown } from 'react-icons/fa';
|
import { FaCaretDown } from 'react-icons/fa';
|
||||||
@@ -6,12 +7,16 @@ import { Swiper, SwiperSlide } from 'swiper/react';
|
|||||||
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
||||||
import ItemProduct from '@/components/Common/ItemProduct';
|
import ItemProduct from '@/components/Common/ItemProduct';
|
||||||
|
|
||||||
import { InfoCategory } from '@/types';
|
|
||||||
|
|
||||||
import { menuData } from '@/components/Other/Header/menuData';
|
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 BoxListCategory: React.FC = () => {
|
||||||
|
const { data: products } = useApiData(() => getProductHot(), [], {
|
||||||
|
initialData: [] as TypeListProduct,
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{menuData[0].product.all_category.map((item, index) => (
|
{menuData[0].product.all_category.map((item, index) => (
|
||||||
@@ -41,9 +46,9 @@ const BoxListCategory: React.FC = () => {
|
|||||||
loop={true}
|
loop={true}
|
||||||
navigation={true}
|
navigation={true}
|
||||||
>
|
>
|
||||||
{productData.map((item, index) => (
|
{products.map((product) => (
|
||||||
<SwiperSlide key={index}>
|
<SwiperSlide key={product.id}>
|
||||||
<ItemProduct item={item} />
|
<ItemProduct item={product} />
|
||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
))}
|
))}
|
||||||
</Swiper>
|
</Swiper>
|
||||||
@@ -2,9 +2,9 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
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 (
|
return (
|
||||||
<Link href={item.url} className="item-category flex flex-col items-center">
|
<Link href={item.url} className="item-category flex flex-col items-center">
|
||||||
<p className="item-category-img">
|
<p className="item-category-img">
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { menuData } from '../../../../components/Other/Header/menuData';
|
import { menuData } from '@components/Other/Header/menuData';
|
||||||
import ItemCategory from './ItemCategory';
|
import ItemCategory from './ItemCategory';
|
||||||
import { InfoCategory } from '@/types';
|
import { InfoCategory } from '@/types';
|
||||||
|
|
||||||
@@ -1,16 +1,26 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||||
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
||||||
import { FaCaretRight } from 'react-icons/fa';
|
import { FaCaretRight } from 'react-icons/fa';
|
||||||
|
import { TypeListProductDeal } from '@/types';
|
||||||
import { ListDealData } from '@/data/deal';
|
import { getDeals } from '@/lib/api/deal';
|
||||||
|
import CountDown from '@/components/Common/CountDown';
|
||||||
import CounDown from '@components/Common/CounDown';
|
|
||||||
import ProductItem from './ProductItem';
|
import ProductItem from './ProductItem';
|
||||||
|
|
||||||
const BoxProductDeal: React.FC = () => {
|
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 (
|
return (
|
||||||
<div className="box-product-deal boder-radius-10">
|
<div className="box-product-deal boder-radius-10">
|
||||||
<div className="box-title-deal flex items-center justify-between">
|
<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>
|
<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>
|
<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">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/deal" className="button-deal color-white mb-10 flex items-center">
|
<Link href="/deal" className="button-deal color-white mb-10 flex items-center">
|
||||||
@@ -30,13 +40,19 @@ const BoxProductDeal: React.FC = () => {
|
|||||||
<Swiper
|
<Swiper
|
||||||
modules={[Autoplay, Navigation, Pagination]}
|
modules={[Autoplay, Navigation, Pagination]}
|
||||||
spaceBetween={12}
|
spaceBetween={12}
|
||||||
slidesPerView={6}
|
|
||||||
loop={true}
|
loop={true}
|
||||||
navigation={true}
|
navigation={true}
|
||||||
|
breakpoints={{
|
||||||
|
320: { slidesPerView: 2 },
|
||||||
|
640: { slidesPerView: 3 },
|
||||||
|
768: { slidesPerView: 4 },
|
||||||
|
1024: { slidesPerView: 5 },
|
||||||
|
1280: { slidesPerView: 6 },
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{ListDealData.map((Item, index) => (
|
{deals.map((item) => (
|
||||||
<SwiperSlide key={index}>
|
<SwiperSlide key={item.id}>
|
||||||
<ProductItem item={Item} />
|
<ProductItem item={item} />
|
||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
))}
|
))}
|
||||||
</Swiper>
|
</Swiper>
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
interface TypeReview {
|
interface TypeReview {
|
||||||
avatar: string;
|
avatar: string;
|
||||||
@@ -32,4 +31,5 @@ const ItemReview: React.FC<ItemReviewProps> = ({ item }) => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ItemReview;
|
export default ItemReview;
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { dataReview } from './dataReview';
|
|
||||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||||
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
||||||
import ItemReview from './ItemReview';
|
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 BoxReviewCustomer: React.FC = () => {
|
||||||
|
const { data: reviews } = useApiData(
|
||||||
|
() => getHomeReviews(),
|
||||||
|
[],
|
||||||
|
{ initialData: [] as HomeReview[] },
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="box-review-from-customer boder-radius-10">
|
<div className="box-review-from-customer boder-radius-10">
|
||||||
<div className="title-box">
|
<div className="title-box">
|
||||||
@@ -17,8 +27,8 @@ const BoxReviewCustomer: React.FC = () => {
|
|||||||
loop={true}
|
loop={true}
|
||||||
pagination={{ clickable: true }}
|
pagination={{ clickable: true }}
|
||||||
>
|
>
|
||||||
{dataReview.map((item, index) => (
|
{reviews.map((item, index) => (
|
||||||
<SwiperSlide key={index} className="item">
|
<SwiperSlide key={`${item.author}-${index}`} className="item">
|
||||||
<ItemReview item={item} />
|
<ItemReview item={item} />
|
||||||
</SwiperSlide>
|
</SwiperSlide>
|
||||||
))}
|
))}
|
||||||
@@ -27,4 +37,5 @@ const BoxReviewCustomer: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default BoxReviewCustomer;
|
export default BoxReviewCustomer;
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||||
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { bannerData } from '@/data/banner';
|
import { getBanners } from '@/lib/api/banner';
|
||||||
|
import { TemplateBanner } from '@/types';
|
||||||
|
|
||||||
const SliderHome: React.FC = () => {
|
const SliderHome: React.FC = () => {
|
||||||
// data banner slider
|
const [banners, setBanners] = useState<TemplateBanner | null>(null);
|
||||||
const dataSlider = bannerData[0].homepage;
|
|
||||||
|
useEffect(() => {
|
||||||
|
getBanners().then(setBanners).catch(console.error);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dataSlider = banners?.homepage;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -1,11 +1,21 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||||
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
import Link from 'next/link';
|
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 BannerCategory = () => {
|
||||||
const dataSlider = bannerData[0].product_list;
|
const { data: banners } = useApiData(
|
||||||
|
() => getBanners(),
|
||||||
|
[],
|
||||||
|
{ initialData: null as TemplateBanner | null },
|
||||||
|
);
|
||||||
|
|
||||||
|
const dataSlider = banners?.product_list;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="box-banner-category">
|
<div className="box-banner-category">
|
||||||
@@ -9,7 +9,7 @@ interface BoxCategoryChildProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ItemCategoryChild: React.FC<BoxCategoryChildProps> = ({ item }) => {
|
const ItemCategoryChild: React.FC<BoxCategoryChildProps> = ({ item }) => {
|
||||||
const ItemImage = item.big_image
|
const itemImage = item.big_image
|
||||||
? item.big_image
|
? item.big_image
|
||||||
: item.thumnail
|
: item.thumnail
|
||||||
? item.thumnail
|
? item.thumnail
|
||||||
@@ -19,7 +19,7 @@ const ItemCategoryChild: React.FC<BoxCategoryChildProps> = ({ item }) => {
|
|||||||
<li>
|
<li>
|
||||||
<Link href={item.url}>
|
<Link href={item.url}>
|
||||||
<div className="border-img lazy flex items-center justify-center">
|
<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>
|
</div>
|
||||||
<p className="txt font-weight-500">{item.title}</p>
|
<p className="txt font-weight-500">{item.title}</p>
|
||||||
</Link>
|
</Link>
|
||||||
@@ -1,35 +1,48 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { useSearchParams } from 'next/navigation';
|
||||||
import type { CategoryData } from '@/types';
|
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 { Breadcrumb } from '@/components/Common/Breadcrumb';
|
||||||
import BannerCategory from './BannerCategory';
|
import BannerCategory from './BannerCategory';
|
||||||
import ItemCategoryChild from './ItemCategoryChild';
|
import ItemCategoryChild from './ItemCategoryChild';
|
||||||
import BoxFilter from '@components/Product/BoxFilter';
|
import BoxFilter from '@components/Product/BoxFilter';
|
||||||
import BoxSort from '@components/Product/BoxSort';
|
import BoxSort from '@components/Product/BoxSort';
|
||||||
import ItemProduct from '@/components/Common/ItemProduct';
|
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 {
|
interface CategoryPageProps {
|
||||||
slug: string; // khai báo prop slug
|
slug: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
|
const CategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
|
||||||
// Ép kiểu dữ liệu từ index.ts về CategoryData[]
|
const searchParams = useSearchParams();
|
||||||
const categories = productCategoryData as unknown as CategoryData[];
|
const search = searchParams.toString();
|
||||||
const currentCategory = findCategoryBySlug(slug, categories);
|
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) => ({
|
const breadcrumbItems = currentCategory?.current_category?.path?.path?.map((p) => ({
|
||||||
name: p.name,
|
name: p.name,
|
||||||
url: p.url,
|
url: p.url,
|
||||||
})) ?? [
|
})) ?? [
|
||||||
{ name: 'Trang chủ', 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) {
|
if (!currentCategory) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 py-50">
|
<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>
|
<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">
|
<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
|
Không thấy link <code className="rounded bg-gray-100 px-2 py-0.5 text-sm">{slug}</code>{' '}
|
||||||
tồn tại hoặc đã bị xoá.
|
không tồn tại hoặc đã bị xóa.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/"
|
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"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// lấy sản phẩm
|
|
||||||
const products = Object.values(currentCategory.product_list);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page-category">
|
<div className="page-category">
|
||||||
<div className="container">
|
<div className="container">
|
||||||
@@ -81,26 +91,23 @@ const CategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
|
|||||||
<h1 className="name-category font-bold">{currentCategory.current_category.name}</h1>
|
<h1 className="name-category font-bold">{currentCategory.current_category.name}</h1>
|
||||||
<div className="box-content-category">
|
<div className="box-content-category">
|
||||||
<ul className="category-child boder-radius-10 flex flex-wrap justify-center">
|
<ul className="category-child boder-radius-10 flex flex-wrap justify-center">
|
||||||
{currentCategory.current_category.children?.map((item, index) => (
|
{currentCategory.current_category.children?.map((item) => (
|
||||||
<ItemCategoryChild item={item} key={index} />
|
<ItemCategoryChild item={item} key={item.id} />
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
{/* filter */}
|
|
||||||
<BoxFilter filters={currentCategory} />
|
<BoxFilter filters={currentCategory} />
|
||||||
|
|
||||||
<div className="box-list-product-category boder-radius-10">
|
<div className="box-list-product-category boder-radius-10">
|
||||||
{/* filter sort */}
|
|
||||||
<BoxSort
|
<BoxSort
|
||||||
sort_by_collection={currentCategory.sort_by_collection}
|
sort_by_collection={currentCategory.sort_by_collection}
|
||||||
product_display_type="grid"
|
display_by_collection={currentCategory.display_by_collection}
|
||||||
|
product_display_type={productDisplayType}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* list product */}
|
|
||||||
|
|
||||||
<div className="list-product-category grid grid-cols-5 gap-3">
|
<div className="list-product-category grid grid-cols-5 gap-3">
|
||||||
{products.map((item, index) => (
|
{currentCategory.product_list.map((item) => (
|
||||||
<ItemProduct key={index} item={item} />
|
<ItemProduct key={item.id} item={item} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</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';
|
'use client';
|
||||||
|
import { useState } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import type { ProductDetailData } from '@/types';
|
import type { ProductDetailData } from '@/types';
|
||||||
import Link from 'next/link';
|
|
||||||
|
|
||||||
import { BoxPrice } from './BoxPrice';
|
import { BoxPrice } from './BoxPrice';
|
||||||
import { BoxBought } from './BoxBought';
|
import { BoxBought } from './BoxBought';
|
||||||
|
|
||||||
// thêm giỏ hàng
|
|
||||||
import { addToCart } from '@/lib/ButtonCart';
|
import { addToCart } from '@/lib/ButtonCart';
|
||||||
|
import { SanitizedHtml } from '@/components/Common/SanitizedHtml';
|
||||||
|
|
||||||
export const BoxInfoRight = (item: ProductDetailData) => {
|
export const BoxInfoRight = (item: ProductDetailData) => {
|
||||||
const router = useRouter();
|
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 = () => {
|
const handleBuyNow = () => {
|
||||||
|
addToCart(item.product_info, quantity);
|
||||||
router.push('/cart');
|
router.push('/cart');
|
||||||
addToCart(item.product_info.productId);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,27 +48,28 @@ export const BoxInfoRight = (item: ProductDetailData) => {
|
|||||||
<div className="item-basic">
|
<div className="item-basic">
|
||||||
Lượt xem: <span className="color-primary">{item.product_info.visit}</span>
|
Lượt xem: <span className="color-primary">{item.product_info.visit}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{item.product_info.extend.buy_count?.length > 0 && (
|
{item.product_info.extend.buy_count?.length > 0 && (
|
||||||
<div className="item-basic last-item-basic position-relative">
|
<div className="item-basic last-item-basic position-relative">
|
||||||
Đã bán: <span className="color-primary">{item.product_info.extend.buy_count}</span>
|
Đã bán: <span className="color-primary">{item.product_info.extend.buy_count}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* tình trạng */}
|
{/* tình trạng */}
|
||||||
<div className="list-basic-product-info flex flex-wrap items-center gap-6">
|
<div className="list-basic-product-info flex flex-wrap items-center gap-6">
|
||||||
<div className="item-basic">
|
<div className="item-basic">
|
||||||
Bảo hành: <span className="color-red">{item.product_info.warranty}</span>
|
Bảo hành: <span className="color-red">{item.product_info.warranty}</span>
|
||||||
</div>
|
</div>
|
||||||
|
{inStock && (
|
||||||
{item.product_info.quantity > '0' && (
|
|
||||||
<div className="item-basic last-item-basic position-relative">
|
<div className="item-basic last-item-basic position-relative">
|
||||||
Tình trạng: <span className="color-green">Còn hàng</span>
|
Tình trạng: <span className="color-green">Còn hàng</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* giá */}
|
{/* giá */}
|
||||||
<BoxPrice {...item} />
|
<BoxPrice {...item} />
|
||||||
|
|
||||||
{item.product_info.specialOffer.all.length > 0 && (
|
{item.product_info.specialOffer.all.length > 0 && (
|
||||||
<div className="box-offer-detail border-radius-10">
|
<div className="box-offer-detail border-radius-10">
|
||||||
<div className="title-offer-detail flex items-center">
|
<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) => (
|
{item.product_info.specialOffer.all.map((_item, idx) => (
|
||||||
<div key={idx} className="item-offer">
|
<div key={idx} className="item-offer">
|
||||||
<i className="icon"></i>
|
<i className="icon"></i>
|
||||||
<div dangerouslySetInnerHTML={{ __html: _item.title }} />
|
<SanitizedHtml html={_item.title} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* mua hàng */}
|
{/* mua hàng */}
|
||||||
{(item.product_info.quantity > '0' || item.product_info.price > '0') && (
|
{(inStock || hasPrice) && (
|
||||||
<>
|
<>
|
||||||
<div className="product-buy-quantity flex items-center">
|
<div className="product-buy-quantity flex items-center">
|
||||||
<p className="title-quantity">Số lượng:</p>
|
<p className="title-quantity">Số lượng:</p>
|
||||||
<div className="cart-quantity-select flex items-center justify-center">
|
<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>
|
</p>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="js-buy-quantity js-quantity-change bk-product-qty font-bold"
|
className="js-buy-quantity bk-product-qty font-bold"
|
||||||
defaultValue={1}
|
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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
className="addCart flex cursor-pointer flex-wrap items-center justify-center gap-3"
|
className="addCart flex cursor-pointer flex-wrap items-center justify-center gap-3"
|
||||||
onClick={() => {
|
onClick={handleAddToCart}
|
||||||
addToCart(item.product_info.productId);
|
|
||||||
alert('Sản phẩm đã được thêm vào giỏ hàng!');
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<i className="sprite sprite-cart-detail"></i>
|
<i className="sprite sprite-cart-detail"></i>
|
||||||
<p className="title-cart">Thêm vào giỏ hàng</p>
|
<p className="title-cart">Thêm vào giỏ hàng</p>
|
||||||
</button>
|
</button>
|
||||||
<input type="hidden" className="js-buy-quantity-temp" value="1" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
{cartMessage && <p className="mt-1 text-sm font-medium text-green-600">{cartMessage}</p>}
|
||||||
id="detail-buy-ads"
|
|
||||||
className="detail-buy grid grid-cols-2 gap-2"
|
<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" onClick={handleBuyNow}>
|
||||||
>
|
|
||||||
<button className="detail-buy-now col-span-2 cursor-pointer">
|
|
||||||
<span>ĐẶT MUA NGAY</span>
|
<span>ĐẶT MUA NGAY</span>
|
||||||
Giao hàng tận nơi nhanh chóng
|
Giao hàng tận nơi nhanh chóng
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button className="detail-add-cart">
|
<button className="detail-add-cart">
|
||||||
<span>TRẢ GÓP QUA HỒ SƠ</span>
|
<span>TRẢ GÓP QUA HỒ SƠ</span>
|
||||||
Chỉ từ 2.665.000₫/ tháng
|
Chỉ từ 2.665.000₫/ tháng
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button className="detail-add-cart">
|
<button className="detail-add-cart">
|
||||||
<span>TRẢ GÓP QUA THẺ</span>
|
<span>TRẢ GÓP QUA THẺ</span>
|
||||||
Chỉ từ 1.332.500₫/ tháng
|
Chỉ từ 1.332.500₫/ tháng
|
||||||
@@ -128,9 +142,10 @@ export const BoxInfoRight = (item: ProductDetailData) => {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* yên tâm mua hàng */}
|
{/* yên tâm mua hàng */}
|
||||||
<div className="box-product-policy-detal boder-radius-10" style={{ marginTop: '24px' }}>
|
<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="list-showroom-detail flex flex-wrap justify-between">
|
||||||
<div className="item flex items-center gap-2">
|
<div className="item flex items-center gap-2">
|
||||||
<i className="sprite sprite-camket-detail"></i>
|
<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 Image from 'next/image';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { ComboProduct } from '@/types';
|
import { ComboProduct } from '@/types';
|
||||||
@@ -22,7 +22,13 @@ export const ChangeProductPopup: React.FC<ChangePopupProps> = ({
|
|||||||
if (!open) return null; // chỉ render khi open = true
|
if (!open) return null; // chỉ render khi open = true
|
||||||
|
|
||||||
return (
|
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="modal-box max-w-5xl bg-white">
|
||||||
<div className="mb-4 flex items-center justify-between">
|
<div className="mb-4 flex items-center justify-between">
|
||||||
<h3 className="text-lg font-bold">Chọn {titleGroup} khác</h3>
|
<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 */}
|
{/* Danh sách sản phẩm */}
|
||||||
<div className="grid grid-cols-4 gap-3">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
{products.map((p) => (
|
{products.map((p, idx) => (
|
||||||
<div key={p.id} className="product-item c-pro-item">
|
<div key={`${p.id}-${idx}`} className="product-item c-pro-item">
|
||||||
<Link href={p.url} className="product-image">
|
<Link href={p.url} className="product-image">
|
||||||
<Image
|
<Image
|
||||||
src={p.images.large || '/static/assets/not-image.png'}
|
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>
|
<h3 className="product-title line-clamp-2">{p.title}</h3>
|
||||||
</Link>
|
</Link>
|
||||||
<div className="product-price-main flex items-center justify-between">
|
<div className="product-price-main flex items-center justify-between">
|
||||||
<div className='class="product-price"'>
|
<div className="product-price">
|
||||||
<b className="price font-[600]">
|
<b className="price font-semibold">
|
||||||
{Number(p.price) > 0 ? `${formatCurrency(p.price)} đ` : 'Liên hệ'}
|
{Number(p.price) > 0 ? `${formatCurrency(p.price)} đ` : 'Liên hệ'}
|
||||||
</b>
|
</b>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<span
|
<button
|
||||||
|
type="button"
|
||||||
className="c-btn js-c-btn"
|
className="c-btn js-c-btn"
|
||||||
onClick={() => {
|
onClick={() => onSelect(p)}
|
||||||
onSelect(p);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
Chọn mua
|
Chọn mua
|
||||||
</span>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 { Swiper, SwiperSlide } from 'swiper/react';
|
||||||
import { Autoplay, Navigation, Pagination, Thumbs } from 'swiper/modules';
|
import { Autoplay, Navigation, Pagination, Thumbs } from 'swiper/modules';
|
||||||
import Image from 'next/image';
|
import Image from 'next/image';
|
||||||
@@ -8,10 +8,10 @@ import type { Swiper as SwiperType } from 'swiper';
|
|||||||
import useFancybox from '@/hooks/useFancybox';
|
import useFancybox from '@/hooks/useFancybox';
|
||||||
|
|
||||||
interface ImageProps {
|
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 [thumbsSwiper, setThumbsSwiper] = useState<SwiperType | null>(null);
|
||||||
|
|
||||||
const [fancyboxRef] = useFancybox({
|
const [fancyboxRef] = useFancybox({
|
||||||
@@ -29,7 +29,7 @@ export const ImageProduct: React.FC<ImageProps> = ({ ItemImage }) => {
|
|||||||
loop={true}
|
loop={true}
|
||||||
thumbs={{ swiper: thumbsSwiper }}
|
thumbs={{ swiper: thumbsSwiper }}
|
||||||
>
|
>
|
||||||
{ItemImage?.map((item, index) => (
|
{images?.map((item, index) => (
|
||||||
<SwiperSlide key={index}>
|
<SwiperSlide key={index}>
|
||||||
<Link href={item.size.original} className="bigImage" data-fancybox>
|
<Link href={item.size.original} className="bigImage" data-fancybox>
|
||||||
<Image src={item.size.original} alt={''} width="595" height="595" />
|
<Image src={item.size.original} alt={''} width="595" height="595" />
|
||||||
@@ -46,7 +46,7 @@ export const ImageProduct: React.FC<ImageProps> = ({ ItemImage }) => {
|
|||||||
loop={true}
|
loop={true}
|
||||||
onSwiper={setThumbsSwiper}
|
onSwiper={setThumbsSwiper}
|
||||||
>
|
>
|
||||||
{ItemImage?.map((item, index) => (
|
{images?.map((item, index) => (
|
||||||
<SwiperSlide key={index}>
|
<SwiperSlide key={index}>
|
||||||
<div className="smallImage">
|
<div className="smallImage">
|
||||||
<Image src={item.size.original} alt={''} width="90" height="60" />
|
<Image src={item.size.original} alt={''} width="90" height="60" />
|
||||||
@@ -1,51 +1,70 @@
|
|||||||
import React, { useState } from 'react';
|
'use client';
|
||||||
import { ListCommentData } from '@/data/ListComment';
|
|
||||||
import Image from 'next/image';
|
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 (
|
return (
|
||||||
<div className="comment-list">
|
<div className="comment-list">
|
||||||
{ListCommentData.slice(0.3).map((item, index) => (
|
{visibleComments.map((item) => (
|
||||||
<div className="item-comment" id={`comment_${item.id}`} key={index}>
|
<div className="item-comment" id={`comment_${item.id}`} key={item.id}>
|
||||||
<div className="form-reply-comment">
|
<div className="form-reply-comment">
|
||||||
{/* header */}
|
|
||||||
<div className="comment-name flex justify-between">
|
<div className="comment-name flex justify-between">
|
||||||
<div className="comment-form-left flex items-center gap-2">
|
<div className="comment-form-left flex items-center gap-2">
|
||||||
{item.user_avatar ? (
|
{item.user_avatar ? (
|
||||||
<b className="avatar-user">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<b className="avatar-user flex items-center justify-center">
|
<b className="avatar-user flex items-center justify-center">
|
||||||
{' '}
|
{item.user_name.charAt(0)}
|
||||||
{item.user_name.charAt(0)}{' '}
|
|
||||||
</b>
|
</b>
|
||||||
)}
|
)}
|
||||||
<b className="user-name">{item.user_name}</b>
|
<b className="user-name">{item.user_name}</b>
|
||||||
</div>
|
</div>
|
||||||
<div className="comment-form-right flex items-center gap-2 text-sm text-gray-500">
|
<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>
|
<span>{new Date(Number(item.post_time) * 1000).toLocaleDateString('vi-VN')}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>{' '}
|
</div>
|
||||||
{/* content */}
|
|
||||||
<div className="comment-content relative mt-3 rounded p-2">
|
<div className="comment-content relative mt-3 rounded p-2">
|
||||||
<p>{item.content}</p>
|
<p>{item.content}</p>
|
||||||
<div className="info_feeback mt-2 flex items-center gap-2">
|
<div className="info_feeback mt-2 flex items-center gap-2">
|
||||||
<i className="sprite sprite-icon-reply-detail"></i>
|
<i className="sprite sprite-icon-reply-detail"></i>
|
||||||
<button className="btn-reply font-medium"> Trả lời </button>{' '}
|
<button className="btn-reply font-medium">Trả lời</button>
|
||||||
</div>{' '}
|
</div>
|
||||||
</div>{' '}
|
</div>
|
||||||
{/* reply list */}
|
|
||||||
<div className="reply-list-container mt-4">
|
<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 key={reply.id} className="item_reply mt-3">
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{reply.user_avatar !== '0' ? (
|
{reply.user_avatar !== '0' ? (
|
||||||
<b className="avatar-user flex items-center justify-center">
|
<b className="avatar-user flex items-center justify-center">
|
||||||
{' '}
|
<Image
|
||||||
<img src={reply.user_avatar} alt={reply.user_name} />{' '}
|
src={reply.user_avatar}
|
||||||
|
alt={reply.user_name}
|
||||||
|
width={32}
|
||||||
|
height={32}
|
||||||
|
/>
|
||||||
</b>
|
</b>
|
||||||
) : (
|
) : (
|
||||||
<b className="avatar-user flex items-center justify-center">
|
<b className="avatar-user flex items-center justify-center">
|
||||||
@@ -55,19 +74,30 @@ export const ListComment = () => {
|
|||||||
<div className="comment-name">
|
<div className="comment-name">
|
||||||
<b className="user-name">{reply.user_name}</b>
|
<b className="user-name">{reply.user_name}</b>
|
||||||
{reply.is_user_admin === '1' && <i className="note font-medium">QTV</i>}
|
{reply.is_user_admin === '1' && <i className="note font-medium">QTV</i>}
|
||||||
</div>{' '}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm text-gray-500">
|
<div className="text-sm text-gray-500">
|
||||||
{new Date(Number(reply.post_time) * 1000).toLocaleDateString('vi-VN')}
|
{new Date(Number(reply.post_time) * 1000).toLocaleDateString('vi-VN')}
|
||||||
</div>{' '}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="comment-content mt-2 rounded p-2">{reply.content} </div>{' '}
|
</div>
|
||||||
|
<div className="comment-content mt-2 rounded p-2">{reply.content}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { FormComment } from './FormComment';
|
import { FormComment } from './FormComment';
|
||||||
import { ListComment } from './ListComment';
|
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);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="box-comment">
|
<div className="box-comment">
|
||||||
<p className="title-comment font-[600]">Hỏi và đáp</p>
|
<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)} />
|
<FormComment open={open} onClose={() => setOpen(false)} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* list comment */}
|
<ListComment slug={slug} />
|
||||||
<ListComment />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { FaAngleDown, FaAngleUp } from 'react-icons/fa6';
|
import { FaAngleDown, FaAngleUp } from 'react-icons/fa6';
|
||||||
import type { ProductDetailData } from '@/types';
|
import type { ProductDetailData } from '@/types';
|
||||||
import Link from 'next/link';
|
import { SanitizedHtml } from '@/components/Common/SanitizedHtml';
|
||||||
|
|
||||||
export const ProductDescription = (item: ProductDetailData) => {
|
export const ProductDescription = (item: ProductDetailData) => {
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
@@ -11,11 +11,11 @@ export const ProductDescription = (item: ProductDetailData) => {
|
|||||||
return (
|
return (
|
||||||
<div className="box-descreption-detail">
|
<div className="box-descreption-detail">
|
||||||
<h2 className="titlle-descreption font-[500]">Giới thiệu {item.product_info.productName}</h2>
|
<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 ${
|
className={`content-descreption-detail static-html relative ${
|
||||||
expanded ? 'max-h-none' : 'max-h-[467px] overflow-hidden'
|
expanded ? 'max-h-none' : 'max-h-[467px] overflow-hidden'
|
||||||
}`}
|
}`}
|
||||||
dangerouslySetInnerHTML={{ __html: item.product_info.productDescription }}
|
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
onClick={() => setExpanded(!expanded)}
|
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';
|
'use client';
|
||||||
import { ListReviewData } from '@/data/ListReview';
|
|
||||||
import Image from 'next/image';
|
|
||||||
|
|
||||||
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 [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 (
|
return (
|
||||||
<div className="list-review">
|
<div className="list-review">
|
||||||
@@ -15,12 +29,16 @@ export const ListReview = () => {
|
|||||||
return (
|
return (
|
||||||
<div key={review.id} className="item-comment">
|
<div key={review.id} className="item-comment">
|
||||||
<div className="form-reply-comment">
|
<div className="form-reply-comment">
|
||||||
{/* header */}
|
|
||||||
<div className="comment-name flex items-center justify-between">
|
<div className="comment-name flex items-center justify-between">
|
||||||
<div className="comment-form-left flex items-center gap-2">
|
<div className="comment-form-left flex items-center gap-2">
|
||||||
{review.user_avatar ? (
|
{review.user_avatar ? (
|
||||||
<b className="avatar-user js-avatar-user flex items-center justify-center">
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<b className="avatar-user js-avatar-user flex items-center justify-center">
|
<b className="avatar-user js-avatar-user flex items-center justify-center">
|
||||||
@@ -35,7 +53,6 @@ export const ListReview = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* content */}
|
|
||||||
<div className="comment-content boder-radius-10 relative mt-3">
|
<div className="comment-content boder-radius-10 relative mt-3">
|
||||||
<div className="text-review flex flex-col gap-2">
|
<div className="text-review flex flex-col gap-2">
|
||||||
<p className="flex items-center">
|
<p className="flex items-center">
|
||||||
@@ -47,13 +64,11 @@ export const ListReview = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* feedback actions */}
|
|
||||||
<div className="info_feeback flex items-center gap-2">
|
<div className="info_feeback flex items-center gap-2">
|
||||||
<i className="sprite sprite-icon-reply-detail"></i>
|
<i className="sprite sprite-icon-reply-detail"></i>
|
||||||
<button className="write_reply btn-reply font-weight-500">Trả lời</button>
|
<button className="write_reply btn-reply font-weight-500">Trả lời</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* images nếu có */}
|
|
||||||
<div className="jd-img-review flex flex-col gap-2">
|
<div className="jd-img-review flex flex-col gap-2">
|
||||||
{review.files.map((file) => (
|
{review.files.map((file) => (
|
||||||
<Image
|
<Image
|
||||||
@@ -67,7 +82,6 @@ export const ListReview = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* reply list */}
|
|
||||||
<div className="reply-holder reply-list-container">
|
<div className="reply-holder reply-list-container">
|
||||||
{review.new_replies.map((reply) => (
|
{review.new_replies.map((reply) => (
|
||||||
<div key={reply.id} className="item_reply relative mt-3">
|
<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">
|
<div className="comment-left-form item-center flex gap-2">
|
||||||
<b className="avatar-user avatar-admin">
|
<b className="avatar-user avatar-admin">
|
||||||
{reply.user_avatar !== '0' ? (
|
{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)
|
reply.user_name.charAt(0)
|
||||||
)}
|
)}
|
||||||
</b>
|
</b>
|
||||||
<div className="comment-name mb-10">
|
<div className="comment-name mb-10">
|
||||||
<b className="user-name">{reply.user_name}</b>
|
<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>
|
</div>
|
||||||
<div className="info_feeback comment-right-form">
|
<div className="info_feeback comment-right-form">
|
||||||
<span style={{ color: '#787878', fontSize: 12 }}>
|
<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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,7 +119,7 @@ export const ListReview = () => {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
{!showAll && ListReviewData.length > 3 && (
|
{!showAll && reviews.length > 3 && (
|
||||||
<button
|
<button
|
||||||
id="first-review"
|
id="first-review"
|
||||||
className="btn-more cursor-pointer"
|
className="btn-more cursor-pointer"
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Review } from '@/types';
|
import { Review } from '@/types';
|
||||||
import { FaStar } from 'react-icons/fa6';
|
import { FaStar } from 'react-icons/fa6';
|
||||||
@@ -5,16 +7,16 @@ import { FormReview } from './FormReview';
|
|||||||
import { ListReview } from './ListReview';
|
import { ListReview } from './ListReview';
|
||||||
|
|
||||||
interface Props {
|
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 [showForm, setShowForm] = useState(false);
|
||||||
|
|
||||||
const { summary } = ItemReview;
|
const { summary } = review;
|
||||||
const totalRate = summary.list_rate.reduce((acc, item) => acc + Number(item.total), 0);
|
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 = {
|
const rates = {
|
||||||
rate1: Number(summary.list_rate.find((r) => r.rate === '1')?.total || 0),
|
rate1: Number(summary.list_rate.find((r) => r.rate === '1')?.total || 0),
|
||||||
rate2: Number(summary.list_rate.find((r) => r.rate === '2')?.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">
|
<div className="box-review">
|
||||||
<p className="title-review font-[600]">Bình luận và đánh giá</p>
|
<p className="title-review font-[600]">Bình luận và đánh giá</p>
|
||||||
<div className="review-customer-detail">
|
<div className="review-customer-detail">
|
||||||
<form
|
<div className="form-post">
|
||||||
action="/ajax/post_comment.php"
|
|
||||||
method="post"
|
|
||||||
encType="multipart/form-data"
|
|
||||||
className="form-post"
|
|
||||||
>
|
|
||||||
<div className="review-info boder-radius-10 flex">
|
<div className="review-info boder-radius-10 flex">
|
||||||
<div className="avgRate flex flex-col items-center justify-center">
|
<div className="avgRate flex flex-col items-center justify-center">
|
||||||
<span className="font-bold">{summary.avgRate}/5</span>
|
<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"
|
className="button-review mx-auto flex cursor-pointer items-center justify-center"
|
||||||
onClick={() => setShowForm(true)}
|
onClick={() => setShowForm(true)}
|
||||||
>
|
>
|
||||||
{' '}
|
Đánh giá ngay
|
||||||
Đánh giá ngay{' '}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className="button-review mx-auto flex cursor-pointer items-center justify-center"
|
className="button-review mx-auto flex cursor-pointer items-center justify-center"
|
||||||
onClick={() => setShowForm(false)}
|
onClick={() => setShowForm(false)}
|
||||||
>
|
>
|
||||||
{' '}
|
Đóng lại
|
||||||
Đóng lại{' '}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* form */}
|
|
||||||
{showForm && <FormReview />}
|
{showForm && <FormReview />}
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
<ListReview />
|
</div>
|
||||||
|
<ListReview slug={slug} />
|
||||||
</div>
|
</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';
|
import { FaAngleDown, FaAngleUp } from 'react-icons/fa6';
|
||||||
|
|
||||||
interface SummaryProps {
|
interface SummaryProps {
|
||||||
ItemSummary: string;
|
summary: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ProductSummary: React.FC<SummaryProps> = ({ ItemSummary }) => {
|
export const ProductSummary: React.FC<SummaryProps> = ({ summary }) => {
|
||||||
const summaryArray = ItemSummary.split('\r\n');
|
const summaryArray = summary.split('\r\n');
|
||||||
const [expanded, setExpanded] = useState(false);
|
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);
|
const visibleItems = expanded ? summaryArray : summaryArray.slice(0, 3);
|
||||||
|
|
||||||
return (
|
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';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
import { ErrorLink } from '@/components/Common/error';
|
import { ErrorLink } from '@/components/Common/Error';
|
||||||
import type { TypeProductHot } from '@/types/producthot';
|
import type { TypeProductHot } from '@/types/producthot';
|
||||||
import { ProductHotPageData } from '@/data/producthot';
|
|
||||||
import { findProductHotBySlug } from '@/lib/product/producthot';
|
|
||||||
|
|
||||||
import { Breadcrumb } from '@/components/Common/Breadcrumb';
|
import { Breadcrumb } from '@/components/Common/Breadcrumb';
|
||||||
import BoxFilter from '@components/Product/BoxFilter';
|
import BoxFilter from '@components/Product/BoxFilter';
|
||||||
import BoxSort from '@components/Product/BoxSort';
|
import BoxSort from '@components/Product/BoxSort';
|
||||||
import ItemProduct from '@/components/Common/ItemProduct';
|
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 {
|
interface ProductHotPageProps {
|
||||||
slug: string; // khai báo prop slug
|
slug: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProductHotPage: React.FC<ProductHotPageProps> = ({ slug }) => {
|
const ProductHotPage: React.FC<ProductHotPageProps> = ({ slug }) => {
|
||||||
const ProductHot = ProductHotPageData as unknown as TypeProductHot[];
|
const { data: page, isLoading } = useApiData(
|
||||||
const Pages = findProductHotBySlug(slug, ProductHot);
|
() => getProductHotPage(slug),
|
||||||
|
[slug],
|
||||||
|
{ initialData: null as TypeProductHot | null },
|
||||||
|
);
|
||||||
|
|
||||||
const breadcrumbItems = [
|
if (isLoading) {
|
||||||
{ name: 'Trang chủ', url: '/' },
|
return <PreLoader />;
|
||||||
{ name: Pages?.title, url: Pages?.url },
|
}
|
||||||
];
|
|
||||||
|
|
||||||
if (!Pages) {
|
if (!page) {
|
||||||
return <ErrorLink />;
|
return <ErrorLink />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// lấy sản phẩm
|
const breadcrumbItems = [
|
||||||
const products = Object.values(Pages.product_list);
|
{ name: 'Trang chủ', url: '/' },
|
||||||
|
{ name: page.title, url: page.url },
|
||||||
|
];
|
||||||
|
|
||||||
|
const products = Object.values(page.product_list);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -40,28 +48,24 @@ const ProductHotPage: React.FC<ProductHotPageProps> = ({ slug }) => {
|
|||||||
<section className="page-category page-search container">
|
<section className="page-category page-search container">
|
||||||
<div className="current-cate-title">
|
<div className="current-cate-title">
|
||||||
<div className="mt-5 flex items-center gap-2">
|
<div className="mt-5 flex items-center gap-2">
|
||||||
<h1 className="current-cate-text font-bold"> {Pages.title} </h1>
|
<h1 className="current-cate-text font-bold">{page.title}</h1>
|
||||||
<span className="current-cate-total">(Tổng {Pages.product_count} sản phẩm)</span>
|
<span className="current-cate-total">(Tổng {page.product_count} sản phẩm)</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="box-content-category">
|
<div className="box-content-category">
|
||||||
{/* filter */}
|
<BoxFilter filters={page} />
|
||||||
<BoxFilter filters={Pages} />
|
|
||||||
|
|
||||||
<div className="box-list-product-category boder-radius-10">
|
<div className="box-list-product-category boder-radius-10">
|
||||||
{/* filter sort */}
|
<BoxSort sort_by_collection={page.sort_by_collection} product_display_type="grid" />
|
||||||
<BoxSort sort_by_collection={Pages.sort_by_collection} product_display_type="grid" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* list product */}
|
|
||||||
|
|
||||||
<div className="list-product-category grid grid-cols-5 gap-3">
|
<div className="list-product-category grid grid-cols-5 gap-3">
|
||||||
{products.map((item, index) => (
|
{products.map((item) => (
|
||||||
<ItemProduct key={index} item={item} />
|
<ItemProduct key={item.id} item={item} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="paging flex items-center justify-center">
|
<div className="paging flex items-center justify-center">
|
||||||
{Pages.paging_collection.map((item, index) => (
|
{page.paging_collection.map((item, index) => (
|
||||||
<Link
|
<Link
|
||||||
key={index}
|
key={index}
|
||||||
href={item.url}
|
href={item.url}
|
||||||
@@ -1,40 +1,47 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useSearchParams } from 'next/navigation';
|
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 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 { Breadcrumb } from '@/components/Common/Breadcrumb';
|
||||||
import BoxFilter from '@components/Product/BoxFilter';
|
import BoxFilter from '@components/Product/BoxFilter';
|
||||||
import BoxSort from '@components/Product/BoxSort';
|
import BoxSort from '@components/Product/BoxSort';
|
||||||
import ItemProduct from '@/components/Common/ItemProduct';
|
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 {
|
const ProductSearchPage: React.FC = () => {
|
||||||
slug: string; // khai báo prop slug
|
const searchParams = useSearchParams();
|
||||||
|
const keyword = searchParams.get('q') ?? '';
|
||||||
|
|
||||||
|
const { data: page, isLoading } = useApiData(
|
||||||
|
() => getProductSearch(keyword),
|
||||||
|
[keyword],
|
||||||
|
{
|
||||||
|
initialData: null as TypeProductSearch | null,
|
||||||
|
enabled: keyword.length > 0,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <PreLoader />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ProductSearchPage: React.FC<ProductSearchPageProps> = ({ slug }) => {
|
if (!page) {
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const keys = searchParams.get('q');
|
|
||||||
|
|
||||||
const Searchs = ProductSearchData as unknown as TypeProductSearch[];
|
|
||||||
const Pages = findSearchBySlug(keys, Searchs);
|
|
||||||
|
|
||||||
const breadcrumbItems = [
|
|
||||||
{ name: 'Trang chủ', url: '/' },
|
|
||||||
{ name: `Tìm kiếm "${keys}"`, url: `/tim?q=${Pages?.keywords}` },
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!Pages) {
|
|
||||||
return <ErrorLink />;
|
return <ErrorLink />;
|
||||||
}
|
}
|
||||||
|
|
||||||
// lấy sản phẩm
|
const breadcrumbItems = [
|
||||||
const products = Object.values(Pages.product_list);
|
{ name: 'Trang chủ', url: '/' },
|
||||||
|
{ name: `Tìm kiếm "${keyword}"`, url: `/tim?q=${page.keywords}` },
|
||||||
|
];
|
||||||
|
|
||||||
|
const products = Object.values(page.product_list);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -43,28 +50,24 @@ const ProductSearchPage: React.FC<ProductSearchPageProps> = ({ slug }) => {
|
|||||||
</div>
|
</div>
|
||||||
<section className="page-category page-search container">
|
<section className="page-category page-search container">
|
||||||
<div className="current-cate-title flex items-center gap-2">
|
<div className="current-cate-title flex items-center gap-2">
|
||||||
<h1 className="current-cate-text font-bold"> Tìm kiếm : {Pages.keywords} </h1>
|
<h1 className="current-cate-text font-bold">Tìm kiếm: {page.keywords}</h1>
|
||||||
<span className="current-cate-total">(Tổng {Pages.product_count} sản phẩm)</span>
|
<span className="current-cate-total">(Tổng {page.product_count} sản phẩm)</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{Pages.product_list ? (
|
{products.length > 0 ? (
|
||||||
<div className="box-content-category">
|
<div className="box-content-category">
|
||||||
{/* filter */}
|
<BoxFilter filters={page} />
|
||||||
<BoxFilter filters={Pages} />
|
|
||||||
<div className="box-list-product-category boder-radius-10">
|
<div className="box-list-product-category boder-radius-10">
|
||||||
{/* filter sort */}
|
<BoxSort sort_by_collection={page.sort_by_collection} product_display_type="grid" />
|
||||||
<BoxSort sort_by_collection={Pages.sort_by_collection} product_display_type="grid" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* list product */}
|
|
||||||
|
|
||||||
<div className="list-product-category grid grid-cols-5 gap-3">
|
<div className="list-product-category grid grid-cols-5 gap-3">
|
||||||
{products.map((item, index) => (
|
{products.map((item) => (
|
||||||
<ItemProduct key={index} item={item} />
|
<ItemProduct key={item.id} item={item} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="paging flex items-center justify-center">
|
<div className="paging flex items-center justify-center">
|
||||||
{Pages.paging_collection.map((item, index) => (
|
{page.paging_collection.map((item, index) => (
|
||||||
<Link
|
<Link
|
||||||
key={index}
|
key={index}
|
||||||
href={item.url}
|
href={item.url}
|
||||||
@@ -78,7 +81,8 @@ const ProductSearchPage: React.FC<ProductSearchPageProps> = ({ slug }) => {
|
|||||||
) : (
|
) : (
|
||||||
<div className="text-center" style={{ padding: 20, fontSize: 15 }}>
|
<div className="text-center" style={{ padding: 20, fontSize: 15 }}>
|
||||||
<p style={{ fontSize: 24, margin: '15px 0 25px 0', fontWeight: 'bold' }}>
|
<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>
|
</p>
|
||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
@@ -101,7 +105,7 @@ const ProductSearchPage: React.FC<ProductSearchPageProps> = ({ slug }) => {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<Link href="/">
|
<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>
|
</Link>
|
||||||
</div>
|
</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 { TypeCartItem } from '@/types/cart';
|
||||||
|
import { readCartFromStorage, writeCartToStorage } from '@/lib/cartStorage';
|
||||||
|
|
||||||
// data
|
type CartSourceProduct = {
|
||||||
import { productData } from '@/data/ListProduct';
|
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) => {
|
export const addToCart = (product: CartSourceProduct, quantity = 1) => {
|
||||||
// Lấy giỏ hàng hiện tại từ localStorage
|
const cart: TypeCartItem[] = readCartFromStorage();
|
||||||
const cart: TypeCartItem[] = JSON.parse(localStorage.getItem('cart') || '[]');
|
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);
|
const existing = cart.find((item) => item.item_info.id == productId);
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
// Nếu có rồi thì tăng số lượng
|
existing.in_cart.quantity = (parseInt(existing.in_cart.quantity) + quantity).toString();
|
||||||
existing.in_cart.quantity = (parseInt(existing.in_cart.quantity) + 1).toString();
|
|
||||||
existing.in_cart.total_price =
|
existing.in_cart.total_price =
|
||||||
Number(existing.in_cart.quantity) * Number(existing.in_cart.price);
|
Number(existing.in_cart.quantity) * Number(existing.in_cart.price);
|
||||||
} else {
|
} else {
|
||||||
// Nếu chưa có thì thêm mới
|
|
||||||
const cartItem = {
|
const cartItem = {
|
||||||
_id: `product-${product.id}-0`,
|
_id: `product-${product.id}-0`,
|
||||||
item_type: 'product',
|
item_type: 'product',
|
||||||
item_id: `${product.id}-0`,
|
item_id: `${product.id}-0`,
|
||||||
item_info: {
|
item_info: {
|
||||||
id: product.productId,
|
id: productId,
|
||||||
priceUnit: product.priceUnit,
|
priceUnit: product.priceUnit,
|
||||||
marketPrice: product.marketPrice,
|
marketPrice: product.marketPrice,
|
||||||
hasVAT: product.hasVAT,
|
hasVAT: product.hasVAT,
|
||||||
@@ -39,31 +60,29 @@ export const addToCart = (productId: string | number) => {
|
|||||||
productName: product.productName,
|
productName: product.productName,
|
||||||
productImage: product.productImage,
|
productImage: product.productImage,
|
||||||
productUrl: product.productUrl,
|
productUrl: product.productUrl,
|
||||||
brand: product.brand,
|
brand: product.brand as TypeCartItem['item_info']['brand'],
|
||||||
productSKU: product.productSKU,
|
|
||||||
quantity: product.quantity,
|
quantity: product.quantity,
|
||||||
addon: product.addon,
|
addon: product.addon,
|
||||||
warranty: product.warranty,
|
warranty: product.warranty,
|
||||||
variants: product.variants,
|
variants: product.variants,
|
||||||
variant_option: product.variant_option,
|
variant_option: product.variant_option,
|
||||||
extend: product.extend,
|
extend: product.extend as TypeCartItem['item_info']['extend'],
|
||||||
categories: product.categories,
|
categories: product.categories as TypeCartItem['item_info']['categories'],
|
||||||
specialOffer: product.specialOffer,
|
specialOffer: product.specialOffer as TypeCartItem['item_info']['specialOffer'],
|
||||||
specialOfferGroup: product.specialOfferGroup,
|
specialOfferGroup: product.specialOfferGroup,
|
||||||
sale_rules: product.sale_rules,
|
sale_rules: product.sale_rules as TypeCartItem['item_info']['sale_rules'],
|
||||||
},
|
},
|
||||||
in_cart: {
|
in_cart: {
|
||||||
quantity: '1',
|
quantity: quantity.toString(),
|
||||||
buyer_note: '',
|
buyer_note: '',
|
||||||
price: product.price,
|
price: product.price,
|
||||||
total_price: Number(product.quantity) * Number(product.price),
|
total_price: quantity * Number(product.price),
|
||||||
weight: '0',
|
weight: '0',
|
||||||
total_weight: '0',
|
total_weight: '0',
|
||||||
},
|
},
|
||||||
};
|
} as TypeCartItem;
|
||||||
|
|
||||||
cart.push(cartItem);
|
cart.push(cartItem);
|
||||||
}
|
}
|
||||||
|
writeCartToStorage(cart);
|
||||||
// Lưu lại vào localStorage
|
|
||||||
localStorage.setItem('cart', JSON.stringify(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