Compare commits
14 Commits
1805ff8674
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 1713fdd89c | |||
| 2d2bf85f83 | |||
| 25111ff10e | |||
| a8e30f32a0 | |||
| 9486dabdb0 | |||
| 28a252f7d7 | |||
| aae8e26135 | |||
| 15240ff81f | |||
| 9fa4b50b68 | |||
| bf063f244c | |||
| 1bb5ad52ed | |||
| 71089d1eef | |||
| 7606157d26 | |||
| e2063bce4c |
13
.claude/settings.local.json
Normal file
13
.claude/settings.local.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(grep -E \"\\\\.\\(tsx|ts|json\\)$\")",
|
||||
"Bash(npm install:*)",
|
||||
"Bash(npx tsc:*)",
|
||||
"Bash(curl -s http://localhost:3000/tin-tuc)",
|
||||
"Bash(grep '\"\"url\"\"' src/data/article/ListArticleNews.ts)",
|
||||
"Bash(cat src/features/Article/HomeArticlePage/index.tsx src/features/Article/CategoryPage/index.tsx src/features/Article/DetailPage/index.tsx src/features/Article/DetailPage/TocBox/index.tsx src/hooks/useApiData.ts src/app/[slug]/page.tsx src/features/Article/HomeArticlePage/BoxArticleMid/index.tsx src/features/Article/HomeArticlePage/BoxVideoArticle/index.tsx src/features/Article/HomeArticlePage/BoxArticleReview/index.tsx src/features/Article/ArticleTopLeft/index.tsx src/features/Article/ArticleTopRight/index.tsx src/components/Common/ItemArticle/index.tsx)",
|
||||
"Bash(find /c/Users/APC/Downloads/work/nguyencongpc_nextjs/src -type f -name *.ts -o -name *.tsx)"
|
||||
]
|
||||
}
|
||||
}
|
||||
13
.vscode/mcp.json
vendored
Normal file
13
.vscode/mcp.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"servers": {
|
||||
"figma": {
|
||||
"url": "https://mcp.figma.com/mcp",
|
||||
"type": "http"
|
||||
},
|
||||
"my-mcp-server-cf2b4222": {
|
||||
"url": "enter",
|
||||
"type": "http"
|
||||
}
|
||||
},
|
||||
"inputs": []
|
||||
}
|
||||
@@ -1,18 +1,11 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
import { defineConfig, globalIgnores } from 'eslint/config';
|
||||
import nextVitals from 'eslint-config-next/core-web-vitals';
|
||||
import nextTs from 'eslint-config-next/typescript';
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
".next/**",
|
||||
"out/**",
|
||||
"build/**",
|
||||
"next-env.d.ts",
|
||||
]),
|
||||
globalIgnores(['.next/**', 'out/**', 'build/**', 'next-env.d.ts']),
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
|
||||
@@ -8,6 +8,11 @@ const nextConfig: NextConfig = {
|
||||
hostname: 'nguyencongpc.vn',
|
||||
pathname: '/**',
|
||||
},
|
||||
{
|
||||
protocol: 'https',
|
||||
hostname: 'www.dmca.com',
|
||||
pathname: '/**',
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
2173
package-lock.json
generated
2173
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
28
package.json
28
package.json
@@ -6,13 +6,24 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"check": "npm run lint && npm run typecheck",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@fancyapps/ui": "^6.1.7",
|
||||
"@tippyjs/react": "^4.2.6",
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"cors": "^2.8.6",
|
||||
"date-fns": "^4.1.0",
|
||||
"dompurify": "^3.3.3",
|
||||
"dotenv": "^17.3.1",
|
||||
"express": "^5.2.1",
|
||||
"framer-motion": "^12.23.26",
|
||||
"next": "16.0.10",
|
||||
"lightgallery": "^2.9.0",
|
||||
"next": "^16.1.6",
|
||||
"postcss": "^8.5.6",
|
||||
"react": "19.2.1",
|
||||
"react-dom": "19.2.1",
|
||||
@@ -25,12 +36,19 @@
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"daisyui": "^5.5.14",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.10",
|
||||
"eslint": "^9.39.4",
|
||||
"eslint-config-next": "^16.1.6",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"msw": "^2.12.7",
|
||||
"nodemon": "^3.1.14",
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"typescript": "^5"
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"msw": {
|
||||
"workerDirectory": [
|
||||
"public"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
348
public/mockServiceWorker.js
Normal file
348
public/mockServiceWorker.js
Normal file
@@ -0,0 +1,348 @@
|
||||
/* tslint:disable */
|
||||
|
||||
/**
|
||||
* Mock Service Worker.
|
||||
* @see https://github.com/mswjs/msw
|
||||
* - Please do NOT modify this file.
|
||||
*/
|
||||
|
||||
const PACKAGE_VERSION = '2.12.7'
|
||||
const INTEGRITY_CHECKSUM = '4db4a41e972cec1b64cc569c66952d82'
|
||||
const IS_MOCKED_RESPONSE = Symbol('isMockedResponse')
|
||||
const activeClientIds = new Set()
|
||||
|
||||
addEventListener('install', function () {
|
||||
self.skipWaiting()
|
||||
})
|
||||
|
||||
addEventListener('activate', function (event) {
|
||||
event.waitUntil(self.clients.claim())
|
||||
})
|
||||
|
||||
addEventListener('message', async function (event) {
|
||||
const clientId = Reflect.get(event.source || {}, 'id')
|
||||
|
||||
if (!clientId || !self.clients) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = await self.clients.get(clientId)
|
||||
|
||||
if (!client) {
|
||||
return
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
switch (event.data) {
|
||||
case 'KEEPALIVE_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'KEEPALIVE_RESPONSE',
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'INTEGRITY_CHECK_REQUEST': {
|
||||
sendToClient(client, {
|
||||
type: 'INTEGRITY_CHECK_RESPONSE',
|
||||
payload: {
|
||||
packageVersion: PACKAGE_VERSION,
|
||||
checksum: INTEGRITY_CHECKSUM,
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'MOCK_ACTIVATE': {
|
||||
activeClientIds.add(clientId)
|
||||
|
||||
sendToClient(client, {
|
||||
type: 'MOCKING_ENABLED',
|
||||
payload: {
|
||||
client: {
|
||||
id: client.id,
|
||||
frameType: client.frameType,
|
||||
},
|
||||
},
|
||||
})
|
||||
break
|
||||
}
|
||||
|
||||
case 'CLIENT_CLOSED': {
|
||||
activeClientIds.delete(clientId)
|
||||
|
||||
const remainingClients = allClients.filter((client) => {
|
||||
return client.id !== clientId
|
||||
})
|
||||
|
||||
// Unregister itself when there are no more clients
|
||||
if (remainingClients.length === 0) {
|
||||
self.registration.unregister()
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
addEventListener('fetch', function (event) {
|
||||
const requestInterceptedAt = Date.now()
|
||||
|
||||
// Bypass navigation requests.
|
||||
if (event.request.mode === 'navigate') {
|
||||
return
|
||||
}
|
||||
|
||||
// Opening the DevTools triggers the "only-if-cached" request
|
||||
// that cannot be handled by the worker. Bypass such requests.
|
||||
if (
|
||||
event.request.cache === 'only-if-cached' &&
|
||||
event.request.mode !== 'same-origin'
|
||||
) {
|
||||
return
|
||||
}
|
||||
|
||||
// Bypass all requests when there are no active clients.
|
||||
// Prevents the self-unregistered worked from handling requests
|
||||
// after it's been terminated (still remains active until the next reload).
|
||||
if (activeClientIds.size === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const requestId = crypto.randomUUID()
|
||||
event.respondWith(handleRequest(event, requestId, requestInterceptedAt))
|
||||
})
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
*/
|
||||
async function handleRequest(event, requestId, requestInterceptedAt) {
|
||||
const client = await resolveMainClient(event)
|
||||
const requestCloneForEvents = event.request.clone()
|
||||
const response = await getResponse(
|
||||
event,
|
||||
client,
|
||||
requestId,
|
||||
requestInterceptedAt,
|
||||
)
|
||||
|
||||
// Send back the response clone for the "response:*" life-cycle events.
|
||||
// Ensure MSW is active and ready to handle the message, otherwise
|
||||
// this message will pend indefinitely.
|
||||
if (client && activeClientIds.has(client.id)) {
|
||||
const serializedRequest = await serializeRequest(requestCloneForEvents)
|
||||
|
||||
// Clone the response so both the client and the library could consume it.
|
||||
const responseClone = response.clone()
|
||||
|
||||
sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'RESPONSE',
|
||||
payload: {
|
||||
isMockedResponse: IS_MOCKED_RESPONSE in response,
|
||||
request: {
|
||||
id: requestId,
|
||||
...serializedRequest,
|
||||
},
|
||||
response: {
|
||||
type: responseClone.type,
|
||||
status: responseClone.status,
|
||||
statusText: responseClone.statusText,
|
||||
headers: Object.fromEntries(responseClone.headers.entries()),
|
||||
body: responseClone.body,
|
||||
},
|
||||
},
|
||||
},
|
||||
responseClone.body ? [serializedRequest.body, responseClone.body] : [],
|
||||
)
|
||||
}
|
||||
|
||||
return response
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the main client for the given event.
|
||||
* Client that issues a request doesn't necessarily equal the client
|
||||
* that registered the worker. It's with the latter the worker should
|
||||
* communicate with during the response resolving phase.
|
||||
* @param {FetchEvent} event
|
||||
* @returns {Promise<Client | undefined>}
|
||||
*/
|
||||
async function resolveMainClient(event) {
|
||||
const client = await self.clients.get(event.clientId)
|
||||
|
||||
if (activeClientIds.has(event.clientId)) {
|
||||
return client
|
||||
}
|
||||
|
||||
if (client?.frameType === 'top-level') {
|
||||
return client
|
||||
}
|
||||
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: 'window',
|
||||
})
|
||||
|
||||
return allClients
|
||||
.filter((client) => {
|
||||
// Get only those clients that are currently visible.
|
||||
return client.visibilityState === 'visible'
|
||||
})
|
||||
.find((client) => {
|
||||
// Find the client ID that's recorded in the
|
||||
// set of clients that have registered the worker.
|
||||
return activeClientIds.has(client.id)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {FetchEvent} event
|
||||
* @param {Client | undefined} client
|
||||
* @param {string} requestId
|
||||
* @param {number} requestInterceptedAt
|
||||
* @returns {Promise<Response>}
|
||||
*/
|
||||
async function getResponse(event, client, requestId, requestInterceptedAt) {
|
||||
// Clone the request because it might've been already used
|
||||
// (i.e. its body has been read and sent to the client).
|
||||
const requestClone = event.request.clone()
|
||||
|
||||
function passthrough() {
|
||||
// Cast the request headers to a new Headers instance
|
||||
// so the headers can be manipulated with.
|
||||
const headers = new Headers(requestClone.headers)
|
||||
|
||||
// Remove the "accept" header value that marked this request as passthrough.
|
||||
// This prevents request alteration and also keeps it compliant with the
|
||||
// user-defined CORS policies.
|
||||
const acceptHeader = headers.get('accept')
|
||||
if (acceptHeader) {
|
||||
const values = acceptHeader.split(',').map((value) => value.trim())
|
||||
const filteredValues = values.filter(
|
||||
(value) => value !== 'msw/passthrough',
|
||||
)
|
||||
|
||||
if (filteredValues.length > 0) {
|
||||
headers.set('accept', filteredValues.join(', '))
|
||||
} else {
|
||||
headers.delete('accept')
|
||||
}
|
||||
}
|
||||
|
||||
return fetch(requestClone, { headers })
|
||||
}
|
||||
|
||||
// Bypass mocking when the client is not active.
|
||||
if (!client) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Bypass initial page load requests (i.e. static assets).
|
||||
// The absence of the immediate/parent client in the map of the active clients
|
||||
// means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet
|
||||
// and is not ready to handle requests.
|
||||
if (!activeClientIds.has(client.id)) {
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
// Notify the client that a request has been intercepted.
|
||||
const serializedRequest = await serializeRequest(event.request)
|
||||
const clientMessage = await sendToClient(
|
||||
client,
|
||||
{
|
||||
type: 'REQUEST',
|
||||
payload: {
|
||||
id: requestId,
|
||||
interceptedAt: requestInterceptedAt,
|
||||
...serializedRequest,
|
||||
},
|
||||
},
|
||||
[serializedRequest.body],
|
||||
)
|
||||
|
||||
switch (clientMessage.type) {
|
||||
case 'MOCK_RESPONSE': {
|
||||
return respondWithMock(clientMessage.data)
|
||||
}
|
||||
|
||||
case 'PASSTHROUGH': {
|
||||
return passthrough()
|
||||
}
|
||||
}
|
||||
|
||||
return passthrough()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Client} client
|
||||
* @param {any} message
|
||||
* @param {Array<Transferable>} transferrables
|
||||
* @returns {Promise<any>}
|
||||
*/
|
||||
function sendToClient(client, message, transferrables = []) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const channel = new MessageChannel()
|
||||
|
||||
channel.port1.onmessage = (event) => {
|
||||
if (event.data && event.data.error) {
|
||||
return reject(event.data.error)
|
||||
}
|
||||
|
||||
resolve(event.data)
|
||||
}
|
||||
|
||||
client.postMessage(message, [
|
||||
channel.port2,
|
||||
...transferrables.filter(Boolean),
|
||||
])
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Response} response
|
||||
* @returns {Response}
|
||||
*/
|
||||
function respondWithMock(response) {
|
||||
// Setting response status code to 0 is a no-op.
|
||||
// However, when responding with a "Response.error()", the produced Response
|
||||
// instance will have status code set to 0. Since it's not possible to create
|
||||
// a Response instance with status code 0, handle that use-case separately.
|
||||
if (response.status === 0) {
|
||||
return Response.error()
|
||||
}
|
||||
|
||||
const mockedResponse = new Response(response.body, response)
|
||||
|
||||
Reflect.defineProperty(mockedResponse, IS_MOCKED_RESPONSE, {
|
||||
value: true,
|
||||
enumerable: true,
|
||||
})
|
||||
|
||||
return mockedResponse
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Request} request
|
||||
*/
|
||||
async function serializeRequest(request) {
|
||||
return {
|
||||
url: request.url,
|
||||
mode: request.mode,
|
||||
method: request.method,
|
||||
headers: Object.fromEntries(request.headers.entries()),
|
||||
cache: request.cache,
|
||||
credentials: request.credentials,
|
||||
destination: request.destination,
|
||||
integrity: request.integrity,
|
||||
redirect: request.redirect,
|
||||
referrer: request.referrer,
|
||||
referrerPolicy: request.referrerPolicy,
|
||||
body: await request.arrayBuffer(),
|
||||
keepalive: request.keepalive,
|
||||
}
|
||||
}
|
||||
@@ -1,22 +1,39 @@
|
||||
'use client';
|
||||
import { useParams } from 'next/navigation';
|
||||
import CategoryPage from '@/components/product/Category';
|
||||
import ProductDetailPage from '@/components/product/ProductDetail';
|
||||
import NotFound from '@/features/NotFoundPage';
|
||||
|
||||
import CategoryPage from '@/features/Product/Category';
|
||||
import ProductSearchPage from '@/features/Product/ProductSearch';
|
||||
import ProductDetailPage from '@/features/Product/ProductDetail';
|
||||
import ProductHotPage from '@/features/Product/ProductHot';
|
||||
import ArticlePage from '@/features/Article/HomeArticlePage';
|
||||
import ArticleCategoryPage from '@/features/Article/CategoryPage';
|
||||
import ArticleDetailPage from '@/features/Article/DetailPage';
|
||||
import { resolvePageType } from '@/lib/resolvePageType';
|
||||
|
||||
export default function DynamicPage() {
|
||||
const { slug } = useParams();
|
||||
const fullSlug = '/' + slug;
|
||||
if (typeof slug !== 'string' || slug.length === 0) return <NotFound />;
|
||||
|
||||
const fullSlug = `/${slug}`;
|
||||
const pageType = resolvePageType(fullSlug);
|
||||
|
||||
switch (pageType) {
|
||||
case 'category':
|
||||
return <CategoryPage slug={fullSlug} />;
|
||||
case 'product':
|
||||
case 'product-search':
|
||||
return <ProductSearchPage />;
|
||||
case 'product-detail':
|
||||
return <ProductDetailPage slug={fullSlug} />;
|
||||
case 'product-hot':
|
||||
return <ProductHotPage slug={fullSlug} />;
|
||||
case 'article-home':
|
||||
return <ArticlePage />;
|
||||
case 'article-category':
|
||||
return <ArticleCategoryPage slug={fullSlug} />;
|
||||
case 'article-detail':
|
||||
return <ArticleDetailPage slug={fullSlug} />;
|
||||
default:
|
||||
return <div>404 Không tìm thấy</div>;
|
||||
return <NotFound />;
|
||||
}
|
||||
}
|
||||
|
||||
30
src/app/api/buildpc/category/route.ts
Normal file
30
src/app/api/buildpc/category/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getBuildPcCategoryData } from '@/lib/buildpc/source';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const categoryId = searchParams.get('categoryId');
|
||||
const q = searchParams.get('q') ?? undefined;
|
||||
const sourceUrl = searchParams.get('sourceUrl') ?? undefined;
|
||||
|
||||
if (!categoryId && !sourceUrl) {
|
||||
return NextResponse.json({ message: 'Missing categoryId or sourceUrl' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getBuildPcCategoryData({
|
||||
categoryId: categoryId ?? undefined,
|
||||
q,
|
||||
sourceUrl,
|
||||
});
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: error instanceof Error ? error.message : 'Failed to load build PC category',
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
185
src/app/buildpc/BoxListAccessory/index.tsx
Normal file
185
src/app/buildpc/BoxListAccessory/index.tsx
Normal file
@@ -0,0 +1,185 @@
|
||||
'use client';
|
||||
|
||||
import Image from 'next/image';
|
||||
import type { BuildPcCategory, BuildPcProduct } from '@/lib/buildpc/source';
|
||||
import { formatCurrency } from '@/lib/formatPrice';
|
||||
|
||||
type SelectedBuildPcItem = {
|
||||
category: BuildPcCategory;
|
||||
product: BuildPcProduct;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
interface BoxListAccessoryProps {
|
||||
categories: BuildPcCategory[];
|
||||
onOpenCategory: (category: BuildPcCategory) => void;
|
||||
onQuantityChange: (categoryId: number, quantity: number) => void;
|
||||
onRemoveProduct: (categoryId: number) => void;
|
||||
selectedItems: Record<number, SelectedBuildPcItem>;
|
||||
}
|
||||
|
||||
export const BoxListAccessory = ({
|
||||
categories,
|
||||
onOpenCategory,
|
||||
onQuantityChange,
|
||||
onRemoveProduct,
|
||||
selectedItems,
|
||||
}: BoxListAccessoryProps) => {
|
||||
return (
|
||||
<section className="rounded-[28px] border border-slate-200 bg-white p-4 shadow-sm md:p-5">
|
||||
<div className="mb-5 flex flex-wrap items-end justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.2em] text-slate-500">
|
||||
Danh sách linh kiện
|
||||
</p>
|
||||
<h3 className="mt-1 border-none pb-0 text-2xl font-semibold normal-case text-slate-900">
|
||||
Hoàn thiện cấu hình từng phần
|
||||
</h3>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">
|
||||
Nhấn vào từng mục để chọn hoặc thay linh kiện phù hợp.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{categories.map((category, index) => {
|
||||
const selectedItem = selectedItems[category.id];
|
||||
|
||||
return (
|
||||
<article
|
||||
key={category.id}
|
||||
className="overflow-hidden rounded-[24px] border border-slate-200 bg-[linear-gradient(180deg,#ffffff_0%,#fbfcff_100%)] shadow-[0_10px_30px_rgba(15,23,42,0.05)]"
|
||||
>
|
||||
<div className="grid gap-4 p-4 lg:grid-cols-[250px_minmax(0,1fr)] lg:p-5">
|
||||
<div className="flex items-start gap-4 rounded-[20px] bg-slate-950 p-4 text-white">
|
||||
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-white/10 text-lg font-semibold">
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs uppercase tracking-[0.22em] text-white/50">Linh kiện</p>
|
||||
<h4 className="mt-2 text-lg font-semibold leading-6">{category.name}</h4>
|
||||
<p className="mt-2 text-sm leading-6 text-white/65">
|
||||
{selectedItem
|
||||
? 'Đã có sản phẩm trong cấu hình. Có thể đổi hoặc xoá bất cứ lúc nào.'
|
||||
: 'Chưa chọn linh kiện. Mở danh sách để xem sản phẩm và bộ lọc.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!selectedItem ? (
|
||||
<button
|
||||
type="button"
|
||||
className="group flex min-h-[180px] w-full items-center justify-center rounded-[20px] border border-dashed border-slate-300 bg-slate-50 px-6 py-8 text-left transition hover:border-red-300 hover:bg-red-50/50"
|
||||
onClick={() => onOpenCategory(category)}
|
||||
>
|
||||
<div className="max-w-lg">
|
||||
<div className="inline-flex h-12 w-12 items-center justify-center rounded-2xl bg-white text-2xl font-light text-red-600 shadow-sm transition group-hover:scale-105">
|
||||
+
|
||||
</div>
|
||||
<h5 className="mt-4 text-lg font-semibold text-slate-900">
|
||||
Chọn {category.name}
|
||||
</h5>
|
||||
<p className="mt-2 text-sm leading-6 text-slate-500">
|
||||
Mở danh sách sản phẩm, lọc theo thương hiệu hoặc mức giá rồi thêm trực tiếp
|
||||
vào cấu hình.
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
) : (
|
||||
<div className="min-w-0 rounded-[20px] border border-slate-200 bg-white p-4 lg:p-5">
|
||||
<div className="grid gap-4 2xl:grid-cols-[minmax(0,1fr)_360px]">
|
||||
<div className="flex min-w-0 items-start gap-4">
|
||||
<div className="relative h-24 w-24 shrink-0 overflow-hidden rounded-2xl bg-slate-50 sm:h-28 sm:w-28">
|
||||
<Image
|
||||
src={selectedItem.product.productImage.small}
|
||||
alt={selectedItem.product.productName}
|
||||
fill
|
||||
className="object-contain p-2"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="inline-flex rounded-full border border-emerald-100 bg-emerald-50 px-3 py-1 text-xs font-semibold uppercase tracking-[0.18em] text-emerald-700">
|
||||
Đã chọn
|
||||
</p>
|
||||
<p className="mt-3 text-base font-semibold leading-6 text-slate-900">
|
||||
{selectedItem.product.productName}
|
||||
</p>
|
||||
<div className="mt-3 grid gap-2 text-sm text-slate-500 sm:grid-cols-2">
|
||||
<span className="truncate">SKU: {selectedItem.product.productSKU}</span>
|
||||
<span className="truncate">
|
||||
Bảo hành: {selectedItem.product.warranty || 'Liên hệ'}
|
||||
</span>
|
||||
<span className="sm:col-span-2">
|
||||
{selectedItem.product.quantity > 0
|
||||
? `Còn hàng: ${selectedItem.product.quantity}`
|
||||
: 'Tạm hết hàng'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-[20px] bg-slate-50 p-4 sm:grid-cols-3 2xl:grid-cols-1">
|
||||
<div className="rounded-2xl bg-white px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-slate-400">
|
||||
Đơn giá
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold text-red-600">
|
||||
{formatCurrency(selectedItem.product.price)}đ
|
||||
</p>
|
||||
</div>
|
||||
<label className="rounded-2xl bg-white px-4 py-3">
|
||||
<span className="text-xs uppercase tracking-[0.18em] text-slate-400">
|
||||
Số lượng
|
||||
</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={50}
|
||||
value={selectedItem.quantity}
|
||||
onChange={(event) =>
|
||||
onQuantityChange(
|
||||
category.id,
|
||||
Math.max(1, Number.parseInt(event.target.value || '1', 10)),
|
||||
)
|
||||
}
|
||||
className="mt-2 h-11 w-full rounded-xl border border-slate-200 bg-white px-3 text-sm font-medium outline-none transition focus:border-red-300"
|
||||
/>
|
||||
</label>
|
||||
<div className="rounded-2xl bg-white px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-slate-400">
|
||||
Thành tiền
|
||||
</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-900">
|
||||
{formatCurrency(selectedItem.product.price * selectedItem.quantity)}đ
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onOpenCategory(category)}
|
||||
className="rounded-xl border border-slate-200 bg-white px-4 py-2.5 text-sm font-semibold text-slate-700 transition hover:border-red-200 hover:text-red-700"
|
||||
>
|
||||
Đổi linh kiện
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemoveProduct(category.id)}
|
||||
className="rounded-xl border border-red-200 bg-red-50 px-4 py-2.5 text-sm font-semibold text-red-600 transition hover:bg-red-100"
|
||||
>
|
||||
Xoá khỏi cấu hình
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
121
src/app/buildpc/BtnAction/index.tsx
Normal file
121
src/app/buildpc/BtnAction/index.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
'use client';
|
||||
|
||||
import { FaFileExcel, FaImage, FaPrint, FaShoppingCart } from 'react-icons/fa';
|
||||
import { formatCurrency } from '@/lib/formatPrice';
|
||||
|
||||
interface BtnActionProps {
|
||||
actionLabels: {
|
||||
addToCart: string;
|
||||
downloadExcel: string;
|
||||
exportImage: string;
|
||||
printView: string;
|
||||
};
|
||||
estimateLabel: string;
|
||||
onAddToCart: () => void;
|
||||
onExportCsv: () => void;
|
||||
onExportJson: () => void;
|
||||
onPrint: () => void;
|
||||
onReset: () => void;
|
||||
selectedCount: number;
|
||||
totalCategories: number;
|
||||
totalPrice: number;
|
||||
}
|
||||
|
||||
const BtnAction = ({
|
||||
actionLabels,
|
||||
estimateLabel,
|
||||
onAddToCart,
|
||||
onExportCsv,
|
||||
onExportJson,
|
||||
onPrint,
|
||||
onReset,
|
||||
selectedCount,
|
||||
totalCategories,
|
||||
totalPrice,
|
||||
}: BtnActionProps) => {
|
||||
const disabled = selectedCount === 0;
|
||||
|
||||
return (
|
||||
<section className="rounded-[28px] border border-slate-200 bg-slate-950 p-5 text-white shadow-[0_20px_50px_rgba(15,23,42,0.24)]">
|
||||
<div className="grid gap-5 xl:grid-cols-[0.9fr_1.1fr]">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-sm font-semibold tracking-[0.2em] text-white/45 uppercase">
|
||||
Tóm tắt cấu hình
|
||||
</p>
|
||||
<p className="mt-3 text-3xl font-semibold">
|
||||
{formatCurrency(totalPrice)}
|
||||
<span className="ml-1 text-xl text-white/60">đ</span>
|
||||
</p>
|
||||
<p className="mt-2 text-sm leading-6 text-white/65">
|
||||
{estimateLabel} cho {selectedCount}/{totalCategories} danh mục đã được chọn.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white/70">
|
||||
Xuất cấu hình để gửi nhanh cho khách, hoặc thêm toàn bộ vào giỏ hàng để tiếp tục quy
|
||||
trình mua.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExportJson}
|
||||
disabled={disabled}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 p-4 text-left transition hover:border-white/20 hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<FaImage className="text-lg text-amber-300" />
|
||||
<p className="mt-4 text-sm font-semibold">{actionLabels.exportImage}</p>
|
||||
<p className="mt-1 text-xs text-white/55">Xuất snapshot cấu hình dạng JSON.</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onExportCsv}
|
||||
disabled={disabled}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 p-4 text-left transition hover:border-white/20 hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<FaFileExcel className="text-lg text-emerald-300" />
|
||||
<p className="mt-4 text-sm font-semibold">{actionLabels.downloadExcel}</p>
|
||||
<p className="mt-1 text-xs text-white/55">Tải danh sách linh kiện dạng CSV.</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onPrint}
|
||||
disabled={disabled}
|
||||
className="rounded-2xl border border-white/10 bg-white/5 p-4 text-left transition hover:border-white/20 hover:bg-white/10 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<FaPrint className="text-lg text-sky-300" />
|
||||
<p className="mt-4 text-sm font-semibold">{actionLabels.printView}</p>
|
||||
<p className="mt-1 text-xs text-white/55">Mở chế độ in cấu hình hiện tại.</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAddToCart}
|
||||
disabled={disabled}
|
||||
className="rounded-2xl border border-red-500/30 bg-[linear-gradient(135deg,#b91c1c_0%,#ef4444_100%)] p-4 text-left transition hover:brightness-105 disabled:cursor-not-allowed disabled:opacity-40"
|
||||
>
|
||||
<FaShoppingCart className="text-lg text-white" />
|
||||
<p className="mt-4 text-sm font-semibold">{actionLabels.addToCart}</p>
|
||||
<p className="mt-1 text-xs text-red-50/80">Thêm toàn bộ sản phẩm đang chọn vào giỏ.</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onReset}
|
||||
className="rounded-2xl border border-white/10 bg-transparent p-4 text-left transition hover:border-white/20 hover:bg-white/5"
|
||||
>
|
||||
<p className="text-lg font-light text-white/80">↺</p>
|
||||
<p className="mt-4 text-sm font-semibold">Làm mới cấu hình</p>
|
||||
<p className="mt-1 text-xs text-white/55">Xoá toàn bộ lựa chọn và bắt đầu lại.</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default BtnAction;
|
||||
308
src/app/buildpc/BuilderClient.tsx
Normal file
308
src/app/buildpc/BuilderClient.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
BuildPcCategory,
|
||||
BuildPcCategoryResponse,
|
||||
BuildPcProduct,
|
||||
BuildPcSnapshot,
|
||||
} from '@/lib/buildpc/source';
|
||||
import { formatCurrency } from '@/lib/formatPrice';
|
||||
import { addToCart } from '@/lib/ButtonCart';
|
||||
import { BoxListAccessory } from './BoxListAccessory';
|
||||
import BtnAction from './BtnAction';
|
||||
import ProductPickerModal from './ProductPickerModal';
|
||||
|
||||
type SelectedBuildPcItem = {
|
||||
category: BuildPcCategory;
|
||||
product: BuildPcProduct;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
interface BuilderClientProps {
|
||||
snapshot: BuildPcSnapshot;
|
||||
}
|
||||
|
||||
const BuilderClient = ({ snapshot }: BuilderClientProps) => {
|
||||
const [selectedItems, setSelectedItems] = useState<Record<number, SelectedBuildPcItem>>({});
|
||||
const [activeCategory, setActiveCategory] = useState<BuildPcCategory | null>(null);
|
||||
const [activeListing, setActiveListing] = useState<BuildPcCategoryResponse | null>(null);
|
||||
const [activeRequestUrl, setActiveRequestUrl] = useState<string | null>(null);
|
||||
const [isLoadingListing, setIsLoadingListing] = useState(false);
|
||||
const [listingError, setListingError] = useState('');
|
||||
|
||||
const totalPrice = useMemo(() => {
|
||||
return Object.values(selectedItems).reduce((sum, item) => {
|
||||
return sum + item.product.price * item.quantity;
|
||||
}, 0);
|
||||
}, [selectedItems]);
|
||||
|
||||
const selectedCount = Object.keys(selectedItems).length;
|
||||
const totalCategories = snapshot.categories.length;
|
||||
const completionPercent =
|
||||
totalCategories === 0 ? 0 : Math.round((selectedCount / totalCategories) * 100);
|
||||
|
||||
const loadListing = async (params: { categoryId?: number; q?: string; sourceUrl?: string }) => {
|
||||
setIsLoadingListing(true);
|
||||
setListingError('');
|
||||
|
||||
try {
|
||||
const url = new URL('/api/buildpc/category', window.location.origin);
|
||||
if (params.categoryId) {
|
||||
url.searchParams.set('categoryId', String(params.categoryId));
|
||||
}
|
||||
if (params.sourceUrl) {
|
||||
url.searchParams.set('sourceUrl', params.sourceUrl);
|
||||
}
|
||||
if (params.q) {
|
||||
url.searchParams.set('q', params.q);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
throw new Error('Không thể tải danh sách linh kiện');
|
||||
}
|
||||
|
||||
const data = (await response.json()) as BuildPcCategoryResponse;
|
||||
setActiveListing(data);
|
||||
setActiveRequestUrl(params.sourceUrl ?? null);
|
||||
} catch (error) {
|
||||
setListingError(error instanceof Error ? error.message : 'Không thể tải danh sách linh kiện');
|
||||
} finally {
|
||||
setIsLoadingListing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeCategory) {
|
||||
return;
|
||||
}
|
||||
|
||||
void loadListing({ categoryId: activeCategory.id });
|
||||
}, [activeCategory]);
|
||||
|
||||
const handleOpenCategory = (category: BuildPcCategory) => {
|
||||
setActiveCategory(category);
|
||||
setActiveListing(null);
|
||||
setActiveRequestUrl(null);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setActiveCategory(null);
|
||||
setActiveListing(null);
|
||||
setActiveRequestUrl(null);
|
||||
setListingError('');
|
||||
};
|
||||
|
||||
const handleSelectProduct = (product: BuildPcProduct) => {
|
||||
if (!activeCategory) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedItems((currentValue) => ({
|
||||
...currentValue,
|
||||
[activeCategory.id]: {
|
||||
category: activeCategory,
|
||||
product,
|
||||
quantity: currentValue[activeCategory.id]?.quantity ?? 1,
|
||||
},
|
||||
}));
|
||||
handleCloseModal();
|
||||
};
|
||||
|
||||
const handleQuantityChange = (categoryId: number, quantity: number) => {
|
||||
setSelectedItems((currentValue) => {
|
||||
const selectedItem = currentValue[categoryId];
|
||||
if (!selectedItem) {
|
||||
return currentValue;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentValue,
|
||||
[categoryId]: {
|
||||
...selectedItem,
|
||||
quantity,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveProduct = (categoryId: number) => {
|
||||
setSelectedItems((currentValue) => {
|
||||
const nextValue = { ...currentValue };
|
||||
delete nextValue[categoryId];
|
||||
return nextValue;
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSelectedItems({});
|
||||
};
|
||||
|
||||
const handleExportJson = () => {
|
||||
if (selectedCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([JSON.stringify(selectedItems, null, 2)], {
|
||||
type: 'application/json;charset=utf-8',
|
||||
});
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = 'build-pc-config.json';
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
};
|
||||
|
||||
const handleExportCsv = () => {
|
||||
if (selectedCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = [
|
||||
['Danh mục', 'Sản phẩm', 'SKU', 'Đơn giá', 'Số lượng', 'Thành tiền'].join(','),
|
||||
...Object.values(selectedItems).map((item) =>
|
||||
[
|
||||
`"${item.category.name}"`,
|
||||
`"${item.product.productName.replace(/"/g, '""')}"`,
|
||||
item.product.productSKU,
|
||||
item.product.price,
|
||||
item.quantity,
|
||||
item.product.price * item.quantity,
|
||||
].join(','),
|
||||
),
|
||||
];
|
||||
|
||||
const blob = new Blob([rows.join('\n')], {
|
||||
type: 'text/csv;charset=utf-8',
|
||||
});
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = 'build-pc-config.csv';
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
};
|
||||
|
||||
const handleAddToCart = () => {
|
||||
Object.values(selectedItems).forEach((item) => {
|
||||
addToCart(item.product, item.quantity);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="grid gap-4 xl:grid-cols-[1.45fr_0.95fr]">
|
||||
<div className="rounded-[24px] border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold tracking-[0.2em] text-slate-500 uppercase">
|
||||
Trình dựng cấu hình
|
||||
</p>
|
||||
<h3 className="border-none pb-0 text-2xl font-semibold text-slate-900 normal-case">
|
||||
Lắp bộ máy theo từng bước
|
||||
</h3>
|
||||
<p className="max-w-2xl text-sm leading-6 text-slate-600">
|
||||
Chọn linh kiện theo từng danh mục, điều chỉnh số lượng rồi xuất cấu hình hoặc thêm
|
||||
toàn bộ vào giỏ hàng.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-w-[220px] rounded-2xl bg-slate-950 px-5 py-4 text-white shadow-[0_18px_40px_rgba(15,23,42,0.24)]">
|
||||
<p className="text-xs tracking-[0.24em] text-white/60 uppercase">
|
||||
{snapshot.estimateLabel}
|
||||
</p>
|
||||
<p className="mt-2 text-3xl font-semibold">
|
||||
{formatCurrency(totalPrice)}
|
||||
<span className="ml-1 text-lg text-white/70">đ</span>
|
||||
</p>
|
||||
<p className="mt-3 text-sm text-white/70">
|
||||
{selectedCount}/{totalCategories} danh mục đã chọn
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="flex items-center justify-between text-sm text-slate-600">
|
||||
<span>Tiến độ hoàn thiện cấu hình</span>
|
||||
<span className="font-semibold text-slate-900">{completionPercent}%</span>
|
||||
</div>
|
||||
<div className="h-3 overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-[linear-gradient(90deg,#ef4444_0%,#f97316_100%)] transition-all"
|
||||
style={{ width: `${completionPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 text-sm text-slate-600 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<p className="text-xs tracking-[0.2em] text-slate-400 uppercase">Danh mục</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-900">{totalCategories}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<p className="text-xs tracking-[0.2em] text-slate-400 uppercase">Đã chọn</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-900">{selectedCount}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<p className="text-xs tracking-[0.2em] text-slate-400 uppercase">Trạng thái</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-900">
|
||||
{selectedCount === 0 ? 'Chưa bắt đầu' : 'Đang cấu hình'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="js-buildpc-promotion-content" />
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-red-100 bg-[linear-gradient(180deg,#fff7f7_0%,#ffffff_100%)] p-5 shadow-sm">
|
||||
<p className="text-sm font-semibold tracking-[0.2em] text-red-500 uppercase">
|
||||
Mẹo cấu hình
|
||||
</p>
|
||||
<div className="mt-4 space-y-3 text-sm leading-6 text-slate-600">
|
||||
<div className="rounded-2xl border border-white bg-white/80 p-4">
|
||||
Chọn CPU trước để lọc nhanh mainboard, RAM và tản nhiệt tương thích hơn.
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white bg-white/80 p-4">
|
||||
Ưu tiên PSU và case ở cuối để cân theo tổng công suất và kích thước thực tế.
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white bg-white/80 p-4">
|
||||
Khi đã đủ cấu hình, xuất CSV hoặc in nhanh trước khi gửi cho khách.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<BoxListAccessory
|
||||
categories={snapshot.categories}
|
||||
selectedItems={selectedItems}
|
||||
onOpenCategory={handleOpenCategory}
|
||||
onQuantityChange={handleQuantityChange}
|
||||
onRemoveProduct={handleRemoveProduct}
|
||||
/>
|
||||
|
||||
<BtnAction
|
||||
actionLabels={snapshot.actionLabels}
|
||||
estimateLabel={snapshot.estimateLabel}
|
||||
selectedCount={selectedCount}
|
||||
totalCategories={totalCategories}
|
||||
totalPrice={totalPrice}
|
||||
onAddToCart={handleAddToCart}
|
||||
onExportCsv={handleExportCsv}
|
||||
onExportJson={handleExportJson}
|
||||
onPrint={() => window.print()}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
|
||||
<ProductPickerModal
|
||||
activeCategory={activeCategory}
|
||||
currentRequestUrl={activeRequestUrl}
|
||||
error={listingError}
|
||||
isLoading={isLoadingListing}
|
||||
listing={activeListing}
|
||||
onClose={handleCloseModal}
|
||||
onLoadListing={loadListing}
|
||||
onSelectProduct={handleSelectProduct}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BuilderClient;
|
||||
351
src/app/buildpc/ProductPickerModal.tsx
Normal file
351
src/app/buildpc/ProductPickerModal.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { BuildPcCategory, BuildPcCategoryResponse, BuildPcProduct } from '@/lib/buildpc/source';
|
||||
import { formatCurrency } from '@/lib/formatPrice';
|
||||
|
||||
interface ProductPickerModalProps {
|
||||
activeCategory: BuildPcCategory | null;
|
||||
currentRequestUrl: string | null;
|
||||
error: string;
|
||||
isLoading: boolean;
|
||||
listing: BuildPcCategoryResponse | null;
|
||||
onClose: () => void;
|
||||
onLoadListing: (params: { categoryId?: number; q?: string; sourceUrl?: string }) => Promise<void>;
|
||||
onSelectProduct: (product: BuildPcProduct) => void;
|
||||
}
|
||||
|
||||
const ProductPickerModal = ({
|
||||
activeCategory,
|
||||
currentRequestUrl,
|
||||
error,
|
||||
isLoading,
|
||||
listing,
|
||||
onClose,
|
||||
onLoadListing,
|
||||
onSelectProduct,
|
||||
}: ProductPickerModalProps) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const totalProducts = useMemo(() => listing?.product_list.length ?? 0, [listing]);
|
||||
const portalTarget = typeof document === 'undefined' ? null : document.body;
|
||||
|
||||
if (!activeCategory || !portalTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
void onLoadListing({
|
||||
categoryId: activeCategory.id,
|
||||
q: searchQuery.trim() || undefined,
|
||||
sourceUrl: currentRequestUrl ?? undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const renderFilterButton = (
|
||||
key: string,
|
||||
label: string,
|
||||
count: number,
|
||||
isSelected: boolean | number,
|
||||
url: string,
|
||||
) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => void onLoadListing({ sourceUrl: url })}
|
||||
className={`inline-flex min-h-10 items-center rounded-2xl border px-3 py-2 text-left text-sm transition ${
|
||||
isSelected
|
||||
? 'border-red-500 bg-red-50 text-red-700 shadow-sm'
|
||||
: 'border-slate-200 bg-white text-slate-600 hover:border-red-200 hover:text-red-700'
|
||||
}`}
|
||||
>
|
||||
<span className="break-words">{label}</span>
|
||||
<span className="ml-1 shrink-0 text-slate-400">({count})</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[2000] bg-slate-950/70 backdrop-blur-sm">
|
||||
<div className="absolute left-1/2 top-1/2 flex h-[calc(100vh-16px)] w-[calc(100vw-16px)] max-w-[1320px] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-[28px] border border-white/10 bg-white shadow-[0_30px_80px_rgba(15,23,42,0.35)] md:h-[calc(100vh-48px)] md:w-[calc(100vw-48px)]">
|
||||
<div className="shrink-0 border-b border-slate-200 bg-[linear-gradient(135deg,#111827_0%,#7f1d1d_100%)] px-4 py-4 text-white md:px-6 md:py-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-white/60">
|
||||
Danh sách linh kiện
|
||||
</p>
|
||||
<h3 className="mt-2 border-none pb-0 text-2xl font-semibold normal-case text-white">
|
||||
Chọn {activeCategory.name}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-white/75">
|
||||
Hiển thị {totalProducts} sản phẩm trong kết quả hiện tại.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/15"
|
||||
>
|
||||
Đóng
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col gap-3 xl:flex-row">
|
||||
<div className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-white/10 p-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
placeholder={`Tìm trong ${activeCategory.name.toLowerCase()}`}
|
||||
className="h-12 min-w-0 flex-1 rounded-xl border border-transparent bg-white px-4 text-sm text-slate-900 outline-none transition focus:border-red-300"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSearch}
|
||||
className="h-12 shrink-0 rounded-xl bg-red-500 px-5 text-sm font-semibold text-white transition hover:bg-red-600 sm:min-w-[150px]"
|
||||
>
|
||||
Tìm linh kiện
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 xl:w-[280px]">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/10 px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-white/55">Kết quả</p>
|
||||
<p className="mt-1 text-lg font-semibold">{totalProducts}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/10 px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-white/55">Bộ lọc</p>
|
||||
<p className="mt-1 text-lg font-semibold">
|
||||
{(listing?.brand_filter_list?.length ?? 0) +
|
||||
(listing?.price_filter_list?.length ?? 0) +
|
||||
(listing?.attribute_filter_list?.length ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 xl:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<aside className="min-h-0 overflow-y-auto border-b border-slate-200 bg-slate-50/80 p-4 md:p-5 xl:border-b-0 xl:border-r">
|
||||
<div className="space-y-5">
|
||||
{listing?.brand_filter_list?.length ? (
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<h4 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
Thương hiệu
|
||||
</h4>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{listing.brand_filter_list.map((item) =>
|
||||
renderFilterButton(
|
||||
`brand-${item.url}-${item.name}`,
|
||||
item.name,
|
||||
item.count,
|
||||
item.is_selected,
|
||||
item.url,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{listing?.price_filter_list?.length ? (
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<h4 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
Khoảng giá
|
||||
</h4>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{listing.price_filter_list.map((item) =>
|
||||
renderFilterButton(
|
||||
`price-${item.url}-${item.name}`,
|
||||
item.name,
|
||||
item.count,
|
||||
item.is_selected,
|
||||
item.url,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{(listing?.attribute_filter_list ?? []).map((filter) => (
|
||||
<section
|
||||
key={filter.filter_code}
|
||||
className="rounded-2xl border border-slate-200 bg-white p-4"
|
||||
>
|
||||
<h4 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
{filter.name}
|
||||
</h4>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{filter.value_list.map((item) =>
|
||||
renderFilterButton(
|
||||
`attribute-${filter.filter_code}-${item.url}-${item.name}`,
|
||||
item.name,
|
||||
item.count,
|
||||
item.is_selected,
|
||||
item.url,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="flex min-h-0 flex-col overflow-hidden bg-white">
|
||||
<div className="shrink-0 border-b border-slate-200 px-4 py-4 md:px-5">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
Sắp xếp
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{(listing?.sort_by_collection ?? []).map((item, index) => (
|
||||
<button
|
||||
key={`sort-${item.url}-${item.name}-${index}`}
|
||||
type="button"
|
||||
onClick={() => void onLoadListing({ sourceUrl: item.url })}
|
||||
className="rounded-full border border-slate-200 px-3 py-2 text-sm font-medium text-slate-600 transition hover:border-red-200 hover:text-red-700"
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">
|
||||
Chọn sản phẩm để thay trực tiếp vào cấu hình hiện tại.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4 md:p-5">
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="animate-pulse rounded-[24px] border border-slate-200 bg-slate-50 p-4"
|
||||
>
|
||||
<div className="aspect-square rounded-2xl bg-slate-200" />
|
||||
<div className="mt-4 h-4 rounded bg-slate-200" />
|
||||
<div className="mt-2 h-4 w-2/3 rounded bg-slate-200" />
|
||||
<div className="mt-6 h-10 rounded-xl bg-slate-200" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && error ? (
|
||||
<div className="rounded-[24px] border border-red-100 bg-red-50 p-5 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && listing?.product_list?.length ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
|
||||
{listing.product_list.map((product) => (
|
||||
<article
|
||||
key={product.productId}
|
||||
className="group flex h-full min-w-0 flex-col rounded-[24px] border border-slate-200 bg-white p-4 shadow-sm transition hover:-translate-y-1 hover:shadow-[0_16px_40px_rgba(15,23,42,0.08)]"
|
||||
>
|
||||
<Link
|
||||
href={product.productUrl}
|
||||
target="_blank"
|
||||
className="relative block aspect-square overflow-hidden rounded-2xl bg-slate-50"
|
||||
>
|
||||
<Image
|
||||
src={product.productImage.large}
|
||||
alt={product.productName}
|
||||
fill
|
||||
className="object-contain p-3 transition duration-300 group-hover:scale-105"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<div className="mt-4 flex min-h-0 flex-1 flex-col space-y-3">
|
||||
<Link
|
||||
href={product.productUrl}
|
||||
target="_blank"
|
||||
className="line-clamp-2 min-h-12 text-sm font-semibold leading-6 text-slate-900 transition group-hover:text-red-600"
|
||||
>
|
||||
{product.productName}
|
||||
</Link>
|
||||
|
||||
<div className="grid gap-2 text-sm text-slate-500">
|
||||
<p>SKU: {product.productSKU}</p>
|
||||
<p>Bảo hành: {product.warranty || 'Liên hệ'}</p>
|
||||
<p>
|
||||
{product.quantity > 0 ? `Còn hàng: ${product.quantity}` : 'Tạm hết hàng'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-2">
|
||||
{product.marketPrice > product.price ? (
|
||||
<p className="text-sm text-slate-400 line-through">
|
||||
{formatCurrency(product.marketPrice)}đ
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-xl font-semibold text-red-600">
|
||||
{formatCurrency(product.price)}đ
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectProduct(product)}
|
||||
className="w-full rounded-xl bg-[linear-gradient(135deg,#b91c1c_0%,#ef4444_100%)] px-4 py-3 text-sm font-semibold text-white transition hover:brightness-105"
|
||||
>
|
||||
Thêm vào cấu hình
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && !listing?.product_list?.length ? (
|
||||
<div className="rounded-[24px] border border-slate-200 bg-slate-50 p-8 text-center">
|
||||
<p className="text-lg font-semibold text-slate-900">Chưa có sản phẩm phù hợp</p>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
Thử đổi bộ lọc hoặc tìm bằng từ khóa khác.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!isLoading && listing?.paging_collection?.length ? (
|
||||
<div className="shrink-0 border-t border-slate-200 px-4 py-4 md:px-5">
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
{listing.paging_collection.map((item, index) => (
|
||||
<button
|
||||
key={`paging-${item.url}-${item.name}-${index}`}
|
||||
type="button"
|
||||
onClick={() => void onLoadListing({ sourceUrl: item.url })}
|
||||
className={`rounded-full border px-4 py-2 text-sm font-medium transition ${
|
||||
item.is_active
|
||||
? 'border-red-500 bg-red-50 text-red-700'
|
||||
: 'border-slate-200 text-slate-600 hover:border-red-200 hover:text-red-700'
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
portalTarget,
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductPickerModal;
|
||||
47
src/app/buildpc/Slider/index.tsx
Normal file
47
src/app/buildpc/Slider/index.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { getBanners } from '@/lib/api/banner';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
import type { TemplateBanner } from '@/types';
|
||||
|
||||
const Slider = () => {
|
||||
const { data: banners } = useApiData(
|
||||
() => getBanners(),
|
||||
[],
|
||||
{ initialData: null as TemplateBanner | null },
|
||||
);
|
||||
|
||||
const dataSlider = banners?.header;
|
||||
|
||||
return (
|
||||
<div className="banner-buildpc" style={{ marginBottom: '40px' }}>
|
||||
<Swiper
|
||||
modules={[Autoplay, Navigation, Pagination]}
|
||||
spaceBetween={12}
|
||||
slidesPerView={1}
|
||||
loop={true}
|
||||
>
|
||||
{dataSlider?.banner_buildpc?.map((banner, index) => (
|
||||
<SwiperSlide key={index}>
|
||||
<Link href={banner.desUrl} className="item-banner boder-radius-10">
|
||||
<Image
|
||||
src={banner.fileUrl}
|
||||
width={1909}
|
||||
height={57}
|
||||
alt={banner.title}
|
||||
priority={true}
|
||||
className="boder-radius-10"
|
||||
/>
|
||||
</Link>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Slider;
|
||||
108
src/app/buildpc/page.tsx
Normal file
108
src/app/buildpc/page.tsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React from 'react';
|
||||
import { Metadata } from 'next';
|
||||
import '@styles/buildpc.css';
|
||||
|
||||
import { Breadcrumb } from '@/components/Common/Breadcrumb';
|
||||
import { getBuildPcSnapshot } from '@/lib/buildpc/source';
|
||||
|
||||
import Slider from '@/app/buildpc/Slider';
|
||||
import BuilderClient from './BuilderClient';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const snapshot = await getBuildPcSnapshot();
|
||||
|
||||
return {
|
||||
title: snapshot.title,
|
||||
description:
|
||||
'Build PC gaming giá tốt, tự chọn linh kiện theo nhu cầu gaming, đồ họa và văn phòng với cấu hình tối ưu.',
|
||||
};
|
||||
}
|
||||
|
||||
export default async function BuildPcPage() {
|
||||
const snapshot = await getBuildPcSnapshot();
|
||||
const breadcrumbItems = [{ name: 'Build PC', url: '/buildpc' }];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="container pt-4">
|
||||
<Breadcrumb items={breadcrumbItems} />
|
||||
</div>
|
||||
<div className="build-pc pc bg-[linear-gradient(180deg,#fff5f5_0%,#ffffff_22%,#f7f8fc_100%)] pb-10">
|
||||
<div className="content container">
|
||||
<div className="build-pc_content mt-0 space-y-6 rounded-[28px] border border-white/70 bg-white/90 p-4 shadow-[0_24px_80px_rgba(15,23,42,0.08)] backdrop-blur md:p-6">
|
||||
<section className="grid gap-5 xl:grid-cols-[1.35fr_0.85fr]">
|
||||
<div className="overflow-hidden rounded-[24px] border border-slate-200 bg-white shadow-sm">
|
||||
<Slider />
|
||||
</div>
|
||||
|
||||
<div className="relative overflow-hidden rounded-[24px] bg-[linear-gradient(135deg,#7f1d1d_0%,#b91c1c_48%,#111827_100%)] p-6 text-white shadow-[0_22px_60px_rgba(127,29,29,0.28)]">
|
||||
<div className="absolute inset-y-0 right-0 w-40 bg-[radial-gradient(circle_at_top,rgba(255,255,255,0.3),transparent_65%)]" />
|
||||
<div className="relative space-y-4">
|
||||
<span className="inline-flex rounded-full border border-white/20 bg-white/10 px-3 py-1 text-xs font-semibold tracking-[0.24em] text-white/80 uppercase">
|
||||
Build PC theo nhu cầu
|
||||
</span>
|
||||
<div className="space-y-3">
|
||||
<h1 className="max-w-3xl text-3xl leading-tight font-semibold md:text-4xl">
|
||||
{snapshot.title}
|
||||
</h1>
|
||||
<p className="max-w-2xl text-sm leading-6 text-white/80 md:text-base">
|
||||
{snapshot.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="rounded-2xl border border-white/15 bg-white/10 p-4">
|
||||
<p className="text-xs tracking-[0.24em] text-white/70 uppercase">Số bước</p>
|
||||
<p className="mt-2 text-2xl font-semibold">{snapshot.categories.length}</p>
|
||||
<p className="mt-1 text-sm text-white/75">
|
||||
Danh mục được chia theo từng linh kiện để lắp cấu hình nhanh hơn.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/15 bg-white/10 p-4">
|
||||
<p className="text-xs tracking-[0.24em] text-white/70 uppercase">
|
||||
Trạng thái dữ liệu
|
||||
</p>
|
||||
<p className="mt-2 text-2xl font-semibold">Live</p>
|
||||
<p className="mt-1 text-sm text-white/75">
|
||||
Danh sách linh kiện lấy từ nguồn thật và đồng bộ qua proxy nội bộ.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-[24px] border border-slate-200 bg-slate-50/80 p-4 md:p-5">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-sm font-semibold tracking-[0.18em] text-slate-500 uppercase">
|
||||
Bộ cấu hình
|
||||
</p>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
Chọn một tab để phân biệt cấu hình, sau đó thêm linh kiện theo từng bước.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{snapshot.tabs.map((tab, index) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
className={`rounded-full border px-4 py-2 text-sm font-semibold transition ${
|
||||
index === 0
|
||||
? 'border-red-600 bg-red-600 text-white shadow-lg shadow-red-200'
|
||||
: 'border-slate-200 bg-white text-slate-700 hover:border-red-200 hover:text-red-700'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<BuilderClient snapshot={snapshot} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
16
src/app/cart/page.tsx
Normal file
16
src/app/cart/page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import HomeCart from '@/components/Cart/Home';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Thông tin giỏ hàng',
|
||||
description: 'Xem các sản phẩm đã thêm vào trong giỏ hàng',
|
||||
};
|
||||
|
||||
export default function CartPage() {
|
||||
return (
|
||||
<>
|
||||
<HomeCart />
|
||||
</>
|
||||
);
|
||||
}
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
11
src/app/deal/page.tsx
Normal file
11
src/app/deal/page.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Metadata } from 'next';
|
||||
import DealPageClient from './DealPageClient';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Danh sách deal',
|
||||
description: 'Sản phẩm khuyến mãi giá ưu đãi',
|
||||
};
|
||||
|
||||
export default function DealPage() {
|
||||
return <DealPageClient />;
|
||||
}
|
||||
@@ -1,38 +1,49 @@
|
||||
'use client';
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { Metadata } from 'next';
|
||||
import '@styles/sf-pro-display.css';
|
||||
import 'swiper/css';
|
||||
import 'swiper/css/navigation';
|
||||
import 'swiper/css/pagination';
|
||||
import '@styles/globals.css';
|
||||
import Header from '@/components/other/Header';
|
||||
import Footer from '@/components/other/Footer';
|
||||
import Header from '@/components/Other/Header';
|
||||
import Footer from '@/components/Other/Footer';
|
||||
import MSWProvider from '@/components/Common/MSWProvider';
|
||||
import { ErrorBoundary } from '@/components/Common/ErrorBoundary';
|
||||
|
||||
import PreLoader from '@components/common/PreLoader';
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
default: 'Nguyễn Công PC - Máy tính, Laptop, Linh kiện chính hãng',
|
||||
template: '%s | Nguyễn Công PC',
|
||||
},
|
||||
description:
|
||||
'Nguyễn Công PC chuyên cung cấp máy tính, laptop, linh kiện và phụ kiện chính hãng với giá tốt, bảo hành uy tín và 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 và phụ kiện chính hãng với giá tốt nhất.',
|
||||
},
|
||||
robots: { index: true, follow: true },
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(() => setLoading(false), 1000);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<html suppressHydrationWarning>
|
||||
<html lang="vi" suppressHydrationWarning>
|
||||
<body>
|
||||
{loading ? (
|
||||
<PreLoader />
|
||||
) : (
|
||||
<>
|
||||
<Header />
|
||||
<main>{children}</main>
|
||||
<Footer />
|
||||
</>
|
||||
)}
|
||||
<MSWProvider>
|
||||
<Header />
|
||||
<main>
|
||||
<ErrorBoundary>{children}</ErrorBoundary>
|
||||
</main>
|
||||
<Footer />
|
||||
</MSWProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
83
src/app/loading.tsx
Normal file
83
src/app/loading.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import Skeleton from '@/components/Common/Skeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="page-hompage mt-4">
|
||||
<div className="container">
|
||||
{/* Slider */}
|
||||
<Skeleton className="h-100 w-full" />
|
||||
<div className="mt-3 flex gap-3">
|
||||
<Skeleton className="h-40 flex-1" />
|
||||
<Skeleton className="h-40 flex-1" />
|
||||
</div>
|
||||
|
||||
{/* Deal */}
|
||||
<div className="box-product-deal boder-radius-10 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-8 w-32" />
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-6 gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
<Skeleton className="aspect-square w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-5 w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Feature */}
|
||||
<div className="box-category-outstanding boder-radius-10 mt-4">
|
||||
<Skeleton className="mb-3 h-6 w-40" />
|
||||
<div className="grid grid-cols-10 gap-3">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div key={i} className="flex flex-col items-center gap-2">
|
||||
<Skeleton className="h-12 w-12 rounded-full" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Box List Category */}
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="box-product-category boder-radius-10 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<div className="flex gap-3">
|
||||
{Array.from({ length: 4 }).map((_, j) => (
|
||||
<Skeleton key={j} className="h-5 w-20" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-5 gap-3">
|
||||
{Array.from({ length: 5 }).map((_, j) => (
|
||||
<div key={j} className="flex flex-col gap-2">
|
||||
<Skeleton className="aspect-square w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-5 w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Box Article */}
|
||||
<div className="box-article-group boder-radius-10 mt-4">
|
||||
<Skeleton className="mb-3 h-6 w-48" />
|
||||
<div className="flex gap-10">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex flex-1 flex-col gap-2">
|
||||
<Skeleton className="aspect-video w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
5
src/app/not-found.tsx
Normal file
5
src/app/not-found.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import NotFoundPage from '@/features/NotFoundPage';
|
||||
|
||||
export default function NotFound() {
|
||||
return <NotFoundPage />;
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import Home from '@/components/home';
|
||||
import Home from '@/features/Home';
|
||||
import { Metadata } from 'next';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
||||
40
src/app/send-cart/page.tsx
Normal file
40
src/app/send-cart/page.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
import { Metadata } from 'next';
|
||||
import Link from 'next/link';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Gửi đơn hàng',
|
||||
description: 'Gửi đơn hàng',
|
||||
};
|
||||
|
||||
export default function SendCartPage() {
|
||||
return (
|
||||
<section className="box-send-cart py-[100px]">
|
||||
<div className="container">
|
||||
<div className="send-cart-success">
|
||||
<div className="send-cart-title">
|
||||
<p className="send-cart-title-name pb-3">
|
||||
<i className="sprite-sub sprite-icon-check-cart"></i>
|
||||
ĐƠN HÀNG ĐÃ ĐƯỢC TIẾP NHẬN
|
||||
</p>
|
||||
<div className="send-cart-title-descreption leading-[150%]">
|
||||
Cảm ơn quý khách đã đặt hàng tại Nguyễn Công PC. Đơn hàng đã được tiếp nhận. Để kiểm
|
||||
tra đơn hàng hoặc thay đổi thông tin, vui lòng
|
||||
<Link href="/dang-nhap" className="red-text px-2">
|
||||
Đăng nhập
|
||||
</Link>
|
||||
vào website. Nếu khách hàng có yêu cầu đặc biệt, vui lòng liên hệ nhân viên tư vấn tại
|
||||
<Link href="https://www.facebook.com/MAY.TINH.NGUYEN.CONG" className="red-text px-1">
|
||||
Facebook
|
||||
</Link>
|
||||
hoặc Mua hàng trực tuyến: Hotline:
|
||||
<b className="red-text">
|
||||
<Link href="tel:0989336366">Điện thoại: 0989336366</Link>
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
202
src/components/Cart/Home/FormCart/index.tsx
Normal file
202
src/components/Cart/Home/FormCart/index.tsx
Normal file
@@ -0,0 +1,202 @@
|
||||
'use client';
|
||||
import { useState, forwardRef, useImperativeHandle } from 'react';
|
||||
|
||||
export interface FormCartRef {
|
||||
validateForm: () => boolean;
|
||||
}
|
||||
|
||||
interface FormFields {
|
||||
name: string;
|
||||
tel: string;
|
||||
email: string;
|
||||
address: string;
|
||||
province: string;
|
||||
district: string;
|
||||
note: string;
|
||||
taxName: string;
|
||||
taxAddress: string;
|
||||
taxCode: string;
|
||||
}
|
||||
|
||||
interface FormErrors {
|
||||
name?: string;
|
||||
tel?: string;
|
||||
address?: string;
|
||||
}
|
||||
|
||||
const REGEX_NO_SPECIAL = /^[\p{L}\p{N}\s]+$/u;
|
||||
const REGEX_PHONE = /^0\d{9}$/;
|
||||
|
||||
export const FormCart = forwardRef<FormCartRef, object>((_, ref) => {
|
||||
const [showTax, setShowTax] = useState(false);
|
||||
const [errors, setErrors] = useState<FormErrors>({});
|
||||
const [fields, setFields] = useState<FormFields>({
|
||||
name: '',
|
||||
tel: '',
|
||||
email: '',
|
||||
address: '',
|
||||
province: '0',
|
||||
district: '0',
|
||||
note: '',
|
||||
taxName: '',
|
||||
taxAddress: '',
|
||||
taxCode: '',
|
||||
});
|
||||
|
||||
const setField =
|
||||
(key: keyof FormFields) =>
|
||||
(e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement>) => {
|
||||
setFields((prev) => ({ ...prev, [key]: e.target.value }));
|
||||
setErrors((prev) => ({ ...prev, [key]: undefined }));
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: FormErrors = {};
|
||||
const name = fields.name.trim();
|
||||
const tel = fields.tel.trim();
|
||||
const address = fields.address.trim();
|
||||
|
||||
if (!name || name.length <= 4 || !REGEX_NO_SPECIAL.test(name)) {
|
||||
newErrors.name = 'Họ tên không hợp lệ (tối thiểu 5 ký tự, không chứa ký tự đặc biệt)';
|
||||
}
|
||||
if (!tel || !REGEX_PHONE.test(tel)) {
|
||||
newErrors.tel = 'Số điện thoại không hợp lệ (Ví dụ: 0912345678)';
|
||||
}
|
||||
if (!address || address.length <= 4 || !REGEX_NO_SPECIAL.test(address)) {
|
||||
newErrors.address = 'Địa chỉ không hợp lệ (tối thiểu 5 ký tự)';
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
useImperativeHandle(ref, () => ({ validateForm }));
|
||||
|
||||
return (
|
||||
<div className="box-cart-info-customer">
|
||||
<p className="title-section-cart font-semibold">Thông tin khách hàng</p>
|
||||
<div className="list-info-customer">
|
||||
<div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Họ tên*"
|
||||
name="user_info[name]"
|
||||
value={fields.name}
|
||||
onChange={setField('name')}
|
||||
/>
|
||||
{errors.name && <p className="mt-1 text-xs text-red-500">{errors.name}</p>}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Số điện thoại*"
|
||||
name="user_info[tel]"
|
||||
value={fields.tel}
|
||||
onChange={setField('tel')}
|
||||
/>
|
||||
{errors.tel && <p className="mt-1 text-xs text-red-500">{errors.tel}</p>}
|
||||
<input
|
||||
type="email"
|
||||
name="user_info[email]"
|
||||
placeholder="Email"
|
||||
value={fields.email}
|
||||
onChange={setField('email')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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="hn">Hà Nội</option>
|
||||
</select>
|
||||
<select
|
||||
name="user_info[district]"
|
||||
value={fields.district}
|
||||
onChange={setField('district')}
|
||||
>
|
||||
<option value="0">Quận/Huyện</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
placeholder="Ghi chú"
|
||||
name="user_info[note]"
|
||||
value={fields.note}
|
||||
onChange={setField('note')}
|
||||
/>
|
||||
|
||||
<div className="form-group-taxt">
|
||||
<label className="tax-title label flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="w-5"
|
||||
checked={showTax}
|
||||
onChange={(e) => setShowTax(e.target.checked)}
|
||||
/>
|
||||
Yêu cầu xuất hóa đơn công ty
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{showTax && (
|
||||
<div className="js-tax-group">
|
||||
<div className="form-group row">
|
||||
<div className="input-taxt">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tên công ty"
|
||||
className="form-control"
|
||||
name="user_info[tax_company]"
|
||||
value={fields.taxName}
|
||||
onChange={setField('taxName')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group row">
|
||||
<div className="input-taxt">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Địa chỉ công ty"
|
||||
className="form-control"
|
||||
name="user_info[tax_address]"
|
||||
value={fields.taxAddress}
|
||||
onChange={setField('taxAddress')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-group row">
|
||||
<div className="input-taxt">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Mã số thuế"
|
||||
className="form-control"
|
||||
name="user_info[tax_code]"
|
||||
value={fields.taxCode}
|
||||
onChange={setField('taxCode')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
FormCart.displayName = 'FormCart';
|
||||
115
src/components/Cart/Home/ItemCart/index.tsx
Normal file
115
src/components/Cart/Home/ItemCart/index.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { TypeCartItem } from '@/types/cart';
|
||||
import { FaSortDown, FaTrashCan } from 'react-icons/fa6';
|
||||
import { formatCurrency } from '@/lib/formatPrice';
|
||||
import { SanitizedHtml } from '@/components/Common/SanitizedHtml';
|
||||
|
||||
interface PropsCart {
|
||||
item: TypeCartItem;
|
||||
onUpdate: (id: string, quantity: number) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}
|
||||
|
||||
export const ItemCart: React.FC<PropsCart> = ({ item, onUpdate, onDelete }) => {
|
||||
const currentQty = parseInt(item.in_cart.quantity) || 1;
|
||||
|
||||
const handleChangeQuantity = (delta: number) => {
|
||||
const newQuantity = Math.max(1, currentQty + delta);
|
||||
onUpdate(item._id, newQuantity);
|
||||
};
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const val = parseInt(e.target.value);
|
||||
if (!isNaN(val) && val >= 1) {
|
||||
onUpdate(item._id, val);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="cart-item-info js-item-row flex justify-between">
|
||||
<div className="cart-item-left flex gap-3">
|
||||
<Link className="cart-item-img relative" href={item.item_info.productUrl}>
|
||||
<Image
|
||||
src={item.item_info.productImage.large}
|
||||
alt="model"
|
||||
className="bk-product-image lazy"
|
||||
width={100}
|
||||
height={100}
|
||||
/>
|
||||
{item.item_info.sale_rules?.type === 'deal' && (
|
||||
<Image
|
||||
className="icon-deal-cart lazy"
|
||||
src="https://nguyencongpc.vn/static/assets/nguyencong_2023/images/static-icon-cart-deal.png"
|
||||
width={100}
|
||||
height={100}
|
||||
alt="deal"
|
||||
/>
|
||||
)}
|
||||
</Link>
|
||||
<div className="cart-info-item flex-1">
|
||||
<Link
|
||||
href={item.item_info.productUrl}
|
||||
className="cart-item-name bk-product-name line-clamp-2"
|
||||
>
|
||||
{item.item_info.productName}
|
||||
</Link>
|
||||
{item.item_info.specialOffer?.all && (
|
||||
<div className="item-offer relative mt-3">
|
||||
<p className="title flex items-center pl-0">
|
||||
Khuyến mại{' '}
|
||||
<span className="flex gap-2">
|
||||
{' '}
|
||||
(Chi tiết)
|
||||
<FaSortDown />
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div className="item-offer-content">
|
||||
{item.item_info.specialOffer.all.map((_item, idx) => (
|
||||
<SanitizedHtml key={idx} html={_item.title} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="box-change-quantity flex items-center">
|
||||
<button
|
||||
onClick={() => handleChangeQuantity(-1)}
|
||||
className="js-quantity-change quantity-change flex items-center"
|
||||
data-value="-1"
|
||||
>
|
||||
-
|
||||
</button>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
className="js-buy-quantity bk-product-qty font-bold"
|
||||
value={currentQty}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleChangeQuantity(1)}
|
||||
className="js-quantity-change quantity-change flex items-center"
|
||||
data-value="1"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="box-item-right flex flex-col items-end justify-between">
|
||||
<div className="price-cart-item">
|
||||
<p className="price cart-item-price item-cart-price js-total-item-price font-bold">
|
||||
{item.in_cart.price === '0' ? '0' : formatCurrency(item.in_cart.total_price)} đ
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => onDelete(item._id)}
|
||||
className="delete-item-cart item-cart-icon js-delete-item flex cursor-pointer items-center justify-center"
|
||||
>
|
||||
<FaTrashCan />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
191
src/components/Cart/Home/index.tsx
Normal file
191
src/components/Cart/Home/index.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FaChevronLeft } from 'react-icons/fa6';
|
||||
import { Breadcrumb } from '@/components/Common/Breadcrumb';
|
||||
import { ItemCart } from './ItemCart';
|
||||
import { FormCart, FormCartRef } from './FormCart';
|
||||
import { formatCurrency } from '@/lib/formatPrice';
|
||||
import {
|
||||
clearCartStorage,
|
||||
getServerCartSnapshot,
|
||||
readCartFromStorage,
|
||||
subscribeCartStorage,
|
||||
writeCartToStorage,
|
||||
} from '@/lib/cartStorage';
|
||||
|
||||
const HomeCart = () => {
|
||||
const router = useRouter();
|
||||
const breadcrumbItems = [{ name: 'Giỏ hàng', url: '/cart' }];
|
||||
const cart = useSyncExternalStore(
|
||||
subscribeCartStorage,
|
||||
readCartFromStorage,
|
||||
getServerCartSnapshot,
|
||||
);
|
||||
const [payMethod, setPayMethod] = useState('2');
|
||||
|
||||
const formRef = useRef<FormCartRef>(null);
|
||||
|
||||
const updateCartItem = useCallback(
|
||||
(id: string, quantity: number) => {
|
||||
const newCart = cart.map((item) =>
|
||||
item._id === id
|
||||
? {
|
||||
...item,
|
||||
in_cart: {
|
||||
...item.in_cart,
|
||||
quantity: quantity.toString(),
|
||||
total_price: quantity * Number(item.in_cart.price),
|
||||
},
|
||||
}
|
||||
: item,
|
||||
);
|
||||
writeCartToStorage(newCart);
|
||||
},
|
||||
[cart],
|
||||
);
|
||||
|
||||
const deleteCartItem = useCallback(
|
||||
(id: string) => {
|
||||
if (!window.confirm('Bạn có chắc chắn xóa sản phẩm này không?')) return;
|
||||
const newCart = cart.filter((item) => item._id !== id);
|
||||
writeCartToStorage(newCart);
|
||||
},
|
||||
[cart],
|
||||
);
|
||||
|
||||
const deleteCart = useCallback(() => {
|
||||
if (!window.confirm('Bạn có chắc chắn xóa toàn bộ giỏ hàng không?')) return;
|
||||
clearCartStorage();
|
||||
}, []);
|
||||
|
||||
const totalPrice = useMemo(
|
||||
() => formatCurrency(cart.reduce((sum, item) => sum + Number(item.in_cart.total_price), 0)),
|
||||
[cart],
|
||||
);
|
||||
|
||||
const handleClickOrder = useCallback(() => {
|
||||
if (formRef.current?.validateForm()) {
|
||||
router.push('/send-cart');
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="container">
|
||||
<Breadcrumb items={breadcrumbItems} />
|
||||
</div>
|
||||
<section className="page-cart">
|
||||
{cart.length === 0 ? (
|
||||
<div className="not-cart pt-5">
|
||||
<Image
|
||||
src="https://nguyencongpc.vn/static/assets/nguyencong_2023/images/cart-home-min.png"
|
||||
className="lazy"
|
||||
width={130}
|
||||
height={130}
|
||||
alt="icon-cart"
|
||||
/>
|
||||
<p>Không có sản phẩm nào trong giỏ hàng của bạn.</p>
|
||||
<Link href="/" className="back-cart">
|
||||
Tiếp tục mua sắm
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="container-cart cart-header-title flex items-center justify-between">
|
||||
<p>Giỏ hàng của bạn</p>
|
||||
<Link className="back-homepage flex items-center gap-2" href="/">
|
||||
<FaChevronLeft />
|
||||
Mua thêm sản phẩm khác
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="box-info-cart container-cart">
|
||||
<div className="box-delete-all flex justify-end">
|
||||
<button className="delete-cart-all" onClick={() => deleteCart()}>
|
||||
Xóa giỏ hàng
|
||||
</button>
|
||||
</div>
|
||||
<div className="box-cart-item-list">
|
||||
{cart.map((item) => (
|
||||
<ItemCart
|
||||
item={item}
|
||||
key={item._id}
|
||||
onUpdate={updateCartItem}
|
||||
onDelete={deleteCartItem}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<FormCart ref={formRef} />
|
||||
<div className="box-payment">
|
||||
<p className="title-section-cart font-bold">Phương thức thanh toán</p>
|
||||
<div className="list-method-payment">
|
||||
<label className="label">
|
||||
<input
|
||||
type="radio"
|
||||
name="pay_method"
|
||||
id="pay2"
|
||||
value="2"
|
||||
checked={payMethod === '2'}
|
||||
onChange={(e) => setPayMethod(e.target.value)}
|
||||
/>
|
||||
Thanh toán khi nhận hàng
|
||||
</label>
|
||||
</div>
|
||||
<p className="title-section-cart font-bold">Tổng tiền</p>
|
||||
<div className="list-price">
|
||||
<p className="price-total1 flex items-center justify-between">
|
||||
<b className="txt">Tổng cộng</b>
|
||||
<b className="price js-total-before-fee-cart-price" id="total-cart-price">
|
||||
{totalPrice} đ
|
||||
</b>
|
||||
</p>
|
||||
<p className="price-total2 flex items-center justify-between">
|
||||
<b className="txt">Thành tiền</b>
|
||||
<b className="price color-red js-total-cart-price font-bold">{totalPrice} đ</b>
|
||||
</p>
|
||||
<span className="has-vat">(Giá đã bao gồm VAT)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="list-btn-cart">
|
||||
<button type="submit" onClick={handleClickOrder} className="js-send-cart font-bold">
|
||||
Đặt hàng
|
||||
</button>
|
||||
<div className="list-print-cart flex justify-between gap-2">
|
||||
<Link
|
||||
href="/export_file.php?file_type=xls&content_type=shopping-cart"
|
||||
className="down-excel font-bold"
|
||||
target="_blank"
|
||||
>
|
||||
Tải file excel
|
||||
</Link>
|
||||
<Link
|
||||
href="javascript:void(0)"
|
||||
rel="nofollow"
|
||||
className="down-img-cart font-bold"
|
||||
>
|
||||
Tải ảnh báo giá
|
||||
</Link>
|
||||
<Link
|
||||
href="/print/user.php?view=cart"
|
||||
target="_blank"
|
||||
className="print-cart font-bold"
|
||||
>
|
||||
In báo giá
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeCart;
|
||||
85
src/components/Deal/ItemDeal.tsx
Normal file
85
src/components/Deal/ItemDeal.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { parse } from 'date-fns';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import CountDown from '@/components/Common/CountDown';
|
||||
import { DealType } from '@/types';
|
||||
import { formatCurrency } from '@/lib/formatPrice';
|
||||
|
||||
type ItemDealProps = {
|
||||
item: DealType;
|
||||
};
|
||||
|
||||
const ItemDeal: React.FC<ItemDealProps> = ({ item }) => {
|
||||
const [now] = useState(() => Date.now());
|
||||
const deadline = parse(item.to_time, 'dd-MM-yyyy, h:mm a', new Date()).getTime();
|
||||
|
||||
if (deadline <= now) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const remainingQuantity = Number(item.quantity) - Number(item.sale_quantity);
|
||||
const percentRemaining = (remainingQuantity / Number(item.quantity)) * 100;
|
||||
|
||||
return (
|
||||
<div className="product-item">
|
||||
<div className="item-deal">
|
||||
<Link href={item.product_info.productUrl} className="product-image position-relative">
|
||||
<Image
|
||||
src={item.product_info.productImage.large}
|
||||
width={250}
|
||||
height={250}
|
||||
alt={item.product_info.productName}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<div className="product-info flex-1">
|
||||
<Link href={item.product_info.productUrl}>
|
||||
<h3 className="product-title line-clamp-3">{item.product_info.productName}</h3>
|
||||
</Link>
|
||||
|
||||
<div className="product-martket-main flex items-center">
|
||||
{Number(item.product_info.marketPrice) > 0 && (
|
||||
<>
|
||||
<p className="product-market-price">
|
||||
{formatCurrency(item.product_info.marketPrice)} đ
|
||||
</p>
|
||||
<div className="product-percent-price">-{item.product_info.price_off || 0}%</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="product-price-main font-bold">
|
||||
{item.product_info.price > '0'
|
||||
? `${formatCurrency(item.product_info.price)} đ`
|
||||
: 'Liên hệ'}
|
||||
</div>
|
||||
|
||||
<div className="p-quantity-sale">
|
||||
<i className="sprite sprite-fire-deal"></i>
|
||||
<div className="bg-gradient"></div>
|
||||
<p className="js-line-deal-left" style={{ width: `${percentRemaining}%` }}></p>
|
||||
<span>
|
||||
Còn {remainingQuantity}/{Number(item.quantity)} sản phẩm
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="js-item-deal-time js-item-time-25404">
|
||||
<div className="time-deal-page flex items-center justify-center gap-2">
|
||||
<div>Kết thúc sau:</div>
|
||||
<CountDown deadline={item.to_time} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link href={item.product_info.productUrl} className="buy-now-deal">
|
||||
Mua giá sốc
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ItemDeal;
|
||||
@@ -12,23 +12,30 @@ interface Filters {
|
||||
current_category?: { url: string };
|
||||
}
|
||||
|
||||
function isFilterUrlActive(pathname: string, currentSearch: string, targetUrl: string) {
|
||||
const current = new URL(`${pathname}${currentSearch ? `?${currentSearch}` : ''}`, 'http://local');
|
||||
const target = new URL(targetUrl, 'http://local');
|
||||
|
||||
return current.pathname === target.pathname && current.search === target.search;
|
||||
}
|
||||
|
||||
const ActiveFilters: React.FC<{ filters: Filters }> = ({ filters }) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const fullUrl = `${pathname}?${searchParams.toString()}`;
|
||||
const currentSearch = searchParams.toString();
|
||||
|
||||
const selectedPrice = filters.price_filter_list?.filter((f) => fullUrl.includes(f.url)) ?? [];
|
||||
const selectedBrand = filters.brand_filter_list?.filter((f) => fullUrl.includes(f.url)) ?? [];
|
||||
const selectedPrice =
|
||||
filters.price_filter_list?.filter((f) => isFilterUrlActive(pathname, currentSearch, f.url)) ?? [];
|
||||
const selectedBrand =
|
||||
filters.brand_filter_list?.filter((f) => isFilterUrlActive(pathname, currentSearch, f.url)) ?? [];
|
||||
const selectedAttr =
|
||||
filters.attribute_filter_list?.flatMap((attr) =>
|
||||
attr.value_list.filter((v) => pathname.includes(v.url)),
|
||||
attr.value_list.filter((v) => isFilterUrlActive(pathname, currentSearch, v.url)),
|
||||
) ?? [];
|
||||
|
||||
const allSelected = [...selectedPrice, ...selectedBrand, ...selectedAttr];
|
||||
const isFiltered = allSelected.length;
|
||||
|
||||
console.log(isFiltered);
|
||||
|
||||
if (isFiltered === 0) return null;
|
||||
|
||||
return (
|
||||
153
src/components/Product/BoxFilter/index.tsx
Normal file
153
src/components/Product/BoxFilter/index.tsx
Normal file
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname, useSearchParams } from 'next/navigation';
|
||||
import { FaSortDown } from 'react-icons/fa6';
|
||||
import { AttributeFilterList, BrandFilter, PriceFilter } from '@/types';
|
||||
import ActiveFilters from './ActiveFilters';
|
||||
|
||||
interface Filters {
|
||||
price_filter_list?: PriceFilter[];
|
||||
attribute_filter_list?: AttributeFilterList[];
|
||||
brand_filter_list?: BrandFilter[];
|
||||
current_category?: { url: string };
|
||||
}
|
||||
|
||||
interface BoxFilterProps {
|
||||
filters: Filters;
|
||||
}
|
||||
|
||||
function isFilterUrlActive(pathname: string, currentSearch: string, targetUrl: string) {
|
||||
const current = new URL(`${pathname}${currentSearch ? `?${currentSearch}` : ''}`, 'http://local');
|
||||
const target = new URL(targetUrl, 'http://local');
|
||||
|
||||
return current.pathname === target.pathname && current.search === target.search;
|
||||
}
|
||||
|
||||
const BoxFilter: React.FC<BoxFilterProps> = ({ filters }) => {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const currentSearch = searchParams.toString();
|
||||
const { price_filter_list, attribute_filter_list, brand_filter_list } = filters;
|
||||
const primaryBrandFilter = brand_filter_list?.[0];
|
||||
|
||||
return (
|
||||
<div className="box-filter-category boder-radius-10">
|
||||
{price_filter_list && (
|
||||
<div className="info-filter-category flex gap-10">
|
||||
<p className="title">Khoang gia:</p>
|
||||
<div className="list-filter-category flex flex-1 flex-wrap items-center gap-2">
|
||||
{price_filter_list.map((itemPrice) => {
|
||||
const isActive = isFilterUrlActive(pathname, currentSearch, itemPrice.url);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={itemPrice.url}
|
||||
className={`item item-cetner flex gap-4 ${isActive ? 'current' : ''}`}
|
||||
>
|
||||
<Link href={itemPrice.url}>{itemPrice.name}</Link>
|
||||
<Link href={itemPrice.url}>({isActive ? 'Xoa' : itemPrice.count})</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{brand_filter_list && (
|
||||
<div className="info-filter-category flex gap-10">
|
||||
<p className="title">Thuong hieu:</p>
|
||||
<div className="list-filter-category flex flex-1 flex-wrap items-center gap-2">
|
||||
{brand_filter_list.map((itemBrand) => {
|
||||
const isActive = isFilterUrlActive(pathname, currentSearch, itemBrand.url);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={itemBrand.url}
|
||||
className={`item item-cetner flex gap-4 ${isActive ? 'current' : ''}`}
|
||||
>
|
||||
<Link href={itemBrand.url}>{itemBrand.name}</Link>
|
||||
<Link href={itemBrand.url}>({isActive ? 'Xoa' : itemBrand.count})</Link>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attribute_filter_list && (
|
||||
<div className="info-filter-category flex gap-10">
|
||||
<p className="title">Chon theo tieu chi:</p>
|
||||
<div className="list-filter-category flex flex-1 flex-wrap items-center gap-3">
|
||||
{primaryBrandFilter && (
|
||||
<div
|
||||
className={`item ${
|
||||
isFilterUrlActive(pathname, currentSearch, primaryBrandFilter.url)
|
||||
? 'current'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center">
|
||||
{isFilterUrlActive(pathname, currentSearch, primaryBrandFilter.url) ? (
|
||||
<span>{primaryBrandFilter.name}</span>
|
||||
) : (
|
||||
<span>Thuong hieu</span>
|
||||
)}
|
||||
<FaSortDown size={16} style={{ marginBottom: 8 }} />
|
||||
</div>
|
||||
<ul>
|
||||
{brand_filter_list.map((item) => {
|
||||
const isActive = isFilterUrlActive(pathname, currentSearch, item.url);
|
||||
|
||||
return (
|
||||
<li key={item.url} className="flex items-center gap-3">
|
||||
<Link href={item.url}>{item.name}</Link>
|
||||
<Link href={item.url}>({isActive ? 'Xoa' : item.count})</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attribute_filter_list.length > 0 &&
|
||||
attribute_filter_list.map((attribute) => {
|
||||
const selectedValue = attribute.value_list.find((value) =>
|
||||
isFilterUrlActive(pathname, currentSearch, value.url),
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={attribute.filter_code}
|
||||
className={`item ${selectedValue ? 'current' : ''}`}
|
||||
>
|
||||
<button type="button" className="flex items-center">
|
||||
<span>{selectedValue?.name ?? attribute.name}</span>
|
||||
<FaSortDown size={16} style={{ marginBottom: 8 }} />
|
||||
</button>
|
||||
<ul>
|
||||
{attribute.value_list.map((value) => {
|
||||
const isActive = isFilterUrlActive(pathname, currentSearch, value.url);
|
||||
|
||||
return (
|
||||
<li key={value.id} className="flex items-center gap-3">
|
||||
<Link href={value.url}>{value.name}</Link>
|
||||
<Link href={value.url}>{isActive ? 'Xoa' : value.count}</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ActiveFilters filters={filters} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoxFilter;
|
||||
@@ -1,7 +1,7 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { FaGrip, FaList } from 'react-icons/fa6';
|
||||
|
||||
interface SortItem {
|
||||
@@ -11,11 +11,22 @@ interface SortItem {
|
||||
|
||||
interface SortProps {
|
||||
sort_by_collection: SortItem[];
|
||||
display_by_collection?: SortItem[];
|
||||
product_display_type?: 'grid' | 'list';
|
||||
}
|
||||
|
||||
const BoxSort: React.FC<SortProps> = ({ sort_by_collection, product_display_type }) => {
|
||||
const pathname = usePathname();
|
||||
const BoxSort: React.FC<SortProps> = ({
|
||||
sort_by_collection,
|
||||
display_by_collection,
|
||||
product_display_type,
|
||||
}) => {
|
||||
const searchParams = useSearchParams();
|
||||
const selectedSortKey = searchParams.get('sort') ?? 'new';
|
||||
const selectedDisplay = searchParams.get('display') ?? product_display_type ?? 'grid';
|
||||
const gridUrl = display_by_collection?.find((item) => item.key === 'grid')?.url;
|
||||
const listUrl =
|
||||
display_by_collection?.find((item) => item.key === 'list')?.url ??
|
||||
display_by_collection?.find((item) => item.key === 'detail')?.url;
|
||||
|
||||
return (
|
||||
<div className="box-sort-category flex items-center justify-between">
|
||||
@@ -53,9 +64,7 @@ const BoxSort: React.FC<SortProps> = ({ sort_by_collection, product_display_type
|
||||
<Link
|
||||
key={item.key}
|
||||
href={item.url}
|
||||
className={`item flex items-center ${
|
||||
pathname.includes(item.key) ? 'selected' : ''
|
||||
}`}
|
||||
className={`item flex items-center ${selectedSortKey === item.key ? 'selected' : ''}`}
|
||||
>
|
||||
{iconClass && <i className={iconClass}></i>}
|
||||
<span>{label}</span>
|
||||
@@ -64,27 +73,20 @@ const BoxSort: React.FC<SortProps> = ({ sort_by_collection, product_display_type
|
||||
})}
|
||||
</div>
|
||||
<div className="sort-bar-select-category flex items-center gap-3">
|
||||
<a
|
||||
href="javascript:;"
|
||||
<Link
|
||||
href={gridUrl ?? '#'}
|
||||
className={`item-sort-bar d-flex align-items-center ${
|
||||
product_display_type === 'grid' ? 'active' : ''
|
||||
selectedDisplay === 'grid' ? 'active' : ''
|
||||
}`}
|
||||
onClick={() => {
|
||||
window.location.reload();
|
||||
}}
|
||||
>
|
||||
<FaGrip />
|
||||
</a>
|
||||
<a
|
||||
href="javascript:;"
|
||||
className={`item-sort-bar ${product_display_type === 'list' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
console.log('Set display to list');
|
||||
window.location.reload();
|
||||
}}
|
||||
</Link>
|
||||
<Link
|
||||
href={listUrl ?? '#'}
|
||||
className={`item-sort-bar ${selectedDisplay !== 'grid' ? 'active' : ''}`}
|
||||
>
|
||||
<FaList />
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -2,42 +2,41 @@ const BoxShowroom: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<dialog id="boxShowroom" className="modal">
|
||||
<div className="modal-box">
|
||||
<div className="modal-box max-w-[1000px] bg-white">
|
||||
<form method="dialog">
|
||||
<button className="btn btn-sm btn-circle btn-ghost absolute top-2 right-2">✕</button>
|
||||
</form>
|
||||
<div className="popup-showrom-container d-block">
|
||||
<p className="group-title">HỆ THỐNG SHOWROOM</p>
|
||||
<div className="flex flex-wrap justify-between">
|
||||
<div className="mt-5 grid grid-cols-2 gap-5">
|
||||
<div className="item">
|
||||
<p className="item-title">1. Hà Nội</p>
|
||||
<p>17 Hà Kế Tấn, Phường Phương Liệt, Hà Nội.</p>
|
||||
<p>
|
||||
Giờ làm việc: <b>08:30 - 20:30</b>
|
||||
</p>
|
||||
<div
|
||||
className="map-holder js-map-holder"
|
||||
data-src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3132.222076725264!2d105.83522224518104!3d20.998217116862435!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x3135ac7b37915991%3A0xe20876d091ded6bc!2zMTcgUC4gSMOgIEvhur8gVOG6pW4sIFBoxrDGoW5nIExp4buHdCwgVGhhbmggWHXDom4sIEjDoCBO4buZaSwgVmnhu4d0IE5hbQ!5e0!3m2!1svi!2s!4v1720509407173!5m2!1svi!2s"
|
||||
></div>
|
||||
<iframe
|
||||
width={'100%'}
|
||||
src="https://www.google.com/maps/embed?pb=!1m18!1m12!1m3!1d3132.222076725264!2d105.83522224518104!3d20.998217116862435!2m3!1f0!2f0!3f0!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x3135ac7b37915991%3A0xe20876d091ded6bc!2zMTcgUC4gSMOgIEvhur8gVOG6pW4sIFBoxrDGoW5nIExp4buHdCwgVGhhbmggWHXDom4sIEjDoCBO4buZaSwgVmnhu4d0IE5hbQ!5e0!3m2!1svi!2s!4v1720509407173!5m2!1svi!2s"
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
<div className="item">
|
||||
<p className="item-title">2. Hồ Chí Minh</p>
|
||||
<p>249 Lý Thường Kiệt, Phường Phú Thọ, TP. Hồ Chí Minh</p>
|
||||
<p>
|
||||
Giờ làm việc: <b>08:30 - 20:30</b>
|
||||
</p>
|
||||
<div
|
||||
className="map-holder js-map-holder"
|
||||
data-src="https://www.google.com/maps/embed?pb=!1m14!1m8!1m3!1d15678.56730501209!2d106.66439700000001!3d10.762063!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x0%3A0x85a7fc3a74bcd7fd!2zTcOheSBUw61uaCBOZ3V54buFbiBDw7RuZyAxNzYgVMOibiBQaMaw4bubYw!5e0!3m2!1svi!2sus!4v1658936898247!5m2!1svi!2sus"
|
||||
></div>
|
||||
<iframe
|
||||
width={'100%'}
|
||||
src="https://www.google.com/maps/embed?pb=!1m14!1m8!1m3!1d15678.56730501209!2d106.66439700000001!3d10.762063!3m2!1i1024!2i768!4f13.1!3m3!1m2!1s0x0%3A0x85a7fc3a74bcd7fd!2zTcOheSBUw61uaCBOZ3V54buFbiBDw7RuZyAxNzYgVMOibiBQaMaw4bubYw!5e0!3m2!1svi!2sus!4v1658936898247!5m2!1svi!2sus"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label className="modal-backdrop" htmlFor="my_modal_7">
|
||||
Close
|
||||
</label>
|
||||
<form method="dialog" className="modal-backdrop">
|
||||
<button>Close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { FaHouse, FaAngleRight } from 'react-icons/fa6';
|
||||
import { FaAngleRight, FaHouse } from 'react-icons/fa6';
|
||||
|
||||
interface BreadcrumbItem {
|
||||
name: string | undefined;
|
||||
@@ -19,25 +20,27 @@ export const Breadcrumb = ({ items }: { items: BreadcrumbItem[] }) => {
|
||||
>
|
||||
<Link href="/" itemProp="item">
|
||||
<span itemProp="name" className="flex items-center gap-2">
|
||||
<span style={{ fontSize: 0 }}>Trang chủ</span> <FaHouse className="text-gray-700" />
|
||||
<span style={{ fontSize: 0 }}>Trang chủ</span>
|
||||
<FaHouse className="text-gray-700" />
|
||||
</span>
|
||||
</Link>{' '}
|
||||
</Link>
|
||||
<FaAngleRight className="text-gray-700" />
|
||||
<meta itemProp="position" content="1" />
|
||||
</li>
|
||||
{items.map((item, idx) => (
|
||||
|
||||
{items.map((item, index) => (
|
||||
<li
|
||||
key={idx}
|
||||
key={`${item.url}-${index}`}
|
||||
itemProp="itemListElement"
|
||||
itemScope
|
||||
itemType="http://schema.org/ListItem"
|
||||
className="flex items-center"
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Link href={item.url ?? '/'} itemProp="item">
|
||||
<span itemProp="name">{item?.name}</span>
|
||||
<span itemProp="name">{item.name}</span>
|
||||
</Link>
|
||||
<meta itemProp="position" content={(idx + 1).toString()} />
|
||||
{idx < items.length - 1 && <span className="mx-1">/</span>}
|
||||
{index < items.length - 1 && <FaAngleRight className="text-gray-700" />}
|
||||
<meta itemProp="position" content={(index + 2).toString()} />
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
57
src/components/common/ItemArticle/index.tsx
Normal file
57
src/components/common/ItemArticle/index.tsx
Normal file
@@ -0,0 +1,57 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import { Article } from '@/types';
|
||||
import { ArticleItem } from '@/types/article/TypeArticleCatePage';
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
|
||||
type ItemArticleProps = {
|
||||
item: Article | ArticleItem;
|
||||
};
|
||||
|
||||
const ItemArticle: React.FC<ItemArticleProps> = ({ item }) => {
|
||||
// chọn link: nếu có external_url thì dùng, ngược lại dùng url
|
||||
const linkHref = item.external_url && item.external_url !== '' ? item.external_url : item.url;
|
||||
|
||||
// chọn ảnh: nếu có original thì dùng, ngược lại ảnh mặc định
|
||||
const imageSrc =
|
||||
item.image?.original && item.image.original !== ''
|
||||
? item.image.original
|
||||
: '/static/assets/nguyencong_2023/images/not-image.png';
|
||||
|
||||
// chọn thời gian: ưu tiên article_time, fallback createDate
|
||||
const timeDisplay =
|
||||
item.article_time && item.article_time !== '' ? item.article_time : item.createDate;
|
||||
|
||||
return (
|
||||
<div className="item-article flex gap-3">
|
||||
<Link href={linkHref} className="img-article boder-radius-10 position-relative">
|
||||
<Image
|
||||
className="boder-radius-10"
|
||||
src={imageSrc}
|
||||
fill
|
||||
alt={item.title}
|
||||
sizes="(max-width: 768px) 100vw, 265px"
|
||||
/>
|
||||
{/* icon video nếu cần */}
|
||||
<i className="sprite sprite-icon-play-video-detail icon-video-feature incon-play-youtube"></i>
|
||||
<i className="sprite sprite-play-youtube incon-play-youtube"></i>
|
||||
</Link>
|
||||
|
||||
<div className="content-article content-article-item flex flex-1 flex-col">
|
||||
<Link href={linkHref} className="title-article">
|
||||
<h3 className="line-clamp-2 font-[400]">{item.title}</h3>
|
||||
</Link>
|
||||
|
||||
<p className="time-article flex items-center gap-1">
|
||||
<i className="sprite sprite-clock-item-article"></i>
|
||||
<span>{timeDisplay}</span>
|
||||
</p>
|
||||
|
||||
<p className="descreption-article line-clamp-2">{item.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ItemArticle;
|
||||
@@ -1,31 +1,29 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import Tippy from '@tippyjs/react';
|
||||
import 'tippy.js/dist/tippy.css';
|
||||
import { Product } from '@/types';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { formatCurrency } from '@/lib/formatPrice';
|
||||
import { SanitizedHtml } from '@/components/Common/SanitizedHtml';
|
||||
|
||||
type ProductItemProps = {
|
||||
item: Product;
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number | string) => {
|
||||
const num = typeof value === 'string' ? parseInt(value) : value;
|
||||
return num.toLocaleString('vi-VN');
|
||||
};
|
||||
|
||||
const ItemProduct: React.FC<ProductItemProps> = ({ item }) => {
|
||||
const offers = item.specialOffer?.all ?? [];
|
||||
const firstOffer = item.specialOffer?.all?.[0];
|
||||
|
||||
return (
|
||||
<div className="product-item js-p-item">
|
||||
<a href={item.productUrl} className="product-image relative">
|
||||
{item.productImage.large ? (
|
||||
<Image src={item.productImage.large} width="203" height="203" alt={item.productName} />
|
||||
<Image src={item.productImage.large} width={203} height={203} alt={item.productName} />
|
||||
) : (
|
||||
<Image
|
||||
src="https://nguyencongpc.vn/static/assets/nguyencong_2023/images/not-image.png"
|
||||
alt={item.productName}
|
||||
width={203}
|
||||
height={203}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -42,39 +40,35 @@ const ItemProduct: React.FC<ProductItemProps> = ({ item }) => {
|
||||
<Link href={item.productUrl}>
|
||||
<h3 className="product-title line-clamp-3">{item.productName}</h3>
|
||||
</Link>
|
||||
{item.marketPrice > 0 ? (
|
||||
{Number(item.marketPrice) > 0 ? (
|
||||
<div className="product-martket-main flex items-center">
|
||||
<p className="product-market-price">
|
||||
{item.marketPrice.toLocaleString()}
|
||||
{formatCurrency(item.marketPrice)}
|
||||
<u>đ</u>
|
||||
</p>
|
||||
<div className="product-percent-price">-{Math.round(item.price_off)} %</div>
|
||||
<div className="product-percent-price">-{Math.round(Number(item.price_off))} %</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="product-martket-main flex items-center"></div>
|
||||
<div className="product-martket-main flex items-center" />
|
||||
)}
|
||||
|
||||
<div className="product-price-main font-[600]">
|
||||
{item.price > '0' ? `${formatCurrency(item.price)}đ` : 'Liên hệ'}
|
||||
<div className="product-price-main font-semibold">
|
||||
{Number(item.price) > 0 ? `${formatCurrency(item.price)}đ` : 'Liên hệ'}
|
||||
</div>
|
||||
{item.specialOffer?.all?.length ? (
|
||||
<div
|
||||
className="product-offer line-clamp-2"
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: item.specialOffer!.all![0].title,
|
||||
}}
|
||||
/>
|
||||
|
||||
{firstOffer ? (
|
||||
<SanitizedHtml html={firstOffer.title} className="product-offer line-clamp-2" />
|
||||
) : (
|
||||
<div className="product-offer line-clamp-2"></div>
|
||||
<div className="product-offer line-clamp-2" />
|
||||
)}
|
||||
|
||||
{item.extend?.buy_count ? (
|
||||
<div style={{ height: 18 }}>
|
||||
{' '}
|
||||
<b>Đã bán: </b> <span>{item.extend.buy_count}</span>{' '}
|
||||
<div className="h-4.5">
|
||||
<b>Đã bán: </b>
|
||||
<span>{item.extend.buy_count}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ height: 18, display: 'block' }}> </div>
|
||||
<div className="h-4.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
32
src/components/common/MSWProvider.tsx
Normal file
32
src/components/common/MSWProvider.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { MSWContext } from '@/contexts/MSWContext';
|
||||
|
||||
/**
|
||||
* Khởi động MSW browser worker trong development.
|
||||
* Cung cấp trạng thái ready qua MSWContext để các hook fetch
|
||||
* (useApiData) tự chờ MSW sẵn sàng trước khi gọi API.
|
||||
*/
|
||||
const MSWProvider = ({ children }: { children: React.ReactNode }) => {
|
||||
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);
|
||||
});
|
||||
}, []);
|
||||
|
||||
return <MSWContext.Provider value={ready}>{children}</MSWContext.Provider>;
|
||||
};
|
||||
|
||||
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 }} />;
|
||||
}
|
||||
5
src/components/common/Skeleton/index.tsx
Normal file
5
src/components/common/Skeleton/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
const Skeleton = ({ className = '' }: { className?: string }) => (
|
||||
<div className={`animate-pulse rounded-lg bg-gray-200 ${className}`} />
|
||||
);
|
||||
|
||||
export default Skeleton;
|
||||
@@ -10,7 +10,6 @@ export const ErrorLink = () => {
|
||||
transition={{ duration: 0.4 }}
|
||||
className="w-full max-w-md rounded-3xl bg-white p-8 text-center shadow-xl"
|
||||
>
|
||||
{/* Icon lỗi link */}
|
||||
<motion.div
|
||||
animate={{ y: [0, -4, 0] }}
|
||||
transition={{ repeat: Infinity, duration: 1.8 }}
|
||||
@@ -34,23 +33,22 @@ export const ErrorLink = () => {
|
||||
<h1 className="text-2xl font-bold text-gray-800">Đường dẫn không hợp lệ</h1>
|
||||
|
||||
<p className="mt-3 text-sm text-gray-600">
|
||||
Bạn truy cập không tồn tại hoặc đường dẫn đã bị thay đổi.
|
||||
Nội dung bạn truy cập không tồn tại hoặc đường dẫn đã được thay đổi.
|
||||
</p>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-8 flex flex-col gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-xl bg-blue-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-blue-700"
|
||||
>
|
||||
← Về trang chủ
|
||||
Về trang chủ
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/products"
|
||||
href="/pc-gaming"
|
||||
className="rounded-xl border border-gray-300 px-6 py-3 text-sm font-medium text-gray-700 transition hover:bg-gray-100"
|
||||
>
|
||||
Xem tất cả sản phẩm
|
||||
Xem danh mục PC Gaming
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -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,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',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -1,26 +0,0 @@
|
||||
import { FaCaretRight } from 'react-icons/fa';
|
||||
import { dataArticle } from './dataArticle';
|
||||
import ItemArticle from './ItemArticle';
|
||||
|
||||
const BoxArticle: React.FC = () => {
|
||||
return (
|
||||
<div className="box-article-group boder-radius-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="title-box">
|
||||
<h2 className="title-box font-[600]">Tin tức công nghệ</h2>
|
||||
</div>
|
||||
<a href="/tin-cong-nghe" className="btn-article-group flex items-center gap-1">
|
||||
<span>Xem tất cả</span>
|
||||
<FaCaretRight size={16} />
|
||||
</a>
|
||||
</div>
|
||||
<div className="list-article-group flex items-center gap-10">
|
||||
{dataArticle.slice(0, 4).map((item, index) => (
|
||||
<ItemArticle item={item} key={index} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoxArticle;
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
];
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,57 +0,0 @@
|
||||
'use client';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
const CounDown: React.FC = () => {
|
||||
const [days, setDays] = useState(0);
|
||||
const [hours, setHours] = useState(0);
|
||||
const [minutes, setMinutes] = useState(0);
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
|
||||
const deadline: Date = new globalThis.Date('2025-12-31');
|
||||
|
||||
const getTime = () => {
|
||||
const time = deadline.getTime() - 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;
|
||||
@@ -1,38 +0,0 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
||||
import { FaCaretRight } from 'react-icons/fa';
|
||||
|
||||
import CounDown from './CounDown';
|
||||
|
||||
const BoxProductDeal: React.FC = () => {
|
||||
return (
|
||||
<div className="box-product-deal boder-radius-10">
|
||||
<div className="box-title-deal flex items-center justify-between">
|
||||
<div className="title-deal flex items-center justify-center gap-10">
|
||||
<i className="sprite sprite-icon-deal-home"></i>
|
||||
<h2 className="title font-bold">Giá tốt mỗi ngày</h2>
|
||||
<span className="text-time-deal-home color-white fz-16 font-bold">Kết thúc sau</span>
|
||||
<div className="global-time-deal flex items-center gap-2">
|
||||
<CounDown />
|
||||
</div>
|
||||
</div>
|
||||
<a href="/deal" className="button-deal color-white mb-10 flex items-center">
|
||||
Xem thêm khuyến mãi <FaCaretRight size={16} />
|
||||
</a>
|
||||
</div>
|
||||
<div className="box-list-item-deal swiper-box-deal">
|
||||
<Swiper
|
||||
modules={[Autoplay, Navigation, Pagination]}
|
||||
spaceBetween={12}
|
||||
slidesPerView={6}
|
||||
loop={true}
|
||||
navigation={true}
|
||||
></Swiper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoxProductDeal;
|
||||
@@ -1,30 +0,0 @@
|
||||
'use client';
|
||||
import { dataReview } from './dataReview';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
||||
import ItemReview from './ItemReview';
|
||||
const BoxReviewCustomer: React.FC = () => {
|
||||
return (
|
||||
<div className="box-review-from-customer boder-radius-10">
|
||||
<div className="title-box">
|
||||
<h2 className="title-box font-[600]">Đánh giá từ khách hàng về Nguyễn Công PC</h2>
|
||||
</div>
|
||||
<div className="list-review-customer-homepage">
|
||||
<Swiper
|
||||
modules={[Autoplay, Navigation, Pagination]}
|
||||
spaceBetween={15}
|
||||
slidesPerView={3}
|
||||
loop={true}
|
||||
pagination={{ clickable: true }}
|
||||
>
|
||||
{dataReview.map((item, index) => (
|
||||
<SwiperSlide key={index} className="item">
|
||||
<ItemReview item={item} />
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default BoxReviewCustomer;
|
||||
134
src/components/other/BoxHotline/index.tsx
Normal file
134
src/components/other/BoxHotline/index.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
const BoxHotLine = () => {
|
||||
return (
|
||||
<dialog id="boxHotline" className="modal">
|
||||
<div className="modal-box max-w-[750px] bg-white">
|
||||
<form method="dialog">
|
||||
<button className="btn btn-sm btn-circle btn-ghost absolute top-2 right-2">✕</button>
|
||||
</form>
|
||||
<div id="popup-hotline">
|
||||
<div className="flex justify-between gap-5">
|
||||
<div className="content-pop khach-ca-nhan khach-hang-ca-nhan">
|
||||
<p className="title-content-pop">Khách cá nhân</p>
|
||||
<div className="item-pop">
|
||||
<div className="title-item-pop">Tư Vấn - Bán Hàng Online:</div>
|
||||
<div className="item-people">
|
||||
<p className="phone">0828.333.363</p>
|
||||
<span>Mr Ngọc</span>
|
||||
</div>
|
||||
<div className="item-people">
|
||||
<p className="phone">0989.336.366</p>
|
||||
<span>Mr Hùng</span>
|
||||
</div>
|
||||
<div className="item-people">
|
||||
<p className="phone">0707.08.6666</p>
|
||||
<span>Mr Hoàng</span>
|
||||
</div>
|
||||
<div className="item-people">
|
||||
<p className="phone">089.9999.191</p>
|
||||
<span>Mr Lộc</span>
|
||||
</div>
|
||||
<div className="item-people">
|
||||
<p className="phone">0812.666.665</p>
|
||||
<span>Mr Tuấn Anh</span>
|
||||
</div>
|
||||
<div className="item-people">
|
||||
<p className="phone">09.8888.2838</p>
|
||||
<span>Mr. Minh</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="item-pop">
|
||||
<div className="title-item-pop">HOTLINE:</div>
|
||||
<div className="item-people">
|
||||
<p className="phone">098.33333.88</p>
|
||||
<span>Showroom TP. Hồ Chí Minh</span>
|
||||
</div>
|
||||
<div className="item-people">
|
||||
<p className="phone">097.9999.191</p>
|
||||
<span>Showroom TP. Hà Nội</span>
|
||||
</div>
|
||||
<div className="item-people">
|
||||
<p className="phone">0765.666.668</p>
|
||||
<span>Showroom TP. Hà Nội</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item-pop">
|
||||
<div className="title-item-pop">Bảo hành - Hỗ trợ kỹ thuật</div>
|
||||
<div className="item-people">
|
||||
<p className="phone">0705.666.668</p>
|
||||
<span>17 Hà Kế Tấn, Phường Phương Liệt, Hà Nội</span>
|
||||
</div>
|
||||
<div className="item-people">
|
||||
<p className="phone">079.9999.191</p>
|
||||
<span>249 Lý Thường Kiệt, phường Phú Thọ, TP. Hồ Chí Minh </span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item-pop">
|
||||
<div className="title-item-pop">Kế toán:</div>
|
||||
<div className="item-people">
|
||||
<p className="phone">0332.101.130</p>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item-pop">
|
||||
<div className="title-item-pop">Kế toán công nợ:</div>
|
||||
<div className="item-people">
|
||||
<p className="phone">0968.929.992</p> <span></span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item-cskh">
|
||||
<b>GÓP Ý</b>:{' '}
|
||||
<a
|
||||
href="javascript:void(0)"
|
||||
style={{ color: '#FFB233', fontWeight: 'bold', fontSize: '16px' }}
|
||||
>
|
||||
097.9999.191 -{' '}
|
||||
</a>{' '}
|
||||
<a
|
||||
href="javascript:void(0)"
|
||||
style={{ color: '#FFB233', fontWeight: 'bold', fontSize: '16px' }}
|
||||
>
|
||||
098.33333.88
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="content-pop khach-doanh-nghiep">
|
||||
<p className="title-content-pop">Khách doanh nghiệp</p>
|
||||
<div className="item-pop">
|
||||
<div className="title-item-pop">Tư Vấn - Bán Hàng Online:</div>
|
||||
<div className="item-people">
|
||||
<p className="phone">097.9999.191</p>
|
||||
<span>Mr Lực</span>
|
||||
</div>
|
||||
<div className="item-people">
|
||||
<p className="phone">0828.333.363</p>
|
||||
<span>Mr Ngọc</span>
|
||||
</div>
|
||||
<div className="item-people">
|
||||
<p className="phone">0707.08.6666</p>
|
||||
<span>Mr Hoàng</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="item-pop">
|
||||
<div className="title-item-pop">Khách hàng đại lý - MUA, BÁN BUÔN</div>
|
||||
<div className="item-people">
|
||||
<a href="tel:0981226969">098.122.6969</a>
|
||||
<span>Ms Tuyết</span>
|
||||
</div>
|
||||
<div className="item-people">
|
||||
<a href="tel:0987414899">098.741.4899</a>
|
||||
<span>Ms Trang</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form method="dialog" className="modal-backdrop">
|
||||
<button>Close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
};
|
||||
export default BoxHotLine;
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import { FaFacebookF, FaYoutube, FaAngleUp } from 'react-icons/fa';
|
||||
import { FaFacebookMessenger } from 'react-icons/fa';
|
||||
import { SiZalo } from 'react-icons/si';
|
||||
|
||||
const IconFixRight: React.FC = () => {
|
||||
return (
|
||||
@@ -25,14 +25,15 @@ const IconFixRight: React.FC = () => {
|
||||
<FaYoutube size={22} />
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="javascript:window.scrollTo({ top: 0, behavior: 'smooth' });"
|
||||
<button
|
||||
type="button"
|
||||
className="scroll-top-btn items-center justify-center"
|
||||
title="Di chuyển lên đầu trang!"
|
||||
style={{ display: 'none' }}
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: 'smooth' })}
|
||||
>
|
||||
<FaAngleUp size={20} />
|
||||
</Link>
|
||||
</button>
|
||||
|
||||
<Link
|
||||
href="https://m.me/nguyencongpc.vn"
|
||||
@@ -41,8 +42,8 @@ const IconFixRight: React.FC = () => {
|
||||
>
|
||||
<Image
|
||||
src="https://nguyencongpc.vn/static/assets/nguyencong_2023/images/facebook_messenger.png"
|
||||
width="40"
|
||||
height="40"
|
||||
width={40}
|
||||
height={40}
|
||||
alt="mes"
|
||||
className="lazy"
|
||||
/>
|
||||
@@ -69,8 +70,8 @@ const IconFixRight: React.FC = () => {
|
||||
>
|
||||
<Image
|
||||
src="https://nguyencongpc.vn/media/lib/24-01-2024/zalo.png"
|
||||
width="40"
|
||||
height="40"
|
||||
width={40}
|
||||
height={40}
|
||||
alt="mes"
|
||||
className="lazy"
|
||||
style={{ marginRight: '10px' }}
|
||||
@@ -83,4 +84,5 @@ const IconFixRight: React.FC = () => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default IconFixRight;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
import IconFixRight from './IconFixRight';
|
||||
|
||||
const Footer: React.FC = () => {
|
||||
return (
|
||||
<>
|
||||
<footer className="footer-main">
|
||||
{/* Chính sách */}
|
||||
<div className="footer-policy">
|
||||
<div className="container flex items-center justify-between gap-12">
|
||||
<div className="item flex items-center justify-center">
|
||||
@@ -18,32 +18,31 @@ const Footer: React.FC = () => {
|
||||
<div className="item flex items-center justify-center">
|
||||
<i className="sprite sprite-doitra-footer"></i>
|
||||
<p className="text box-title-policy m-0">
|
||||
<b className="block">đổi trả dễ dàng</b>
|
||||
<b className="block">Đổi trả dễ dàng</b>
|
||||
<span className="grey block">1 đổi 1 trong 15 ngày</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="item flex items-center justify-center">
|
||||
<i className="sprite sprite-thanhtoan-footer"></i>
|
||||
<p className="text box-title-policy m-0">
|
||||
<b className="block">thanh toán tiện lợi</b>
|
||||
<b className="block">Thanh toán tiện lợi</b>
|
||||
<span className="grey block">tiền mặt, CK, trả góp 0%</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="item flex items-center justify-center">
|
||||
<i className="sprite sprite-hotro-footer"></i>
|
||||
<p className="text box-title-policy m-0">
|
||||
<b className="block">hỗ trợ nhiệt tình</b>
|
||||
<b className="block">Hỗ trợ nhiệt tình</b>
|
||||
<span className="grey block">tư vấn, giải đáp mọi thắc mắc</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Box info */}
|
||||
<div className="box-info-main">
|
||||
<div className="justify-content-between footer-list-info-main container flex">
|
||||
<div className="item-info-main">
|
||||
<p className="title font-weight-700">Giới thiệu nguyễn công</p>
|
||||
<p className="title font-weight-700">Giới thiệu Nguyễn Công</p>
|
||||
<Link href="https://nguyencongpc.vn/pages/profile.html" className="text">
|
||||
Giới thiệu công ty
|
||||
</Link>
|
||||
@@ -75,12 +74,12 @@ const Footer: React.FC = () => {
|
||||
>
|
||||
<i className="sprite sprite-youtube-fotoer"></i>
|
||||
</Link>
|
||||
<a href="javascript:;" className="item-social" aria-label="Instagram">
|
||||
<Link href="#" className="item-social" aria-label="Instagram">
|
||||
<i className="sprite sprite-instagram-footer"></i>
|
||||
</a>
|
||||
<a href="javascript:;" className="item-social" aria-label="Tiktok">
|
||||
</Link>
|
||||
<Link href="#" className="item-social" aria-label="Tiktok">
|
||||
<i className="sprite sprite-tiktok-footer"></i>
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="bct-footer flex gap-3">
|
||||
<Link
|
||||
@@ -90,10 +89,10 @@ const Footer: React.FC = () => {
|
||||
>
|
||||
<Image
|
||||
src="https://nguyencongpc.vn/static/assets/nguyencong_2023/images/footer-bct.png"
|
||||
alt="bộ công thương"
|
||||
alt="Bộ công thương"
|
||||
className="lazy"
|
||||
width={132}
|
||||
height="1"
|
||||
height={40}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
@@ -103,7 +102,7 @@ const Footer: React.FC = () => {
|
||||
target="_blank"
|
||||
rel="nofollow"
|
||||
>
|
||||
<img
|
||||
<Image
|
||||
src="https://www.dmca.com/img/dmca-compliant-grayscale.png"
|
||||
alt="DMCA compliant"
|
||||
width={115}
|
||||
@@ -128,7 +127,7 @@ const Footer: React.FC = () => {
|
||||
Gửi yêu cầu bảo hành
|
||||
</Link>
|
||||
<Link href="/lien-he" className="text">
|
||||
Góp ý, Khiếu Nại
|
||||
Góp ý, Khiếu nại
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -174,7 +173,6 @@ const Footer: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer bottom */}
|
||||
<div className="footer-bottom">
|
||||
<div className="container">
|
||||
<div className="copyright">
|
||||
@@ -184,12 +182,12 @@ const Footer: React.FC = () => {
|
||||
<p>Mã số thuế: 0107568451 do Sở Kế Hoạch và Đầu Tư TP.Hà Nội (17/09/2016)</p>
|
||||
<p>
|
||||
Mua hàng: <Link href="tel:0866666166">089.9999.191</Link> -{' '}
|
||||
<a href="tel:0812666665">0812.666.665</a>
|
||||
<Link href="tel:0812666665">0812.666.665</Link>
|
||||
</p>
|
||||
<p className="list-contact-footer flex items-center">
|
||||
<span>
|
||||
GÓP Ý : <Link href="tel:0979999191">097.9999.191</Link> -{' '}
|
||||
<a href="tel:0983333388">098.33333.88</a>.
|
||||
GÓP Ý: <Link href="tel:0979999191">097.9999.191</Link> -{' '}
|
||||
<Link href="tel:0983333388">098.33333.88</Link>.
|
||||
</span>
|
||||
<span>
|
||||
Email: <Link href="mailto:info@nguyencongpc.vn">info@nguyencongpc.vn</Link>.
|
||||
@@ -199,9 +197,9 @@ const Footer: React.FC = () => {
|
||||
</span>
|
||||
<span>
|
||||
Fanpage:{' '}
|
||||
<a href="https://www.facebook.com/MAY.TINH.NGUYEN.CONG">
|
||||
<Link href="https://www.facebook.com/MAY.TINH.NGUYEN.CONG">
|
||||
facebook.com/MAY.TINH.NGUYEN.CONG
|
||||
</a>
|
||||
</Link>
|
||||
.
|
||||
</span>
|
||||
</p>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
'use client';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import React from 'react';
|
||||
import { FaBars } from 'react-icons/fa';
|
||||
import { menuData } from '../menuData';
|
||||
import Image from 'next/image';
|
||||
@@ -30,7 +31,6 @@ const HeaderBottom: React.FC = () => {
|
||||
<span className="cat-title line-clamp-1">{item.title}</span>
|
||||
</Link>
|
||||
|
||||
{/* Cấp 2 & Cấp 3 */}
|
||||
{item.children && item.children.length > 0 && (
|
||||
<div className="sub-menu-list">
|
||||
{item.children.map((_children2) => (
|
||||
@@ -38,7 +38,6 @@ const HeaderBottom: React.FC = () => {
|
||||
<Link href={_children2.url} className="cat-2">
|
||||
{_children2.title}
|
||||
</Link>
|
||||
{/* Cấp 3 */}
|
||||
{_children2.children && _children2.children.length > 0 && (
|
||||
<>
|
||||
{_children2.children.map((_children3) => (
|
||||
|
||||
@@ -1,41 +1,71 @@
|
||||
'use client';
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import React, { useEffect, useState, useSyncExternalStore } from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { FaMapMarkerAlt, FaBars } from 'react-icons/fa';
|
||||
import BoxShowroom from '@components/common/BoxShowroom';
|
||||
import { FaBars, FaMapMarkerAlt } from 'react-icons/fa';
|
||||
import BoxShowroom from '@/components/Common/BoxShowroom';
|
||||
import BoxHotLine from '../../BoxHotline';
|
||||
import { formatCurrency } from '@/lib/formatPrice';
|
||||
import {
|
||||
getServerCartSnapshot,
|
||||
readCartFromStorage,
|
||||
subscribeCartStorage,
|
||||
} from '@/lib/cartStorage';
|
||||
|
||||
const HeaderMid: React.FC = () => {
|
||||
const PopupAddress = () => {
|
||||
const modal = document.getElementById('boxShowroom') as HTMLDialogElement;
|
||||
modal?.showModal();
|
||||
const cart = useSyncExternalStore(
|
||||
subscribeCartStorage,
|
||||
readCartFromStorage,
|
||||
getServerCartSnapshot,
|
||||
);
|
||||
const [isFixed, setIsFixed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handleScroll = () => setIsFixed(window.scrollY > 680);
|
||||
window.addEventListener('scroll', handleScroll);
|
||||
return () => window.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
const cartCount = cart.length;
|
||||
const cartQuantity = cart.reduce((sum, item) => sum + Number(item.in_cart.quantity), 0);
|
||||
const cartTotal = cart.reduce((sum, item) => sum + Number(item.in_cart.total_price), 0);
|
||||
|
||||
const openModal = (id: string) => {
|
||||
(document.getElementById(id) as HTMLDialogElement | null)?.showModal();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="header-middle">
|
||||
<div className={`header-middle ${isFixed ? 'header-fixed' : ''}`}>
|
||||
<div className="container flex items-center justify-between">
|
||||
<div className="header-middle-left flex items-center">
|
||||
<Link href="/">
|
||||
<Image
|
||||
src="https://nguyencongpc.vn/media/lib/18-02-2025/logowhite-dfvefb.png"
|
||||
width="170"
|
||||
height="38"
|
||||
alt="logo"
|
||||
width={170}
|
||||
height={38}
|
||||
alt="Nguyễn Công PC"
|
||||
className="logo-header"
|
||||
/>
|
||||
</Link>
|
||||
<button className="icon-showroom flex items-center justify-center" onClick={PopupAddress}>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-showroom flex items-center justify-center"
|
||||
onClick={() => openModal('boxShowroom')}
|
||||
>
|
||||
<FaMapMarkerAlt size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="header-middle-right flex items-center">
|
||||
<div className="header-menu-category">
|
||||
<div className="box-title flex items-center justify-center gap-8">
|
||||
<FaBars size={16} />
|
||||
<p className="title-menu font-weight-500">Danh mục sản phẩm</p>
|
||||
<p className="title-menu font-medium">Danh mục sản phẩm</p>
|
||||
</div>
|
||||
<div className="cau-noi"></div>
|
||||
</div>
|
||||
|
||||
<div className="header-search-group">
|
||||
<form method="get" action="/tim" name="searchForm">
|
||||
<div className="box-search-input">
|
||||
@@ -63,68 +93,110 @@ const HeaderMid: React.FC = () => {
|
||||
</div>
|
||||
|
||||
<div className="box-tabs-header flex items-center">
|
||||
<Link href="/buildpc" className="item-tab-header flex-column flex items-center gap-4">
|
||||
<Link href="/buildpc" className="item-tab-header flex flex-col items-center gap-4">
|
||||
<p className="icon-item-tab flex items-center justify-center">
|
||||
<i className="sprite sprite-buildpc-header"></i>
|
||||
</p>
|
||||
<span className="font-500">Xây dựng cấu hình</span>
|
||||
<span className="font-medium">Xây dựng cấu hình</span>
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="javascript:void(0)"
|
||||
className="item-tab-header flex-column flex items-center gap-4"
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openModal('boxHotline')}
|
||||
className="item-tab-header flex flex-col items-center gap-4"
|
||||
>
|
||||
<p className="icon-item-tab flex items-center justify-center">
|
||||
<i className="sprite sprite-lienhe-header"></i>
|
||||
</p>
|
||||
<span className="font-500">Khách hàng liên hệ</span>
|
||||
</Link>
|
||||
<span className="font-medium">Khách hàng liên hệ</span>
|
||||
</button>
|
||||
|
||||
<Link href="/tin-tuc" className="item-tab-header flex-column flex 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">
|
||||
<i className="sprite sprite-article-header"></i>
|
||||
</p>
|
||||
<span className="font-weight-500">Tin tức công nghệ</span>
|
||||
<span className="font-medium">Tin tức công nghệ</span>
|
||||
</Link>
|
||||
|
||||
<div id="js-header-cart" className="position-relative">
|
||||
<Link href="/cart" className="item-tab-header flex-column flex items-center gap-4">
|
||||
<div id="js-header-cart" className="relative">
|
||||
<Link href="/cart" className="item-tab-header flex flex-col items-center gap-4">
|
||||
<p className="icon-item-tab icon-cart-header flex items-center justify-center">
|
||||
<i className="sprite sprite-cart-header"></i>
|
||||
<u className="cart-count header-features-cart-amount">1</u>
|
||||
<u className="cart-count header-features-cart-amount">{cartCount}</u>
|
||||
</p>
|
||||
<span className="font-weight-500">Giỏ hàng</span>
|
||||
<span className="font-medium">Giỏ hàng</span>
|
||||
</Link>
|
||||
<div className="cau-noi"></div>
|
||||
|
||||
<div className="cart-ttip" id="js-cart-tooltip">
|
||||
<div className="cart-ttip-item-container"></div>
|
||||
<div className="cart-ttip-price justify-content-end flex items-center gap-6">
|
||||
<p>Tổng tiền hàng</p>
|
||||
<p id="js-header-cart-quantity" className="font-weight-500"></p>
|
||||
<p id="js-header-cart-total-price" className="font-weight-700"></p>
|
||||
<div className="cart-ttip-item-container">
|
||||
{cart.map((item) => (
|
||||
<div
|
||||
className="compare-item js-compare-item flex items-center gap-2"
|
||||
key={item._id}
|
||||
>
|
||||
<Link className="img-compare" href={item.item_info.productUrl}>
|
||||
<Image
|
||||
src={item.item_info.productImage.large}
|
||||
width={80}
|
||||
height={80}
|
||||
alt={item.item_info.productName}
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<div className="compare-item-text flex-1">
|
||||
<Link
|
||||
href={item.item_info.productUrl}
|
||||
className="name-compare-item mb-10 line-clamp-2"
|
||||
>
|
||||
{item.item_info.productName}
|
||||
</Link>
|
||||
|
||||
<div className="header-cart-item-price flex justify-between">
|
||||
<b>x {item.in_cart.quantity}</b>
|
||||
<b className="price-compare">
|
||||
{item.in_cart.price === '0'
|
||||
? 'Liên hệ'
|
||||
: `${formatCurrency(item.in_cart.total_price)} đ`}
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="cart-ttip-price flex items-center justify-end gap-2">
|
||||
<p>Tổng tiền hàng</p>
|
||||
<p className="font-medium">({cartQuantity} sản phẩm)</p>
|
||||
<p className="font-bold">{formatCurrency(cartTotal)} đ</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/cart"
|
||||
className="cart-ttip-price-button flex items-center justify-center"
|
||||
>
|
||||
<p className="font-weight-700">THANH TOÁN NGAY </p>
|
||||
<p className="font-bold">THANH TOÁN NGAY</p>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/taikhoan"
|
||||
className="user-header item-tab-header flex-column flex items-center gap-4"
|
||||
<button
|
||||
type="button"
|
||||
className="user-header item-tab-header flex flex-col items-center gap-4 opacity-70"
|
||||
aria-disabled="true"
|
||||
title="Tính năng tài khoản đang được cập nhật"
|
||||
>
|
||||
<p className="icon-item-tab flex items-center justify-center">
|
||||
<i className="sprite sprite-account-header"></i>
|
||||
</p>
|
||||
<span className="font-weight-500">Tài khoản</span>
|
||||
</Link>
|
||||
<span className="font-medium">Tài khoản</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<BoxShowroom />
|
||||
<BoxHotLine />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@ import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
// Định nghĩa kiểu dữ liệu cho mỗi Banner
|
||||
interface BannerItem {
|
||||
id: number;
|
||||
link: string;
|
||||
@@ -13,7 +12,6 @@ interface BannerItem {
|
||||
altText: string;
|
||||
}
|
||||
|
||||
// Dữ liệu mẫu (Bạn có thể fetch từ API)
|
||||
const BANNER_DATA: BannerItem[] = [
|
||||
{
|
||||
id: 429,
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { PriceFilter, AttributeFilterList, BrandFilter } from '@/types';
|
||||
import { FaSortDown } from 'react-icons/fa6';
|
||||
import ActiveFilters from './ActiveFilters';
|
||||
|
||||
interface Filters {
|
||||
price_filter_list?: PriceFilter[];
|
||||
attribute_filter_list?: AttributeFilterList[];
|
||||
brand_filter_list?: BrandFilter[];
|
||||
}
|
||||
|
||||
interface BoxFilterProps {
|
||||
filters: Filters;
|
||||
}
|
||||
|
||||
const BoxFilter: React.FC<BoxFilterProps> = ({ filters }) => {
|
||||
const { price_filter_list, attribute_filter_list, brand_filter_list } = filters;
|
||||
|
||||
return (
|
||||
<div className="box-filter-category boder-radius-10">
|
||||
{/* khoảng giá */}
|
||||
{price_filter_list && (
|
||||
<div className="info-filter-category flex gap-10">
|
||||
<p className="title">Khoảng giá:</p>
|
||||
<div className="list-filter-category flex flex-1 flex-wrap items-center gap-2">
|
||||
{price_filter_list.map((ItemPrice, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`item item-cetner flex gap-4 ${ItemPrice.is_selected == '1' ? 'current' : ''}`}
|
||||
>
|
||||
<Link href={ItemPrice.url}>{ItemPrice.name}</Link>
|
||||
<a href={ItemPrice.url}>
|
||||
(${ItemPrice.is_selected == '1' ? 'Xóa' : ItemPrice.count})
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* chọn thiêu tiêu trí */}
|
||||
{attribute_filter_list && (
|
||||
<div className="info-filter-category flex gap-10">
|
||||
<p className="title">Chọn theo tiêu chí:</p>
|
||||
<div className="list-filter-category flex flex-1 flex-wrap items-center gap-3">
|
||||
{/* thương hiệu */}
|
||||
{brand_filter_list && brand_filter_list.length > 0 && (
|
||||
<div className={`item ${brand_filter_list[0].is_selected === '1' ? 'current' : ''}`}>
|
||||
<div className="flex items-center">
|
||||
{brand_filter_list[0].is_selected === '1' ? (
|
||||
<span>{brand_filter_list[0].name}</span>
|
||||
) : (
|
||||
<span>Thương hiệu</span>
|
||||
)}
|
||||
<FaSortDown size={16} style={{ marginBottom: 8 }} />
|
||||
</div>
|
||||
<ul>
|
||||
{brand_filter_list.map((item, idx) => (
|
||||
<li key={idx} className="flex items-center gap-3">
|
||||
<Link href={item.url}>{item.name}</Link>
|
||||
<Link href={item.url}>({item.is_selected === '1' ? 'Xóa' : item.count})</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attribute filter */}
|
||||
{attribute_filter_list && attribute_filter_list.length > 0 && (
|
||||
<>
|
||||
{attribute_filter_list.map((attr, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`item ${attr.value_list[0]?.is_selected === '1' ? 'current' : ''}`}
|
||||
>
|
||||
<a href="javascript:void(0)" className="flex items-center">
|
||||
{attr.value_list[0]?.is_selected === '1' ? (
|
||||
<span>{attr.value_list[0].name}</span>
|
||||
) : (
|
||||
<span>{attr.name}</span>
|
||||
)}
|
||||
<FaSortDown size={16} style={{ marginBottom: 8 }} />
|
||||
</a>
|
||||
<ul>
|
||||
{attr.value_list.map((val) => (
|
||||
<li key={val.id} className="flex items-center gap-3">
|
||||
<Link href={val.url}>{val.name}</Link>
|
||||
<Link href={val.url}>{val.is_selected === '1' ? 'Xóa' : val.count}</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ActiveFilters filters={filters} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export default BoxFilter;
|
||||
@@ -1,51 +0,0 @@
|
||||
'use client';
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import type { ProductDetailData } from '@/types';
|
||||
import { productDetailData } from '@/data/product/detail';
|
||||
import { findProductDetailBySlug } from '@/lib/product/productdetail';
|
||||
import { ErrorLink } from '@components/common/error';
|
||||
|
||||
import { Breadcrumb } from '@components/common/Breadcrumb';
|
||||
import { ImageProduct } from './ImageProduct';
|
||||
|
||||
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} />
|
||||
</div>
|
||||
<div className="box-right"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductDetailPage;
|
||||
8
src/contexts/MSWContext.tsx
Normal file
8
src/contexts/MSWContext.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
'use client';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export const MSWContext = createContext<boolean>(true);
|
||||
|
||||
export function useMSWReady() {
|
||||
return useContext(MSWContext);
|
||||
}
|
||||
647
src/data/ListComment/index.tsx
Normal file
647
src/data/ListComment/index.tsx
Normal file
@@ -0,0 +1,647 @@
|
||||
import { ProductCommentData } from '@/types/Comment';
|
||||
|
||||
export const ListCommentData: ProductCommentData[] = [
|
||||
{
|
||||
id: '2434',
|
||||
item_type: 'product',
|
||||
item_id: '25404',
|
||||
people_like_count: '0',
|
||||
people_dislike_count: '0',
|
||||
reply_count: '1',
|
||||
is_user_admin: '0',
|
||||
user_avatar: '',
|
||||
user_name: 'Nguyễn Thanh Tùng',
|
||||
rate: '5',
|
||||
title: 'Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA 4GB [TẶNG MÀN HÌNH]',
|
||||
content: 'Cây này treo được bao nhiêu tab giả lập vậy ạ',
|
||||
files: [],
|
||||
approved: '1',
|
||||
post_time: '1766807320',
|
||||
counter: 1,
|
||||
new_replies: [
|
||||
{
|
||||
id: '1616',
|
||||
comment_id: '2434',
|
||||
user_avatar: '0',
|
||||
user_name: 'Trần Mạnh',
|
||||
is_user_admin: '1',
|
||||
people_like_count: '0',
|
||||
approved: '0',
|
||||
people_dislike_count: '0',
|
||||
content:
|
||||
'Bạn vui lòng liên hệ 0828.333.363, nhân viên kinh doanh bên mình sẽ tư vấn cụ thể nhé',
|
||||
post_time: '1766824192',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2389',
|
||||
item_type: 'product',
|
||||
item_id: '25404',
|
||||
people_like_count: '0',
|
||||
people_dislike_count: '0',
|
||||
reply_count: '1',
|
||||
is_user_admin: '0',
|
||||
user_avatar: '',
|
||||
user_name: 'Lưu Gia Dân ',
|
||||
rate: '5',
|
||||
title: 'Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB [TẶNG MÀN HÌNH]',
|
||||
content: 'Bộ pc tặng màn hình này có ở sài gòn 0? Cụ thể là quận 10 cũ. ',
|
||||
files: [],
|
||||
approved: '1',
|
||||
post_time: '1764736998',
|
||||
counter: 2,
|
||||
new_replies: [
|
||||
{
|
||||
id: '1579',
|
||||
comment_id: '2389',
|
||||
user_avatar: '0',
|
||||
user_name: 'Trần Mạnh',
|
||||
is_user_admin: '1',
|
||||
people_like_count: '0',
|
||||
approved: '0',
|
||||
people_dislike_count: '0',
|
||||
content:
|
||||
'Dạ bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB sẵn hàng tại HCM ạ',
|
||||
post_time: '1764745059',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2377',
|
||||
item_type: 'product',
|
||||
item_id: '25404',
|
||||
people_like_count: '0',
|
||||
people_dislike_count: '0',
|
||||
reply_count: '1',
|
||||
is_user_admin: '0',
|
||||
user_avatar: '',
|
||||
user_name: 'Phan Van Manh',
|
||||
rate: '5',
|
||||
title: 'Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB [TẶNG MÀN HÌNH]',
|
||||
content: 'mình có như cầu mua, cần tư vấn thêm',
|
||||
files: [],
|
||||
approved: '1',
|
||||
post_time: '1764322841',
|
||||
counter: 3,
|
||||
new_replies: [
|
||||
{
|
||||
id: '1571',
|
||||
comment_id: '2377',
|
||||
user_avatar: '0',
|
||||
user_name: 'Trần Mạnh',
|
||||
is_user_admin: '1',
|
||||
people_like_count: '0',
|
||||
approved: '0',
|
||||
people_dislike_count: '0',
|
||||
content:
|
||||
'Bạn vui lòng liên hệ 0828.333.363, nhân viên kinh doanh bên mình sẽ tư vấn cụ thể nhé',
|
||||
post_time: '1764383633',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2368',
|
||||
item_type: 'product',
|
||||
item_id: '25404',
|
||||
people_like_count: '0',
|
||||
people_dislike_count: '0',
|
||||
reply_count: '1',
|
||||
is_user_admin: '0',
|
||||
user_avatar: '',
|
||||
user_name: 'Nguyen Van Phung',
|
||||
rate: '5',
|
||||
title: '[TẶNG MÀN HÌNH] Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB',
|
||||
content: 'Bộ này em có sẵn màn LG UltraGear 24GS50F-B 24 inch Gắn dc kh ạ',
|
||||
files: [],
|
||||
approved: '1',
|
||||
post_time: '1764167165',
|
||||
counter: 4,
|
||||
new_replies: [
|
||||
{
|
||||
id: '1563',
|
||||
comment_id: '2368',
|
||||
user_avatar: '0',
|
||||
user_name: 'Trần Mạnh',
|
||||
is_user_admin: '1',
|
||||
people_like_count: '0',
|
||||
approved: '0',
|
||||
people_dislike_count: '0',
|
||||
content: 'Dạ bộ Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB gắn được ạ',
|
||||
post_time: '1764211206',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2362',
|
||||
item_type: 'product',
|
||||
item_id: '25404',
|
||||
people_like_count: '0',
|
||||
people_dislike_count: '0',
|
||||
reply_count: '1',
|
||||
is_user_admin: '0',
|
||||
user_avatar: '',
|
||||
user_name: 'Xuân hữu',
|
||||
rate: '5',
|
||||
title: '[TẶNG MÀN HÌNH] Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB',
|
||||
content: 'Bộ này chơi TFT FPS CÓ BỊ TỤT KHÔNG SHOP',
|
||||
files: [],
|
||||
approved: '1',
|
||||
post_time: '1763897716',
|
||||
counter: 5,
|
||||
new_replies: [
|
||||
{
|
||||
id: '1557',
|
||||
comment_id: '2362',
|
||||
user_avatar: '0',
|
||||
user_name: 'Trần Mạnh',
|
||||
is_user_admin: '1',
|
||||
people_like_count: '0',
|
||||
approved: '0',
|
||||
people_dislike_count: '0',
|
||||
content:
|
||||
'Dạ bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB chơi TFT thoải mái ạ',
|
||||
post_time: '1763950132',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2341',
|
||||
item_type: 'product',
|
||||
item_id: '25404',
|
||||
people_like_count: '0',
|
||||
people_dislike_count: '0',
|
||||
reply_count: '1',
|
||||
is_user_admin: '0',
|
||||
user_avatar: '',
|
||||
user_name: 'dat phan',
|
||||
rate: '5',
|
||||
title: '[TẶNG MÀN HÌNH] Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB',
|
||||
content: 'Bộ này dùng để code được kh ạ\n',
|
||||
files: [],
|
||||
approved: '1',
|
||||
post_time: '1763463359',
|
||||
counter: 6,
|
||||
new_replies: [
|
||||
{
|
||||
id: '1546',
|
||||
comment_id: '2341',
|
||||
user_avatar: '0',
|
||||
user_name: 'Trần Mạnh',
|
||||
is_user_admin: '1',
|
||||
people_like_count: '0',
|
||||
approved: '0',
|
||||
people_dislike_count: '0',
|
||||
content:
|
||||
'Dạ code được ạ. Bạn vui lòng liên hệ 0828.333.363, nhân viên kinh doanh bên mình sẽ tư vấn cụ thể nhé',
|
||||
post_time: '1763536700',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2291',
|
||||
item_type: 'product',
|
||||
item_id: '25404',
|
||||
people_like_count: '0',
|
||||
people_dislike_count: '0',
|
||||
reply_count: '1',
|
||||
is_user_admin: '0',
|
||||
user_avatar: '',
|
||||
user_name: 'Tú nè nè',
|
||||
rate: '5',
|
||||
title: '[TẶNG MÀN HÌNH] Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB',
|
||||
content: 'Có giảm gì không anh em còn đúng 10tr',
|
||||
files: [],
|
||||
approved: '1',
|
||||
post_time: '1762067763',
|
||||
counter: 7,
|
||||
new_replies: [
|
||||
{
|
||||
id: '1506',
|
||||
comment_id: '2291',
|
||||
user_avatar: '0',
|
||||
user_name: 'Trần Mạnh',
|
||||
is_user_admin: '1',
|
||||
people_like_count: '0',
|
||||
approved: '0',
|
||||
people_dislike_count: '0',
|
||||
content: 'Dạ bộ PC có chương trình tặng màn hình ạ.',
|
||||
post_time: '1762136212',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2282',
|
||||
item_type: 'product',
|
||||
item_id: '25404',
|
||||
people_like_count: '0',
|
||||
people_dislike_count: '0',
|
||||
reply_count: '1',
|
||||
is_user_admin: '0',
|
||||
user_avatar: '',
|
||||
user_name: 'Nguyễn Văn Sáu',
|
||||
rate: '5',
|
||||
title: '[TẶNG MÀN HÌNH] Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB',
|
||||
content:
|
||||
'Shop ơi mình chưa lắp pc bao giờ shop có hỗ trợ lắp đặt ở Phú Thọ k ạ, mình ở Phú Thọ',
|
||||
files: [],
|
||||
approved: '1',
|
||||
post_time: '1761828423',
|
||||
counter: 8,
|
||||
new_replies: [
|
||||
{
|
||||
id: '1499',
|
||||
comment_id: '2282',
|
||||
user_avatar: '0',
|
||||
user_name: 'Trần Mạnh',
|
||||
is_user_admin: '1',
|
||||
people_like_count: '0',
|
||||
approved: '0',
|
||||
people_dislike_count: '0',
|
||||
content:
|
||||
'Bạn vui lòng liên hệ 0828.333.363, nhân viên kinh doanh bên mình sẽ tư vấn cụ thể nhé',
|
||||
post_time: '1761875528',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2260',
|
||||
item_type: 'product',
|
||||
item_id: '25404',
|
||||
people_like_count: '0',
|
||||
people_dislike_count: '0',
|
||||
reply_count: '0',
|
||||
is_user_admin: '0',
|
||||
user_avatar: '',
|
||||
user_name: 'Trần Huy',
|
||||
rate: '5',
|
||||
title: '[TẶNG MÀN HÌNH] Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB',
|
||||
content:
|
||||
'Mình đặt hàng từ lúc giá là 10tr9 mà không được liên hệ tư vấn, đến nay lên 11tr4 rồi thì giá lúc đặt hàng vẫn áp dụng chứ ?',
|
||||
files: [],
|
||||
approved: '0',
|
||||
post_time: '1761133383',
|
||||
counter: 9,
|
||||
new_replies: [],
|
||||
},
|
||||
{
|
||||
id: '2253',
|
||||
item_type: 'product',
|
||||
item_id: '25404',
|
||||
people_like_count: '0',
|
||||
people_dislike_count: '0',
|
||||
reply_count: '1',
|
||||
is_user_admin: '0',
|
||||
user_avatar: '',
|
||||
user_name: 'Minh Hải Nguyễn',
|
||||
rate: '5',
|
||||
title: '[TẶNG MÀN HÌNH] Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB',
|
||||
content: 'Shop ơi bộ này kèm cả case và màn đúng không ạ?\n',
|
||||
files: [],
|
||||
approved: '1',
|
||||
post_time: '1760971409',
|
||||
counter: 10,
|
||||
new_replies: [
|
||||
{
|
||||
id: '1473',
|
||||
comment_id: '2253',
|
||||
user_avatar: '0',
|
||||
user_name: 'Trần Mạnh',
|
||||
is_user_admin: '1',
|
||||
people_like_count: '0',
|
||||
approved: '0',
|
||||
people_dislike_count: '0',
|
||||
content: 'Dạ bộ PC đã kèm vỏ ạ và được tặng màn hình ạ',
|
||||
post_time: '1761011806',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2222',
|
||||
item_type: 'product',
|
||||
item_id: '25404',
|
||||
people_like_count: '0',
|
||||
people_dislike_count: '0',
|
||||
reply_count: '1',
|
||||
is_user_admin: '0',
|
||||
user_avatar: '',
|
||||
user_name: 'thái 0507',
|
||||
rate: '5',
|
||||
title: '[TẶNG MÀN HÌNH] Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB',
|
||||
content: 'bộ này có màn k ạ\n',
|
||||
files: [],
|
||||
approved: '1',
|
||||
post_time: '1759637210',
|
||||
counter: 11,
|
||||
new_replies: [
|
||||
{
|
||||
id: '1449',
|
||||
comment_id: '2222',
|
||||
user_avatar: '0',
|
||||
user_name: 'Trần Mạnh',
|
||||
is_user_admin: '1',
|
||||
people_like_count: '0',
|
||||
approved: '0',
|
||||
people_dislike_count: '0',
|
||||
content: 'Dạ bộ này có tặng màn hình ạ',
|
||||
post_time: '1759715368',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2161',
|
||||
item_type: 'product',
|
||||
item_id: '25404',
|
||||
people_like_count: '0',
|
||||
people_dislike_count: '0',
|
||||
reply_count: '1',
|
||||
is_user_admin: '0',
|
||||
user_avatar: '',
|
||||
user_name: 'Hải Đăng',
|
||||
rate: '5',
|
||||
title: '[TẶNG MÀN HÌNH] Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB',
|
||||
content: 'Shop có quyẹt thẻ tín dụng tốn phí ko nhỉ',
|
||||
files: [],
|
||||
approved: '1',
|
||||
post_time: '1757032318',
|
||||
counter: 12,
|
||||
new_replies: [
|
||||
{
|
||||
id: '1393',
|
||||
comment_id: '2161',
|
||||
user_avatar: '0',
|
||||
user_name: 'Trần Mạnh',
|
||||
is_user_admin: '1',
|
||||
people_like_count: '0',
|
||||
approved: '0',
|
||||
people_dislike_count: '0',
|
||||
content:
|
||||
'Bạn vui lòng liên hệ 0828.333.363, nhân viên kinh doanh bên mình sẽ tư vấn cụ thể nhé',
|
||||
post_time: '1757038972',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '2096',
|
||||
item_type: 'product',
|
||||
item_id: '25404',
|
||||
people_like_count: '0',
|
||||
people_dislike_count: '0',
|
||||
reply_count: '1',
|
||||
is_user_admin: '0',
|
||||
user_avatar: '',
|
||||
user_name: 'Bui Van Giang',
|
||||
rate: '5',
|
||||
title: '[TẶNG MÀN HÌNH] Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB',
|
||||
content: 'shop co mua dc phu kien roi ko',
|
||||
files: [],
|
||||
approved: '1',
|
||||
post_time: '1755095868',
|
||||
counter: 13,
|
||||
new_replies: [
|
||||
{
|
||||
id: '1336',
|
||||
comment_id: '2096',
|
||||
user_avatar: '0',
|
||||
user_name: 'Trần Mạnh',
|
||||
is_user_admin: '1',
|
||||
people_like_count: '0',
|
||||
approved: '0',
|
||||
people_dislike_count: '0',
|
||||
content:
|
||||
'Bạn vui lòng liên hệ 0828.333.363, nhân viên kinh doanh bên mình sẽ tư vấn cụ thể nhé',
|
||||
post_time: '1755135208',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '1991',
|
||||
item_type: 'product',
|
||||
item_id: '25404',
|
||||
people_like_count: '0',
|
||||
people_dislike_count: '0',
|
||||
reply_count: '1',
|
||||
is_user_admin: '0',
|
||||
user_avatar: '',
|
||||
user_name: 'phạm long',
|
||||
rate: '5',
|
||||
title: '[TẶNG MÀN HÌNH] Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB',
|
||||
content:
|
||||
'bên shop miễn phí lắp đặt nhà ở hà nội ko shop hay tính bao nhiêu phí nữa như là vânhj chuyển ',
|
||||
files: [],
|
||||
approved: '1',
|
||||
post_time: '1751287702',
|
||||
counter: 14,
|
||||
new_replies: [
|
||||
{
|
||||
id: '1247',
|
||||
comment_id: '1991',
|
||||
user_avatar: '0',
|
||||
user_name: 'Trần Mạnh',
|
||||
is_user_admin: '1',
|
||||
people_like_count: '0',
|
||||
approved: '0',
|
||||
people_dislike_count: '0',
|
||||
content: 'Chào bạn, shop miễn phí lắp đặt bán kính 20km nội thành ạ',
|
||||
post_time: '1751334482',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '1978',
|
||||
item_type: 'product',
|
||||
item_id: '25404',
|
||||
people_like_count: '0',
|
||||
people_dislike_count: '0',
|
||||
reply_count: '1',
|
||||
is_user_admin: '0',
|
||||
user_avatar: '',
|
||||
user_name: 'Nguyễn Xuân Hùng ',
|
||||
rate: '5',
|
||||
title: '[TẶNG MÀN HÌNH] Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB',
|
||||
content: 'Giá 10tr590 là kèm màn hình luôn hả shop ',
|
||||
files: [],
|
||||
approved: '1',
|
||||
post_time: '1751003230',
|
||||
counter: 15,
|
||||
new_replies: [
|
||||
{
|
||||
id: '1237',
|
||||
comment_id: '1978',
|
||||
user_avatar: '0',
|
||||
user_name: 'Trần Mạnh',
|
||||
is_user_admin: '1',
|
||||
people_like_count: '0',
|
||||
approved: '0',
|
||||
people_dislike_count: '0',
|
||||
content:
|
||||
'Dạ đúng rồi ạ. Bạn vui lòng liên hệ 0828.333.363, nhân viên kinh doanh bên mình sẽ tư vấn cụ thể nhé',
|
||||
post_time: '1751006270',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '1977',
|
||||
item_type: 'product',
|
||||
item_id: '25404',
|
||||
people_like_count: '0',
|
||||
people_dislike_count: '0',
|
||||
reply_count: '1',
|
||||
is_user_admin: '0',
|
||||
user_avatar: '',
|
||||
user_name: 'Nguyễn Xuân Hùng ',
|
||||
rate: '5',
|
||||
title: '[TẶNG MÀN HÌNH] Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB',
|
||||
content:
|
||||
'Mình mua kèm card wifi thì shop gắn vào pc luôn hay là mình nhận được hàng rồi tự gắn vậy shop',
|
||||
files: [],
|
||||
approved: '1',
|
||||
post_time: '1750987766',
|
||||
counter: 16,
|
||||
new_replies: [
|
||||
{
|
||||
id: '1236',
|
||||
comment_id: '1977',
|
||||
user_avatar: '0',
|
||||
user_name: 'Trần Mạnh',
|
||||
is_user_admin: '1',
|
||||
people_like_count: '0',
|
||||
approved: '0',
|
||||
people_dislike_count: '0',
|
||||
content:
|
||||
'Chào bạn, bạn vui lòng liên hệ 0828.333.363, nhân viên kinh doanh bên mình sẽ tư vấn cụ thể nhé',
|
||||
post_time: '1750988487',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '1974',
|
||||
item_type: 'product',
|
||||
item_id: '25404',
|
||||
people_like_count: '0',
|
||||
people_dislike_count: '0',
|
||||
reply_count: '1',
|
||||
is_user_admin: '0',
|
||||
user_avatar: '',
|
||||
user_name: 'Nguyễn hải Dăng ',
|
||||
rate: '5',
|
||||
title: '[TẶNG MÀN HÌNH] Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB',
|
||||
content: 'vga chuyển từ rx 6500 xt sang 1660ti thì bộ này nhiêu v shop ',
|
||||
files: [],
|
||||
approved: '1',
|
||||
post_time: '1750897125',
|
||||
counter: 17,
|
||||
new_replies: [
|
||||
{
|
||||
id: '1231',
|
||||
comment_id: '1974',
|
||||
user_avatar: '0',
|
||||
user_name: 'Trần Mạnh',
|
||||
is_user_admin: '1',
|
||||
people_like_count: '0',
|
||||
approved: '0',
|
||||
people_dislike_count: '0',
|
||||
content:
|
||||
'Bạn vui lòng liên hệ 0828.333.363, nhân viên kinh doanh bên mình sẽ tư vấn cụ thể nhé',
|
||||
post_time: '1750902650',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '1972',
|
||||
item_type: 'product',
|
||||
item_id: '25404',
|
||||
people_like_count: '0',
|
||||
people_dislike_count: '0',
|
||||
reply_count: '1',
|
||||
is_user_admin: '0',
|
||||
user_avatar: '',
|
||||
user_name: 'La Thanh Ki ',
|
||||
rate: '5',
|
||||
title: '[TẶNG MÀN HÌNH] Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB',
|
||||
content: 'bộ này chuyển từ rx 6500xt sang 1660super thì nhiêu shop ',
|
||||
files: [],
|
||||
approved: '1',
|
||||
post_time: '1750862925',
|
||||
counter: 18,
|
||||
new_replies: [
|
||||
{
|
||||
id: '1232',
|
||||
comment_id: '1972',
|
||||
user_avatar: '0',
|
||||
user_name: 'Trần Mạnh',
|
||||
is_user_admin: '1',
|
||||
people_like_count: '0',
|
||||
approved: '0',
|
||||
people_dislike_count: '0',
|
||||
content:
|
||||
'Bạn vui lòng liên hệ 0828.333.363, nhân viên kinh doanh bên mình sẽ tư vấn cụ thể nhé',
|
||||
post_time: '1750902659',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '1941',
|
||||
item_type: 'product',
|
||||
item_id: '25404',
|
||||
people_like_count: '0',
|
||||
people_dislike_count: '0',
|
||||
reply_count: '1',
|
||||
is_user_admin: '0',
|
||||
user_avatar: '',
|
||||
user_name: 'Minh Tú',
|
||||
rate: '5',
|
||||
title: '[TẶNG MÀN HÌNH] Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA 4GB',
|
||||
content: 'Màn tặng hả shop',
|
||||
files: [],
|
||||
approved: '1',
|
||||
post_time: '1749125191',
|
||||
counter: 19,
|
||||
new_replies: [
|
||||
{
|
||||
id: '1205',
|
||||
comment_id: '1941',
|
||||
user_avatar: '0',
|
||||
user_name: 'Trần Mạnh',
|
||||
is_user_admin: '1',
|
||||
people_like_count: '0',
|
||||
approved: '0',
|
||||
people_dislike_count: '0',
|
||||
content: 'Dạ, mua PC được tặng màn hình ạ',
|
||||
post_time: '1749184735',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '1757',
|
||||
item_type: 'product',
|
||||
item_id: '25404',
|
||||
people_like_count: '0',
|
||||
people_dislike_count: '0',
|
||||
reply_count: '1',
|
||||
is_user_admin: '0',
|
||||
user_avatar: '',
|
||||
user_name: 'Hoang ky',
|
||||
rate: '5',
|
||||
title: 'BỘ PC GAMING AMD Ryzen 5 5500/ RAM 16GB/ VGA 4G ',
|
||||
content: 'ổ có cài win sẵn không ạ',
|
||||
files: [],
|
||||
approved: '1',
|
||||
post_time: '1741874131',
|
||||
counter: 20,
|
||||
new_replies: [
|
||||
{
|
||||
id: '1113',
|
||||
comment_id: '1757',
|
||||
user_avatar: '0',
|
||||
user_name: 'Trần Mạnh',
|
||||
is_user_admin: '1',
|
||||
people_like_count: '0',
|
||||
approved: '0',
|
||||
people_dislike_count: '0',
|
||||
content: 'Dạ có ạ',
|
||||
post_time: '1748338266',
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
4468
src/data/ListProduct/index.tsx
Normal file
4468
src/data/ListProduct/index.tsx
Normal file
File diff suppressed because it is too large
Load Diff
277
src/data/ListReview/index.ts
Normal file
277
src/data/ListReview/index.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
|
||||
import { ProductReviewData } from '@/types/Review'
|
||||
|
||||
export const ListReviewData: ProductReviewData[] = [
|
||||
{
|
||||
"id": "758",
|
||||
"item_type": "product",
|
||||
"item_id": "25404",
|
||||
"people_like_count": "0",
|
||||
"people_dislike_count": "0",
|
||||
"reply_count": "0",
|
||||
"is_user_admin": "0",
|
||||
"user_avatar": "",
|
||||
"user_name": "Đỗ Văn Bính",
|
||||
"rate": "5",
|
||||
"title": "Đánh giá sản phẩm Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA 4GB [TẶNG MÀN HÌNH]",
|
||||
"content": "đến tận showroom ở HCM mua cho yên tâm, được unbox đồ nhé mọi người. Cảm quan ban đầu thấy showroom rất to , sạch, nhân viên nhiệt tình, giá tốt, quà tặng màn hình là tặng thật,",
|
||||
"files": [
|
||||
{
|
||||
"id": "375",
|
||||
"title": "",
|
||||
"file_path": "https://nguyencongpc.vn/media/user_upload/19-12-2025/MzK0dsw3o8EaSfOUnuhn/2114970452708823840.jpg",
|
||||
"width": "600",
|
||||
"height": "338",
|
||||
"approved": "0",
|
||||
"create_time": "1766118957"
|
||||
}
|
||||
],
|
||||
"approved": "1",
|
||||
"post_time": "1766119040",
|
||||
"counter": 1,
|
||||
"new_replies": []
|
||||
},
|
||||
{
|
||||
"id": "741",
|
||||
"item_type": "product",
|
||||
"item_id": "25404",
|
||||
"people_like_count": "0",
|
||||
"people_dislike_count": "0",
|
||||
"reply_count": "1",
|
||||
"is_user_admin": "0",
|
||||
"user_avatar": "",
|
||||
"user_name": "Duy trường",
|
||||
"rate": "5",
|
||||
"title": "Đánh giá sản phẩm Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB [TẶNG MÀN HÌNH]",
|
||||
"content": "Ai mua rồi cho mình hỏi valorant dc bao nhiêu fps ạ",
|
||||
"files": [],
|
||||
"approved": "1",
|
||||
"post_time": "1765170972",
|
||||
"counter": 2,
|
||||
"new_replies": [
|
||||
{
|
||||
"id": "51",
|
||||
"comment_id": "741",
|
||||
"user_avatar": "0",
|
||||
"user_name": "Trần Mạnh",
|
||||
"is_user_admin": "1",
|
||||
"people_like_count": "0",
|
||||
"approved": "0",
|
||||
"people_dislike_count": "0",
|
||||
"content": "Dạ khoảng 170-180fps ở 1080P ạ",
|
||||
"post_time": "1765277614"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "707",
|
||||
"item_type": "product",
|
||||
"item_id": "25404",
|
||||
"people_like_count": "0",
|
||||
"people_dislike_count": "0",
|
||||
"reply_count": "0",
|
||||
"is_user_admin": "0",
|
||||
"user_avatar": "",
|
||||
"user_name": "Phong Nguyễn",
|
||||
"rate": "5",
|
||||
"title": "Đánh giá sản phẩm Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB [TẶNG MÀN HÌNH]",
|
||||
"content": "giá tốt, con màn trên bàn là con được tặng luôn nha",
|
||||
"files": [
|
||||
{
|
||||
"id": "328",
|
||||
"title": "",
|
||||
"file_path": "https://nguyencongpc.vn/media/user_upload/01-12-2025/FC5YOYSvCSQtAD6ukdTy/f9a2f40c6fb2e3ecbaa3.jpg",
|
||||
"width": "500",
|
||||
"height": "646",
|
||||
"approved": "0",
|
||||
"create_time": "1764586266"
|
||||
}
|
||||
],
|
||||
"approved": "1",
|
||||
"post_time": "1764586309",
|
||||
"counter": 3,
|
||||
"new_replies": []
|
||||
},
|
||||
{
|
||||
"id": "690",
|
||||
"item_type": "product",
|
||||
"item_id": "25404",
|
||||
"people_like_count": "0",
|
||||
"people_dislike_count": "0",
|
||||
"reply_count": "0",
|
||||
"is_user_admin": "0",
|
||||
"user_avatar": "",
|
||||
"user_name": "Quang Bình",
|
||||
"rate": "5",
|
||||
"title": "Đánh giá sản phẩm Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB [TẶNG MÀN HÌNH]",
|
||||
"content": "Ryzen 5 5500 chơi game làm việc ổn, giá tốt và có tặng màn hình như thông báo nhé , 5 SAO",
|
||||
"files": [
|
||||
{
|
||||
"id": "311",
|
||||
"title": "",
|
||||
"file_path": "https://nguyencongpc.vn/media/user_upload/29-11-2025/PiKzIDmPTSvb0ngJbVAV/e1b569fd265aaa04f34b-2.jpg",
|
||||
"width": "500",
|
||||
"height": "375",
|
||||
"approved": "0",
|
||||
"create_time": "1764387131"
|
||||
}
|
||||
],
|
||||
"approved": "1",
|
||||
"post_time": "1764387192",
|
||||
"counter": 4,
|
||||
"new_replies": []
|
||||
},
|
||||
{
|
||||
"id": "674",
|
||||
"item_type": "product",
|
||||
"item_id": "25404",
|
||||
"people_like_count": "0",
|
||||
"people_dislike_count": "0",
|
||||
"reply_count": "0",
|
||||
"is_user_admin": "0",
|
||||
"user_avatar": "",
|
||||
"user_name": "Đình Ấn",
|
||||
"rate": "5",
|
||||
"title": "Đánh giá sản phẩm [TẶNG MÀN HÌNH] Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB",
|
||||
"content": "Pc có sử dụng wifi được không shop, hay cần phải gắn dây mạng",
|
||||
"files": [],
|
||||
"approved": "1",
|
||||
"post_time": "1764170309",
|
||||
"counter": 5,
|
||||
"new_replies": []
|
||||
},
|
||||
{
|
||||
"id": "673",
|
||||
"item_type": "product",
|
||||
"item_id": "25404",
|
||||
"people_like_count": "0",
|
||||
"people_dislike_count": "0",
|
||||
"reply_count": "0",
|
||||
"is_user_admin": "0",
|
||||
"user_avatar": "",
|
||||
"user_name": "Nguyen Van Thinh",
|
||||
"rate": "5",
|
||||
"title": "Đánh giá sản phẩm [TẶNG MÀN HÌNH] Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB",
|
||||
"content": "Bỏ màn giá sao ạ",
|
||||
"files": [],
|
||||
"approved": "1",
|
||||
"post_time": "1764164501",
|
||||
"counter": 6,
|
||||
"new_replies": []
|
||||
},
|
||||
{
|
||||
"id": "672",
|
||||
"item_type": "product",
|
||||
"item_id": "25404",
|
||||
"people_like_count": "0",
|
||||
"people_dislike_count": "0",
|
||||
"reply_count": "0",
|
||||
"is_user_admin": "0",
|
||||
"user_avatar": "",
|
||||
"user_name": "Nguyen Van Thinh",
|
||||
"rate": "5",
|
||||
"title": "Đánh giá sản phẩm [TẶNG MÀN HÌNH] Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB",
|
||||
"content": "Bỏ màn giá sao ạ",
|
||||
"files": [],
|
||||
"approved": "1",
|
||||
"post_time": "1764164500",
|
||||
"counter": 7,
|
||||
"new_replies": []
|
||||
},
|
||||
{
|
||||
"id": "653",
|
||||
"item_type": "product",
|
||||
"item_id": "25404",
|
||||
"people_like_count": "0",
|
||||
"people_dislike_count": "0",
|
||||
"reply_count": "1",
|
||||
"is_user_admin": "0",
|
||||
"user_avatar": "",
|
||||
"user_name": "Phụng ",
|
||||
"rate": "5",
|
||||
"title": "Đánh giá sản phẩm [TẶNG MÀN HÌNH] Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB",
|
||||
"content": "Dùng loại tai nghe kh chụp tai dc kh ạ ",
|
||||
"files": [],
|
||||
"approved": "1",
|
||||
"post_time": "1763484666",
|
||||
"counter": 8,
|
||||
"new_replies": [
|
||||
{
|
||||
"id": "46",
|
||||
"comment_id": "653",
|
||||
"user_avatar": "0",
|
||||
"user_name": "Trần Mạnh",
|
||||
"is_user_admin": "1",
|
||||
"people_like_count": "0",
|
||||
"approved": "0",
|
||||
"people_dislike_count": "0",
|
||||
"content": "dạ được ạ",
|
||||
"post_time": "1763541896"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "626",
|
||||
"item_type": "product",
|
||||
"item_id": "25404",
|
||||
"people_like_count": "0",
|
||||
"people_dislike_count": "0",
|
||||
"reply_count": "1",
|
||||
"is_user_admin": "0",
|
||||
"user_avatar": "",
|
||||
"user_name": "Văn đạt",
|
||||
"rate": "5",
|
||||
"title": "Đánh giá sản phẩm [TẶNG MÀN HÌNH] Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB",
|
||||
"content": "Bộ này chơi dc gta5 k shop",
|
||||
"files": [],
|
||||
"approved": "1",
|
||||
"post_time": "1759751921",
|
||||
"counter": 9,
|
||||
"new_replies": [
|
||||
{
|
||||
"id": "44",
|
||||
"comment_id": "626",
|
||||
"user_avatar": "0",
|
||||
"user_name": "Trần Mạnh",
|
||||
"is_user_admin": "1",
|
||||
"people_like_count": "0",
|
||||
"approved": "0",
|
||||
"people_dislike_count": "0",
|
||||
"content": "dạ được ạ, nhưng setting thấp ạ",
|
||||
"post_time": "1759974716"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "625",
|
||||
"item_type": "product",
|
||||
"item_id": "25404",
|
||||
"people_like_count": "0",
|
||||
"people_dislike_count": "0",
|
||||
"reply_count": "1",
|
||||
"is_user_admin": "0",
|
||||
"user_avatar": "",
|
||||
"user_name": "thái 0507",
|
||||
"rate": "5",
|
||||
"title": "Đánh giá sản phẩm [TẶNG MÀN HÌNH] Bộ PC Gaming AMD Ryzen 5 5500, RAM 16GB, VGA RX 6500 XT 4GB",
|
||||
"content": "shop ơi bộ này có kèm màn k ạ\n",
|
||||
"files": [],
|
||||
"approved": "1",
|
||||
"post_time": "1759626317",
|
||||
"counter": 10,
|
||||
"new_replies": [
|
||||
{
|
||||
"id": "43",
|
||||
"comment_id": "625",
|
||||
"user_avatar": "0",
|
||||
"user_name": "Trần Mạnh",
|
||||
"is_user_admin": "1",
|
||||
"people_like_count": "0",
|
||||
"approved": "0",
|
||||
"people_dislike_count": "0",
|
||||
"content": "Dạ bộ này được tặng màn hình ạ",
|
||||
"post_time": "1759736954"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
2124
src/data/article/ArticleCateDetailPageData.ts
Normal file
2124
src/data/article/ArticleCateDetailPageData.ts
Normal file
File diff suppressed because it is too large
Load Diff
3429
src/data/article/ArticleDetailPageData.ts
Normal file
3429
src/data/article/ArticleDetailPageData.ts
Normal file
File diff suppressed because one or more lines are too long
264
src/data/article/ListAricleVideo.ts
Normal file
264
src/data/article/ListAricleVideo.ts
Normal file
@@ -0,0 +1,264 @@
|
||||
import { ListArticle } from '@/types/article/TypeListArticle'
|
||||
|
||||
export const DataListArticleVideo: 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": 32,
|
||||
"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": 11,
|
||||
"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": 8,
|
||||
"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": 29,
|
||||
"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": 90,
|
||||
"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": 47,
|
||||
"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": 52,
|
||||
"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"
|
||||
}
|
||||
}
|
||||
]
|
||||
274
src/data/article/ListArticleNews.ts
Normal file
274
src/data/article/ListArticleNews.ts
Normal file
@@ -0,0 +1,274 @@
|
||||
import { ListArticle } from '@/types/article/TypeListArticle'
|
||||
|
||||
export const DataListArticleNews: 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": "24-12-2025, 11:11 am",
|
||||
"lastUpdateBy": "75",
|
||||
"visit": 198,
|
||||
"is_featured": 0,
|
||||
"lastUpdateByUser": "Diệu Linh",
|
||||
"article_time": "",
|
||||
"review_rate": 0,
|
||||
"review_count": 0,
|
||||
"video_code": "",
|
||||
"external_url": "",
|
||||
"author": "Diệu Linh",
|
||||
"counter": 7,
|
||||
"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": 3440,
|
||||
"is_featured": 0,
|
||||
"lastUpdateByUser": "Diệu Linh",
|
||||
"article_time": "",
|
||||
"review_rate": 0,
|
||||
"review_count": 0,
|
||||
"video_code": "",
|
||||
"external_url": "",
|
||||
"author": "Diệu Linh",
|
||||
"counter": 4,
|
||||
"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": 37078,
|
||||
"is_featured": 0,
|
||||
"lastUpdateByUser": "Trần Mạnh",
|
||||
"article_time": "07-11-2025, 9:00 am",
|
||||
"review_rate": 0,
|
||||
"review_count": 0,
|
||||
"video_code": "",
|
||||
"external_url": "",
|
||||
"author": "Trần Mạnh",
|
||||
"counter": 2,
|
||||
"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": 24523,
|
||||
"is_featured": 0,
|
||||
"lastUpdateByUser": "Anh Tuấn",
|
||||
"article_time": "05-11-2025, 10:00 am",
|
||||
"review_rate": 0,
|
||||
"review_count": 0,
|
||||
"video_code": "",
|
||||
"external_url": "",
|
||||
"author": "Anh Tuấn",
|
||||
"counter": 1,
|
||||
"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": 63,
|
||||
"is_featured": 0,
|
||||
"lastUpdateByUser": "Trần Mạnh",
|
||||
"article_time": "",
|
||||
"review_rate": 0,
|
||||
"review_count": 0,
|
||||
"video_code": "",
|
||||
"external_url": "",
|
||||
"author": "Trần Mạnh",
|
||||
"counter": 8,
|
||||
"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": 83,
|
||||
"is_featured": 0,
|
||||
"lastUpdateByUser": "Diệu Linh",
|
||||
"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": 108,
|
||||
"is_featured": 0,
|
||||
"lastUpdateByUser": "Diệu Linh",
|
||||
"article_time": "",
|
||||
"review_rate": 0,
|
||||
"review_count": 0,
|
||||
"video_code": "",
|
||||
"external_url": "",
|
||||
"author": "Diệu Linh",
|
||||
"counter": 5,
|
||||
"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": 6972,
|
||||
"is_featured": 0,
|
||||
"lastUpdateByUser": "Diệu Linh",
|
||||
"article_time": "",
|
||||
"review_rate": 0,
|
||||
"review_count": 0,
|
||||
"video_code": "",
|
||||
"external_url": "",
|
||||
"author": "Diệu Linh",
|
||||
"counter": 3,
|
||||
"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": 4212,
|
||||
"title": "Trải nghiệm Photoshop 2026: Tính năng AI đỉnh cao và Cách cài đặt nhanh chóng",
|
||||
"extend": {
|
||||
"pixel_code": ""
|
||||
},
|
||||
"summary": "Phiên bản Photoshop 2026 tập trung vào việc ứng dụng AI để rút ngắn thời gian chỉnh sửa và nâng cao độ chính xác. Giao diện được tối ưu giúp người dùng thao tác nhanh hơn trên nhiều thiết bị cấu hình khác nhau. Nhờ đó, cả designer chuyên nghiệp lẫn người mới đều có thể khai thác tối đa sức mạnh phần mềm.",
|
||||
"createDate": "18-12-2025, 3:20 pm",
|
||||
"createBy": "75",
|
||||
"lastUpdate": "24-12-2025, 4:31 pm",
|
||||
"lastUpdateBy": "53",
|
||||
"visit": 250,
|
||||
"is_featured": 0,
|
||||
"lastUpdateByUser": "Trần Mạnh",
|
||||
"article_time": "",
|
||||
"review_rate": 0,
|
||||
"review_count": 0,
|
||||
"video_code": "",
|
||||
"external_url": "",
|
||||
"author": "Trần Mạnh",
|
||||
"counter": 9,
|
||||
"url": "/trai-nghiem-photoshop-2026-tinh-nang-ai-dinh-cao-va-cach-cai-dat-nhanh-chong",
|
||||
"image": {
|
||||
"thum": "https://nguyencongpc.vn/media/news/120-4212-z7359296182053_5ab9b88e01d2b87a466f12e021064a29.jpg",
|
||||
"original": "https://nguyencongpc.vn/media/news/4212-z7359296182053_5ab9b88e01d2b87a466f12e021064a29.jpg"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 4213,
|
||||
"title": "Top 30+ Hình Nền 2K 4K Tết Nguyên Đán Bính Ngọ 2026 Cực Hot ",
|
||||
"extend": {
|
||||
"pixel_code": ""
|
||||
},
|
||||
"summary": "Bài viết giới thiệu bộ sưu tập hơn 30 hình nền Tết Nguyên Đán Bính Ngọ 2026 với độ phân giải 2K và 4K sắc nét. Nội dung tập trung vào các chủ đề truyền thống như hoa mai, hoa đào, linh vật Ngọ và không khí xuân rộn ràng. Đây là lựa chọn lý tưởng để trang trí màn hình PC, laptop và điện thoại dịp đầu năm mới.",
|
||||
"createDate": "19-12-2025, 11:46 am",
|
||||
"createBy": "75",
|
||||
"lastUpdate": "24-12-2025, 4:30 pm",
|
||||
"lastUpdateBy": "53",
|
||||
"visit": 169,
|
||||
"is_featured": 0,
|
||||
"lastUpdateByUser": "Trần Mạnh",
|
||||
"article_time": "",
|
||||
"review_rate": 0,
|
||||
"review_count": 0,
|
||||
"video_code": "",
|
||||
"external_url": "",
|
||||
"author": "Trần Mạnh",
|
||||
"counter": 10,
|
||||
"url": "/top-30-hinh-nen-tet-2026-cuc-hot-cho-nguoi-dung",
|
||||
"image": {
|
||||
"thum": "https://nguyencongpc.vn/media/news/120-4213-top-30-hinh-nen-tet-2026-cuc-hot-cho-nguoi-dung18.jpg",
|
||||
"original": "https://nguyencongpc.vn/media/news/4213-top-30-hinh-nen-tet-2026-cuc-hot-cho-nguoi-dung18.jpg"
|
||||
}
|
||||
}
|
||||
]
|
||||
186
src/data/article/ListCategory.ts
Normal file
186
src/data/article/ListCategory.ts
Normal file
@@ -0,0 +1,186 @@
|
||||
import { TypeArticleCategory } from '@/types/article/ListCategoryArticle';
|
||||
|
||||
export const DataArticleCategory: TypeArticleCategory[] = [
|
||||
{
|
||||
id: '243',
|
||||
title: 'C\u00f4ng ngh\u1ec7',
|
||||
summary: '',
|
||||
parentId: '0',
|
||||
isParent: '0',
|
||||
thumbnail: '0',
|
||||
type: 'article',
|
||||
url: '\/tin-cong-nghe',
|
||||
item_count: '2787',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: '2490',
|
||||
title: 'Review',
|
||||
summary: '0',
|
||||
parentId: '0',
|
||||
isParent: '0',
|
||||
thumbnail: '0',
|
||||
type: 'article',
|
||||
url: '\/tin-tuc-review',
|
||||
item_count: '1235',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: '2491',
|
||||
title: 'H\u01b0\u1edbng d\u1eabn',
|
||||
summary: '0',
|
||||
parentId: '0',
|
||||
isParent: '1',
|
||||
thumbnail: '0',
|
||||
type: 'article',
|
||||
url: '\/tin-tuc-huong-dan',
|
||||
item_count: '1828',
|
||||
children: [
|
||||
{
|
||||
id: '2493',
|
||||
title: 'Ki\u1ebfn th\u1ee9c m\u00e1y t\u00ednh',
|
||||
summary: '0',
|
||||
parentId: '2491',
|
||||
isParent: '0',
|
||||
thumbnail: '0',
|
||||
type: 'article',
|
||||
url: '\/kien-thuc-may-tinh',
|
||||
item_count: '1558',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: '2494',
|
||||
title: 'Ph\u1ea7n m\u1ec1m \u0111\u1ed3 h\u1ecda',
|
||||
summary: '0',
|
||||
parentId: '2491',
|
||||
isParent: '0',
|
||||
thumbnail: '0',
|
||||
type: 'article',
|
||||
url: '\/phan-mem-do-hoa',
|
||||
item_count: '174',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: '2495',
|
||||
title: 'Ph\u1ea7n m\u1ec1m v\u0103n ph\u00f2ng',
|
||||
summary: '0',
|
||||
parentId: '2491',
|
||||
isParent: '0',
|
||||
thumbnail: '0',
|
||||
type: 'article',
|
||||
url: '\/phan-mem-van-phong',
|
||||
item_count: '71',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: '263',
|
||||
title: 'Tuy\u1ec3n d\u1ee5ng',
|
||||
summary: '0',
|
||||
parentId: '0',
|
||||
isParent: '0',
|
||||
thumbnail: '0',
|
||||
type: 'article',
|
||||
url: '\/tuyen-dung',
|
||||
item_count: '19',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: '2488',
|
||||
title: 'Tin t\u1ee9c khuy\u1ebfn m\u1ea1i',
|
||||
summary: '0',
|
||||
parentId: '0',
|
||||
isParent: '0',
|
||||
thumbnail: '0',
|
||||
type: 'article',
|
||||
url: '\/tin-tuc-khuyen-mai',
|
||||
item_count: '90',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: '2497',
|
||||
title: 'Tin t\u1ee9c build PC',
|
||||
summary: '',
|
||||
parentId: '0',
|
||||
isParent: '0',
|
||||
thumbnail: '',
|
||||
type: 'article',
|
||||
url: '\/tin-tuc-build-pc',
|
||||
item_count: '36',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: '2505',
|
||||
title: 'Game',
|
||||
summary: '',
|
||||
parentId: '0',
|
||||
isParent: '0',
|
||||
thumbnail: '',
|
||||
type: 'article',
|
||||
url: '\/game',
|
||||
item_count: '16',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: '2501',
|
||||
title: 'S\u1ef1 ki\u1ec7n',
|
||||
summary: '',
|
||||
parentId: '0',
|
||||
isParent: '1',
|
||||
thumbnail: '',
|
||||
type: 'article',
|
||||
url: '\/su-kien',
|
||||
item_count: '65',
|
||||
children: [
|
||||
{
|
||||
id: '2504',
|
||||
title: 'Chung',
|
||||
summary: '',
|
||||
parentId: '2501',
|
||||
isParent: '0',
|
||||
thumbnail: '',
|
||||
type: 'article',
|
||||
url: '\/chung',
|
||||
item_count: '30',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: '2500',
|
||||
title: 'COMPUTEX 2025',
|
||||
summary: '',
|
||||
parentId: '2501',
|
||||
isParent: '0',
|
||||
thumbnail: '',
|
||||
type: 'article',
|
||||
url: '\/computex-2025',
|
||||
item_count: '16',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: '2502',
|
||||
title: 'Ng\u00e0y h\u1ed9i tuy\u1ec3n sinh 2025',
|
||||
summary: '',
|
||||
parentId: '2501',
|
||||
isParent: '0',
|
||||
thumbnail: '',
|
||||
type: 'article',
|
||||
url: '\/ngay-hoi-tuyen-sinh-2025',
|
||||
item_count: '6',
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: '2503',
|
||||
title: 'Ch\u00e0o t\u00e2n sinh vi\u00ean',
|
||||
summary: '',
|
||||
parentId: '2501',
|
||||
isParent: '0',
|
||||
thumbnail: '',
|
||||
type: 'article',
|
||||
url: '\/chao-tan-sinh-vien',
|
||||
item_count: '15',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
114
src/data/buildpc/category/index.ts
Normal file
114
src/data/buildpc/category/index.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
export const category_config = [
|
||||
{
|
||||
id: 277,
|
||||
name: 'CPU - B\u1ed9 Vi X\u1eed L\u00fd',
|
||||
},
|
||||
{
|
||||
id: 278,
|
||||
name: 'Main - Bo M\u1ea1ch Ch\u1ee7',
|
||||
},
|
||||
{
|
||||
id: 283,
|
||||
name: 'RAM - B\u1ed9 Nh\u1edb Trong',
|
||||
},
|
||||
{
|
||||
id: 3274,
|
||||
name: '\u1ed4 C\u1ee9ng SSD 1',
|
||||
},
|
||||
{
|
||||
id: 3644,
|
||||
name: '\u1ed4 C\u1ee9ng SSD 2',
|
||||
},
|
||||
{
|
||||
id: 3273,
|
||||
name: '\u1ed4 C\u1ee9ng HDD',
|
||||
},
|
||||
{
|
||||
id: 279,
|
||||
name: 'VGA - Card M\u00e0n H\u00ecnh',
|
||||
},
|
||||
{
|
||||
id: 282,
|
||||
name: 'PSU - Ngu\u1ed3n M\u00e1y T\u00ednh',
|
||||
},
|
||||
{
|
||||
id: 280,
|
||||
name: 'Case - V\u1ecf M\u00e1y T\u00ednh',
|
||||
},
|
||||
{
|
||||
id: 3270,
|
||||
name: 'T\u1ea3n Nhi\u1ec7t Kh\u00ed',
|
||||
},
|
||||
{
|
||||
id: 3269,
|
||||
name: 'T\u1ea3n Nhi\u1ec7t N\u01b0\u1edbc AIO',
|
||||
},
|
||||
{
|
||||
id: 3630,
|
||||
name: 'T\u1ea3n Nhi\u1ec7t N\u01b0\u1edbc Custom',
|
||||
},
|
||||
{
|
||||
id: 3271,
|
||||
name: 'Fan T\u1ea3n Nhi\u1ec7t',
|
||||
},
|
||||
{
|
||||
id: 281,
|
||||
name: 'Monitor - M\u00e0n H\u00ecnh',
|
||||
},
|
||||
{
|
||||
id: 3705,
|
||||
name: 'Monitor - M\u00e0n H\u00ecnh 2',
|
||||
},
|
||||
{
|
||||
id: 1235,
|
||||
name: 'B\u00e0n Ph\u00edm',
|
||||
},
|
||||
{
|
||||
id: 1147,
|
||||
name: 'Mouse - Chu\u1ed9t',
|
||||
},
|
||||
{
|
||||
id: 1118,
|
||||
name: 'Pad - B\u00e0n Di Chu\u1ed9t',
|
||||
},
|
||||
{
|
||||
id: 3309,
|
||||
name: 'Tai Nghe',
|
||||
},
|
||||
{
|
||||
id: 3308,
|
||||
name: 'Loa',
|
||||
},
|
||||
{
|
||||
id: 3307,
|
||||
name: 'Gh\u1ebf Gaming',
|
||||
},
|
||||
{
|
||||
id: 3411,
|
||||
name: 'B\u00e0n Gaming',
|
||||
},
|
||||
{
|
||||
id: 3287,
|
||||
name: 'Webcam',
|
||||
},
|
||||
{
|
||||
id: 3341,
|
||||
name: 'Microphones',
|
||||
},
|
||||
{
|
||||
id: 3413,
|
||||
name: 'Thi\u1ebft B\u1ecb Studio, Stream',
|
||||
},
|
||||
{
|
||||
id: 1751,
|
||||
name: 'Thi\u1ebft B\u1ecb M\u1ea1ng',
|
||||
},
|
||||
{
|
||||
id: 3598,
|
||||
name: 'Gi\u00e1 treo m\u00e0n h\u00ecnh',
|
||||
},
|
||||
{
|
||||
id: 3437,
|
||||
name: 'Ph\u1ea7n m\u1ec1m',
|
||||
},
|
||||
];
|
||||
0
src/data/buildpc/index.ts
Normal file
0
src/data/buildpc/index.ts
Normal file
@@ -1,6 +1,6 @@
|
||||
import { TypeListProductDeal } from '@/types';
|
||||
|
||||
export const productDealData: TypeListProductDeal = [
|
||||
export const ListDealData: TypeListProductDeal = [
|
||||
{
|
||||
id: '565',
|
||||
pro_id: '25404',
|
||||
@@ -11,7 +11,7 @@ export const productDealData: TypeListProductDeal = [
|
||||
min_purchase: '1',
|
||||
max_purchase: '0',
|
||||
from_time: '19-12-2025, 8:00 am',
|
||||
to_time: '22-12-2025, 9:30 am',
|
||||
to_time: '31-01-2026, 9:30 am',
|
||||
is_featured: '0',
|
||||
last_update: '1766109733',
|
||||
last_update_by: '0',
|
||||
@@ -287,6 +287,291 @@ export const productDealData: TypeListProductDeal = [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '560',
|
||||
pro_id: '27720',
|
||||
title: 'Bộ PC Gaming Intel Core i5-13400F, RAM 16GB, RTX 5060 Ti [TẶNG MÀN HÌNH]',
|
||||
price: '24990000',
|
||||
customer_group_price: '[]',
|
||||
quantity: '5',
|
||||
min_purchase: '1',
|
||||
max_purchase: '0',
|
||||
from_time: '08-12-2025, 8:00 am',
|
||||
to_time: '31-01-2026, 9:30 am',
|
||||
is_featured: '0',
|
||||
last_update: '1766108851',
|
||||
last_update_by: '0',
|
||||
ordering: '0',
|
||||
sale_order: '3',
|
||||
sale_quantity: '3',
|
||||
views: '1',
|
||||
rating: '0',
|
||||
review_count: '0',
|
||||
auto_renew: '0',
|
||||
auto_renew_history: null,
|
||||
counter: 6,
|
||||
request_path: '/deal/560',
|
||||
deal_time_happen: 971685,
|
||||
deal_time_left: 243315,
|
||||
is_start: 1,
|
||||
is_end: 0,
|
||||
is_active: '1',
|
||||
product_info: {
|
||||
id: 27720,
|
||||
productId: 27720,
|
||||
priceUnit: 'chiếc',
|
||||
marketPrice: 27690000,
|
||||
price: '24990000',
|
||||
price_off: 6,
|
||||
currency: 'vnd',
|
||||
sale_rules: {
|
||||
price: '24990000',
|
||||
normal_price: 25990000,
|
||||
min_purchase: '1',
|
||||
max_purchase: '0',
|
||||
remain_quantity: 1,
|
||||
from_time: '1765155600',
|
||||
to_time: '1766370600',
|
||||
type: 'deal',
|
||||
type_id: '560',
|
||||
},
|
||||
lastUpdate: '2025-12-18 15:39:17',
|
||||
warranty: 'Bảo hành dài theo từng linh kiện',
|
||||
productName: 'Bộ PC Gaming Intel Core i5-13400F, RAM 16GB, RTX 5060 Ti [TẶNG MÀN HÌNH]',
|
||||
productSummary:
|
||||
'CPU Intel Core i5-13400F Tray New (Up To 4.60GHz, 10 Nhân 16 Luồng, 20 MB Cache, LGA 1700)\r\nMainboard BIOSTAR Z690MX2-E D4 (Intel Z690, Socket 1700, 2xDDR4, mATX)\r\nRAM Colorful Battle AX 16GB DDR4 3200MHz\r\nỔ Cứng SSD Acer FA100 512GB (NVMe PCIe/ Gen3x4 M2.2280/ 3200MB/s/ 2200MB/s)\r\nCard Màn Hình MSI RTX 5060 Ti 8GB SHADOW 2X OC Plus\r\nNguồn máy tính MIK C750B 750W PLUS BRONZE\r\nVỏ Case Xigmatek BLAST M (M-ATX) - Black\r\nTản Nhiệt Khí JONSBO CR-1000 EVO BLACK (Color RGB)\r\nFan Tản Nhiệt JUNGLE LEOPARD Prism 6Pro Black\r\n',
|
||||
package_accessory: '',
|
||||
productImage: {
|
||||
small: 'https://nguyencongpc.vn/media/product/75-27720-pc-gaming-25251326.jpg',
|
||||
large: 'https://nguyencongpc.vn/media/product/250-27720-pc-gaming-25251326.jpg',
|
||||
original: '',
|
||||
},
|
||||
imageCollection: [
|
||||
{
|
||||
image: {
|
||||
small:
|
||||
'https://nguyencongpc.vn/media/product/75-27720-pc-gaming-intel-core-i5-13400f-ram-16g-vga-rtx-5060-ti-001.jpg',
|
||||
large:
|
||||
'https://nguyencongpc.vn/media/product/250-27720-pc-gaming-intel-core-i5-13400f-ram-16g-vga-rtx-5060-ti-001.jpg',
|
||||
original: '',
|
||||
},
|
||||
alt: '',
|
||||
},
|
||||
{
|
||||
image: {
|
||||
small:
|
||||
'https://nguyencongpc.vn/media/product/75-27720-pc-gaming-intel-core-i5-13400f-ram-16g-vga-rtx-5060-ti-1.jpg',
|
||||
large:
|
||||
'https://nguyencongpc.vn/media/product/250-27720-pc-gaming-intel-core-i5-13400f-ram-16g-vga-rtx-5060-ti-1.jpg',
|
||||
original: '',
|
||||
},
|
||||
alt: '',
|
||||
},
|
||||
{
|
||||
image: {
|
||||
small:
|
||||
'https://nguyencongpc.vn/media/product/75-27720-pc-gaming-intel-core-i5-13400f-ram-16g-vga-rtx-5060-ti-2.jpg',
|
||||
large:
|
||||
'https://nguyencongpc.vn/media/product/250-27720-pc-gaming-intel-core-i5-13400f-ram-16g-vga-rtx-5060-ti-2.jpg',
|
||||
original: '',
|
||||
},
|
||||
alt: '',
|
||||
},
|
||||
{
|
||||
image: {
|
||||
small:
|
||||
'https://nguyencongpc.vn/media/product/75-27720-pc-gaming-intel-core-i5-13400f-ram-16g-vga-rtx-5060-ti-3.jpg',
|
||||
large:
|
||||
'https://nguyencongpc.vn/media/product/250-27720-pc-gaming-intel-core-i5-13400f-ram-16g-vga-rtx-5060-ti-3.jpg',
|
||||
original: '',
|
||||
},
|
||||
alt: '',
|
||||
},
|
||||
{
|
||||
image: {
|
||||
small:
|
||||
'https://nguyencongpc.vn/media/product/75-27720-pc-gaming-intel-core-i5-13400f-ram-16g-vga-rtx-5060-ti-4.jpg',
|
||||
large:
|
||||
'https://nguyencongpc.vn/media/product/250-27720-pc-gaming-intel-core-i5-13400f-ram-16g-vga-rtx-5060-ti-4.jpg',
|
||||
original: '',
|
||||
},
|
||||
alt: '',
|
||||
},
|
||||
{
|
||||
image: {
|
||||
small: 'https://nguyencongpc.vn/media/product/75-27720-pc-gaming-25251326.jpg',
|
||||
large: 'https://nguyencongpc.vn/media/product/250-27720-pc-gaming-25251326.jpg',
|
||||
original: '',
|
||||
},
|
||||
alt: '',
|
||||
},
|
||||
],
|
||||
productUrl: '/pc-gaming-ncpc-15',
|
||||
brand: {
|
||||
id: 124,
|
||||
brand_index: 'ncpc',
|
||||
name: 'NCPC',
|
||||
image: '',
|
||||
url: '/brand/ncpc',
|
||||
},
|
||||
visit: 39556,
|
||||
rating: 5,
|
||||
reviewCount: 1,
|
||||
review: {
|
||||
rate: 5,
|
||||
total: 1,
|
||||
},
|
||||
comment: {
|
||||
rate: 5,
|
||||
total: 3,
|
||||
},
|
||||
quantity: 1,
|
||||
productSKU: '',
|
||||
productModel: '',
|
||||
hasVAT: 0,
|
||||
condition: 'Mới',
|
||||
config_count: 0,
|
||||
configurable: 0,
|
||||
component_count: 0,
|
||||
specialOffer: {
|
||||
other: [
|
||||
{
|
||||
id: 0,
|
||||
title:
|
||||
'<p><span style="font-size: 10pt;"><strong><span style="color: #ff0000;">TẶNG MÀN HÌNH : Màn hình Gaming cong MSI MAG 276CF E20 27\' FHD VA 200Hz 0.5Ms</span></strong></span></p>\r\n<p><span style="font-size: 10pt;"><strong>Giá PC khi không lấy quà tặng : 23.490.000đ</strong></span></p>\r\n<p><a href="https://khuyenmai.nguyencongpc.vn/build-pc"><img src="https://nguyencongpc.vn/media/lib/24-09-2025/z7044410660344_5550774fd1a8b1c78c2735d5f4aab705.jpg" alt="" width="100%" /></a></p>',
|
||||
type: '',
|
||||
thumbnail: '',
|
||||
cash_value: 0,
|
||||
quantity: 1,
|
||||
from_time: '',
|
||||
to_time: '',
|
||||
url: '',
|
||||
description: '',
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
all: [
|
||||
{
|
||||
id: 0,
|
||||
title:
|
||||
'<p><span style="font-size: 10pt;"><strong><span style="color: #ff0000;">TẶNG MÀN HÌNH : Màn hình Gaming cong MSI MAG 276CF E20 27\' FHD VA 200Hz 0.5Ms</span></strong></span></p>\r\n<p><span style="font-size: 10pt;"><strong>Giá PC khi không lấy quà tặng : 23.490.000đ</strong></span></p>\r\n<p><a href="https://khuyenmai.nguyencongpc.vn/build-pc"><img src="https://nguyencongpc.vn/media/lib/24-09-2025/z7044410660344_5550774fd1a8b1c78c2735d5f4aab705.jpg" alt="" width="100%" /></a></p>',
|
||||
type: '',
|
||||
thumbnail: '',
|
||||
cash_value: 0,
|
||||
quantity: 1,
|
||||
from_time: '',
|
||||
to_time: '',
|
||||
url: '',
|
||||
description: '',
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
specialOfferGroup: [],
|
||||
productType: {
|
||||
isNew: 0,
|
||||
isHot: 0,
|
||||
isBestSale: 0,
|
||||
isSaleOff: 0,
|
||||
'online-only': 0,
|
||||
},
|
||||
bulk_price: [],
|
||||
thum_poster: '0',
|
||||
thum_poster_type: '',
|
||||
addon: [],
|
||||
variants: [],
|
||||
variant_option: [],
|
||||
extend: {
|
||||
buy_count: '492',
|
||||
pixel_code: '',
|
||||
review_count: '43',
|
||||
review_score: '4.3',
|
||||
},
|
||||
weight: 0,
|
||||
promotion_price: null,
|
||||
deal_list: [
|
||||
{
|
||||
id: '560',
|
||||
pro_id: '27720',
|
||||
title: 'Bộ PC Gaming Intel Core i5-13400F, RAM 16GB, RTX 5060 Ti [TẶNG MÀN HÌNH]',
|
||||
price: '24990000',
|
||||
quantity: '5',
|
||||
min_purchase: '1',
|
||||
max_purchase: '0',
|
||||
is_featured: '0',
|
||||
from_time: '1765155600',
|
||||
to_time: '1766370600',
|
||||
is_started: 1,
|
||||
},
|
||||
],
|
||||
pricing_traces: [
|
||||
{
|
||||
price: '11690000',
|
||||
type: 'deal',
|
||||
type_id: '565',
|
||||
},
|
||||
{
|
||||
price: '7600000',
|
||||
type: 'deal',
|
||||
type_id: '563',
|
||||
},
|
||||
{
|
||||
price: '6990000',
|
||||
type: 'deal',
|
||||
type_id: '562',
|
||||
},
|
||||
{
|
||||
price: '24990000',
|
||||
type: 'deal',
|
||||
type_id: '560',
|
||||
},
|
||||
],
|
||||
categories: [
|
||||
{
|
||||
id: '1829',
|
||||
catPath: ':1829:0',
|
||||
name: 'PC GAMING',
|
||||
url: '/pc-gaming',
|
||||
},
|
||||
{
|
||||
id: '3468',
|
||||
catPath: ':3468:1829:0',
|
||||
name: 'CHỌN THEO NHU CẦU',
|
||||
url: '/chon-theo-nhu-cau-1',
|
||||
},
|
||||
{
|
||||
id: '3432',
|
||||
catPath: ':3432:3468:1829:0',
|
||||
name: 'PC ESPORT',
|
||||
url: '/pc-esport',
|
||||
},
|
||||
{
|
||||
id: '3433',
|
||||
catPath: ':3433:3468:1829:0',
|
||||
name: 'PC GAME AAA',
|
||||
url: '/pc-game-aaa',
|
||||
},
|
||||
{
|
||||
id: '3434',
|
||||
catPath: ':3434:3468:1829:0',
|
||||
name: 'PC STREAM GAME',
|
||||
url: '/pc-stream-game',
|
||||
},
|
||||
{
|
||||
id: '3469',
|
||||
catPath: ':3469:1829:0',
|
||||
name: 'CHỌN THEO KHOẢNG GIÁ',
|
||||
url: '/chon-theo-khoang-gia-1',
|
||||
},
|
||||
{
|
||||
id: '3472',
|
||||
catPath: ':3472:3469:1829:0',
|
||||
name: '20 Triệu - 30 Triệu',
|
||||
url: '/20-trieu-30-trieu-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '564',
|
||||
pro_id: '28304',
|
||||
@@ -1376,289 +1661,4 @@ export const productDealData: TypeListProductDeal = [
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '560',
|
||||
pro_id: '27720',
|
||||
title: 'Bộ PC Gaming Intel Core i5-13400F, RAM 16GB, RTX 5060 Ti [TẶNG MÀN HÌNH]',
|
||||
price: '24990000',
|
||||
customer_group_price: '[]',
|
||||
quantity: '5',
|
||||
min_purchase: '1',
|
||||
max_purchase: '0',
|
||||
from_time: '08-12-2025, 8:00 am',
|
||||
to_time: '22-12-2025, 9:30 am',
|
||||
is_featured: '0',
|
||||
last_update: '1766108851',
|
||||
last_update_by: '0',
|
||||
ordering: '0',
|
||||
sale_order: '3',
|
||||
sale_quantity: '3',
|
||||
views: '1',
|
||||
rating: '0',
|
||||
review_count: '0',
|
||||
auto_renew: '0',
|
||||
auto_renew_history: null,
|
||||
counter: 6,
|
||||
request_path: '/deal/560',
|
||||
deal_time_happen: 971685,
|
||||
deal_time_left: 243315,
|
||||
is_start: 1,
|
||||
is_end: 0,
|
||||
is_active: '1',
|
||||
product_info: {
|
||||
id: 27720,
|
||||
productId: 27720,
|
||||
priceUnit: 'chiếc',
|
||||
marketPrice: 27690000,
|
||||
price: '24990000',
|
||||
price_off: 6,
|
||||
currency: 'vnd',
|
||||
sale_rules: {
|
||||
price: '24990000',
|
||||
normal_price: 25990000,
|
||||
min_purchase: '1',
|
||||
max_purchase: '0',
|
||||
remain_quantity: 1,
|
||||
from_time: '1765155600',
|
||||
to_time: '1766370600',
|
||||
type: 'deal',
|
||||
type_id: '560',
|
||||
},
|
||||
lastUpdate: '2025-12-18 15:39:17',
|
||||
warranty: 'Bảo hành dài theo từng linh kiện',
|
||||
productName: 'Bộ PC Gaming Intel Core i5-13400F, RAM 16GB, RTX 5060 Ti [TẶNG MÀN HÌNH]',
|
||||
productSummary:
|
||||
'CPU Intel Core i5-13400F Tray New (Up To 4.60GHz, 10 Nhân 16 Luồng, 20 MB Cache, LGA 1700)\r\nMainboard BIOSTAR Z690MX2-E D4 (Intel Z690, Socket 1700, 2xDDR4, mATX)\r\nRAM Colorful Battle AX 16GB DDR4 3200MHz\r\nỔ Cứng SSD Acer FA100 512GB (NVMe PCIe/ Gen3x4 M2.2280/ 3200MB/s/ 2200MB/s)\r\nCard Màn Hình MSI RTX 5060 Ti 8GB SHADOW 2X OC Plus\r\nNguồn máy tính MIK C750B 750W PLUS BRONZE\r\nVỏ Case Xigmatek BLAST M (M-ATX) - Black\r\nTản Nhiệt Khí JONSBO CR-1000 EVO BLACK (Color RGB)\r\nFan Tản Nhiệt JUNGLE LEOPARD Prism 6Pro Black\r\n',
|
||||
package_accessory: '',
|
||||
productImage: {
|
||||
small: 'https://nguyencongpc.vn/media/product/75-27720-pc-gaming-25251326.jpg',
|
||||
large: 'https://nguyencongpc.vn/media/product/250-27720-pc-gaming-25251326.jpg',
|
||||
original: '',
|
||||
},
|
||||
imageCollection: [
|
||||
{
|
||||
image: {
|
||||
small:
|
||||
'https://nguyencongpc.vn/media/product/75-27720-pc-gaming-intel-core-i5-13400f-ram-16g-vga-rtx-5060-ti-001.jpg',
|
||||
large:
|
||||
'https://nguyencongpc.vn/media/product/250-27720-pc-gaming-intel-core-i5-13400f-ram-16g-vga-rtx-5060-ti-001.jpg',
|
||||
original: '',
|
||||
},
|
||||
alt: '',
|
||||
},
|
||||
{
|
||||
image: {
|
||||
small:
|
||||
'https://nguyencongpc.vn/media/product/75-27720-pc-gaming-intel-core-i5-13400f-ram-16g-vga-rtx-5060-ti-1.jpg',
|
||||
large:
|
||||
'https://nguyencongpc.vn/media/product/250-27720-pc-gaming-intel-core-i5-13400f-ram-16g-vga-rtx-5060-ti-1.jpg',
|
||||
original: '',
|
||||
},
|
||||
alt: '',
|
||||
},
|
||||
{
|
||||
image: {
|
||||
small:
|
||||
'https://nguyencongpc.vn/media/product/75-27720-pc-gaming-intel-core-i5-13400f-ram-16g-vga-rtx-5060-ti-2.jpg',
|
||||
large:
|
||||
'https://nguyencongpc.vn/media/product/250-27720-pc-gaming-intel-core-i5-13400f-ram-16g-vga-rtx-5060-ti-2.jpg',
|
||||
original: '',
|
||||
},
|
||||
alt: '',
|
||||
},
|
||||
{
|
||||
image: {
|
||||
small:
|
||||
'https://nguyencongpc.vn/media/product/75-27720-pc-gaming-intel-core-i5-13400f-ram-16g-vga-rtx-5060-ti-3.jpg',
|
||||
large:
|
||||
'https://nguyencongpc.vn/media/product/250-27720-pc-gaming-intel-core-i5-13400f-ram-16g-vga-rtx-5060-ti-3.jpg',
|
||||
original: '',
|
||||
},
|
||||
alt: '',
|
||||
},
|
||||
{
|
||||
image: {
|
||||
small:
|
||||
'https://nguyencongpc.vn/media/product/75-27720-pc-gaming-intel-core-i5-13400f-ram-16g-vga-rtx-5060-ti-4.jpg',
|
||||
large:
|
||||
'https://nguyencongpc.vn/media/product/250-27720-pc-gaming-intel-core-i5-13400f-ram-16g-vga-rtx-5060-ti-4.jpg',
|
||||
original: '',
|
||||
},
|
||||
alt: '',
|
||||
},
|
||||
{
|
||||
image: {
|
||||
small: 'https://nguyencongpc.vn/media/product/75-27720-pc-gaming-25251326.jpg',
|
||||
large: 'https://nguyencongpc.vn/media/product/250-27720-pc-gaming-25251326.jpg',
|
||||
original: '',
|
||||
},
|
||||
alt: '',
|
||||
},
|
||||
],
|
||||
productUrl: '/pc-gaming-ncpc-15',
|
||||
brand: {
|
||||
id: 124,
|
||||
brand_index: 'ncpc',
|
||||
name: 'NCPC',
|
||||
image: '',
|
||||
url: '/brand/ncpc',
|
||||
},
|
||||
visit: 39556,
|
||||
rating: 5,
|
||||
reviewCount: 1,
|
||||
review: {
|
||||
rate: 5,
|
||||
total: 1,
|
||||
},
|
||||
comment: {
|
||||
rate: 5,
|
||||
total: 3,
|
||||
},
|
||||
quantity: 1,
|
||||
productSKU: '',
|
||||
productModel: '',
|
||||
hasVAT: 0,
|
||||
condition: 'Mới',
|
||||
config_count: 0,
|
||||
configurable: 0,
|
||||
component_count: 0,
|
||||
specialOffer: {
|
||||
other: [
|
||||
{
|
||||
id: 0,
|
||||
title:
|
||||
'<p><span style="font-size: 10pt;"><strong><span style="color: #ff0000;">TẶNG MÀN HÌNH : Màn hình Gaming cong MSI MAG 276CF E20 27\' FHD VA 200Hz 0.5Ms</span></strong></span></p>\r\n<p><span style="font-size: 10pt;"><strong>Giá PC khi không lấy quà tặng : 23.490.000đ</strong></span></p>\r\n<p><a href="https://khuyenmai.nguyencongpc.vn/build-pc"><img src="https://nguyencongpc.vn/media/lib/24-09-2025/z7044410660344_5550774fd1a8b1c78c2735d5f4aab705.jpg" alt="" width="100%" /></a></p>',
|
||||
type: '',
|
||||
thumbnail: '',
|
||||
cash_value: 0,
|
||||
quantity: 1,
|
||||
from_time: '',
|
||||
to_time: '',
|
||||
url: '',
|
||||
description: '',
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
all: [
|
||||
{
|
||||
id: 0,
|
||||
title:
|
||||
'<p><span style="font-size: 10pt;"><strong><span style="color: #ff0000;">TẶNG MÀN HÌNH : Màn hình Gaming cong MSI MAG 276CF E20 27\' FHD VA 200Hz 0.5Ms</span></strong></span></p>\r\n<p><span style="font-size: 10pt;"><strong>Giá PC khi không lấy quà tặng : 23.490.000đ</strong></span></p>\r\n<p><a href="https://khuyenmai.nguyencongpc.vn/build-pc"><img src="https://nguyencongpc.vn/media/lib/24-09-2025/z7044410660344_5550774fd1a8b1c78c2735d5f4aab705.jpg" alt="" width="100%" /></a></p>',
|
||||
type: '',
|
||||
thumbnail: '',
|
||||
cash_value: 0,
|
||||
quantity: 1,
|
||||
from_time: '',
|
||||
to_time: '',
|
||||
url: '',
|
||||
description: '',
|
||||
status: 1,
|
||||
},
|
||||
],
|
||||
},
|
||||
specialOfferGroup: [],
|
||||
productType: {
|
||||
isNew: 0,
|
||||
isHot: 0,
|
||||
isBestSale: 0,
|
||||
isSaleOff: 0,
|
||||
'online-only': 0,
|
||||
},
|
||||
bulk_price: [],
|
||||
thum_poster: '0',
|
||||
thum_poster_type: '',
|
||||
addon: [],
|
||||
variants: [],
|
||||
variant_option: [],
|
||||
extend: {
|
||||
buy_count: '492',
|
||||
pixel_code: '',
|
||||
review_count: '43',
|
||||
review_score: '4.3',
|
||||
},
|
||||
weight: 0,
|
||||
promotion_price: null,
|
||||
deal_list: [
|
||||
{
|
||||
id: '560',
|
||||
pro_id: '27720',
|
||||
title: 'Bộ PC Gaming Intel Core i5-13400F, RAM 16GB, RTX 5060 Ti [TẶNG MÀN HÌNH]',
|
||||
price: '24990000',
|
||||
quantity: '5',
|
||||
min_purchase: '1',
|
||||
max_purchase: '0',
|
||||
is_featured: '0',
|
||||
from_time: '1765155600',
|
||||
to_time: '1766370600',
|
||||
is_started: 1,
|
||||
},
|
||||
],
|
||||
pricing_traces: [
|
||||
{
|
||||
price: '11690000',
|
||||
type: 'deal',
|
||||
type_id: '565',
|
||||
},
|
||||
{
|
||||
price: '7600000',
|
||||
type: 'deal',
|
||||
type_id: '563',
|
||||
},
|
||||
{
|
||||
price: '6990000',
|
||||
type: 'deal',
|
||||
type_id: '562',
|
||||
},
|
||||
{
|
||||
price: '24990000',
|
||||
type: 'deal',
|
||||
type_id: '560',
|
||||
},
|
||||
],
|
||||
categories: [
|
||||
{
|
||||
id: '1829',
|
||||
catPath: ':1829:0',
|
||||
name: 'PC GAMING',
|
||||
url: '/pc-gaming',
|
||||
},
|
||||
{
|
||||
id: '3468',
|
||||
catPath: ':3468:1829:0',
|
||||
name: 'CHỌN THEO NHU CẦU',
|
||||
url: '/chon-theo-nhu-cau-1',
|
||||
},
|
||||
{
|
||||
id: '3432',
|
||||
catPath: ':3432:3468:1829:0',
|
||||
name: 'PC ESPORT',
|
||||
url: '/pc-esport',
|
||||
},
|
||||
{
|
||||
id: '3433',
|
||||
catPath: ':3433:3468:1829:0',
|
||||
name: 'PC GAME AAA',
|
||||
url: '/pc-game-aaa',
|
||||
},
|
||||
{
|
||||
id: '3434',
|
||||
catPath: ':3434:3468:1829:0',
|
||||
name: 'PC STREAM GAME',
|
||||
url: '/pc-stream-game',
|
||||
},
|
||||
{
|
||||
id: '3469',
|
||||
catPath: ':3469:1829:0',
|
||||
name: 'CHỌN THEO KHOẢNG GIÁ',
|
||||
url: '/chon-theo-khoang-gia-1',
|
||||
},
|
||||
{
|
||||
id: '3472',
|
||||
catPath: ':3472:3469:1829:0',
|
||||
name: '20 Triệu - 30 Triệu',
|
||||
url: '/20-trieu-30-trieu-1',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -320,7 +320,7 @@ export const productDetailData = [
|
||||
max_purchase: '0',
|
||||
remain_quantity: '1',
|
||||
from_time: '1766106000',
|
||||
to_time: '1766716200',
|
||||
to_time: '1766975400',
|
||||
type: 'deal',
|
||||
type_id: '565',
|
||||
},
|
||||
@@ -388,6 +388,8 @@ export const productDetailData = [
|
||||
from_time: '1766106000',
|
||||
to_time: '1766716200',
|
||||
is_started: '1',
|
||||
sale_order: 2,
|
||||
sale_quantity: 2,
|
||||
},
|
||||
],
|
||||
pricing_traces: [
|
||||
@@ -1351,9 +1353,9 @@ export const productDetailData = [
|
||||
min_purchase: '1',
|
||||
max_purchase: '1',
|
||||
remain_quantity: '1',
|
||||
from_time: '0',
|
||||
to_time: '0',
|
||||
type: '',
|
||||
from_time: '19-12-2025, 8:00 am',
|
||||
to_time: '29-12-2025, 9:30 am',
|
||||
type: 'deal',
|
||||
},
|
||||
categoryInfo: [
|
||||
{
|
||||
|
||||
13214
src/data/product/search/index.ts
Normal file
13214
src/data/product/search/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
29269
src/data/producthot/index.ts
Normal file
29269
src/data/producthot/index.ts
Normal file
File diff suppressed because it is too large
Load Diff
27
src/features/Article/ArticleTopLeft/index.tsx
Normal file
27
src/features/Article/ArticleTopLeft/index.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
'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[0] && <ItemArticle item={articles[0]} key={articles[0].id} />}
|
||||
</div>
|
||||
<div className="box-right flex flex-1 flex-col gap-3">
|
||||
{articles.slice(1, 5).map((item) => (
|
||||
<ItemArticle item={item} key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
42
src/features/Article/ArticleTopRight/index.tsx
Normal file
42
src/features/Article/ArticleTopRight/index.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { getArticles } from '@/lib/api/article';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
import type { ListArticle } from '@/types/article/TypeListArticle';
|
||||
|
||||
export const ArticleTopRight = () => {
|
||||
const { data: articles } = useApiData(
|
||||
() => getArticles(),
|
||||
[],
|
||||
{ initialData: [] as ListArticle },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="col-right-article box-view-article flex-1">
|
||||
<form
|
||||
method="get"
|
||||
action="/tim-bai"
|
||||
name="search"
|
||||
className="boder-radius-10 border-box-article article-search-container"
|
||||
>
|
||||
<input type="text" name="q" placeholder="Tìm kiếm bài viết" defaultValue="" />
|
||||
<button type="submit" className="fas fa-search"></button>
|
||||
</form>
|
||||
|
||||
<div className="boder-radius-10 border-box-article">
|
||||
<div className="title-box-article font-bold">Xem nhiều</div>
|
||||
<ul className="list-most-view-article flex flex-col gap-4">
|
||||
{articles.slice(0, 6).map((item, index) => (
|
||||
<li className="item-most-view-article flex items-center gap-2" key={item.id}>
|
||||
<span className="number flex items-center justify-center font-[600]">{index + 1}</span>
|
||||
<Link href={item.url} className="line-clamp-2 flex-1">
|
||||
{item.title}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
109
src/features/Article/CategoryPage/index.tsx
Normal file
109
src/features/Article/CategoryPage/index.tsx
Normal file
@@ -0,0 +1,109 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import type { TypeArticleCatePage } from '@/types/article/TypeArticleCatePage';
|
||||
|
||||
import { Breadcrumb } from '@/components/Common/Breadcrumb';
|
||||
import { ErrorLink } from '@/components/Common/Error';
|
||||
import { ArticleTopLeft } from '../ArticleTopLeft';
|
||||
import { ArticleTopRight } from '../ArticleTopRight';
|
||||
import ItemArticle from '@/components/Common/ItemArticle';
|
||||
import PreLoader from '@/components/Common/PreLoader';
|
||||
import { getArticleCategories, getArticleCategoryDetail, getArticles } from '@/lib/api/article';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
import type { TypeArticleCategory } from '@/types/article/ListCategoryArticle';
|
||||
import type { ListArticle } from '@/types/article/TypeListArticle';
|
||||
|
||||
interface CategoryPageProps {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
const ArticleCategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
|
||||
const { data: currentCategory, isLoading } = useApiData(
|
||||
() => getArticleCategoryDetail(slug),
|
||||
[slug],
|
||||
{ initialData: null as TypeArticleCatePage | null },
|
||||
);
|
||||
const { data: categories } = useApiData(
|
||||
() => getArticleCategories(),
|
||||
[],
|
||||
{ initialData: [] as TypeArticleCategory[] },
|
||||
);
|
||||
const { data: articles } = useApiData(
|
||||
() => getArticles(),
|
||||
[],
|
||||
{ initialData: [] as ListArticle },
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <PreLoader />;
|
||||
}
|
||||
|
||||
if (!currentCategory) {
|
||||
return <ErrorLink />;
|
||||
}
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ name: 'Tin tức', url: '/tin-tuc' },
|
||||
{ name: currentCategory.category_info.name, url: currentCategory.category_info.request_path },
|
||||
];
|
||||
|
||||
const articleList = Object.values(currentCategory.article_list);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="container">
|
||||
<Breadcrumb items={breadcrumbItems} />
|
||||
</div>
|
||||
<section className="page-article page-article-category container">
|
||||
<div className="tabs-category-article flex items-center">
|
||||
{categories.map((item, index) => (
|
||||
<Link
|
||||
href={item.url}
|
||||
key={`${item.id}-${index}`}
|
||||
className={`item-tab-article ${currentCategory.title === item.title ? 'active' : ''}`}
|
||||
>
|
||||
<h2 className="title-cate-article font-[400]">{item.title}</h2>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="box-article-home-top grid grid-cols-3 gap-3">
|
||||
<div className="col-left-article border-box-article box-new-article boder-radius-10 col-span-2">
|
||||
<ArticleTopLeft />
|
||||
</div>
|
||||
<ArticleTopRight />
|
||||
</div>
|
||||
|
||||
<div className="box-article-home-middle mt-5 grid grid-cols-3 gap-3">
|
||||
<div className="box-article-tech col-left-article boder-radius-10 border-box-article col-span-2">
|
||||
<p className="title-box-article font-[600]">{currentCategory.title}</p>
|
||||
<div className="list-article-tech">
|
||||
{articleList.slice(0, 9).map((item) => (
|
||||
<ItemArticle item={item} key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
href={currentCategory.category_info.request_path}
|
||||
className="btn-article-col flex items-center justify-center gap-2 font-[500]"
|
||||
>
|
||||
Xem tất cả
|
||||
</Link>
|
||||
</div>
|
||||
<div className="col-right-article page-hompage flex-1">
|
||||
<div className="box-article-global border-box-article boder-radius-10">
|
||||
<p className="title-box-article font-bold">Tin nổi bật</p>
|
||||
<div className="list-article-global flex flex-col gap-2">
|
||||
{articles.slice(0, 5).map((item) => (
|
||||
<ItemArticle item={item} key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArticleCategoryPage;
|
||||
113
src/features/Article/DetailPage/TocBox/index.tsx
Normal file
113
src/features/Article/DetailPage/TocBox/index.tsx
Normal file
@@ -0,0 +1,113 @@
|
||||
'use client';
|
||||
import { useMemo } from 'react';
|
||||
|
||||
type HeadingItem = {
|
||||
id: string;
|
||||
text: string;
|
||||
level: number;
|
||||
children?: HeadingItem[];
|
||||
};
|
||||
|
||||
function convertToSlug(text: string) {
|
||||
return text
|
||||
.toLowerCase()
|
||||
.replace(/đ/g, 'd') // phải xử lý trước NFD vì NFD sẽ phân rã đ thành ký tự khác
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^\w ]+/g, '')
|
||||
.trim()
|
||||
.replace(/\s+/g, '-');
|
||||
}
|
||||
|
||||
// Hàm xây dựng cây TOC từ danh sách heading
|
||||
function buildTree(headings: HeadingItem[]): HeadingItem[] {
|
||||
const root: HeadingItem[] = [];
|
||||
const stack: HeadingItem[] = [];
|
||||
|
||||
headings.forEach((h) => {
|
||||
const node = { ...h, children: [] };
|
||||
|
||||
while (stack.length && stack[stack.length - 1].level >= node.level) {
|
||||
stack.pop();
|
||||
}
|
||||
|
||||
if (stack.length === 0) {
|
||||
root.push(node);
|
||||
} else {
|
||||
stack[stack.length - 1].children!.push(node);
|
||||
}
|
||||
|
||||
stack.push(node);
|
||||
});
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
function renderTree(nodes: HeadingItem[]) {
|
||||
return (
|
||||
<ol>
|
||||
{nodes.map((n) => (
|
||||
<li key={n.id}>
|
||||
<a
|
||||
href={`#${n.id}`}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
const el = document.getElementById(n.id);
|
||||
if (el) {
|
||||
const y = el.getBoundingClientRect().top + window.scrollY - 120;
|
||||
window.scrollTo({ top: y, behavior: 'smooth' });
|
||||
}
|
||||
}}
|
||||
className="text-blue-600 hover:underline"
|
||||
>
|
||||
{n.text}
|
||||
</a>
|
||||
{n.children && n.children.length > 0 && renderTree(n.children)}
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TocBox({ htmlContent }: { htmlContent: string }) {
|
||||
const { headingsTree, contentWithIds } = useMemo(() => {
|
||||
const parser = new DOMParser();
|
||||
const doc = parser.parseFromString(htmlContent, 'text/html');
|
||||
const nodes = doc.querySelectorAll('h1,h2,h3,h4,h5,h6');
|
||||
|
||||
const flat: HeadingItem[] = Array.from(nodes).map((node) => {
|
||||
const text = node.textContent || '';
|
||||
const id = convertToSlug(text);
|
||||
node.setAttribute('id', id);
|
||||
return {
|
||||
id,
|
||||
text,
|
||||
level: parseInt(node.tagName.substring(1)),
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
headingsTree: buildTree(flat),
|
||||
contentWithIds: doc.body.innerHTML,
|
||||
};
|
||||
}, [htmlContent]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{headingsTree.length > 0 && (
|
||||
<div className="archor-text-group">
|
||||
<div className="toc_title flex items-center justify-between gap-2">
|
||||
<b className="text-fint-toc flex items-center text-base font-bold">
|
||||
<span>Nội dung chính</span>
|
||||
</b>
|
||||
</div>
|
||||
<div id="js-outp">{renderTree(headingsTree)}</div>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="box-article-detail-ct nd js_find"
|
||||
dangerouslySetInnerHTML={{ __html: contentWithIds }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
117
src/features/Article/DetailPage/index.tsx
Normal file
117
src/features/Article/DetailPage/index.tsx
Normal file
@@ -0,0 +1,117 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import Image from 'next/image';
|
||||
|
||||
import type { TypeArticleDetailPage } from '@/types/article/TypeArticleDetailPage';
|
||||
import { ErrorLink } from '@/components/Common/Error';
|
||||
|
||||
import { Breadcrumb } from '@/components/Common/Breadcrumb';
|
||||
import TocBox from './TocBox';
|
||||
import PreLoader from '@/components/Common/PreLoader';
|
||||
import { getArticleCategories, getArticleDetail } from '@/lib/api/article';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
import type { TypeArticleCategory } from '@/types/article/ListCategoryArticle';
|
||||
|
||||
interface DetailPageProps {
|
||||
slug: string;
|
||||
}
|
||||
|
||||
const ArticleDetailPage: React.FC<DetailPageProps> = ({ slug }) => {
|
||||
const { data: page, isLoading } = useApiData(
|
||||
() => getArticleDetail(slug),
|
||||
[slug],
|
||||
{ initialData: null as TypeArticleDetailPage | null },
|
||||
);
|
||||
const { data: categories } = useApiData(
|
||||
() => getArticleCategories(),
|
||||
[],
|
||||
{ initialData: [] as TypeArticleCategory[] },
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return <PreLoader />;
|
||||
}
|
||||
|
||||
if (!page) {
|
||||
return <ErrorLink />;
|
||||
}
|
||||
|
||||
const breadcrumbItems = [
|
||||
{ name: 'Tin tức', url: '/tin-tuc' },
|
||||
{ name: page.article_detail.title, url: page.article_detail.url },
|
||||
];
|
||||
|
||||
const listRelayNew = Object.values(page.article_other_same_category.new);
|
||||
const listRelayOld = Object.values(page.article_other_same_category.old);
|
||||
const combinedList = [...listRelayNew.slice(0, 6), ...listRelayOld.slice(0, 6)];
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="container">
|
||||
<Breadcrumb items={breadcrumbItems} />
|
||||
</div>
|
||||
<section className="page-article box-article-detail container">
|
||||
<div className="tabs-category-article flex items-center">
|
||||
{categories.map((item, index) => (
|
||||
<Link
|
||||
href={item.url}
|
||||
key={`${item.id}-${index}`}
|
||||
className={`item-tab-article ${page.article_detail.categoryInfo[0]?.id === item.id ? 'active' : ''}`}
|
||||
>
|
||||
<h2 className="title-cate-article font-[400]">{item.title}</h2>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="row article-detail-page mt-5">
|
||||
<div className="col-md-8">
|
||||
<div className="box-article-detail-title">
|
||||
<h1 className="font-weight-700">{page.article_detail.title}</h1>
|
||||
<div className="post__user border-bottom my-5 flex items-center gap-2">
|
||||
<span className="author-name">{page.article_detail.author}</span>
|
||||
<span className="post-time">{page.article_detail.createDate}</span>
|
||||
</div>
|
||||
<TocBox htmlContent={page.article_detail.content} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{combinedList.length > 0 && (
|
||||
<div className="col-md-4">
|
||||
<div className="box-article-relay">
|
||||
<p className="title-ar">
|
||||
Bài viết <span>liên quan</span>
|
||||
</p>
|
||||
<div className="article-list list-article-relative flex flex-wrap gap-3">
|
||||
{combinedList.map((item) => (
|
||||
<div className="item-article d-flex flex-column gap-12" key={item.id}>
|
||||
<Link href={item.url} className="img-article boder-radius-10">
|
||||
<Image
|
||||
className="boder-radius-10"
|
||||
src={item.image.original}
|
||||
fill
|
||||
alt={item.title}
|
||||
/>
|
||||
</Link>
|
||||
<div className="content-article flex-1">
|
||||
<Link href={item.url} className="title-article">
|
||||
<h3 className="font-weight-400 line-clamp-2">{item.title}</h3>
|
||||
</Link>
|
||||
<p className="time-article d-flex align-items-center gap-4">
|
||||
<i className="sprite sprite-clock-item-article"></i>
|
||||
<span>{item.createDate}</span>
|
||||
</p>
|
||||
<p className="descreption-article line-clamp-2">{item.summary}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArticleDetailPage;
|
||||
44
src/features/Article/HomeArticlePage/BoxArticleMid/index.tsx
Normal file
44
src/features/Article/HomeArticlePage/BoxArticleMid/index.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
'use client';
|
||||
|
||||
import ItemArticle from '@/components/Common/ItemArticle';
|
||||
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 BoxArticleMid = () => {
|
||||
const { data: articles } = useApiData(
|
||||
() => getArticles(),
|
||||
[],
|
||||
{ initialData: [] as ListArticle },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="box-article-home-middle grid grid-cols-3 gap-2">
|
||||
<div className="box-article-tech col-left-article boder-radius-10 border-box-article col-span-2">
|
||||
<p className="title-box-article font-[600]">Tin công nghệ</p>
|
||||
<div className="list-article-tech">
|
||||
{articles.slice(0, 9).map((item) => (
|
||||
<ItemArticle item={item} key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
<Link
|
||||
href="/tin-cong-nghe"
|
||||
className="btn-article-col flex items-center justify-center gap-2 font-[500]"
|
||||
>
|
||||
Xem tất cả
|
||||
</Link>
|
||||
</div>
|
||||
<div className="col-right-article flex-1">
|
||||
<div className="box-article-hot border-box-article boder-radius-10">
|
||||
<p className="title-box-article font-bold">Tin nổi bật</p>
|
||||
<div className="list-article-hot">
|
||||
{articles.slice(0, 5).map((item) => (
|
||||
<ItemArticle item={item} key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,55 @@
|
||||
'use client';
|
||||
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay, Navigation, Pagination, Thumbs } from 'swiper/modules';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { getArticles } from '@/lib/api/article';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
import type { ListArticle } from '@/types/article/TypeListArticle';
|
||||
|
||||
export const BoxArticleReview = () => {
|
||||
const { data: articles } = useApiData(
|
||||
() => getArticles(),
|
||||
[],
|
||||
{ initialData: [] as ListArticle },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="box-article-category page-hompage">
|
||||
<div className="box-article-global box-artice-review">
|
||||
<div className="title-box-product-home mb-5 flex flex-col items-center">
|
||||
<p className="title font-[500]">Review sản phẩm</p>
|
||||
<p className="border-title"></p>
|
||||
</div>
|
||||
<div className="list-article-global">
|
||||
<Swiper
|
||||
modules={[Autoplay, Navigation, Pagination, Thumbs]}
|
||||
spaceBetween={15}
|
||||
slidesPerView={3}
|
||||
loop={true}
|
||||
>
|
||||
{articles.slice(0, 9).map((item) => (
|
||||
<SwiperSlide key={item.id}>
|
||||
<div className="item-article">
|
||||
<Link href={item.url} className="img-article">
|
||||
<Image src={item.image.original} fill alt={item.title} sizes="(max-width: 768px) 100vw, 33vw" />
|
||||
</Link>
|
||||
<div className="content-article-item">
|
||||
<Link href={item.url} className="title font-weight-500 line-clamp-2">
|
||||
{item.title}
|
||||
</Link>
|
||||
<div className="time-aricle-item flex items-center">
|
||||
<i className="sprite sprite-clock-item-article"></i>
|
||||
<span>{item.createDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
130
src/features/Article/HomeArticlePage/BoxVideoArticle/index.tsx
Normal file
130
src/features/Article/HomeArticlePage/BoxVideoArticle/index.tsx
Normal file
@@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { FaYoutube } from 'react-icons/fa6';
|
||||
import Image from 'next/image';
|
||||
import useFancybox from '@/hooks/useFancybox';
|
||||
import { getArticleVideos } from '@/lib/api/article';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
import type { ListArticle } from '@/types/article/TypeListArticle';
|
||||
|
||||
export const BoxVideoArticle = () => {
|
||||
const { data: videos } = useApiData(
|
||||
() => getArticleVideos(),
|
||||
[],
|
||||
{ initialData: [] as ListArticle },
|
||||
);
|
||||
|
||||
const getYoutubeEmbedUrl = (url: string): string => {
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
if (urlObj.hostname.includes('youtube.com')) {
|
||||
const videoId = urlObj.searchParams.get('v');
|
||||
if (videoId) {
|
||||
return `https://www.youtube.com/embed/${videoId}?autoplay=1`;
|
||||
}
|
||||
}
|
||||
if (urlObj.hostname.includes('youtu.be')) {
|
||||
const videoId = urlObj.pathname.replace('/', '');
|
||||
if (videoId) {
|
||||
return `https://www.youtube.com/embed/${videoId}?autoplay=1`;
|
||||
}
|
||||
}
|
||||
return url;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
};
|
||||
|
||||
const [fancyboxRef] = useFancybox({
|
||||
closeButton: 'auto',
|
||||
dragToClose: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="box-video-article boder-radius-10">
|
||||
<div className="title-video-article flex justify-between">
|
||||
<p className="title font-bold">Youtube channel</p>
|
||||
<Link
|
||||
href="https://www.youtube.com/c/NGUYENCONGPC"
|
||||
className="follow-youtube flex items-center gap-2"
|
||||
>
|
||||
<FaYoutube />
|
||||
<span className="font-bold">Theo dõi trên YouTube</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="list-video-article flex justify-between gap-2">
|
||||
<div className="box-left" ref={fancyboxRef}>
|
||||
{videos.slice(0, 1).map((item) => (
|
||||
<div className="item-article-video d-flex w-50 gap-10" key={item.id}>
|
||||
<Link
|
||||
href={getYoutubeEmbedUrl(item.external_url)}
|
||||
className="img-article img-article-video boder-radius-10 relative"
|
||||
data-fancybox
|
||||
>
|
||||
<Image
|
||||
src={item.image.original}
|
||||
width={430}
|
||||
height={310}
|
||||
className="boder-radius-10"
|
||||
alt={item.title}
|
||||
/>
|
||||
|
||||
<i className="sprite sprite-big-play-video-article icon-play"></i>
|
||||
<Image
|
||||
className="icon-play-small"
|
||||
src="https://nguyencongpc.vn/static/assets/nguyencong_2023/images/small-play-youtube.png"
|
||||
alt="play"
|
||||
width={58}
|
||||
height={41}
|
||||
/>
|
||||
</Link>
|
||||
<Link
|
||||
href={getYoutubeEmbedUrl(item.external_url)}
|
||||
className="title-article-video flex-1"
|
||||
data-fancybox
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="box-right grid grid-cols-2 gap-2">
|
||||
{videos.slice(1, 7).map((item) => (
|
||||
<div className="item-article-video flex w-50 gap-2" key={item.id}>
|
||||
<Link
|
||||
href={getYoutubeEmbedUrl(item.external_url)}
|
||||
className="img-article img-article-video boder-radius-10 relative"
|
||||
data-fancybox
|
||||
>
|
||||
<Image
|
||||
src={item.image.original}
|
||||
width={430}
|
||||
height={310}
|
||||
className="boder-radius-10"
|
||||
alt={item.title}
|
||||
/>
|
||||
|
||||
<i className="sprite sprite-big-play-video-article icon-play"></i>
|
||||
<Image
|
||||
className="icon-play-small"
|
||||
src="https://nguyencongpc.vn/static/assets/nguyencong_2023/images/small-play-youtube.png"
|
||||
alt="play"
|
||||
width={58}
|
||||
height={41}
|
||||
/>
|
||||
</Link>
|
||||
<Link
|
||||
href={getYoutubeEmbedUrl(item.external_url)}
|
||||
className="title-article-video flex-1"
|
||||
data-fancybox
|
||||
>
|
||||
{item.title}
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
84
src/features/Article/HomeArticlePage/index.tsx
Normal file
84
src/features/Article/HomeArticlePage/index.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Breadcrumb } from '@/components/Common/Breadcrumb';
|
||||
import Skeleton from '@/components/Common/Skeleton';
|
||||
|
||||
import { ArticleTopLeft } from '../ArticleTopLeft';
|
||||
import { ArticleTopRight } from '../ArticleTopRight';
|
||||
import { BoxVideoArticle } from './BoxVideoArticle';
|
||||
import { BoxArticleMid } from './BoxArticleMid';
|
||||
import { BoxArticleReview } from './BoxArticleReview';
|
||||
import { getArticleCategories } from '@/lib/api/article';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
import type { TypeArticleCategory } from '@/types/article/ListCategoryArticle';
|
||||
|
||||
const ArticleHomeSkeleton = () => (
|
||||
<section className="page-article pb-10">
|
||||
<div className="container">
|
||||
<Skeleton className="mb-4 h-5 w-48" />
|
||||
<div className="tabs-category-article flex items-center gap-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-24" />
|
||||
))}
|
||||
</div>
|
||||
<div className="box-article-home-top mt-4 grid grid-cols-3 gap-3">
|
||||
<div className="col-span-2 flex gap-3">
|
||||
<Skeleton className="aspect-video flex-1" />
|
||||
<div className="flex flex-1 flex-col gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-20 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3">
|
||||
<Skeleton className="h-10 w-full" />
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
const ArticleHome = () => {
|
||||
const breadcrumbItems = [{ name: 'Tin tức', url: '/tin-tuc' }];
|
||||
const { data: categories, isLoading } = useApiData(
|
||||
() => getArticleCategories(),
|
||||
[],
|
||||
{ initialData: [] as TypeArticleCategory[] },
|
||||
);
|
||||
|
||||
if (isLoading) return <ArticleHomeSkeleton />;
|
||||
|
||||
return (
|
||||
<section className="page-article pb-10">
|
||||
<div className="container">
|
||||
<Breadcrumb items={breadcrumbItems} />
|
||||
|
||||
<div className="tabs-category-article flex items-center">
|
||||
{categories.map((item, index) => (
|
||||
<Link href={item.url} key={`${item.id}-${index}`} className="item-tab-article">
|
||||
<h2 className="title-cate-article font-[400]">{item.title}</h2>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="box-article-home-top grid grid-cols-3 gap-3">
|
||||
<div className="col-left-article border-box-article box-new-article boder-radius-10 col-span-2">
|
||||
<ArticleTopLeft />
|
||||
</div>
|
||||
<ArticleTopRight />
|
||||
</div>
|
||||
|
||||
<BoxVideoArticle />
|
||||
<BoxArticleMid />
|
||||
<BoxArticleReview />
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default ArticleHome;
|
||||
@@ -1,8 +1,20 @@
|
||||
'use client';
|
||||
|
||||
import { FaCaretRight } from 'react-icons/fa';
|
||||
import Link from 'next/link';
|
||||
import { dataArticle } from './dataArticle';
|
||||
import ItemArticleVideo from './ItemArticleVideo';
|
||||
import { getArticleVideos } from '@/lib/api/article';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
import type { Article } from '@/types';
|
||||
import Skeleton from '@/components/Common/Skeleton';
|
||||
|
||||
const BoxArticleVideo: React.FC = () => {
|
||||
const { data: videos, isLoading } = useApiData(
|
||||
() => getArticleVideos(),
|
||||
[],
|
||||
{ initialData: [] as Article[] },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="box-videos-group box-article-group boder-radius-10 relative">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -15,14 +27,22 @@ const BoxArticleVideo: React.FC = () => {
|
||||
rel="nofollow"
|
||||
className="btn-article-group flex items-center gap-2"
|
||||
>
|
||||
<span>Xem tất cả </span>
|
||||
<span>Xem tất cả</span>
|
||||
<FaCaretRight size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="list-videos-group list-article-group flex items-center gap-10">
|
||||
{dataArticle.slice(0, 4).map((item, index) => (
|
||||
<ItemArticleVideo item={item} key={index} />
|
||||
))}
|
||||
{isLoading
|
||||
? Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex flex-1 flex-col gap-2">
|
||||
<Skeleton className="aspect-video w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
</div>
|
||||
))
|
||||
: videos.slice(0, 4).map((item) => (
|
||||
<ItemArticleVideo item={item} key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
47
src/features/Home/BoxArticle/index.tsx
Normal file
47
src/features/Home/BoxArticle/index.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client';
|
||||
|
||||
import { FaCaretRight } from 'react-icons/fa';
|
||||
import Link from 'next/link';
|
||||
import ItemArticle from './ItemArticle';
|
||||
import { getArticles } from '@/lib/api/article';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
import type { Article } from '@/types';
|
||||
import Skeleton from '@/components/Common/Skeleton';
|
||||
|
||||
const BoxArticle: React.FC = () => {
|
||||
const { data: articles, isLoading } = useApiData(
|
||||
() => getArticles(),
|
||||
[],
|
||||
{ initialData: [] as Article[] },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="box-article-group boder-radius-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="title-box">
|
||||
<h2 className="title-box font-[600]">Tin tức công nghệ</h2>
|
||||
</div>
|
||||
<Link href="/tin-cong-nghe" className="btn-article-group flex items-center gap-1">
|
||||
<span>Xem tất cả</span>
|
||||
<FaCaretRight size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="list-article-group flex items-center gap-10">
|
||||
{isLoading
|
||||
? Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex flex-1 flex-col gap-2">
|
||||
<Skeleton className="aspect-video w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
</div>
|
||||
))
|
||||
: articles.slice(0, 4).map((item) => (
|
||||
<ItemArticle item={item} key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoxArticle;
|
||||
@@ -1,17 +1,56 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { FaCaretDown } from 'react-icons/fa';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
||||
import ItemProduct from '@/components/common/ItemProduct';
|
||||
import ItemProduct from '@/components/Common/ItemProduct';
|
||||
import Skeleton from '@/components/Common/Skeleton';
|
||||
|
||||
import { InfoCategory } from '@/types';
|
||||
import { menuData } from '@/components/Other/Header/menuData';
|
||||
import { getProductHot } from '@/lib/api/product';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
import type { TypeListProduct } from '@/types/global/TypeListProduct';
|
||||
|
||||
import { menuData } from '@/components/other/Header/menuData';
|
||||
import { productData } from './productData';
|
||||
const CategorySkeleton = () => (
|
||||
<div className="box-product-category boder-radius-10">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<div className="flex gap-3">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-5 w-20" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-5 gap-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
<Skeleton className="aspect-square w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-5 w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const BoxListCategory: React.FC = () => {
|
||||
const { data: products, isLoading } = useApiData(() => getProductHot(), [], {
|
||||
initialData: [] as TypeListProduct,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
{menuData[0].product.all_category.map((_, index) => (
|
||||
<CategorySkeleton key={index} />
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{menuData[0].product.all_category.map((item, index) => (
|
||||
@@ -41,9 +80,9 @@ const BoxListCategory: React.FC = () => {
|
||||
loop={true}
|
||||
navigation={true}
|
||||
>
|
||||
{productData.map((item, index) => (
|
||||
<SwiperSlide key={index}>
|
||||
<ItemProduct item={item} />
|
||||
{products.map((product) => (
|
||||
<SwiperSlide key={product.id}>
|
||||
<ItemProduct item={product} />
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
@@ -2,9 +2,9 @@
|
||||
import React from 'react';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { Category } from '@/types/global/Menu';
|
||||
import type { InfoCategory } from '@/types/global/Menu';
|
||||
|
||||
const ItemCategory: React.FC<{ item: Category }> = ({ item }) => {
|
||||
const ItemCategory: React.FC<{ item: InfoCategory }> = ({ item }) => {
|
||||
return (
|
||||
<Link href={item.url} className="item-category flex flex-col items-center">
|
||||
<p className="item-category-img">
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { menuData } from '../../other/Header/menuData';
|
||||
import { menuData } from '@/components/Other/Header/menuData';
|
||||
import ItemCategory from './ItemCategory';
|
||||
import { InfoCategory } from '@/types';
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import React from 'react';
|
||||
import Tippy from '@tippyjs/react';
|
||||
import 'tippy.js/dist/tippy.css';
|
||||
import { DealType } from '@/types';
|
||||
import { formatCurrency } from '@/lib/formatPrice';
|
||||
import Image from 'next/image';
|
||||
|
||||
type ProductItemProps = {
|
||||
item: DealType;
|
||||
};
|
||||
|
||||
const formatCurrency = (value: number | string) => {
|
||||
const num = typeof value === 'string' ? parseInt(value) : value;
|
||||
return num.toLocaleString('vi-VN');
|
||||
};
|
||||
|
||||
const ProductItem: React.FC<ProductItemProps> = ({ item }) => {
|
||||
const { product_info } = item;
|
||||
const offers = product_info.specialOffer?.all ?? [];
|
||||
@@ -20,7 +15,7 @@ const ProductItem: React.FC<ProductItemProps> = ({ item }) => {
|
||||
<div className="product-item">
|
||||
<a href={product_info.productUrl} className="product-image relative">
|
||||
{product_info.productImage.large ? (
|
||||
<img
|
||||
<Image
|
||||
src={product_info.productImage.large}
|
||||
width="164"
|
||||
height="164"
|
||||
@@ -28,7 +23,7 @@ const ProductItem: React.FC<ProductItemProps> = ({ item }) => {
|
||||
className="lazy"
|
||||
/>
|
||||
) : (
|
||||
<img
|
||||
<Image
|
||||
src="/static/assets/nguyencong_2023/images/not-image.png"
|
||||
width="164"
|
||||
height="164"
|
||||
85
src/features/Home/Deal/index.tsx
Normal file
85
src/features/Home/Deal/index.tsx
Normal file
@@ -0,0 +1,85 @@
|
||||
'use client';
|
||||
import React, { useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
||||
import { FaCaretRight } from 'react-icons/fa';
|
||||
import { TypeListProductDeal } from '@/types';
|
||||
import { getDeals } from '@/lib/api/deal';
|
||||
import CountDown from '@/components/Common/CountDown';
|
||||
import ProductItem from './ProductItem';
|
||||
import Skeleton from '@/components/Common/Skeleton';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
|
||||
const BoxProductDeal: React.FC = () => {
|
||||
const [expired, setExpired] = useState(false);
|
||||
const { data: deals, isLoading } = useApiData(() => getDeals(), [], {
|
||||
initialData: [] as TypeListProductDeal,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="box-product-deal boder-radius-10">
|
||||
<div className="box-title-deal flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-8 w-32" />
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-6 gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
<Skeleton className="aspect-square w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-5 w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (expired) return null;
|
||||
|
||||
const deadline = deals[0]?.to_time ?? '31-01-2026, 9:30 am';
|
||||
|
||||
return (
|
||||
<div className="box-product-deal boder-radius-10">
|
||||
<div className="box-title-deal flex items-center justify-between">
|
||||
<div className="title-deal flex items-center justify-center gap-10">
|
||||
<i className="sprite sprite-icon-deal-home"></i>
|
||||
<h2 className="title font-bold">Giá tốt mỗi ngày</h2>
|
||||
<span className="text-time-deal-home color-white fz-16 font-bold">Kết thúc sau</span>
|
||||
<div className="global-time-deal flex items-center gap-2">
|
||||
<CountDown deadline={deadline} onExpire={() => setExpired(true)} />
|
||||
</div>
|
||||
</div>
|
||||
<Link href="/deal" className="button-deal color-white mb-10 flex items-center">
|
||||
Xem thêm khuyến mãi <FaCaretRight size={16} />
|
||||
</Link>
|
||||
</div>
|
||||
<div className="box-list-item-deal swiper-box-deal">
|
||||
<Swiper
|
||||
modules={[Autoplay, Navigation, Pagination]}
|
||||
spaceBetween={12}
|
||||
loop={true}
|
||||
navigation={true}
|
||||
breakpoints={{
|
||||
320: { slidesPerView: 2 },
|
||||
640: { slidesPerView: 3 },
|
||||
768: { slidesPerView: 4 },
|
||||
1024: { slidesPerView: 5 },
|
||||
1280: { slidesPerView: 6 },
|
||||
}}
|
||||
>
|
||||
{deals.map((item) => (
|
||||
<SwiperSlide key={item.id}>
|
||||
<ProductItem item={item} />
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoxProductDeal;
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
|
||||
interface TypeReview {
|
||||
avatar: string;
|
||||
@@ -32,4 +31,5 @@ const ItemReview: React.FC<ItemReviewProps> = ({ item }) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ItemReview;
|
||||
61
src/features/Home/ReviewCustomer/index.tsx
Normal file
61
src/features/Home/ReviewCustomer/index.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client';
|
||||
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
||||
import ItemReview from './ItemReview';
|
||||
import { getHomeReviews } from '@/lib/api/home';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
import type { HomeReview } from '@/types';
|
||||
import Skeleton from '@/components/Common/Skeleton';
|
||||
|
||||
const BoxReviewCustomer: React.FC = () => {
|
||||
const { data: reviews, isLoading } = useApiData(
|
||||
() => getHomeReviews(),
|
||||
[],
|
||||
{ initialData: [] as HomeReview[] },
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="box-review-from-customer boder-radius-10">
|
||||
<div className="title-box">
|
||||
<h2 className="title-box font-[600]">Đánh giá từ khách hàng về Nguyễn Công PC</h2>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="mt-4 grid grid-cols-3 gap-4">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="flex flex-col gap-3 rounded-lg p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-10 w-10 rounded-full" />
|
||||
<div className="flex flex-col gap-1">
|
||||
<Skeleton className="h-4 w-24" />
|
||||
<Skeleton className="h-3 w-16" />
|
||||
</div>
|
||||
</div>
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="list-review-customer-homepage">
|
||||
<Swiper
|
||||
modules={[Autoplay, Navigation, Pagination]}
|
||||
spaceBetween={15}
|
||||
slidesPerView={3}
|
||||
loop={true}
|
||||
pagination={{ clickable: true }}
|
||||
>
|
||||
{reviews.map((item, index) => (
|
||||
<SwiperSlide key={`${item.author}-${index}`} className="item">
|
||||
<ItemReview item={item} />
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BoxReviewCustomer;
|
||||
@@ -4,11 +4,29 @@ import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { bannerData } from '@/data/banner';
|
||||
import { getBanners } from '@/lib/api/banner';
|
||||
import { TemplateBanner } from '@/types';
|
||||
import Skeleton from '@/components/Common/Skeleton';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
|
||||
const SliderHome: React.FC = () => {
|
||||
// data banner slider
|
||||
const dataSlider = bannerData[0].homepage;
|
||||
const { data: banners, isLoading } = useApiData(() => getBanners(), [], {
|
||||
initialData: null as TemplateBanner | null,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<>
|
||||
<Skeleton className="h-100 w-full" />
|
||||
<div className="mt-3 flex gap-3">
|
||||
<Skeleton className="h-40 flex-1" />
|
||||
<Skeleton className="h-40 flex-1" />
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const dataSlider = banners?.homepage;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -3,7 +3,7 @@ import SliderHome from './SliderHome';
|
||||
import BoxProductDeal from './Deal';
|
||||
import CategoryFeature from './CategoryFeature';
|
||||
import BoxListCategory from './Category';
|
||||
import BoxArticle from './Article';
|
||||
import BoxArticle from './BoxArticle';
|
||||
import BoxArticleVideo from './ArticleVideo';
|
||||
import BoxReviewCustomer from './ReviewCustomer';
|
||||
|
||||
54
src/features/NotFoundPage/index.tsx
Normal file
54
src/features/NotFoundPage/index.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { motion } from 'framer-motion';
|
||||
|
||||
const NotFound = () => {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-50 via-white to-blue-100 px-4">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
className="w-full max-w-md rounded-3xl bg-white p-8 text-center shadow-xl"
|
||||
>
|
||||
<motion.div
|
||||
animate={{ y: [0, -4, 0] }}
|
||||
transition={{ repeat: Infinity, duration: 1.8 }}
|
||||
className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-full bg-orange-100"
|
||||
>
|
||||
<svg
|
||||
className="h-10 w-10 text-orange-500"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
d="M13.828 10.172a4 4 0 00-5.656 5.656M7 7a7 7 0 019.9 9.9M12 12l.01.01"
|
||||
/>
|
||||
</svg>
|
||||
</motion.div>
|
||||
|
||||
<h1 className="text-2xl font-bold text-gray-800">Đường dẫn không hợp lệ</h1>
|
||||
|
||||
<p className="mt-3 text-sm text-gray-600">
|
||||
Bạn truy cập không tồn tại hoặc đường dẫn đã bị thay đổi.
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-col gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-xl bg-blue-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-blue-700"
|
||||
>
|
||||
Về trang chủ
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
@@ -1,11 +1,21 @@
|
||||
'use client';
|
||||
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { bannerData } from '@/data/banner';
|
||||
import { getBanners } from '@/lib/api/banner';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
import type { TemplateBanner } from '@/types';
|
||||
|
||||
const BannerCategory = () => {
|
||||
const dataSlider = bannerData[0].product_list;
|
||||
const { data: banners } = useApiData(
|
||||
() => getBanners(),
|
||||
[],
|
||||
{ initialData: null as TemplateBanner | null },
|
||||
);
|
||||
|
||||
const dataSlider = banners?.product_list;
|
||||
|
||||
return (
|
||||
<div className="box-banner-category">
|
||||
@@ -9,7 +9,7 @@ interface BoxCategoryChildProps {
|
||||
}
|
||||
|
||||
const ItemCategoryChild: React.FC<BoxCategoryChildProps> = ({ item }) => {
|
||||
const ItemImage = item.big_image
|
||||
const itemImage = item.big_image
|
||||
? item.big_image
|
||||
: item.thumnail
|
||||
? item.thumnail
|
||||
@@ -19,7 +19,7 @@ const ItemCategoryChild: React.FC<BoxCategoryChildProps> = ({ item }) => {
|
||||
<li>
|
||||
<Link href={item.url}>
|
||||
<div className="border-img lazy flex items-center justify-center">
|
||||
<Image src={ItemImage} width={60} height={60} alt={item.title} />
|
||||
<Image src={itemImage} width={60} height={60} alt={item.title} />
|
||||
</div>
|
||||
<p className="txt font-weight-500">{item.title}</p>
|
||||
</Link>
|
||||
@@ -1,35 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import type { CategoryData } from '@/types';
|
||||
import { productCategoryData } from '@/data/product/category';
|
||||
import { findCategoryBySlug } from '@/lib/product/category';
|
||||
|
||||
// box
|
||||
import { Breadcrumb } from '@components/common/Breadcrumb';
|
||||
import { Breadcrumb } from '@/components/Common/Breadcrumb';
|
||||
import BannerCategory from './BannerCategory';
|
||||
import ItemCategoryChild from './ItemCategoryChild';
|
||||
import BoxFilter from './BoxFilter';
|
||||
import BoxSort from './BoxSort';
|
||||
import ItemProduct from '@/components/common/ItemProduct';
|
||||
import BoxFilter from '@/components/Product/BoxFilter';
|
||||
import BoxSort from '@/components/Product/BoxSort';
|
||||
import ItemProduct from '@/components/Common/ItemProduct';
|
||||
import PreLoader from '@/components/Common/PreLoader';
|
||||
import { getProductCategory } from '@/lib/api/product';
|
||||
import { useApiData } from '@/hooks/useApiData';
|
||||
|
||||
interface CategoryPageProps {
|
||||
slug: string; // khai báo prop slug
|
||||
slug: string;
|
||||
}
|
||||
|
||||
const CategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
|
||||
// Ép kiểu dữ liệu từ index.ts về CategoryData[]
|
||||
const categories = productCategoryData as unknown as CategoryData[];
|
||||
const currentCategory = findCategoryBySlug(slug, categories);
|
||||
const searchParams = useSearchParams();
|
||||
const search = searchParams.toString();
|
||||
const productDisplayType =
|
||||
searchParams.get('display') === 'list' ? 'list' : searchParams.get('display') === 'detail' ? 'list' : 'grid';
|
||||
const {
|
||||
data: currentCategory,
|
||||
isLoading,
|
||||
} = useApiData(() => getProductCategory(slug, search), [slug, search], {
|
||||
initialData: null as CategoryData | null,
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return <PreLoader />;
|
||||
}
|
||||
|
||||
const breadcrumbItems = currentCategory?.current_category?.path?.path?.map((p) => ({
|
||||
name: p.name,
|
||||
url: p.url,
|
||||
})) ?? [
|
||||
{ name: 'Trang chủ', url: '/' },
|
||||
{ name: currentCategory?.current_category.name, url: currentCategory?.current_category.url },
|
||||
{ name: currentCategory?.current_category.name ?? 'Danh mục', url: slug },
|
||||
];
|
||||
// Trường hợp không tìm thấy danh mục
|
||||
|
||||
if (!currentCategory) {
|
||||
return (
|
||||
<div className="flex items-center justify-center bg-gradient-to-br from-blue-50 to-indigo-100 py-50">
|
||||
@@ -53,24 +66,21 @@ const CategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
|
||||
<h1 className="text-2xl font-bold text-gray-800">Không tìm thấy danh mục</h1>
|
||||
|
||||
<p className="mt-3 text-gray-600">
|
||||
Đường dẫn <code className="rounded bg-gray-100 px-2 py-0.5 text-sm">{slug}</code> không
|
||||
tồn tại hoặc đã bị xoá.
|
||||
Không thấy link <code className="rounded bg-gray-100 px-2 py-0.5 text-sm">{slug}</code>{' '}
|
||||
không tồn tại hoặc đã bị xóa.
|
||||
</p>
|
||||
|
||||
<Link
|
||||
href="/"
|
||||
className="mt-6 inline-flex items-center justify-center rounded-lg bg-blue-600 px-6 py-2.5 font-medium text-white transition hover:bg-blue-700 focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
||||
>
|
||||
← Về trang chủ
|
||||
Về trang chủ
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// lấy sản phẩm
|
||||
const products = Object.values(currentCategory.product_list);
|
||||
|
||||
return (
|
||||
<div className="page-category">
|
||||
<div className="container">
|
||||
@@ -81,26 +91,23 @@ const CategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
|
||||
<h1 className="name-category font-bold">{currentCategory.current_category.name}</h1>
|
||||
<div className="box-content-category">
|
||||
<ul className="category-child boder-radius-10 flex flex-wrap justify-center">
|
||||
{currentCategory.current_category.children?.map((item, index) => (
|
||||
<ItemCategoryChild item={item} key={index} />
|
||||
{currentCategory.current_category.children?.map((item) => (
|
||||
<ItemCategoryChild item={item} key={item.id} />
|
||||
))}
|
||||
</ul>
|
||||
{/* filter */}
|
||||
<BoxFilter filters={currentCategory} />
|
||||
|
||||
<div className="box-list-product-category boder-radius-10">
|
||||
{/* filter sort */}
|
||||
<BoxSort
|
||||
sort_by_collection={currentCategory.sort_by_collection}
|
||||
product_display_type="grid"
|
||||
display_by_collection={currentCategory.display_by_collection}
|
||||
product_display_type={productDisplayType}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* list product */}
|
||||
|
||||
<div className="list-product-category grid grid-cols-5 gap-3">
|
||||
{products.map((item, index) => (
|
||||
<ItemProduct key={index} item={item} />
|
||||
{currentCategory.product_list.map((item) => (
|
||||
<ItemProduct key={item.id} item={item} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { Autoplay } from 'swiper/modules';
|
||||
|
||||
const BOUGHT_DATA = [
|
||||
{ name: 'Anh Tuấn', phone: '036 856 xxxx', time: '2 giờ trước' },
|
||||
{ name: 'Quốc Trung', phone: '035 348 xxxx', time: '1 giờ trước' },
|
||||
{ name: 'Quang Ngọc', phone: '097 478 xxxx', time: '30 phút trước' },
|
||||
{ name: 'Mạnh Lực', phone: '037 204 xxxx', time: '25 phút trước' },
|
||||
{ name: 'Hiếu', phone: '096 859 xxxx', time: '20 phút trước' },
|
||||
];
|
||||
|
||||
export const BoxBought = () => {
|
||||
return (
|
||||
<div className="pro-customer-bought">
|
||||
<svg
|
||||
className="pcb-icon"
|
||||
viewBox="0 0 438.533 438.533"
|
||||
width={16}
|
||||
height={16}
|
||||
fill="red"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<g>
|
||||
<path d="M409.133,109.203c-19.608-33.592-46.205-60.189-79.798-79.796C295.736,9.801,259.058,0,219.273,0c-39.781,0-76.47,9.801-110.063,29.407c-33.595,19.604-60.192,46.201-79.8,79.796C9.801,142.8,0,179.489,0,219.267c0,39.78,9.804,76.463,29.407,110.062c19.607,33.592,46.204,60.189,79.799,79.798c33.597,19.605,70.283,29.407,110.063,29.407s76.47-9.802,110.065-29.407c33.593-19.602,60.189-46.206,79.795-79.798c19.603-33.596,29.403-70.284,29.403-110.062C438.533,179.485,428.732,142.795,409.133,109.203z M334.332,232.111L204.71,361.736c-3.617,3.613-7.896,5.428-12.847,5.428c-4.952,0-9.235-1.814-12.85-5.428l-29.121-29.13c-3.617-3.613-5.426-7.898-5.426-12.847c0-4.941,1.809-9.232,5.426-12.847l87.653-87.646l-87.657-87.65c-3.617-3.612-5.426-7.898-5.426-12.845c0-4.949,1.809-9.231,5.426-12.847l29.121-29.13c3.619-3.615,7.898-5.424,12.85-5.424c4.95,0,9.233,1.809,12.85,5.424l129.622,129.621c3.613,3.614,5.42,7.898,5.42,12.847C339.752,224.213,337.945,228.498,334.332,232.111z" />
|
||||
</g>
|
||||
</svg>
|
||||
|
||||
<div className="pcb-slider swiper-customer-bought">
|
||||
<Swiper
|
||||
modules={[Autoplay]}
|
||||
spaceBetween={12}
|
||||
slidesPerView={1}
|
||||
loop={true}
|
||||
autoplay={{ delay: 3000, disableOnInteraction: false }}
|
||||
>
|
||||
{BOUGHT_DATA.map((customer, idx) => (
|
||||
<SwiperSlide key={idx}>
|
||||
<div>
|
||||
<p>
|
||||
<b>Khách hàng {customer.name} ({customer.phone})</b>
|
||||
</p>
|
||||
<p>Đã mua hàng {customer.time}</p>
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
))}
|
||||
</Swiper>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from 'react';
|
||||
import type { ProductDetailData } from '@/types';
|
||||
import CountDown from '@/components/Common/CountDown';
|
||||
import { formatCurrency } from '@/lib/formatPrice';
|
||||
|
||||
export const BoxPrice = (item: ProductDetailData) => {
|
||||
const [now] = useState(() => Date.now());
|
||||
const { sale_rules, deal_list, marketPrice, price } = item.product_info;
|
||||
const isFlashSale = sale_rules.type === 'deal' && Number(sale_rules.to_time) > now;
|
||||
|
||||
const deal = deal_list[0];
|
||||
const remaining = deal ? Number(deal.quantity) - Number(deal.sale_order) : 0;
|
||||
const total = deal ? Number(deal.quantity) : 0;
|
||||
const percentRemaining = total > 0 ? (remaining / total) * 100 : 0;
|
||||
const hasMarketPrice = Number(marketPrice) > 0;
|
||||
const savings = hasMarketPrice ? Number(marketPrice) - Number(price) : 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
{isFlashSale && (
|
||||
<div className="box-flash-sale boder-radius-10 flex items-center">
|
||||
<div className="box-left relative flex items-center">
|
||||
<i className="sprite sprite-flashsale-detail"></i>
|
||||
<p className="title-deal font-weight-800">flash sale</p>
|
||||
</div>
|
||||
<div className="box-middle product-time-holder global-time-deal flex gap-2">
|
||||
<CountDown deadline={Number(sale_rules.to_time)} />
|
||||
</div>
|
||||
<div className="box-right">
|
||||
<div className="box-product-deal">
|
||||
<p className="text-deal-detail">
|
||||
Còn {remaining}/{total} sản phẩm
|
||||
</p>
|
||||
<div
|
||||
className="p-quantity-sale"
|
||||
data-quantity-left={remaining}
|
||||
data-quantity-sale-total={total}
|
||||
>
|
||||
<i className="sprite sprite-fire-deal"></i>
|
||||
<div className="bg-gradient"></div>
|
||||
<p className="js-line-deal-left" style={{ width: `${percentRemaining}%` }}></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasMarketPrice && isFlashSale && (
|
||||
<div
|
||||
className="box-price-detail boder-radius-10 flex flex-wrap items-center"
|
||||
style={{ rowGap: '8px' }}
|
||||
>
|
||||
<p className="price-detail font-bold">
|
||||
{Number(price) > 0 ? `${formatCurrency(price)}đ` : 'Liên hệ'}
|
||||
</p>
|
||||
<span className="market-price-detail font-weight-500">
|
||||
{formatCurrency(marketPrice)}đ
|
||||
</span>
|
||||
<div className="save-price-detail flex items-center gap-1">
|
||||
<span>Tiết kiệm</span>
|
||||
{formatCurrency(savings)}
|
||||
<span>đ</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
171
src/features/Product/ProductDetail/BoxInfoRight/index.tsx
Normal file
171
src/features/Product/ProductDetail/BoxInfoRight/index.tsx
Normal file
@@ -0,0 +1,171 @@
|
||||
'use client';
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import type { ProductDetailData } from '@/types';
|
||||
|
||||
import { BoxPrice } from './BoxPrice';
|
||||
import { BoxBought } from './BoxBought';
|
||||
import { addToCart } from '@/lib/ButtonCart';
|
||||
import { SanitizedHtml } from '@/components/Common/SanitizedHtml';
|
||||
|
||||
export const BoxInfoRight = (item: ProductDetailData) => {
|
||||
const router = useRouter();
|
||||
const [quantity, setQuantity] = useState(1);
|
||||
const [cartMessage, setCartMessage] = useState('');
|
||||
|
||||
const inStock = Number(item.product_info.quantity) > 0;
|
||||
const hasPrice = Number(item.product_info.price) > 0;
|
||||
|
||||
const changeQty = (delta: number) => setQuantity((prev) => Math.max(1, prev + delta));
|
||||
|
||||
const handleAddToCart = () => {
|
||||
addToCart(item.product_info, quantity);
|
||||
setCartMessage('Đã thêm vào giỏ hàng!');
|
||||
setTimeout(() => setCartMessage(''), 2500);
|
||||
};
|
||||
|
||||
const handleBuyNow = () => {
|
||||
addToCart(item.product_info, quantity);
|
||||
router.push('/cart');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<h1 className="product-name color-black line-clamp-3 font-bold">
|
||||
{item.product_info.productName}
|
||||
</h1>
|
||||
<div className="list-basic-product-info flex flex-wrap items-center">
|
||||
<div className="item-basic">
|
||||
Mã SP: <span className="color-primary">{item.product_info.productSKU}</span>
|
||||
</div>
|
||||
<div className="item-basic">
|
||||
Đánh giá: <span className="color-primary">{item.product_info.review.summary.total}</span>
|
||||
</div>
|
||||
<div className="item-basic">
|
||||
Bình luận:{' '}
|
||||
<span className="color-primary">{item.product_info.comment.summary.total}</span>
|
||||
</div>
|
||||
<div className="item-basic">
|
||||
Lượt xem: <span className="color-primary">{item.product_info.visit}</span>
|
||||
</div>
|
||||
{item.product_info.extend.buy_count?.length > 0 && (
|
||||
<div className="item-basic last-item-basic position-relative">
|
||||
Đã bán: <span className="color-primary">{item.product_info.extend.buy_count}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* tình trạng */}
|
||||
<div className="list-basic-product-info flex flex-wrap items-center gap-6">
|
||||
<div className="item-basic">
|
||||
Bảo hành: <span className="color-red">{item.product_info.warranty}</span>
|
||||
</div>
|
||||
{inStock && (
|
||||
<div className="item-basic last-item-basic position-relative">
|
||||
Tình trạng: <span className="color-green">Còn hàng</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* giá */}
|
||||
<BoxPrice {...item} />
|
||||
|
||||
{item.product_info.specialOffer.all.length > 0 && (
|
||||
<div className="box-offer-detail border-radius-10">
|
||||
<div className="title-offer-detail flex items-center">
|
||||
<i className="sprite sprite-gift-detail"></i>
|
||||
<p className="font-weight-600">Khuyến mãi</p>
|
||||
</div>
|
||||
<div className="list-info-offter">
|
||||
{item.product_info.specialOffer.all.map((_item, idx) => (
|
||||
<div key={idx} className="item-offer">
|
||||
<i className="icon"></i>
|
||||
<SanitizedHtml html={_item.title} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* mua hàng */}
|
||||
{(inStock || hasPrice) && (
|
||||
<>
|
||||
<div className="product-buy-quantity flex items-center">
|
||||
<p className="title-quantity">Số lượng:</p>
|
||||
<div className="cart-quantity-select flex items-center justify-center">
|
||||
<p
|
||||
className="js-quantity-change cursor-pointer select-none"
|
||||
onClick={() => changeQty(-1)}
|
||||
>
|
||||
−
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
className="js-buy-quantity bk-product-qty font-bold"
|
||||
value={quantity}
|
||||
onChange={(e) => {
|
||||
const val = parseInt(e.target.value);
|
||||
if (!isNaN(val) && val > 0) setQuantity(val);
|
||||
}}
|
||||
/>
|
||||
<p
|
||||
className="js-quantity-change cursor-pointer select-none"
|
||||
onClick={() => changeQty(1)}
|
||||
>
|
||||
+
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
className="addCart flex cursor-pointer flex-wrap items-center justify-center gap-3"
|
||||
onClick={handleAddToCart}
|
||||
>
|
||||
<i className="sprite sprite-cart-detail"></i>
|
||||
<p className="title-cart">Thêm vào giỏ hàng</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{cartMessage && <p className="mt-1 text-sm font-medium text-green-600">{cartMessage}</p>}
|
||||
|
||||
<div id="detail-buy-ads" className="detail-buy grid grid-cols-2 gap-2">
|
||||
<button className="detail-buy-now col-span-2 cursor-pointer" onClick={handleBuyNow}>
|
||||
<span>ĐẶT MUA NGAY</span>
|
||||
Giao hàng tận nơi nhanh chóng
|
||||
</button>
|
||||
<button className="detail-add-cart">
|
||||
<span>TRẢ GÓP QUA HỒ SƠ</span>
|
||||
Chỉ từ 2.665.000₫/ tháng
|
||||
</button>
|
||||
<button className="detail-add-cart">
|
||||
<span>TRẢ GÓP QUA THẺ</span>
|
||||
Chỉ từ 1.332.500₫/ tháng
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* yên tâm mua hàng */}
|
||||
<div className="box-product-policy-detal boder-radius-10" style={{ marginTop: '24px' }}>
|
||||
<h2 className="title font-semibold">Yên tâm mua hàng</h2>
|
||||
<div className="list-showroom-detail flex flex-wrap justify-between">
|
||||
<div className="item flex items-center gap-2">
|
||||
<i className="sprite sprite-camket-detail"></i>
|
||||
<p>Cam kết giá tốt nhất thị trường.</p>
|
||||
</div>
|
||||
<div className="item flex items-center gap-2">
|
||||
<i className="sprite sprite-sanphammoi-detail"></i>
|
||||
<p>Sản phẩm mới 100%.</p>
|
||||
</div>
|
||||
<div className="item flex items-center gap-2">
|
||||
<i className="sprite sprite-1doi1-detail"></i>
|
||||
<p>Lỗi 1 đổi 1 ngay lập tức.</p>
|
||||
</div>
|
||||
<div className="item flex items-center gap-2">
|
||||
<i className="sprite sprite-hotrotragop-detail"></i>
|
||||
<p>Hỗ trợ trả góp - Thủ tục nhanh gọn.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<BoxBought />
|
||||
</>
|
||||
);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user