Compare commits

...

14 Commits

Author SHA1 Message Date
1713fdd89c update 2026-04-09 15:58:45 +07:00
2d2bf85f83 update 2026-03-13 17:23:37 +07:00
25111ff10e update 2026-03-13 13:54:45 +07:00
a8e30f32a0 update 2026-01-06 13:53:48 +07:00
9486dabdb0 update 2026-01-06 11:02:01 +07:00
28a252f7d7 update 2026-01-05 13:50:16 +07:00
aae8e26135 update 2025-12-30 17:03:47 +07:00
15240ff81f update 2025-12-30 16:47:24 +07:00
9fa4b50b68 update 2025-12-29 23:46:30 +07:00
bf063f244c update 2025-12-29 17:29:51 +07:00
1bb5ad52ed update 2025-12-28 21:43:14 +07:00
71089d1eef update 2025-12-27 12:04:51 +07:00
7606157d26 update 2025-12-27 12:01:54 +07:00
e2063bce4c update 2025-12-27 10:03:53 +07:00
163 changed files with 66385 additions and 4302 deletions

View 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
View File

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

View File

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

View File

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

2173
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -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
View File

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

View File

@@ -1,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 />;
}
}

View 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 },
);
}
}

View 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>
);
};

View 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 bắt đu lại.</p>
</button>
</div>
</div>
</section>
);
};
export default BtnAction;

View 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 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 case cuối đ cân theo tổng công suất 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;

View 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 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;

View 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
View 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 đ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
View 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 />
</>
);
}

View File

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

11
src/app/deal/page.tsx Normal file
View 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 />;
}

View File

@@ -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
View 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
View File

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

View File

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

View 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 yêu cầu đc biệt, vui lòng liên hệ nhân viên 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>
);
}

View 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"> 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';

View 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>
);
};

View 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 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;

View 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;

View File

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

View File

@@ -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;

View File

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

View File

@@ -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. Nội</p>
<p>17 Kế Tấn, Phường Phương Liệt, 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 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>
</>
);

View File

@@ -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>

View File

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

View File

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

View File

@@ -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;

View File

@@ -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>

View 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;

View File

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

View File

@@ -0,0 +1,5 @@
const Skeleton = ({ className = '' }: { className?: string }) => (
<div className={`animate-pulse rounded-lg bg-gray-200 ${className}`} />
);
export default Skeleton;

View File

@@ -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>

View File

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

View File

@@ -1,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',
},
},
];

View File

@@ -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;

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View 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 nhân</p>
<div className="item-pop">
<div className="title-item-pop"> 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. Nội</span>
</div>
<div className="item-people">
<p className="phone">0765.666.668</p>
<span>Showroom TP. 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 Kế Tấn, Phường Phương Liệt, Nội</span>
</div>
<div className="item-people">
<p className="phone">079.9999.191</p>
<span>249 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"> 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 - 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;

View File

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

View File

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

View File

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

View File

@@ -1,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>
);
};

View File

@@ -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,

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,8 @@
'use client';
import { createContext, useContext } from 'react';
export const MSWContext = createContext<boolean>(true);
export function useMSWReady() {
return useContext(MSWContext);
}

View 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',
},
],
},
];

File diff suppressed because it is too large Load Diff

View 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"
}
]
}
]

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View 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"
}
}
]

View 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"
}
}
]

View 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: [],
},
],
},
];

View 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',
},
];

View File

View 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&Agrave;N H&Igrave;NH :&nbsp;&nbsp;M&agrave;n h&igrave;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&aacute; PC khi kh&ocirc;ng lấy qu&agrave; 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&Agrave;N H&Igrave;NH :&nbsp;&nbsp;M&agrave;n h&igrave;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&aacute; PC khi kh&ocirc;ng lấy qu&agrave; 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&Agrave;N H&Igrave;NH :&nbsp;&nbsp;M&agrave;n h&igrave;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&aacute; PC khi kh&ocirc;ng lấy qu&agrave; 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&Agrave;N H&Igrave;NH :&nbsp;&nbsp;M&agrave;n h&igrave;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&aacute; PC khi kh&ocirc;ng lấy qu&agrave; 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',
},
],
},
},
];

View File

@@ -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: [
{

File diff suppressed because it is too large Load Diff

29269
src/data/producthot/index.ts Normal file

File diff suppressed because it is too large Load Diff

View 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>
);
};

