update
This commit is contained in:
@@ -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)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
30
src/app/api/buildpc/category/route.ts
Normal file
30
src/app/api/buildpc/category/route.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { getBuildPcCategoryData } from '@/lib/buildpc/source';
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const categoryId = searchParams.get('categoryId');
|
||||
const q = searchParams.get('q') ?? undefined;
|
||||
const sourceUrl = searchParams.get('sourceUrl') ?? undefined;
|
||||
|
||||
if (!categoryId && !sourceUrl) {
|
||||
return NextResponse.json({ message: 'Missing categoryId or sourceUrl' }, { status: 400 });
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getBuildPcCategoryData({
|
||||
categoryId: categoryId ?? undefined,
|
||||
q,
|
||||
sourceUrl,
|
||||
});
|
||||
|
||||
return NextResponse.json(data);
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
message: error instanceof Error ? error.message : 'Failed to load build PC category',
|
||||
},
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 và bắt đầu lại.</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
};
|
||||
|
||||
export default BtnAction;
|
||||
|
||||
308
src/app/buildpc/BuilderClient.tsx
Normal file
308
src/app/buildpc/BuilderClient.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
BuildPcCategory,
|
||||
BuildPcCategoryResponse,
|
||||
BuildPcProduct,
|
||||
BuildPcSnapshot,
|
||||
} from '@/lib/buildpc/source';
|
||||
import { formatCurrency } from '@/lib/formatPrice';
|
||||
import { addToCart } from '@/lib/ButtonCart';
|
||||
import { BoxListAccessory } from './BoxListAccessory';
|
||||
import BtnAction from './BtnAction';
|
||||
import ProductPickerModal from './ProductPickerModal';
|
||||
|
||||
type SelectedBuildPcItem = {
|
||||
category: BuildPcCategory;
|
||||
product: BuildPcProduct;
|
||||
quantity: number;
|
||||
};
|
||||
|
||||
interface BuilderClientProps {
|
||||
snapshot: BuildPcSnapshot;
|
||||
}
|
||||
|
||||
const BuilderClient = ({ snapshot }: BuilderClientProps) => {
|
||||
const [selectedItems, setSelectedItems] = useState<Record<number, SelectedBuildPcItem>>({});
|
||||
const [activeCategory, setActiveCategory] = useState<BuildPcCategory | null>(null);
|
||||
const [activeListing, setActiveListing] = useState<BuildPcCategoryResponse | null>(null);
|
||||
const [activeRequestUrl, setActiveRequestUrl] = useState<string | null>(null);
|
||||
const [isLoadingListing, setIsLoadingListing] = useState(false);
|
||||
const [listingError, setListingError] = useState('');
|
||||
|
||||
const totalPrice = useMemo(() => {
|
||||
return Object.values(selectedItems).reduce((sum, item) => {
|
||||
return sum + item.product.price * item.quantity;
|
||||
}, 0);
|
||||
}, [selectedItems]);
|
||||
|
||||
const selectedCount = Object.keys(selectedItems).length;
|
||||
const totalCategories = snapshot.categories.length;
|
||||
const completionPercent =
|
||||
totalCategories === 0 ? 0 : Math.round((selectedCount / totalCategories) * 100);
|
||||
|
||||
const loadListing = async (params: { categoryId?: number; q?: string; sourceUrl?: string }) => {
|
||||
setIsLoadingListing(true);
|
||||
setListingError('');
|
||||
|
||||
try {
|
||||
const url = new URL('/api/buildpc/category', window.location.origin);
|
||||
if (params.categoryId) {
|
||||
url.searchParams.set('categoryId', String(params.categoryId));
|
||||
}
|
||||
if (params.sourceUrl) {
|
||||
url.searchParams.set('sourceUrl', params.sourceUrl);
|
||||
}
|
||||
if (params.q) {
|
||||
url.searchParams.set('q', params.q);
|
||||
}
|
||||
|
||||
const response = await fetch(url.toString(), { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
throw new Error('Không thể tải danh sách linh kiện');
|
||||
}
|
||||
|
||||
const data = (await response.json()) as BuildPcCategoryResponse;
|
||||
setActiveListing(data);
|
||||
setActiveRequestUrl(params.sourceUrl ?? null);
|
||||
} catch (error) {
|
||||
setListingError(error instanceof Error ? error.message : 'Không thể tải danh sách linh kiện');
|
||||
} finally {
|
||||
setIsLoadingListing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeCategory) {
|
||||
return;
|
||||
}
|
||||
|
||||
void loadListing({ categoryId: activeCategory.id });
|
||||
}, [activeCategory]);
|
||||
|
||||
const handleOpenCategory = (category: BuildPcCategory) => {
|
||||
setActiveCategory(category);
|
||||
setActiveListing(null);
|
||||
setActiveRequestUrl(null);
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setActiveCategory(null);
|
||||
setActiveListing(null);
|
||||
setActiveRequestUrl(null);
|
||||
setListingError('');
|
||||
};
|
||||
|
||||
const handleSelectProduct = (product: BuildPcProduct) => {
|
||||
if (!activeCategory) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSelectedItems((currentValue) => ({
|
||||
...currentValue,
|
||||
[activeCategory.id]: {
|
||||
category: activeCategory,
|
||||
product,
|
||||
quantity: currentValue[activeCategory.id]?.quantity ?? 1,
|
||||
},
|
||||
}));
|
||||
handleCloseModal();
|
||||
};
|
||||
|
||||
const handleQuantityChange = (categoryId: number, quantity: number) => {
|
||||
setSelectedItems((currentValue) => {
|
||||
const selectedItem = currentValue[categoryId];
|
||||
if (!selectedItem) {
|
||||
return currentValue;
|
||||
}
|
||||
|
||||
return {
|
||||
...currentValue,
|
||||
[categoryId]: {
|
||||
...selectedItem,
|
||||
quantity,
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleRemoveProduct = (categoryId: number) => {
|
||||
setSelectedItems((currentValue) => {
|
||||
const nextValue = { ...currentValue };
|
||||
delete nextValue[categoryId];
|
||||
return nextValue;
|
||||
});
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
setSelectedItems({});
|
||||
};
|
||||
|
||||
const handleExportJson = () => {
|
||||
if (selectedCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = new Blob([JSON.stringify(selectedItems, null, 2)], {
|
||||
type: 'application/json;charset=utf-8',
|
||||
});
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = 'build-pc-config.json';
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
};
|
||||
|
||||
const handleExportCsv = () => {
|
||||
if (selectedCount === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rows = [
|
||||
['Danh mục', 'Sản phẩm', 'SKU', 'Đơn giá', 'Số lượng', 'Thành tiền'].join(','),
|
||||
...Object.values(selectedItems).map((item) =>
|
||||
[
|
||||
`"${item.category.name}"`,
|
||||
`"${item.product.productName.replace(/"/g, '""')}"`,
|
||||
item.product.productSKU,
|
||||
item.product.price,
|
||||
item.quantity,
|
||||
item.product.price * item.quantity,
|
||||
].join(','),
|
||||
),
|
||||
];
|
||||
|
||||
const blob = new Blob([rows.join('\n')], {
|
||||
type: 'text/csv;charset=utf-8',
|
||||
});
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = 'build-pc-config.csv';
|
||||
link.click();
|
||||
URL.revokeObjectURL(link.href);
|
||||
};
|
||||
|
||||
const handleAddToCart = () => {
|
||||
Object.values(selectedItems).forEach((item) => {
|
||||
addToCart(item.product, item.quantity);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="grid gap-4 xl:grid-cols-[1.45fr_0.95fr]">
|
||||
<div className="rounded-[24px] border border-slate-200 bg-white p-5 shadow-sm">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-semibold tracking-[0.2em] text-slate-500 uppercase">
|
||||
Trình dựng cấu hình
|
||||
</p>
|
||||
<h3 className="border-none pb-0 text-2xl font-semibold text-slate-900 normal-case">
|
||||
Lắp bộ máy theo từng bước
|
||||
</h3>
|
||||
<p className="max-w-2xl text-sm leading-6 text-slate-600">
|
||||
Chọn linh kiện theo từng danh mục, điều chỉnh số lượng rồi xuất cấu hình hoặc thêm
|
||||
toàn bộ vào giỏ hàng.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="min-w-[220px] rounded-2xl bg-slate-950 px-5 py-4 text-white shadow-[0_18px_40px_rgba(15,23,42,0.24)]">
|
||||
<p className="text-xs tracking-[0.24em] text-white/60 uppercase">
|
||||
{snapshot.estimateLabel}
|
||||
</p>
|
||||
<p className="mt-2 text-3xl font-semibold">
|
||||
{formatCurrency(totalPrice)}
|
||||
<span className="ml-1 text-lg text-white/70">đ</span>
|
||||
</p>
|
||||
<p className="mt-3 text-sm text-white/70">
|
||||
{selectedCount}/{totalCategories} danh mục đã chọn
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 space-y-3">
|
||||
<div className="flex items-center justify-between text-sm text-slate-600">
|
||||
<span>Tiến độ hoàn thiện cấu hình</span>
|
||||
<span className="font-semibold text-slate-900">{completionPercent}%</span>
|
||||
</div>
|
||||
<div className="h-3 overflow-hidden rounded-full bg-slate-100">
|
||||
<div
|
||||
className="h-full rounded-full bg-[linear-gradient(90deg,#ef4444_0%,#f97316_100%)] transition-all"
|
||||
style={{ width: `${completionPercent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-3 text-sm text-slate-600 sm:grid-cols-3">
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<p className="text-xs tracking-[0.2em] text-slate-400 uppercase">Danh mục</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-900">{totalCategories}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<p className="text-xs tracking-[0.2em] text-slate-400 uppercase">Đã chọn</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-900">{selectedCount}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-slate-200 bg-slate-50 px-4 py-3">
|
||||
<p className="text-xs tracking-[0.2em] text-slate-400 uppercase">Trạng thái</p>
|
||||
<p className="mt-1 text-lg font-semibold text-slate-900">
|
||||
{selectedCount === 0 ? 'Chưa bắt đầu' : 'Đang cấu hình'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="js-buildpc-promotion-content" />
|
||||
</div>
|
||||
|
||||
<div className="rounded-[24px] border border-red-100 bg-[linear-gradient(180deg,#fff7f7_0%,#ffffff_100%)] p-5 shadow-sm">
|
||||
<p className="text-sm font-semibold tracking-[0.2em] text-red-500 uppercase">
|
||||
Mẹo cấu hình
|
||||
</p>
|
||||
<div className="mt-4 space-y-3 text-sm leading-6 text-slate-600">
|
||||
<div className="rounded-2xl border border-white bg-white/80 p-4">
|
||||
Chọn CPU trước để lọc nhanh mainboard, RAM và tản nhiệt tương thích hơn.
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white bg-white/80 p-4">
|
||||
Ưu tiên PSU và case ở cuối để cân theo tổng công suất và kích thước thực tế.
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white bg-white/80 p-4">
|
||||
Khi đã đủ cấu hình, xuất CSV hoặc in nhanh trước khi gửi cho khách.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<BoxListAccessory
|
||||
categories={snapshot.categories}
|
||||
selectedItems={selectedItems}
|
||||
onOpenCategory={handleOpenCategory}
|
||||
onQuantityChange={handleQuantityChange}
|
||||
onRemoveProduct={handleRemoveProduct}
|
||||
/>
|
||||
|
||||
<BtnAction
|
||||
actionLabels={snapshot.actionLabels}
|
||||
estimateLabel={snapshot.estimateLabel}
|
||||
selectedCount={selectedCount}
|
||||
totalCategories={totalCategories}
|
||||
totalPrice={totalPrice}
|
||||
onAddToCart={handleAddToCart}
|
||||
onExportCsv={handleExportCsv}
|
||||
onExportJson={handleExportJson}
|
||||
onPrint={() => window.print()}
|
||||
onReset={handleReset}
|
||||
/>
|
||||
|
||||
<ProductPickerModal
|
||||
activeCategory={activeCategory}
|
||||
currentRequestUrl={activeRequestUrl}
|
||||
error={listingError}
|
||||
isLoading={isLoadingListing}
|
||||
listing={activeListing}
|
||||
onClose={handleCloseModal}
|
||||
onLoadListing={loadListing}
|
||||
onSelectProduct={handleSelectProduct}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default BuilderClient;
|
||||
351
src/app/buildpc/ProductPickerModal.tsx
Normal file
351
src/app/buildpc/ProductPickerModal.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import Image from 'next/image';
|
||||
import Link from 'next/link';
|
||||
import { BuildPcCategory, BuildPcCategoryResponse, BuildPcProduct } from '@/lib/buildpc/source';
|
||||
import { formatCurrency } from '@/lib/formatPrice';
|
||||
|
||||
interface ProductPickerModalProps {
|
||||
activeCategory: BuildPcCategory | null;
|
||||
currentRequestUrl: string | null;
|
||||
error: string;
|
||||
isLoading: boolean;
|
||||
listing: BuildPcCategoryResponse | null;
|
||||
onClose: () => void;
|
||||
onLoadListing: (params: { categoryId?: number; q?: string; sourceUrl?: string }) => Promise<void>;
|
||||
onSelectProduct: (product: BuildPcProduct) => void;
|
||||
}
|
||||
|
||||
const ProductPickerModal = ({
|
||||
activeCategory,
|
||||
currentRequestUrl,
|
||||
error,
|
||||
isLoading,
|
||||
listing,
|
||||
onClose,
|
||||
onLoadListing,
|
||||
onSelectProduct,
|
||||
}: ProductPickerModalProps) => {
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const totalProducts = useMemo(() => listing?.product_list.length ?? 0, [listing]);
|
||||
const portalTarget = typeof document === 'undefined' ? null : document.body;
|
||||
|
||||
if (!activeCategory || !portalTarget) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleSearch = () => {
|
||||
void onLoadListing({
|
||||
categoryId: activeCategory.id,
|
||||
q: searchQuery.trim() || undefined,
|
||||
sourceUrl: currentRequestUrl ?? undefined,
|
||||
});
|
||||
};
|
||||
|
||||
const renderFilterButton = (
|
||||
key: string,
|
||||
label: string,
|
||||
count: number,
|
||||
isSelected: boolean | number,
|
||||
url: string,
|
||||
) => (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
onClick={() => void onLoadListing({ sourceUrl: url })}
|
||||
className={`inline-flex min-h-10 items-center rounded-2xl border px-3 py-2 text-left text-sm transition ${
|
||||
isSelected
|
||||
? 'border-red-500 bg-red-50 text-red-700 shadow-sm'
|
||||
: 'border-slate-200 bg-white text-slate-600 hover:border-red-200 hover:text-red-700'
|
||||
}`}
|
||||
>
|
||||
<span className="break-words">{label}</span>
|
||||
<span className="ml-1 shrink-0 text-slate-400">({count})</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[2000] bg-slate-950/70 backdrop-blur-sm">
|
||||
<div className="absolute left-1/2 top-1/2 flex h-[calc(100vh-16px)] w-[calc(100vw-16px)] max-w-[1320px] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-[28px] border border-white/10 bg-white shadow-[0_30px_80px_rgba(15,23,42,0.35)] md:h-[calc(100vh-48px)] md:w-[calc(100vw-48px)]">
|
||||
<div className="shrink-0 border-b border-slate-200 bg-[linear-gradient(135deg,#111827_0%,#7f1d1d_100%)] px-4 py-4 text-white md:px-6 md:py-5">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs uppercase tracking-[0.24em] text-white/60">
|
||||
Danh sách linh kiện
|
||||
</p>
|
||||
<h3 className="mt-2 border-none pb-0 text-2xl font-semibold normal-case text-white">
|
||||
Chọn {activeCategory.name}
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-white/75">
|
||||
Hiển thị {totalProducts} sản phẩm trong kết quả hiện tại.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/15"
|
||||
>
|
||||
Đóng
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex flex-col gap-3 xl:flex-row">
|
||||
<div className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-white/10 p-2">
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(event) => setSearchQuery(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
placeholder={`Tìm trong ${activeCategory.name.toLowerCase()}`}
|
||||
className="h-12 min-w-0 flex-1 rounded-xl border border-transparent bg-white px-4 text-sm text-slate-900 outline-none transition focus:border-red-300"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSearch}
|
||||
className="h-12 shrink-0 rounded-xl bg-red-500 px-5 text-sm font-semibold text-white transition hover:bg-red-600 sm:min-w-[150px]"
|
||||
>
|
||||
Tìm linh kiện
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3 xl:w-[280px]">
|
||||
<div className="rounded-2xl border border-white/10 bg-white/10 px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-white/55">Kết quả</p>
|
||||
<p className="mt-1 text-lg font-semibold">{totalProducts}</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-white/10 bg-white/10 px-4 py-3">
|
||||
<p className="text-xs uppercase tracking-[0.2em] text-white/55">Bộ lọc</p>
|
||||
<p className="mt-1 text-lg font-semibold">
|
||||
{(listing?.brand_filter_list?.length ?? 0) +
|
||||
(listing?.price_filter_list?.length ?? 0) +
|
||||
(listing?.attribute_filter_list?.length ?? 0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid min-h-0 flex-1 xl:grid-cols-[320px_minmax(0,1fr)]">
|
||||
<aside className="min-h-0 overflow-y-auto border-b border-slate-200 bg-slate-50/80 p-4 md:p-5 xl:border-b-0 xl:border-r">
|
||||
<div className="space-y-5">
|
||||
{listing?.brand_filter_list?.length ? (
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<h4 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
Thương hiệu
|
||||
</h4>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{listing.brand_filter_list.map((item) =>
|
||||
renderFilterButton(
|
||||
`brand-${item.url}-${item.name}`,
|
||||
item.name,
|
||||
item.count,
|
||||
item.is_selected,
|
||||
item.url,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{listing?.price_filter_list?.length ? (
|
||||
<section className="rounded-2xl border border-slate-200 bg-white p-4">
|
||||
<h4 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
Khoảng giá
|
||||
</h4>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{listing.price_filter_list.map((item) =>
|
||||
renderFilterButton(
|
||||
`price-${item.url}-${item.name}`,
|
||||
item.name,
|
||||
item.count,
|
||||
item.is_selected,
|
||||
item.url,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{(listing?.attribute_filter_list ?? []).map((filter) => (
|
||||
<section
|
||||
key={filter.filter_code}
|
||||
className="rounded-2xl border border-slate-200 bg-white p-4"
|
||||
>
|
||||
<h4 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-500">
|
||||
{filter.name}
|
||||
</h4>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{filter.value_list.map((item) =>
|
||||
renderFilterButton(
|
||||
`attribute-${filter.filter_code}-${item.url}-${item.name}`,
|
||||
item.name,
|
||||
item.count,
|
||||
item.is_selected,
|
||||
item.url,
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="flex min-h-0 flex-col overflow-hidden bg-white">
|
||||
<div className="shrink-0 border-b border-slate-200 px-4 py-4 md:px-5">
|
||||
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
|
||||
Sắp xếp
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{(listing?.sort_by_collection ?? []).map((item, index) => (
|
||||
<button
|
||||
key={`sort-${item.url}-${item.name}-${index}`}
|
||||
type="button"
|
||||
onClick={() => void onLoadListing({ sourceUrl: item.url })}
|
||||
className="rounded-full border border-slate-200 px-3 py-2 text-sm font-medium text-slate-600 transition hover:border-red-200 hover:text-red-700"
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500">
|
||||
Chọn sản phẩm để thay trực tiếp vào cấu hình hiện tại.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="min-h-0 flex-1 overflow-y-auto p-4 md:p-5">
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="animate-pulse rounded-[24px] border border-slate-200 bg-slate-50 p-4"
|
||||
>
|
||||
<div className="aspect-square rounded-2xl bg-slate-200" />
|
||||
<div className="mt-4 h-4 rounded bg-slate-200" />
|
||||
<div className="mt-2 h-4 w-2/3 rounded bg-slate-200" />
|
||||
<div className="mt-6 h-10 rounded-xl bg-slate-200" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && error ? (
|
||||
<div className="rounded-[24px] border border-red-100 bg-red-50 p-5 text-sm text-red-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && listing?.product_list?.length ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
|
||||
{listing.product_list.map((product) => (
|
||||
<article
|
||||
key={product.productId}
|
||||
className="group flex h-full min-w-0 flex-col rounded-[24px] border border-slate-200 bg-white p-4 shadow-sm transition hover:-translate-y-1 hover:shadow-[0_16px_40px_rgba(15,23,42,0.08)]"
|
||||
>
|
||||
<Link
|
||||
href={product.productUrl}
|
||||
target="_blank"
|
||||
className="relative block aspect-square overflow-hidden rounded-2xl bg-slate-50"
|
||||
>
|
||||
<Image
|
||||
src={product.productImage.large}
|
||||
alt={product.productName}
|
||||
fill
|
||||
className="object-contain p-3 transition duration-300 group-hover:scale-105"
|
||||
/>
|
||||
</Link>
|
||||
|
||||
<div className="mt-4 flex min-h-0 flex-1 flex-col space-y-3">
|
||||
<Link
|
||||
href={product.productUrl}
|
||||
target="_blank"
|
||||
className="line-clamp-2 min-h-12 text-sm font-semibold leading-6 text-slate-900 transition group-hover:text-red-600"
|
||||
>
|
||||
{product.productName}
|
||||
</Link>
|
||||
|
||||
<div className="grid gap-2 text-sm text-slate-500">
|
||||
<p>SKU: {product.productSKU}</p>
|
||||
<p>Bảo hành: {product.warranty || 'Liên hệ'}</p>
|
||||
<p>
|
||||
{product.quantity > 0 ? `Còn hàng: ${product.quantity}` : 'Tạm hết hàng'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto pt-2">
|
||||
{product.marketPrice > product.price ? (
|
||||
<p className="text-sm text-slate-400 line-through">
|
||||
{formatCurrency(product.marketPrice)}đ
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-xl font-semibold text-red-600">
|
||||
{formatCurrency(product.price)}đ
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSelectProduct(product)}
|
||||
className="w-full rounded-xl bg-[linear-gradient(135deg,#b91c1c_0%,#ef4444_100%)] px-4 py-3 text-sm font-semibold text-white transition hover:brightness-105"
|
||||
>
|
||||
Thêm vào cấu hình
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isLoading && !error && !listing?.product_list?.length ? (
|
||||
<div className="rounded-[24px] border border-slate-200 bg-slate-50 p-8 text-center">
|
||||
<p className="text-lg font-semibold text-slate-900">Chưa có sản phẩm phù hợp</p>
|
||||
<p className="mt-2 text-sm text-slate-500">
|
||||
Thử đổi bộ lọc hoặc tìm bằng từ khóa khác.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{!isLoading && listing?.paging_collection?.length ? (
|
||||
<div className="shrink-0 border-t border-slate-200 px-4 py-4 md:px-5">
|
||||
<div className="flex flex-wrap items-center justify-center gap-2">
|
||||
{listing.paging_collection.map((item, index) => (
|
||||
<button
|
||||
key={`paging-${item.url}-${item.name}-${index}`}
|
||||
type="button"
|
||||
onClick={() => void onLoadListing({ sourceUrl: item.url })}
|
||||
className={`rounded-full border px-4 py-2 text-sm font-medium transition ${
|
||||
item.is_active
|
||||
? 'border-red-500 bg-red-50 text-red-700'
|
||||
: 'border-slate-200 text-slate-600 hover:border-red-200 hover:text-red-700'
|
||||
}`}
|
||||
>
|
||||
{item.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
portalTarget,
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductPickerModal;
|
||||
@@ -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 và đồ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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 và phụ kiện chính hãng với giá tốt, bảo hành uy tín và giao hàng nhanh toàn quốc.',
|
||||
keywords: ['máy tính', 'laptop', 'linh kiện máy tính', 'Nguyễn Công PC', 'PC gaming'],
|
||||
authors: [{ name: 'Nguyễn Công PC' }],
|
||||
openGraph: {
|
||||
type: 'website',
|
||||
@@ -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 và 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
83
src/app/loading.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import Skeleton from '@/components/Common/Skeleton';
|
||||
|
||||
export default function Loading() {
|
||||
return (
|
||||
<div className="page-hompage mt-4">
|
||||
<div className="container">
|
||||
{/* Slider */}
|
||||
<Skeleton className="h-100 w-full" />
|
||||
<div className="mt-3 flex gap-3">
|
||||
<Skeleton className="h-40 flex-1" />
|
||||
<Skeleton className="h-40 flex-1" />
|
||||
</div>
|
||||
|
||||
{/* Deal */}
|
||||
<div className="box-product-deal boder-radius-10 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-8 w-32" />
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-6 gap-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
<Skeleton className="aspect-square w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-5 w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Category Feature */}
|
||||
<div className="box-category-outstanding boder-radius-10 mt-4">
|
||||
<Skeleton className="mb-3 h-6 w-40" />
|
||||
<div className="grid grid-cols-10 gap-3">
|
||||
{Array.from({ length: 10 }).map((_, i) => (
|
||||
<div key={i} className="flex flex-col items-center gap-2">
|
||||
<Skeleton className="h-12 w-12 rounded-full" />
|
||||
<Skeleton className="h-3 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Box List Category */}
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<div key={i} className="box-product-category boder-radius-10 mt-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Skeleton className="h-6 w-40" />
|
||||
<div className="flex gap-3">
|
||||
{Array.from({ length: 4 }).map((_, j) => (
|
||||
<Skeleton key={j} className="h-5 w-20" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-5 gap-3">
|
||||
{Array.from({ length: 5 }).map((_, j) => (
|
||||
<div key={j} className="flex flex-col gap-2">
|
||||
<Skeleton className="aspect-square w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-5 w-1/2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Box Article */}
|
||||
<div className="box-article-group boder-radius-10 mt-4">
|
||||
<Skeleton className="mb-3 h-6 w-48" />
|
||||
<div className="flex gap-10">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="flex flex-1 flex-col gap-2">
|
||||
<Skeleton className="aspect-video w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-3 w-3/4" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
5
src/components/common/Skeleton/index.tsx
Normal file
5
src/components/common/Skeleton/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
const Skeleton = ({ className = '' }: { className?: string }) => (
|
||||
<div className={`animate-pulse rounded-lg bg-gray-200 ${className}`} />
|
||||
);
|
||||
|
||||
export default Skeleton;
|
||||
@@ -10,7 +10,6 @@ export const ErrorLink = () => {
|
||||
transition={{ duration: 0.4 }}
|
||||
className="w-full max-w-md rounded-3xl bg-white p-8 text-center shadow-xl"
|
||||
>
|
||||
{/* Icon lỗi link */}
|
||||
<motion.div
|
||||
animate={{ y: [0, -4, 0] }}
|
||||
transition={{ repeat: Infinity, duration: 1.8 }}
|
||||
@@ -34,23 +33,22 @@ export const ErrorLink = () => {
|
||||
<h1 className="text-2xl font-bold text-gray-800">Đường dẫn không hợp lệ</h1>
|
||||
|
||||
<p className="mt-3 text-sm text-gray-600">
|
||||
Bạn truy cập không tồn tại hoặc đường dẫn đã bị thay đổi.
|
||||
Nội dung bạn truy cập không tồn tại hoặc đường dẫn đã được thay đổi.
|
||||
</p>
|
||||
|
||||
{/* CTA */}
|
||||
<div className="mt-8 flex flex-col gap-3">
|
||||
<Link
|
||||
href="/"
|
||||
className="rounded-xl bg-blue-600 px-6 py-3 text-sm font-semibold text-white transition hover:bg-blue-700"
|
||||
>
|
||||
← Về trang chủ
|
||||
Về trang chủ
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href="/products"
|
||||
href="/pc-gaming"
|
||||
className="rounded-xl border border-gray-300 px-6 py-3 text-sm font-medium text-gray-700 transition hover:bg-gray-100"
|
||||
>
|
||||
Xem tất cả sản phẩm
|
||||
Xem danh mục PC Gaming
|
||||
</Link>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
8
src/contexts/MSWContext.tsx
Normal file
8
src/contexts/MSWContext.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
'use client';
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export const MSWContext = createContext<boolean>(true);
|
||||
|
||||
export function useMSWReady() {
|
||||
return useContext(MSWContext);
|
||||
}
|
||||
@@ -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ả
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) => (
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -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' : ''}`}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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
366
src/lib/buildpc/source.ts
Normal 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(/ /g, ' ')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -20,10 +20,8 @@
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@components/*": ["./src/components/*"],
|
||||
"@types/*": ["./src/types/*"],
|
||||
"@styles/*": ["./src/styles/*"],
|
||||
"@Common/*": ["./src/components/Common/*"]
|
||||
"@styles/*": ["./src/styles/*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
|
||||
Reference in New Issue
Block a user