This commit is contained in:
2026-03-13 17:23:37 +07:00
parent 25111ff10e
commit 2d2bf85f83
43 changed files with 2094 additions and 448 deletions

View File

@@ -2,7 +2,10 @@
"permissions": {
"allow": [
"Bash(grep -E \"\\\\.\\(tsx|ts|json\\)$\")",
"Bash(npm install:*)"
"Bash(npm install:*)",
"Bash(npx tsc:*)",
"Bash(curl -s http://localhost:3000/tin-tuc)",
"Bash(grep '\"\"url\"\"' src/data/article/ListArticleNews.ts)"
]
}
}

View File

@@ -6,7 +6,11 @@
"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",

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

@@ -1,44 +1,185 @@
'use client';
import React from 'react';
import { getBuildPcCategories } from '@/lib/api/buildpc';
import { useApiData } from '@/hooks/useApiData';
import Image from 'next/image';
import type { BuildPcCategory, BuildPcProduct } from '@/lib/buildpc/source';
import { formatCurrency } from '@/lib/formatPrice';
interface BuildPcCategory {
id: string | number;
name: string;
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 = () => {
const { data: categories } = useApiData(() => getBuildPcCategories(), [], {
initialData: [] as BuildPcCategory[],
});
export const BoxListAccessory = ({
categories,
onOpenCategory,
onQuantityChange,
onRemoveProduct,
selectedItems,
}: BoxListAccessoryProps) => {
return (
<div className="list-drive" id="js-buildpc-layout" style={{ border: 'solid 1px #e1e1e1' }}>
{categories.map((category, index) => (
<div key={category.id} className="item-drive flex">
<div className="name-item-drive">
<h3
className="d-name d-name-{{id}}"
style={{ fontSize: '15px', borderBottom: 'none', marginBottom: 10 }}
>
{index + 1}. {category.name}
</h3>
</div>
<div className="drive-checked flex-1" style={{ marginLeft: 0 }}>
<span
className="show-popup_select span-last open-selection"
id="js-category-info-{{id}}"
data-info="{{info}}"
>
+ {''}
<span> Chọn {category.name}</span>
</span>
<div id="js-selected-item-{{id}}" data-id="{{id}}" className="js-item-row"></div>
</div>
<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>
))}
</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

@@ -1,49 +1,121 @@
import { FaImage, FaFileExcel, FaPrint, FaShoppingCart } from 'react-icons/fa';
'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;
const BtnAction = () => {
return (
<>
<div className="clear"></div>
<p style={{ float: 'right', fontSize: '18px', color: '#d00', marginTop: '10px' }}>
Chi phí dự tính: <span className="js-config-summary"></span>
</p>
<div className="clear"></div>
<ul className="list-btn-action" id="js-buildpc-action">
<li>
<span data-action="create-image">
<div className="flex items-center justify-center gap-3">
<p> tải nh cấu hình</p>
<FaImage />
</div>
</span>
</li>
<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 uppercase tracking-[0.2em] text-white/45">
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>
<li>
<span data-action="download-excel">
<div className="flex items-center justify-center gap-3">
<p>tải file excel cấu hình </p>
<FaFileExcel />
</div>
</span>
</li>
<li>
<span data-action="view">
<div className="flex items-center justify-center gap-3">
<p>Xem & In </p>
<FaPrint />
</div>
</span>
</li>
<li>
<span data-action="add-cart">
<div className="flex items-center justify-center gap-3">
<p>Thêm vào giỏ hàng </p>
<FaShoppingCart />
</div>
</span>
</li>
</ul>
</>
<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

@@ -1,114 +1,105 @@
import React from 'react';
import { Metadata } from 'next';
import '@styles/buildpc.css';
import { FaUndo } from 'react-icons/fa';
import { Breadcrumb } from '@/components/Common/Breadcrumb';
import { getBuildPcSnapshot } from '@/lib/buildpc/source';
import Slider from '@/app/buildpc/Slider';
import { BoxListAccessory } from './BoxListAccessory';
import BtnAction from './BtnAction';
import BuilderClient from './BuilderClient';
export const metadata: Metadata = {
title: 'Build PC - Xây dựng cấu hình máy tính PC giá rẻ chuẩn nhất',
description:
'Build pc gaming giá rẻ nhất 2025 - Tự build PC đơn giản nhất - Xây dựng cấu hình máy tính PC với mọi nhu cầu gaming, đồ họa, văn phòng phù hợp với ngân sách',
};
export async function generateMetadata(): Promise<Metadata> {
const snapshot = await getBuildPcSnapshot();
export default function BuildPcPage() {
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">
<div className="container pt-4">
<Breadcrumb items={breadcrumbItems} />
</div>
<div className="build-pc pc">
<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"
style={{ background: '#fff', padding: '20px', marginTop: '0px' }}
>
<Slider />
<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>
<h1
style={{
fontSize: '30px',
lineHeight: '36px',
marginBottom: '10px',
textAlign: 'center',
fontWeight: 500,
}}
>
Build PC - Xây dựng cấu hình máy tính PC giá rẻ chuẩn nhất
</h1>
<h2
style={{
fontSize: '26px',
lineHeight: '30px',
marginBottom: '10px',
fontWeight: 500,
}}
>
Chọn linh kiện xây dựng cấu hình - Tự build PC
</h2>
{/* tab */}
<ul
className="list-btn-action list-btn-action-new"
style={{
marginTop: 10,
float: 'left',
border: 'none',
width: '100%',
marginBottom: 20,
}}
>
<li style={{ width: 'auto' }} className="active">
<span style={{ padding: '0 20px' }}>Cấu hình 1</span>
</li>
<li style={{ width: 'auto' }}>
<span style={{ padding: '0 20px' }}>Cấu hình 2</span>
</li>
<li style={{ width: 'auto' }}>
<span style={{ padding: '0 20px' }}>Cấu hình 3</span>
</li>
<li style={{ width: 'auto' }}>
<span style={{ padding: '0 20px' }}>Cấu hình 4</span>
</li>
<li style={{ width: 'auto' }}>
<span style={{ padding: '0 20px' }}>Cấu hình 5</span>
</li>
</ul>
<div className="clear"></div>
<ul
className="list-btn-action"
style={{ margin: '0 0 0 0', float: 'left', border: 'none' }}
>
<li style={{ width: 'auto' }}>
<div
className="flex cursor-pointer items-center gap-2 text-sm"
style={{ padding: '10px 20px', background: 'var(--color-primary)' }}
>
<p>Làm mới</p>
<FaUndo />
<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 uppercase tracking-[0.24em] text-white/80">
Build PC theo nhu cầu
</span>
<div className="space-y-3">
<h1 className="max-w-3xl text-3xl font-semibold leading-tight 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 uppercase tracking-[0.24em] text-white/70">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 uppercase tracking-[0.24em] text-white/70">
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>
</li>
</ul>
<div>
<p style={{ float: 'right', fontSize: '18px', color: '#d00', marginTop: '10px' }}>
Chi phí dự tính: <span className="js-config-summary"></span>{' '}
</p>
<div className="js-buildpc-promotion-content" style={{ marginBottom: '0px' }}></div>
</div>
<div className="clear"></div>
</div>
</section>
{/* Hiển thị dữ liệu tai đây */}
<BoxListAccessory />
<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 uppercase tracking-[0.18em] text-slate-500">
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>
{/* btn */}
<BtnAction />
<BuilderClient snapshot={snapshot} />
</div>
</div>
</div>

View File

@@ -4,8 +4,8 @@ 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 { 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';

View File

@@ -15,8 +15,8 @@ export const metadata: Metadata = {
template: '%s | Nguyễn Công PC',
},
description:
'Nguyễn Công PC - Chuyên cung cấp máy tính, laptop, linh kiện, phụ kiện chính hãng với giá tốt nhất thị trường. Bảo hành uy tín, giao hàng nhanh toàn quốc.',
keywords: ['máy tính', 'laptop', 'linh kiện máy tính', 'nguyễn công pc', 'pc gaming'],
'Nguyễn Công PC chuyên cung cấp máy tính, laptop, linh kiện phụ kiện chính hãng với giá tốt, bảo hành uy tín giao hàng nhanh toàn quốc.',
keywords: ['máy tính', 'laptop', 'linh kiện máy tính', 'Nguyễn Công PC', 'PC gaming'],
authors: [{ name: 'Nguyễn Công PC' }],
openGraph: {
type: 'website',
@@ -24,7 +24,7 @@ export const metadata: Metadata = {
siteName: 'Nguyễn Công PC',
title: 'Nguyễn Công PC - Máy tính, Laptop, Linh kiện chính hãng',
description:
'Chuyên cung cấp máy tính, laptop, linh kiện, phụ kiện chính hãng với giá tốt nhất.',
'Chuyên cung cấp máy tính, laptop, linh kiện phụ kiện chính hãng với giá tốt nhất.',
},
robots: { index: true, follow: true },
};
@@ -35,15 +35,15 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html suppressHydrationWarning>
<html lang="vi" suppressHydrationWarning>
<body>
<Header />
<MSWProvider>
<Header />
<main>
<ErrorBoundary>{children}</ErrorBoundary>
</main>
<Footer />
</MSWProvider>
<Footer />
</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>
);
}

View File

@@ -20,6 +20,9 @@ const ItemDeal: React.FC<ItemDealProps> = ({ item }) => {
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">
@@ -31,10 +34,12 @@ const ItemDeal: React.FC<ItemDealProps> = ({ item }) => {
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 && (
<>
@@ -45,40 +50,31 @@ const ItemDeal: React.FC<ItemDealProps> = ({ item }) => {
</>
)}
</div>
<div className="product-price-main font-bold">
{item.product_info.price > '0'
? `${formatCurrency(item.product_info.price)}đ`
? `${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>
{(() => {
const percentRemaining =
((Number(item.quantity) - Number(item.sale_quantity)) / Number(item.quantity)) *
100;
return <p className="js-line-deal-left" style={{ width: `${percentRemaining}%` }}></p>;
})()}
<p className="js-line-deal-left" style={{ width: `${percentRemaining}%` }}></p>
<span>
Còn {Number(item.quantity) - Number(item.sale_quantity)}/{Number(item.quantity)} sản
phẩm
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>
<a href="javascript:buyNow(25404)" className="buy-now-deal">
<Link href={item.product_info.productUrl} className="buy-now-deal">
Mua giá sốc
</a>
<Link
href="/bts-gaming-02"
className="text-deal-item color-primary mt-3 hidden font-bold"
>
Xem sản phẩm
</Link>
</div>
</div>

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 gap-2"
>
<Link href={item.url ?? '/'} itemProp="item">
<span itemProp="name">{item?.name}</span>
<span itemProp="name">{item.name}</span>
</Link>
{idx < items.length - 1 && <FaAngleRight className="text-gray-700" />}
<meta itemProp="position" content={(idx + 1).toString()} />
{index < items.length - 1 && <FaAngleRight className="text-gray-700" />}
<meta itemProp="position" content={(index + 2).toString()} />
</li>
))}
</ol>

View File

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

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

@@ -3,10 +3,9 @@
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 { FaBars, FaMapMarkerAlt } from 'react-icons/fa';
import BoxShowroom from '@/components/Common/BoxShowroom';
import BoxHotLine from '../../BoxHotline';
import { formatCurrency } from '@/lib/formatPrice';
import {
getServerCartSnapshot,
@@ -33,7 +32,7 @@ const HeaderMid: React.FC = () => {
const cartTotal = cart.reduce((sum, item) => sum + Number(item.in_cart.total_price), 0);
const openModal = (id: string) => {
(document.getElementById(id) as HTMLDialogElement)?.showModal();
(document.getElementById(id) as HTMLDialogElement | null)?.showModal();
};
return (
@@ -43,19 +42,21 @@ const HeaderMid: React.FC = () => {
<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
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">
@@ -64,6 +65,7 @@ const HeaderMid: React.FC = () => {
</div>
<div className="cau-noi"></div>
</div>
<div className="header-search-group">
<form method="get" action="/tim" name="searchForm">
<div className="box-search-input">
@@ -99,6 +101,7 @@ const HeaderMid: React.FC = () => {
</Link>
<button
type="button"
onClick={() => openModal('boxHotline')}
className="item-tab-header flex flex-col items-center gap-4"
>
@@ -124,6 +127,7 @@ const HeaderMid: React.FC = () => {
<span className="font-medium">Giỏ hàng</span>
</Link>
<div className="cau-noi"></div>
<div className="cart-ttip" id="js-cart-tooltip">
<div className="cart-ttip-item-container">
{cart.map((item) => (
@@ -139,6 +143,7 @@ const HeaderMid: React.FC = () => {
alt={item.item_info.productName}
/>
</Link>
<div className="compare-item-text flex-1">
<Link
href={item.item_info.productUrl}
@@ -146,10 +151,11 @@ const HeaderMid: React.FC = () => {
>
{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'
{item.in_cart.price === '0'
? 'Liên hệ'
: `${formatCurrency(item.in_cart.total_price)} đ`}
</b>
@@ -158,11 +164,13 @@ const HeaderMid: React.FC = () => {
</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>
<p className="font-bold">{formatCurrency(cartTotal)} đ</p>
</div>
<Link
href="/cart"
className="cart-ttip-price-button flex items-center justify-center"
@@ -172,18 +180,21 @@ const HeaderMid: React.FC = () => {
</div>
</div>
<Link
href="/taikhoan"
className="user-header item-tab-header flex flex-col 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-medium">Tài khoản</span>
</Link>
</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

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

@@ -5,8 +5,8 @@ import Image from 'next/image';
import type { TypeArticleCatePage } from '@/types/article/TypeArticleCatePage';
import { Breadcrumb } from '@components/Common/Breadcrumb';
import { ErrorLink } from '@components/Common/Error';
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';
@@ -85,7 +85,7 @@ const ArticleCategoryPage: React.FC<CategoryPageProps> = ({ slug }) => {
))}
</div>
<Link
href="/tin-cong-nghe"
href={currentCategory.category_info.request_path}
className="btn-article-col flex items-center justify-center gap-2 font-[500]"
>
Xem tất cả

View File

@@ -92,18 +92,18 @@ export default function TocBox({ htmlContent }: { htmlContent: string }) {
};
}, [htmlContent]);
if (!headingsTree.length) return null;
return (
<>
<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>
{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 id="js-outp">{renderTree(headingsTree)}</div>
</div>
)}
<div
className="box-article-detail-ct nd js_find"
dangerouslySetInnerHTML={{ __html: contentWithIds }}

View File

@@ -4,9 +4,9 @@ import Link from 'next/link';
import Image from 'next/image';
import type { TypeArticleDetailPage } from '@/types/article/TypeArticleDetailPage';
import { ErrorLink } from '@components/Common/Error';
import { ErrorLink } from '@/components/Common/Error';
import { Breadcrumb } from '@components/Common/Breadcrumb';
import { Breadcrumb } from '@/components/Common/Breadcrumb';
import TocBox from './TocBox';
import PreLoader from '@/components/Common/PreLoader';
import { getArticleCategories, getArticleDetail } from '@/lib/api/article';
@@ -93,9 +93,9 @@ const ArticleDetailPage: React.FC<DetailPageProps> = ({ slug }) => {
/>
</Link>
<div className="content-article flex-1">
<a href={item.url} className="title-article">
<Link href={item.url} className="title-article">
<h3 className="font-weight-400 line-clamp-2">{item.title}</h3>
</a>
</Link>
<p className="time-article d-flex align-items-center gap-4">
<i className="sprite sprite-clock-item-article"></i>
<span>{item.createDate}</span>

View File

@@ -2,7 +2,8 @@
import React from 'react';
import Link from 'next/link';
import { Breadcrumb } from '@components/Common/Breadcrumb';
import { Breadcrumb } from '@/components/Common/Breadcrumb';
import Skeleton from '@/components/Common/Skeleton';
import { ArticleTopLeft } from '../ArticleTopLeft';
import { ArticleTopRight } from '../ArticleTopRight';
@@ -13,14 +14,45 @@ 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 } = useApiData(
const { data: categories, isLoading } = useApiData(
() => getArticleCategories(),
[],
{ initialData: [] as TypeArticleCategory[] },
);
if (isLoading) return <ArticleHomeSkeleton />;
return (
<section className="page-article pb-10">
<div className="container">

View File

@@ -6,9 +6,10 @@ 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 } = useApiData(
const { data: videos, isLoading } = useApiData(
() => getArticleVideos(),
[],
{ initialData: [] as Article[] },
@@ -31,9 +32,17 @@ const BoxArticleVideo: React.FC = () => {
</Link>
</div>
<div className="list-videos-group list-article-group flex items-center gap-10">
{videos.slice(0, 4).map((item) => (
<ItemArticleVideo item={item} key={item.id} />
))}
{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

@@ -6,9 +6,10 @@ 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 } = useApiData(
const { data: articles, isLoading } = useApiData(
() => getArticles(),
[],
{ initialData: [] as Article[] },
@@ -26,9 +27,18 @@ const BoxArticle: React.FC = () => {
</Link>
</div>
<div className="list-article-group flex items-center gap-10">
{articles.slice(0, 4).map((item) => (
<ItemArticle item={item} key={item.id} />
))}
{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>
);

View File

@@ -6,17 +6,51 @@ 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 Skeleton from '@/components/Common/Skeleton';
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';
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 } = useApiData(() => getProductHot(), [], {
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) => (

View File

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

View File

@@ -1,5 +1,5 @@
'use client';
import React, { useEffect, useState } from 'react';
import React, { useState } from 'react';
import Link from 'next/link';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
@@ -8,14 +8,35 @@ 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 [deals, setDeals] = useState<TypeListProductDeal>([]);
const [expired, setExpired] = useState(false);
const { data: deals, isLoading } = useApiData(() => getDeals(), [], {
initialData: [] as TypeListProductDeal,
});
useEffect(() => {
getDeals().then(setDeals).catch(console.error);
}, []);
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;

View File

@@ -6,9 +6,10 @@ 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 } = useApiData(
const { data: reviews, isLoading } = useApiData(
() => getHomeReviews(),
[],
{ initialData: [] as HomeReview[] },
@@ -19,21 +20,40 @@ const BoxReviewCustomer: React.FC = () => {
<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 }}
>
{reviews.map((item, index) => (
<SwiperSlide key={`${item.author}-${index}`} className="item">
<ItemReview item={item} />
</SwiperSlide>
{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>
))}
</Swiper>
</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>
);
};

View File

@@ -1,18 +1,30 @@
'use client';
import React, { useEffect, useState } from 'react';
import React from 'react';
import { Swiper, SwiperSlide } from 'swiper/react';
import { Autoplay, Navigation, Pagination } from 'swiper/modules';
import Image from 'next/image';
import Link from 'next/link';
import { getBanners } from '@/lib/api/banner';
import { TemplateBanner } from '@/types';
import Skeleton from '@/components/Common/Skeleton';
import { useApiData } from '@/hooks/useApiData';
const SliderHome: React.FC = () => {
const [banners, setBanners] = useState<TemplateBanner | null>(null);
const { data: banners, isLoading } = useApiData(() => getBanners(), [], {
initialData: null as TemplateBanner | null,
});
useEffect(() => {
getBanners().then(setBanners).catch(console.error);
}, []);
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;

View File

@@ -1,4 +1,5 @@
'use client';
import Link from 'next/link';
import { motion } from 'framer-motion';
@@ -11,7 +12,6 @@ const NotFound = () => {
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 }}
@@ -38,13 +38,12 @@ const NotFound = () => {
Bạn truy cập không tồn tại hoặc đưng dẫn đã bị 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>
</div>
</motion.div>

View File

@@ -8,8 +8,8 @@ import type { CategoryData } from '@/types';
import { Breadcrumb } from '@/components/Common/Breadcrumb';
import BannerCategory from './BannerCategory';
import ItemCategoryChild from './ItemCategoryChild';
import BoxFilter from '@components/Product/BoxFilter';
import BoxSort from '@components/Product/BoxSort';
import 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';

View File

@@ -1,5 +1,7 @@
'use client';
import React, { useState } from 'react';
import { submitProductReview } from '@/lib/api/product';
const STAR_OPTIONS = [5, 4, 3, 2, 1] as const;
@@ -8,36 +10,61 @@ interface ReviewFormError {
name?: string;
}
export const FormReview: React.FC = () => {
interface Props {
slug: string;
}
export const FormReview: React.FC<Props> = ({ slug }) => {
const [rate, setRate] = useState<number>(5);
const [content, setContent] = useState('');
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [errors, setErrors] = useState<ReviewFormError>({});
const [submitted, setSubmitted] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [submitError, setSubmitError] = useState('');
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const newErrors: ReviewFormError = {};
if (!content.trim() || content.trim().length < 10) {
newErrors.content = 'Nội dung đánh giá tối thiểu 10 ký tự';
}
if (!name.trim()) {
newErrors.name = 'Vui lòng nhập tên của bạn';
}
setErrors(newErrors);
if (Object.keys(newErrors).length > 0) return;
// TODO: gọi API gửi đánh giá
console.log({ rate, content, name, email });
setSubmitted(true);
setErrors(newErrors);
setSubmitError('');
if (Object.keys(newErrors).length > 0) {
return;
}
try {
setIsSubmitting(true);
await submitProductReview(slug, {
content: content.trim(),
email: email.trim(),
name: name.trim(),
rate,
});
setSubmitted(true);
} catch {
setSubmitError('Không thể gửi đánh giá lúc này. Vui lòng thử lại sau.');
} finally {
setIsSubmitting(false);
}
};
if (submitted) {
return (
<div className="box-form-review">
<p className="font-medium text-green-600">
Cảm ơn bạn đã gửi đánh giá! Đánh giá của bạn đang chờ duyệt.
Cảm ơn bạn đã gửi đánh giá. Đánh giá của bạn đang chờ duyệt.
</p>
</div>
);
@@ -51,8 +78,8 @@ export const FormReview: React.FC = () => {
placeholder="Mời bạn để lại đánh giá..."
name="user_post[content]"
value={content}
onChange={(e) => {
setContent(e.target.value);
onChange={(event) => {
setContent(event.target.value);
setErrors((prev) => ({ ...prev, content: undefined }));
}}
/>
@@ -103,8 +130,8 @@ export const FormReview: React.FC = () => {
name="user_post[user_name]"
className="form-control"
value={name}
onChange={(e) => {
setName(e.target.value);
onChange={(event) => {
setName(event.target.value);
setErrors((prev) => ({ ...prev, name: undefined }));
}}
/>
@@ -123,7 +150,7 @@ export const FormReview: React.FC = () => {
name="user_post[user_email]"
className="form-control"
value={email}
onChange={(e) => setEmail(e.target.value)}
onChange={(event) => setEmail(event.target.value)}
/>
</td>
</tr>
@@ -131,8 +158,10 @@ export const FormReview: React.FC = () => {
</table>
</div>
<button type="submit" className="btn-review send_form mb-10 mt-12">
Gửi đánh giá
{submitError && <p className="mb-3 text-sm text-red-500">{submitError}</p>}
<button type="submit" className="btn-review send_form mb-10 mt-12" disabled={isSubmitting}>
{isSubmitting ? 'Đang gửi...' : 'Gửi đánh giá'}
</button>
</div>
</form>

View File

@@ -1,8 +1,8 @@
'use client';
import React, { useState } from 'react';
import { Review } from '@/types';
import { FaStar } from 'react-icons/fa6';
import { Review } from '@/types';
import { FormReview } from './FormReview';
import { ListReview } from './ListReview';
@@ -15,14 +15,16 @@ export const ProductReview: React.FC<Props> = ({ review, slug }) => {
const [showForm, setShowForm] = useState(false);
const { summary } = review;
const totalRate = summary.list_rate.reduce((acc, item) => acc + Number(item.total), 0);
const totalRate = summary.list_rate.reduce((accumulator, item) => {
return accumulator + Number(item.total);
}, 0);
const rates = {
rate1: Number(summary.list_rate.find((r) => r.rate === '1')?.total || 0),
rate2: Number(summary.list_rate.find((r) => r.rate === '2')?.total || 0),
rate3: Number(summary.list_rate.find((r) => r.rate === '3')?.total || 0),
rate4: Number(summary.list_rate.find((r) => r.rate === '4')?.total || 0),
rate5: Number(summary.list_rate.find((r) => r.rate === '5')?.total || 0),
rate1: Number(summary.list_rate.find((item) => item.rate === '1')?.total || 0),
rate2: Number(summary.list_rate.find((item) => item.rate === '2')?.total || 0),
rate3: Number(summary.list_rate.find((item) => item.rate === '3')?.total || 0),
rate4: Number(summary.list_rate.find((item) => item.rate === '4')?.total || 0),
rate5: Number(summary.list_rate.find((item) => item.rate === '5')?.total || 0),
};
const percents = {
percent1: totalRate > 0 ? (rates.rate1 / totalRate) * 100 : 0,
@@ -48,16 +50,14 @@ export const ProductReview: React.FC<Props> = ({ review, slug }) => {
{[5, 4, 3, 2, 1].map((rate) => {
const percent = percents[`percent${rate}` as keyof typeof percents];
const total = rates[`rate${rate}` as keyof typeof rates];
return (
<div key={rate} className="avg-rate-item mt-2 flex items-center justify-center">
<span className="rate-number flex items-center gap-1">
{rate} <FaStar className="text-yellow-500" />
</span>
<div className="nhan-xet-bar">
<div
className={`percent percent${rate}`}
style={{ width: `${percent}%` }}
></div>
<div className={`percent percent${rate}`} style={{ width: `${percent}%` }} />
</div>
<span className="total-avg-rate">
<strong>{total}</strong> đánh giá
@@ -68,24 +68,18 @@ export const ProductReview: React.FC<Props> = ({ review, slug }) => {
</div>
</div>
</div>
<p className="text-danh-gia mb-5">Bạn đánh giá sao sản phẩm này</p>
{!showForm ? (
<div
className="button-review mx-auto flex cursor-pointer items-center justify-center"
onClick={() => setShowForm(true)}
>
Đánh giá ngay
</div>
) : (
<div
className="button-review mx-auto flex cursor-pointer items-center justify-center"
onClick={() => setShowForm(false)}
>
Đóng lại
</div>
)}
{showForm && <FormReview />}
<p className="text-danh-gia mb-5">Bạn đánh giá sao sản phẩm này</p>
<button
type="button"
className="button-review mx-auto flex cursor-pointer items-center justify-center"
onClick={() => setShowForm((currentValue) => !currentValue)}
>
{showForm ? 'Đóng lại' : 'Đánh giá ngay'}
</button>
{showForm && <FormReview slug={slug} />}
</div>
</div>
<ListReview slug={slug} />

View File

@@ -81,10 +81,11 @@ const ProductDetailPage: React.FC<ProductDetailPageProps> = ({ slug }) => {
return <ErrorLink />;
}
const breadcrumbItems = product.product_info.productPath?.[0]?.path.map((item) => ({
name: item.name,
url: item.url,
})) ?? [{ name: 'Trang chủ', url: '/' }];
const breadcrumbItems =
product.product_info.productPath?.[0]?.path.map((item) => ({
name: item.name,
url: item.url,
})) ?? [{ name: 'Trang chủ', url: '/' }];
return (
<>

View File

@@ -2,26 +2,28 @@
import React from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { ErrorLink } from '@/components/Common/Error';
import type { TypeProductHot } from '@/types/producthot';
import { Breadcrumb } from '@/components/Common/Breadcrumb';
import BoxFilter from '@components/Product/BoxFilter';
import BoxSort from '@components/Product/BoxSort';
import BoxFilter from '@/components/Product/BoxFilter';
import BoxSort from '@/components/Product/BoxSort';
import ItemProduct from '@/components/Common/ItemProduct';
import PreLoader from '@/components/Common/PreLoader';
import { getProductHotPage } from '@/lib/api/product';
import { useApiData } from '@/hooks/useApiData';
import { getProductHotPage } from '@/lib/api/product';
import type { TypeProductHot } from '@/types/producthot';
interface ProductHotPageProps {
slug: string;
}
const ProductHotPage: React.FC<ProductHotPageProps> = ({ slug }) => {
const searchParams = useSearchParams();
const search = searchParams.toString();
const productDisplayType = searchParams.get('display') === 'list' ? 'list' : 'grid';
const { data: page, isLoading } = useApiData(
() => getProductHotPage(slug),
[slug],
() => getProductHotPage(slug, search),
[slug, search],
{ initialData: null as TypeProductHot | null },
);
@@ -37,7 +39,6 @@ const ProductHotPage: React.FC<ProductHotPageProps> = ({ slug }) => {
{ name: 'Trang chủ', url: '/' },
{ name: page.title, url: page.url },
];
const products = Object.values(page.product_list);
return (
@@ -52,11 +53,16 @@ const ProductHotPage: React.FC<ProductHotPageProps> = ({ slug }) => {
<span className="current-cate-total">(Tổng {page.product_count} sản phẩm)</span>
</div>
</div>
<div className="box-content-category">
<BoxFilter filters={page} />
<div className="box-list-product-category boder-radius-10">
<BoxSort sort_by_collection={page.sort_by_collection} product_display_type="grid" />
<BoxSort
sort_by_collection={page.sort_by_collection}
display_by_collection={page.display_by_collection}
product_display_type={productDisplayType}
/>
</div>
<div className="list-product-category grid grid-cols-5 gap-3">
@@ -64,10 +70,11 @@ const ProductHotPage: React.FC<ProductHotPageProps> = ({ slug }) => {
<ItemProduct key={item.id} item={item} />
))}
</div>
<div className="paging flex items-center justify-center">
{page.paging_collection.map((item, index) => (
{page.paging_collection.map((item) => (
<Link
key={index}
key={item.url}
href={item.url}
className={`item ${item.is_active === '1' ? 'current' : ''}`}
>

View File

@@ -3,25 +3,24 @@
import React from 'react';
import Link from 'next/link';
import { useSearchParams } from 'next/navigation';
import { ErrorLink } from '@/components/Common/Error';
import type { TypeProductSearch } from '@/types/product/search';
import { Breadcrumb } from '@/components/Common/Breadcrumb';
import BoxFilter from '@components/Product/BoxFilter';
import BoxSort from '@components/Product/BoxSort';
import BoxFilter from '@/components/Product/BoxFilter';
import BoxSort from '@/components/Product/BoxSort';
import ItemProduct from '@/components/Common/ItemProduct';
import PreLoader from '@/components/Common/PreLoader';
import { getProductSearch } from '@/lib/api/product';
import { useApiData } from '@/hooks/useApiData';
import { getProductSearch } from '@/lib/api/product';
import type { TypeProductSearch } from '@/types/product/search';
const ProductSearchPage: React.FC = () => {
const searchParams = useSearchParams();
const keyword = searchParams.get('q') ?? '';
const search = searchParams.toString();
const productDisplayType = searchParams.get('display') === 'list' ? 'list' : 'grid';
const { data: page, isLoading } = useApiData(
() => getProductSearch(keyword),
[keyword],
() => getProductSearch(keyword, search),
[keyword, search],
{
initialData: null as TypeProductSearch | null,
enabled: keyword.length > 0,
@@ -40,7 +39,6 @@ const ProductSearchPage: React.FC = () => {
{ name: 'Trang chủ', url: '/' },
{ name: `Tìm kiếm "${keyword}"`, url: `/tim?q=${page.keywords}` },
];
const products = Object.values(page.product_list);
return (
@@ -57,8 +55,13 @@ const ProductSearchPage: React.FC = () => {
{products.length > 0 ? (
<div className="box-content-category">
<BoxFilter filters={page} />
<div className="box-list-product-category boder-radius-10">
<BoxSort sort_by_collection={page.sort_by_collection} product_display_type="grid" />
<BoxSort
sort_by_collection={page.sort_by_collection}
display_by_collection={page.display_by_collection}
product_display_type={productDisplayType}
/>
</div>
<div className="list-product-category grid grid-cols-5 gap-3">
@@ -66,10 +69,11 @@ const ProductSearchPage: React.FC = () => {
<ItemProduct key={item.id} item={item} />
))}
</div>
<div className="paging flex items-center justify-center">
{page.paging_collection.map((item, index) => (
{page.paging_collection.map((item) => (
<Link
key={index}
key={item.url}
href={item.url}
className={`item ${item.is_active === '1' ? 'current' : ''}`}
>
@@ -104,9 +108,7 @@ const ProductSearchPage: React.FC = () => {
<li>Thử lại bằng các từ khóa ngắn gọn hơn</li>
</ul>
</div>
<Link href="/">
<i className="fa fa-long-arrow-alt-left"></i> Quay lại trang chủ
</Link>
<Link href="/">Quay lại trang chủ</Link>
</div>
)}
</section>

View File

@@ -1,6 +1,7 @@
'use client';
import { DependencyList, useEffect, useRef, useState } from 'react';
import { useMSWReady } from '@/contexts/MSWContext';
interface UseApiDataOptions<T> {
initialData: T;
@@ -19,22 +20,25 @@ export function useApiData<T>(
options: UseApiDataOptions<T>,
): UseApiDataState<T> {
const { initialData, enabled = true } = options;
const mswReady = useMSWReady();
const shouldFetch = enabled && mswReady;
// Khởi tạo isLoading = enabled (true) để skeleton hiện ngay,
// tránh flash nội dung trống khi MSW chưa ready.
const [data, setData] = useState<T>(initialData);
const [isLoading, setIsLoading] = useState(enabled);
const [error, setError] = useState<Error | null>(null);
const loaderRef = useRef(loader);
const initialDataRef = useRef(initialData);
useEffect(() => {
loaderRef.current = loader;
}, [loader]);
useEffect(() => {
if (!enabled) {
return;
}
if (!shouldFetch) return;
let isMounted = true;
setIsLoading(true);
setError(null);
@@ -46,7 +50,7 @@ export function useApiData<T>(
.catch((err: unknown) => {
if (!isMounted) return;
setError(err instanceof Error ? err : new Error('Unknown API error'));
setData(initialData);
setData(initialDataRef.current);
})
.finally(() => {
if (!isMounted) return;
@@ -58,11 +62,7 @@ export function useApiData<T>(
};
// This hook accepts a caller-provided dependency list by design.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [enabled, initialData, ...deps]);
if (!enabled) {
return { data: initialData, isLoading: false, error: null };
}
}, [shouldFetch, ...deps]);
return { data, isLoading, error };
}

View File

@@ -7,13 +7,35 @@ import type {
PagingCollection,
PriceFilter,
Product,
ProductDetailData,
ProductImageDetail,
ProductImageGallery,
ProductDetailData,
} from '@/types';
import type { ProductCommentData } from '@/types/Comment';
import type { ProductReviewData } from '@/types/Review';
import type { TypeProductHot } from '@/types/producthot';
import type { TypeProductSearch } from '@/types/product/search';
import type { TypeListProduct } from '@/types/global/TypeListProduct';
import { normalizeAssetUrl, normalizeHtmlAssetUrls } from '@/lib/normalizeAssetUrl';
const SEARCH_PAGE_PATH = '/tim';
interface ListingPageBase {
brand_filter_list?: BrandFilter[];
paging_collection: PagingCollection[];
paging_count: string;
price_filter_list?: PriceFilter[];
product_count: string;
product_list: Product[] | Record<string, Product>;
}
export interface SubmitProductReviewPayload {
content: string;
email: string;
name: string;
rate: number;
}
function normalizeSlug(slug: string) {
return slug.replace(/^\/+/, '');
}
@@ -26,7 +48,21 @@ function normalizeSearch(search?: string) {
return search.startsWith('?') ? search : `?${search}`;
}
function normalizeProductList(productList: Product[] | Record<string, Product> | undefined) {
function toNumber(value: string | number | null | undefined) {
const normalizedValue =
typeof value === 'string' ? value.replace(/[^\d.-]/g, '') : (value ?? 0).toString();
const parsedValue = Number(normalizedValue);
return Number.isFinite(parsedValue) ? parsedValue : 0;
}
function normalizeText(value: string | number | undefined) {
return String(value ?? '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
function toProductArray(productList: Product[] | Record<string, Product> | undefined) {
if (!productList) {
return [];
}
@@ -34,6 +70,13 @@ function normalizeProductList(productList: Product[] | Record<string, Product> |
return Array.isArray(productList) ? productList : Object.values(productList);
}
function toProductRecord(products: Product[]) {
return products.reduce<Record<string, Product>>((accumulator, product) => {
accumulator[String(product.id)] = product;
return accumulator;
}, {});
}
function normalizeProductImage(image: ProductImageDetail): ProductImageDetail {
return {
small: normalizeAssetUrl(image.small),
@@ -73,38 +116,6 @@ function normalizeComboSet(comboSet: ComboSet[] | undefined) {
}));
}
function normalizeProductDetail(data: ProductDetailData): ProductDetailData {
return {
...data,
image: normalizeAssetUrl(data.image),
combo_set: normalizeComboSet(data.combo_set),
product_info: {
...data.product_info,
productImage: normalizeProductImage(data.product_info.productImage),
productImageGallery: normalizeProductGallery(data.product_info.productImageGallery),
productDescription: normalizeHtmlAssetUrls(data.product_info.productDescription),
productSpec: normalizeHtmlAssetUrls(data.product_info.productSpec),
multipartSpec: normalizeHtmlAssetUrls(data.product_info.multipartSpec),
productSummary: normalizeHtmlAssetUrls(data.product_info.productSummary),
thum_poster: normalizeAssetUrl(data.product_info.thum_poster),
},
};
}
function toNumber(value: string | number | null | undefined) {
const normalizedValue =
typeof value === 'string' ? value.replace(/[^\d.-]/g, '') : (value ?? 0).toString();
const parsedValue = Number(normalizedValue);
return Number.isFinite(parsedValue) ? parsedValue : 0;
}
function normalizeText(value: string | number | undefined) {
return String(value ?? '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
function isCurrentFilterUrl(searchParams: URLSearchParams, targetUrl: string) {
const target = new URL(targetUrl, 'http://local');
const targetParams = new URLSearchParams(target.search);
@@ -147,12 +158,15 @@ function filterByPrice(products: Product[], priceFilter: PriceFilter | undefined
return products.filter((product) => {
const price = toNumber(product.price);
if (min > 0 && price < min) {
return false;
}
if (max > 0 && price > max) {
return false;
}
return true;
});
}
@@ -193,7 +207,7 @@ function sortProducts(products: Product[], sortKey: string | null) {
}
function buildPagingCollection(
category: CategoryApiData,
basePath: string,
searchParams: URLSearchParams,
totalPages: number,
currentPage: number,
@@ -212,49 +226,103 @@ function buildPagingCollection(
return {
name: String(page),
url: queryString ? `${category.current_category.url}?${queryString}` : category.current_category.url,
url: queryString ? `${basePath}?${queryString}` : basePath,
is_active: page === currentPage ? '1' : '0',
};
});
}
function applyCategorySearchParams(category: CategoryApiData, search?: string): CategoryData {
function applyListingSearchParams<T extends ListingPageBase>(
page: T,
search: string | undefined,
basePath: string,
): T {
const searchParams = new URLSearchParams(normalizeSearch(search));
let productList = normalizeProductList(category.product_list);
const originalProducts = toProductArray(page.product_list);
let products = [...originalProducts];
if (searchParams.get('other_filter') === 'in-stock') {
productList = productList.filter((product) => toNumber(product.quantity) > 0);
products = products.filter((product) => toNumber(product.quantity) > 0);
}
const selectedBrand = category.brand_filter_list?.find((item) =>
const selectedBrand = page.brand_filter_list?.find((item) =>
isCurrentFilterUrl(searchParams, item.url),
);
productList = filterByBrand(productList, selectedBrand);
products = filterByBrand(products, selectedBrand);
const selectedPrice = category.price_filter_list?.find((item) =>
const selectedPrice = page.price_filter_list?.find((item) =>
isCurrentFilterUrl(searchParams, item.url),
);
productList = filterByPrice(productList, selectedPrice);
products = filterByPrice(products, selectedPrice);
productList = sortProducts(productList, searchParams.get('sort'));
products = sortProducts(products, searchParams.get('sort'));
const totalProducts = productList.length;
const configuredPageCount = toNumber(category.paging_count);
const perPage =
configuredPageCount > 0 ? Math.max(1, Math.ceil(totalProducts / configuredPageCount)) : totalProducts;
const totalPages =
perPage > 0 ? Math.max(1, Math.ceil(totalProducts / perPage)) : Math.max(1, configuredPageCount);
const pageSize =
toNumber(page.paging_count) > 0
? Math.max(1, Math.ceil(originalProducts.length / toNumber(page.paging_count)))
: Math.max(1, originalProducts.length);
const totalProducts = products.length;
const totalPages = Math.max(1, Math.ceil(totalProducts / pageSize));
const requestedPage = toNumber(searchParams.get('page'));
const currentPage = Math.min(Math.max(requestedPage || 1, 1), totalPages);
const pagedProducts =
perPage > 0 ? productList.slice((currentPage - 1) * perPage, currentPage * perPage) : productList;
const pagedProducts = products.slice((currentPage - 1) * pageSize, currentPage * pageSize);
return {
...category,
...page,
product_count: String(totalProducts),
paging_count: String(totalPages),
paging_collection: buildPagingCollection(category, searchParams, totalPages, currentPage),
product_list: pagedProducts,
paging_collection: buildPagingCollection(basePath, searchParams, totalPages, currentPage),
product_list: (
Array.isArray(page.product_list) ? pagedProducts : toProductRecord(pagedProducts)
) as T['product_list'],
};
}
function normalizeReviews(reviews: ProductReviewData[]) {
return reviews.map((review) => ({
...review,
user_avatar: review.user_avatar !== '0' ? normalizeAssetUrl(review.user_avatar) : review.user_avatar,
files: review.files.map((file) => ({
...file,
file_path: normalizeAssetUrl(file.file_path),
})),
new_replies: review.new_replies.map((reply) => ({
...reply,
user_avatar: reply.user_avatar !== '0' ? normalizeAssetUrl(reply.user_avatar) : reply.user_avatar,
})),
}));
}
function normalizeComments(comments: ProductCommentData[]) {
return comments.map((comment) => ({
...comment,
user_avatar: comment.user_avatar ? normalizeAssetUrl(comment.user_avatar) : comment.user_avatar,
files: comment.files.map((file) => ({
...file,
file_path: normalizeAssetUrl(file.file_path),
})),
new_replies: comment.new_replies.map((reply) => ({
...reply,
user_avatar: reply.user_avatar !== '0' ? normalizeAssetUrl(reply.user_avatar) : reply.user_avatar,
})),
}));
}
function normalizeProductDetail(data: ProductDetailData): ProductDetailData {
return {
...data,
image: normalizeAssetUrl(data.image),
combo_set: normalizeComboSet(data.combo_set),
product_info: {
...data.product_info,
productImage: normalizeProductImage(data.product_info.productImage),
productImageGallery: normalizeProductGallery(data.product_info.productImageGallery),
productDescription: normalizeHtmlAssetUrls(data.product_info.productDescription),
productSpec: normalizeHtmlAssetUrls(data.product_info.productSpec),
multipartSpec: normalizeHtmlAssetUrls(data.product_info.multipartSpec),
productSummary: normalizeHtmlAssetUrls(data.product_info.productSummary),
thum_poster: normalizeAssetUrl(data.product_info.thum_poster),
},
};
}
@@ -269,31 +337,55 @@ export function getProductDetail(slug: string) {
}
export function getProductComments(slug: string) {
return apiFetch<import('@/types/Comment').ProductCommentData[]>(
`/products/${normalizeSlug(slug)}/comments`,
return apiFetch<ProductCommentData[]>(`/products/${normalizeSlug(slug)}/comments`).then(
normalizeComments,
);
}
export function getProductReviews(slug: string) {
return apiFetch<import('@/types/Review').ProductReviewData[]>(
`/products/${normalizeSlug(slug)}/reviews`,
return apiFetch<ProductReviewData[]>(`/products/${normalizeSlug(slug)}/reviews`).then(
normalizeReviews,
);
}
export function submitProductReview(slug: string, payload: SubmitProductReviewPayload) {
return apiFetch<{ message: string; success: boolean }>(`/products/${normalizeSlug(slug)}/reviews`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(payload),
});
}
export function getProductCategory(slug: string, search?: string) {
const normalizedSearch = normalizeSearch(search);
return apiFetch<CategoryApiData>(`/products/category/${normalizeSlug(slug)}${normalizeSearch(search)}`).then(
(category): CategoryData => {
const normalizedCategory = applyListingSearchParams(
{
...category,
product_list: toProductArray(category.product_list),
},
search,
category.current_category.url,
);
return apiFetch<CategoryApiData>(`/products/category/${normalizeSlug(slug)}${normalizedSearch}`).then(
(category) => applyCategorySearchParams(category, normalizedSearch),
return {
...normalizedCategory,
product_list: toProductArray(normalizedCategory.product_list),
};
},
);
}
export function getProductHotPage(slug: string) {
return apiFetch<import('@/types/producthot').TypeProductHot>(
`/products/hot-page/${normalizeSlug(slug)}`,
export function getProductHotPage(slug: string, search?: string) {
return apiFetch<TypeProductHot>(`/products/hot-page/${normalizeSlug(slug)}`).then((page) =>
applyListingSearchParams(page, search, page.url),
);
}
export function getProductSearch(q: string) {
return apiFetch<import('@/types/product/search').TypeProductSearch>(`/products/search?q=${encodeURIComponent(q)}`);
export function getProductSearch(q: string, search?: string) {
return apiFetch<TypeProductSearch>(`/products/search?q=${encodeURIComponent(q)}`).then((page) =>
applyListingSearchParams(page, search, SEARCH_PAGE_PATH),
);
}

366
src/lib/buildpc/source.ts Normal file
View File

@@ -0,0 +1,366 @@
import { category_config } from '@/data/buildpc/category';
import { normalizeAssetUrl } from '@/lib/normalizeAssetUrl';
const BUILD_PC_ORIGIN = 'https://nguyencongpc.vn';
const BUILD_PC_SOURCE_URL = `${BUILD_PC_ORIGIN}/buildpc`;
const BUILD_PC_API_AUTHORIZATION = 'Basic ssaaAS76DAs6faFFghs1';
export interface BuildPcCategory {
id: number;
name: string;
}
export interface BuildPcFilterOption {
count: number;
is_selected: boolean | number;
name: string;
url: string;
}
export interface BuildPcAttributeFilter {
filter_code: string;
is_selected: boolean | number;
name: string;
value_list: BuildPcFilterOption[];
}
export interface BuildPcSortOption {
key: string;
name: string;
url: string;
}
export interface BuildPcPagingItem {
is_active: boolean | number;
name: number | string;
url: string;
}
export interface BuildPcProduct {
brand: {
brand_index?: string;
id?: number;
image?: string;
name?: string;
url?: string;
};
categories?: Array<{
id: string;
name: string;
url: string;
}>;
id: number;
marketPrice: number;
price: number;
price_off: number;
productId: number;
productImage: {
large: string;
original: string;
small: string;
};
productName: string;
productSKU: string;
productSummary: string;
productUrl: string;
quantity: number;
review?: {
rate: number;
total: number;
};
specialOffer?: {
all?: Array<{ title: string }>;
other?: Array<{ title: string }>;
};
warranty: string;
}
export interface BuildPcCategoryResponse {
attribute_filter_list: BuildPcAttributeFilter[];
brand_filter_list: BuildPcFilterOption[];
paging_collection: BuildPcPagingItem[];
paging_count: number;
price_filter_list: BuildPcFilterOption[];
product_list: BuildPcProduct[];
search_query?: string;
search_url?: string;
sort_by_collection: BuildPcSortOption[];
}
export interface BuildPcSnapshot {
actionLabels: {
addToCart: string;
downloadExcel: string;
exportImage: string;
printView: string;
};
categories: BuildPcCategory[];
estimateLabel: string;
refreshLabel: string;
sourceUrl: string;
subtitle: string;
tabs: string[];
title: string;
}
const DEFAULT_BUILD_PC_SNAPSHOT: BuildPcSnapshot = {
actionLabels: {
addToCart: 'Thêm vào giỏ hàng',
downloadExcel: 'Tải file excel cấu hình',
exportImage: 'Tải ảnh cấu hình',
printView: 'Xem & In',
},
categories: category_config,
estimateLabel: 'Chi phí dự tính',
refreshLabel: 'Làm mới',
sourceUrl: BUILD_PC_SOURCE_URL,
subtitle: 'Chọn linh kiện xây dựng cấu hình - Tự build PC',
tabs: ['Cấu hình 1', 'Cấu hình 2', 'Cấu hình 3', 'Cấu hình 4', 'Cấu hình 5'],
title: 'Build PC - Xây dựng cấu hình máy tính PC giá rẻ chuẩn nhất',
};
function decodeHtmlEntities(value: string) {
return value
.replace(/&nbsp;/g, ' ')
.replace(/&#39;/g, "'")
.replace(/&quot;/g, '"')
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>');
}
function cleanText(value: string) {
return decodeHtmlEntities(value)
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
function uniqueTexts(values: string[]) {
return [...new Set(values.map(cleanText).filter(Boolean))];
}
function extractFirstMatch(html: string, pattern: RegExp, fallback: string) {
const match = pattern.exec(html);
return match?.[1] ? cleanText(match[1]) : fallback;
}
function extractTabs(html: string) {
const sectionMatch = html.match(
/<ul[^>]*class="[^"]*list-btn-action-new[^"]*"[\s\S]*?>([\s\S]*?)<\/ul>/i,
);
if (!sectionMatch?.[1]) {
return DEFAULT_BUILD_PC_SNAPSHOT.tabs;
}
const spanMatches = [...sectionMatch[1].matchAll(/<span[^>]*>([\s\S]*?)<\/span>/gi)];
const tabs = uniqueTexts(spanMatches.map((match) => match[1]));
return tabs.length > 0 ? tabs : DEFAULT_BUILD_PC_SNAPSHOT.tabs;
}
function extractCategories(html: string) {
const matches = [...html.matchAll(/Chọn\s+([^<\r\n]+)/gi)];
const names = uniqueTexts(matches.map((match) => match[1]));
if (names.length === 0) {
return DEFAULT_BUILD_PC_SNAPSHOT.categories;
}
return names.map((name, index) => ({
id: category_config[index]?.id ?? index + 1,
name,
}));
}
function extractActionLabels(html: string) {
const actionBlockMatch = html.match(
/<ul[^>]*id="js-buildpc-action"[\s\S]*?>([\s\S]*?)<\/ul>/i,
);
if (!actionBlockMatch?.[1]) {
return DEFAULT_BUILD_PC_SNAPSHOT.actionLabels;
}
const labels = uniqueTexts(
[...actionBlockMatch[1].matchAll(/<span[^>]*data-action[^>]*>([\s\S]*?)<\/span>/gi)].map(
(match) => match[1],
),
);
return {
addToCart: labels[2] ?? DEFAULT_BUILD_PC_SNAPSHOT.actionLabels.addToCart,
downloadExcel: labels[1] ?? DEFAULT_BUILD_PC_SNAPSHOT.actionLabels.downloadExcel,
exportImage: labels[0] ?? DEFAULT_BUILD_PC_SNAPSHOT.actionLabels.exportImage,
printView: labels[3] ?? DEFAULT_BUILD_PC_SNAPSHOT.actionLabels.printView,
};
}
function toAbsoluteBuildPcUrl(url: string) {
if (url.startsWith('http://') || url.startsWith('https://')) {
return url;
}
if (url.startsWith('/')) {
return `${BUILD_PC_ORIGIN}${url}`;
}
return `${BUILD_PC_ORIGIN}/${url}`;
}
function normalizeBuildPcUrl(url: string) {
if (!url) {
return '';
}
if (url.startsWith('http://') || url.startsWith('https://')) {
const parsed = new URL(url);
return parsed.pathname + parsed.search;
}
return url.startsWith('/') ? url : `/${url}`;
}
function normalizeBuildPcFilterOption(option: BuildPcFilterOption): BuildPcFilterOption {
return {
...option,
url: toAbsoluteBuildPcUrl(option.url),
};
}
function normalizeBuildPcProduct(product: BuildPcProduct): BuildPcProduct {
return {
...product,
brand: {
...product.brand,
image: product.brand?.image ? normalizeAssetUrl(product.brand.image) : product.brand?.image,
url: product.brand?.url ? normalizeBuildPcUrl(product.brand.url) : product.brand?.url,
},
categories: product.categories?.map((category) => ({
...category,
url: normalizeBuildPcUrl(category.url),
})),
productImage: {
large: normalizeAssetUrl(product.productImage.large),
original: normalizeAssetUrl(product.productImage.original || product.productImage.large),
small: normalizeAssetUrl(product.productImage.small),
},
productUrl: normalizeBuildPcUrl(product.productUrl),
};
}
function normalizeBuildPcCategoryResponse(data: BuildPcCategoryResponse): BuildPcCategoryResponse {
const productList = (
Array.isArray(data.product_list) ? data.product_list : Object.values(data.product_list ?? {})
) as BuildPcProduct[];
return {
...data,
attribute_filter_list: (data.attribute_filter_list ?? []).map((filter) => ({
...filter,
value_list: (filter.value_list ?? []).map(normalizeBuildPcFilterOption),
})),
brand_filter_list: (data.brand_filter_list ?? []).map(normalizeBuildPcFilterOption),
paging_collection: (data.paging_collection ?? []).map((item) => ({
...item,
url: toAbsoluteBuildPcUrl(item.url),
})),
price_filter_list: (data.price_filter_list ?? []).map(normalizeBuildPcFilterOption),
product_list: productList.map(normalizeBuildPcProduct),
search_url: data.search_url ? toAbsoluteBuildPcUrl(data.search_url) : data.search_url,
sort_by_collection: (data.sort_by_collection ?? []).map((item) => ({
...item,
url: toAbsoluteBuildPcUrl(item.url),
})),
};
}
function withSearchQuery(url: string, q?: string) {
if (!q) {
return url;
}
const parsed = new URL(url);
parsed.searchParams.set('q', q);
return parsed.toString();
}
async function fetchBuildPcJson<T>(url: string): Promise<T> {
const response = await fetch(url, {
headers: {
Accept: 'application/json, text/javascript, */*; q=0.01',
Authorization: BUILD_PC_API_AUTHORIZATION,
Referer: BUILD_PC_SOURCE_URL,
'User-Agent': 'Mozilla/5.0 (compatible; NguyenCongPCNext/1.0)',
'X-Requested-With': 'XMLHttpRequest',
},
next: { revalidate: 300 },
});
if (!response.ok) {
throw new Error(`Build PC API ${response.status}`);
}
return response.json() as Promise<T>;
}
export async function getBuildPcSnapshot(): Promise<BuildPcSnapshot> {
try {
const response = await fetch(BUILD_PC_SOURCE_URL, {
headers: {
Accept: 'text/html,application/xhtml+xml',
'User-Agent': 'Mozilla/5.0 (compatible; NguyenCongPCNext/1.0)',
},
next: { revalidate: 3600 },
});
if (!response.ok) {
return DEFAULT_BUILD_PC_SNAPSHOT;
}
const html = await response.text();
return {
actionLabels: extractActionLabels(html),
categories: extractCategories(html),
estimateLabel: DEFAULT_BUILD_PC_SNAPSHOT.estimateLabel,
refreshLabel: extractFirstMatch(
html,
/<p[^>]*>\s*(Làm mới)\s*<\/p>/i,
DEFAULT_BUILD_PC_SNAPSHOT.refreshLabel,
),
sourceUrl: BUILD_PC_SOURCE_URL,
subtitle: extractFirstMatch(
html,
/<h2[^>]*>([\s\S]*?)<\/h2>/i,
DEFAULT_BUILD_PC_SNAPSHOT.subtitle,
),
tabs: extractTabs(html),
title: extractFirstMatch(
html,
/<h1[^>]*>([\s\S]*?)<\/h1>/i,
DEFAULT_BUILD_PC_SNAPSHOT.title,
),
};
} catch {
return DEFAULT_BUILD_PC_SNAPSHOT;
}
}
export async function getBuildPcCategoryData(params: {
categoryId?: number | string;
q?: string;
sourceUrl?: string;
}) {
const { categoryId, q, sourceUrl } = params;
let targetUrl = sourceUrl
? toAbsoluteBuildPcUrl(sourceUrl)
: `${BUILD_PC_ORIGIN}/ajax/get_json.php?action=pcbuilder&action_type=get-product-category&category_id=${categoryId}`;
targetUrl = withSearchQuery(targetUrl, q);
const data = await fetchBuildPcJson<BuildPcCategoryResponse>(targetUrl);
return normalizeBuildPcCategoryResponse(data);
}

View File

@@ -1,14 +1,12 @@
import { TypeProductSearch } from '@/types/product/search';
/**
* Tìm danh mục theo mảng slug (ví dụ: ["pc-gaming","cao-cap","rtx-4090"])
* Tìm danh mục theo slug tìm kiếm.
*/
export function findSearchBySlug(
keys: string | null,
categories: TypeProductSearch[],
): TypeProductSearch | null {
console.log('Searching for keys:', keys);
const found = categories.find((item) => item.keywords === keys);
return found ?? null;
}

View File

@@ -82,6 +82,27 @@ export const handlers = [
return HttpResponse.json(ListReviewData);
}),
http.post('/products/:slug/reviews', async ({ request }) => {
const payload = (await request.json()) as {
content?: string;
email?: string;
name?: string;
rate?: number;
};
if (!payload.content || !payload.name || !payload.rate) {
return HttpResponse.json(
{ message: 'Missing review fields', success: false },
{ status: 400 },
);
}
return HttpResponse.json({
message: 'Review submitted successfully',
success: true,
});
}),
// articles list
http.get('/articles', () => {
return HttpResponse.json(DataListArticleNews);

View File

@@ -20,10 +20,8 @@
],
"paths": {
"@/*": ["./src/*"],
"@components/*": ["./src/components/*"],
"@types/*": ["./src/types/*"],
"@styles/*": ["./src/styles/*"],
"@Common/*": ["./src/components/Common/*"]
"@styles/*": ["./src/styles/*"]
}
},
"include": [