View 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>
);
};

View 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;

View 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 }}
/>
</>
);
}

View 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;

View 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>
);
};

View File

@@ -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>
);
};

View 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>
);
};

View 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;

View File

@@ -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>
);

View 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;

View File

@@ -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>

View File

@@ -2,9 +2,9 @@
import React from 'react';
import Image from 'next/image';
import Link from 'next/link';
import { Category } from '@/types/global/Menu';
import type { InfoCategory } from '@/types/global/Menu';
const ItemCategory: React.FC<{ item: Category }> = ({ item }) => {
const ItemCategory: React.FC<{ item: InfoCategory }> = ({ item }) => {
return (
<Link href={item.url} className="item-category flex flex-col items-center">
<p className="item-category-img">

View File

@@ -1,6 +1,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';

View File

@@ -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"

View 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;

View File

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

View File

@@ -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;

View File

@@ -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 (
<>

View File

@@ -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';

View 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;

View File

@@ -1,11 +1,21 @@
'use client';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
import Image from 'next/image';
import Link from 'next/link';
import { bannerData } from '@/data/banner';
import { getBanners } from '@/lib/api/banner';
import { useApiData } from '@/hooks/useApiData';
import type { TemplateBanner } from '@/types';
const BannerCategory = () => {
const dataSlider = bannerData[0].product_list;
const { data: banners } = useApiData(
() => getBanners(),
[],
{ initialData: null as TemplateBanner | null },
);
const dataSlider = banners?.product_list;
return (
<div className="box-banner-category">

View File

@@ -9,7 +9,7 @@ interface BoxCategoryChildProps {
}
const ItemCategoryChild: React.FC<BoxCategoryChildProps> = ({ item }) => {
const ItemImage = item.big_image
const itemImage = item.big_image
? item.big_image
: item.thumnail
? item.thumnail
@@ -19,7 +19,7 @@ const ItemCategoryChild: React.FC<BoxCategoryChildProps> = ({ item }) => {
<li>
<Link href={item.url}>
<div className="border-img lazy flex items-center justify-center">
<Image src={ItemImage} width={60} height={60} alt={item.title} />
<Image src={itemImage} width={60} height={60} alt={item.title} />
</div>
<p className="txt font-weight-500">{item.title}</p>
</Link>

View File

@@ -1,35 +1,48 @@
'use client';
import React from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import type { CategoryData } from '@/types';
import { productCategoryData } from '@/data/product/category';
import { findCategoryBySlug } from '@/lib/product/category';
// box
import { Breadcrumb } from '@components/common/Breadcrumb';
import { 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ị x.
Không thấy link <code className="rounded bg-gray-100 px-2 py-0.5 text-sm">{slug}</code>{' '}
không tồn tại hoặc đã bị xóa.
</p>
<Link
href="/"
className="mt-6 inline-flex items-center justify-center rounded-lg bg-blue-600 px-6 py-2.5 font-medium text-white transition hover:bg-blue-700 focus:ring-2 focus:ring-blue-400 focus:outline-none"
>
Về trang chủ
Về trang chủ
</Link>
</div>
</div>
);
}
// lấy sản phẩm
const products = Object.values(currentCategory.product_list);
return (
<div className="page-category">
<div className="container">
@@ -81,26 +91,23 @@ const CategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
<h1 className="name-category font-bold">{currentCategory.current_category.name}</h1>
<div className="box-content-category">
<ul className="category-child boder-radius-10 flex flex-wrap justify-center">
{currentCategory.current_category.children?.map((item, index) => (
<ItemCategoryChild item={item} key={index} />
{currentCategory.current_category.children?.map((item) => (
<ItemCategoryChild item={item} key={item.id} />
))}
</ul>
{/* filter */}
<BoxFilter filters={currentCategory} />
<div className="box-list-product-category boder-radius-10">
{/* filter sort */}
<BoxSort
sort_by_collection={currentCategory.sort_by_collection}
product_display_type="grid"
display_by_collection={currentCategory.display_by_collection}
product_display_type={productDisplayType}
/>
</div>
{/* list product */}
<div className="list-product-category grid grid-cols-5 gap-3">
{products.map((item, index) => (
<ItemProduct key={index} item={item} />
{currentCategory.product_list.map((item) => (
<ItemProduct key={item.id} item={item} />
))}
</div>

View File

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

View File

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

View File

@@ -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">
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Ồ </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