diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b9667e5..a1843ed 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -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)" ] } } diff --git a/package.json b/package.json index 90e198b..c3f4cad 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/api/buildpc/category/route.ts b/src/app/api/buildpc/category/route.ts new file mode 100644 index 0000000..988f366 --- /dev/null +++ b/src/app/api/buildpc/category/route.ts @@ -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 }, + ); + } +} diff --git a/src/app/buildpc/BoxListAccessory/index.tsx b/src/app/buildpc/BoxListAccessory/index.tsx index 5aa48dc..7dd45c1 100644 --- a/src/app/buildpc/BoxListAccessory/index.tsx +++ b/src/app/buildpc/BoxListAccessory/index.tsx @@ -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; } -export const BoxListAccessory = () => { - const { data: categories } = useApiData(() => getBuildPcCategories(), [], { - initialData: [] as BuildPcCategory[], - }); - +export const BoxListAccessory = ({ + categories, + onOpenCategory, + onQuantityChange, + onRemoveProduct, + selectedItems, +}: BoxListAccessoryProps) => { return ( -
- {categories.map((category, index) => ( -
-
-

- {index + 1}. {category.name} -

-
-
- - + {''} - Chọn {category.name} - -
-
+
+
+
+

+ Danh sách linh kiện +

+

+ Hoàn thiện cấu hình từng phần +

- ))} -
+

+ Nhấn vào từng mục để chọn hoặc thay linh kiện phù hợp. +

+
+ +
+ {categories.map((category, index) => { + const selectedItem = selectedItems[category.id]; + + return ( +
+
+
+
+ {index + 1} +
+
+

Linh kiện

+

{category.name}

+

+ {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.'} +

+
+
+ + {!selectedItem ? ( + + ) : ( +
+
+
+
+ {selectedItem.product.productName} +
+ +
+

+ Đã chọn +

+

+ {selectedItem.product.productName} +

+
+ SKU: {selectedItem.product.productSKU} + + Bảo hành: {selectedItem.product.warranty || 'Liên hệ'} + + + {selectedItem.product.quantity > 0 + ? `Còn hàng: ${selectedItem.product.quantity}` + : 'Tạm hết hàng'} + +
+
+
+ +
+
+

+ Đơn giá +

+

+ {formatCurrency(selectedItem.product.price)}đ +

+
+ +
+

+ Thành tiền +

+

+ {formatCurrency(selectedItem.product.price * selectedItem.quantity)}đ +

+
+
+
+ +
+ + +
+
+ )} +
+
+ ); + })} +
+ ); }; diff --git a/src/app/buildpc/BtnAction/index.tsx b/src/app/buildpc/BtnAction/index.tsx index 03b8d82..b54401d 100644 --- a/src/app/buildpc/BtnAction/index.tsx +++ b/src/app/buildpc/BtnAction/index.tsx @@ -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 ( - <> -
-

- Chi phí dự tính: -

-
- - +
+ 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. +
+
+ +
+ + + + + + + + + +
+ + ); }; + export default BtnAction; diff --git a/src/app/buildpc/BuilderClient.tsx b/src/app/buildpc/BuilderClient.tsx new file mode 100644 index 0000000..6c0e2f6 --- /dev/null +++ b/src/app/buildpc/BuilderClient.tsx @@ -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>({}); + const [activeCategory, setActiveCategory] = useState(null); + const [activeListing, setActiveListing] = useState(null); + const [activeRequestUrl, setActiveRequestUrl] = useState(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 ( + <> +
+
+
+
+

+ Trình dựng cấu hình +

+

+ Lắp bộ máy theo từng bước +

+

+ 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. +

+
+ +
+

+ {snapshot.estimateLabel} +

+

+ {formatCurrency(totalPrice)} + đ +

+

+ {selectedCount}/{totalCategories} danh mục đã chọn +

+
+
+ +
+
+ Tiến độ hoàn thiện cấu hình + {completionPercent}% +
+
+
+
+
+
+

Danh mục

+

{totalCategories}

+
+
+

Đã chọn

+

{selectedCount}

+
+
+

Trạng thái

+

+ {selectedCount === 0 ? 'Chưa bắt đầu' : 'Đang cấu hình'} +

+
+
+
+
+
+ +
+

+ Mẹo cấu hình +

+
+
+ Chọn CPU trước để lọc nhanh mainboard, RAM và tản nhiệt tương thích hơn. +
+
+ Ư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ế. +
+
+ Khi đã đủ cấu hình, xuất CSV hoặc in nhanh trước khi gửi cho khách. +
+
+
+
+ + + + window.print()} + onReset={handleReset} + /> + + + + ); +}; + +export default BuilderClient; diff --git a/src/app/buildpc/ProductPickerModal.tsx b/src/app/buildpc/ProductPickerModal.tsx new file mode 100644 index 0000000..034d2d1 --- /dev/null +++ b/src/app/buildpc/ProductPickerModal.tsx @@ -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; + 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, + ) => ( + + ); + + return createPortal( +
+
+
+
+
+

+ Danh sách linh kiện +

+

+ Chọn {activeCategory.name} +

+

+ Hiển thị {totalProducts} sản phẩm trong kết quả hiện tại. +

+
+ +
+ +
+
+
+ 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" + /> + +
+
+ +
+
+

Kết quả

+

{totalProducts}

+
+
+

Bộ lọc

+

+ {(listing?.brand_filter_list?.length ?? 0) + + (listing?.price_filter_list?.length ?? 0) + + (listing?.attribute_filter_list?.length ?? 0)} +

+
+
+
+
+ +
+ + +
+
+
+
+

+ Sắp xếp +

+
+ {(listing?.sort_by_collection ?? []).map((item, index) => ( + + ))} +
+
+

+ Chọn sản phẩm để thay trực tiếp vào cấu hình hiện tại. +

+
+
+ +
+ {isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, index) => ( +
+
+
+
+
+
+ ))} +
+ ) : null} + + {!isLoading && error ? ( +
+ {error} +
+ ) : null} + + {!isLoading && !error && listing?.product_list?.length ? ( +
+ {listing.product_list.map((product) => ( +
+ + {product.productName} + + +
+ + {product.productName} + + +
+

SKU: {product.productSKU}

+

Bảo hành: {product.warranty || 'Liên hệ'}

+

+ {product.quantity > 0 ? `Còn hàng: ${product.quantity}` : 'Tạm hết hàng'} +

+
+ +
+ {product.marketPrice > product.price ? ( +

+ {formatCurrency(product.marketPrice)}đ +

+ ) : null} +

+ {formatCurrency(product.price)}đ +

+
+ + +
+
+ ))} +
+ ) : null} + + {!isLoading && !error && !listing?.product_list?.length ? ( +
+

Chưa có sản phẩm phù hợp

+

+ Thử đổi bộ lọc hoặc tìm bằng từ khóa khác. +

+
+ ) : null} +
+ + {!isLoading && listing?.paging_collection?.length ? ( +
+
+ {listing.paging_collection.map((item, index) => ( + + ))} +
+
+ ) : null} +
+
+
+
, + portalTarget, + ); +}; + +export default ProductPickerModal; diff --git a/src/app/buildpc/page.tsx b/src/app/buildpc/page.tsx index 8eeed9a..c83d070 100644 --- a/src/app/buildpc/page.tsx +++ b/src/app/buildpc/page.tsx @@ -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 { + 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 ( <> -
+
-
+
-
- +
+
+
+ +
-

- Build PC - Xây dựng cấu hình máy tính PC giá rẻ chuẩn nhất -

-

- Chọn linh kiện xây dựng cấu hình - Tự build PC -

- - {/* tab */} -
    -
  • - 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 -
  • -
-
- -
    -
  • -
    -

    Làm mới

    - +
    +
    +
    + + Build PC theo nhu cầu + +
    +

    + {snapshot.title} +

    +

    + {snapshot.subtitle} +

    +
    +
    +
    +

    Số bước

    +

    {snapshot.categories.length}

    +

    + Danh mục được chia theo từng linh kiện để lắp cấu hình nhanh hơn. +

    +
    +
    +

    + Trạng thái dữ liệu +

    +

    Live

    +

    + Danh sách linh kiện lấy từ nguồn thật và đồng bộ qua proxy nội bộ. +

    +
    +
    -
  • -
-
-

- Chi phí dự tính: {' '} -

-
-
-
+
+ - {/* Hiển thị dữ liệu tai đây */} - +
+
+
+

+ Bộ cấu hình +

+

+ Chọn một tab để phân biệt cấu hình, sau đó thêm linh kiện theo từng bước. +

+
+
+
+ {snapshot.tabs.map((tab, index) => ( + + ))} +
+
- {/* btn */} - +
diff --git a/src/app/deal/DealPageClient.tsx b/src/app/deal/DealPageClient.tsx index 8a770b9..3b37680 100644 --- a/src/app/deal/DealPageClient.tsx +++ b/src/app/deal/DealPageClient.tsx @@ -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'; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index b13057f..33700de 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -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 ( - + -
+
{children}
+
-
); diff --git a/src/app/loading.tsx b/src/app/loading.tsx new file mode 100644 index 0000000..ad130b8 --- /dev/null +++ b/src/app/loading.tsx @@ -0,0 +1,83 @@ +import Skeleton from '@/components/Common/Skeleton'; + +export default function Loading() { + return ( +
+
+ {/* Slider */} + +
+ + +
+ + {/* Deal */} +
+
+ + +
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+ + + +
+ ))} +
+
+ + {/* Category Feature */} +
+ +
+ {Array.from({ length: 10 }).map((_, i) => ( +
+ + +
+ ))} +
+
+ + {/* Box List Category */} + {Array.from({ length: 3 }).map((_, i) => ( +
+
+ +
+ {Array.from({ length: 4 }).map((_, j) => ( + + ))} +
+
+
+ {Array.from({ length: 5 }).map((_, j) => ( +
+ + + +
+ ))} +
+
+ ))} + + {/* Box Article */} +
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + + +
+ ))} +
+
+
+
+ ); +} diff --git a/src/components/Deal/ItemDeal.tsx b/src/components/Deal/ItemDeal.tsx index 8dc9455..8aa329f 100644 --- a/src/components/Deal/ItemDeal.tsx +++ b/src/components/Deal/ItemDeal.tsx @@ -20,6 +20,9 @@ const ItemDeal: React.FC = ({ item }) => { return null; } + const remainingQuantity = Number(item.quantity) - Number(item.sale_quantity); + const percentRemaining = (remainingQuantity / Number(item.quantity)) * 100; + return (
@@ -31,10 +34,12 @@ const ItemDeal: React.FC = ({ item }) => { alt={item.product_info.productName} /> +

{item.product_info.productName}

+
{Number(item.product_info.marketPrice) > 0 && ( <> @@ -45,40 +50,31 @@ const ItemDeal: React.FC = ({ item }) => { )}
+
{item.product_info.price > '0' - ? `${formatCurrency(item.product_info.price)}đ` + ? `${formatCurrency(item.product_info.price)} đ` : 'Liên hệ'}
+
- {(() => { - const percentRemaining = - ((Number(item.quantity) - Number(item.sale_quantity)) / Number(item.quantity)) * - 100; - - return

; - })()} +

- 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
+
Kết thúc sau:
- + + Mua giá sốc - - - Xem sản phẩm
diff --git a/src/components/common/Breadcrumb.tsx b/src/components/common/Breadcrumb.tsx index 42d40c0..71246e8 100644 --- a/src/components/common/Breadcrumb.tsx +++ b/src/components/common/Breadcrumb.tsx @@ -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[] }) => { > - Trang chủ + Trang chủ + - {' '} + - {items.map((item, idx) => ( + + {items.map((item, index) => (
  • - {item?.name} + {item.name} - {idx < items.length - 1 && } - + {index < items.length - 1 && } +
  • ))} diff --git a/src/components/common/MSWProvider.tsx b/src/components/common/MSWProvider.tsx index 5372ac2..d3f66ab 100644 --- a/src/components/common/MSWProvider.tsx +++ b/src/components/common/MSWProvider.tsx @@ -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 {children}; }; export default MSWProvider; diff --git a/src/components/common/Skeleton/index.tsx b/src/components/common/Skeleton/index.tsx new file mode 100644 index 0000000..a8a565e --- /dev/null +++ b/src/components/common/Skeleton/index.tsx @@ -0,0 +1,5 @@ +const Skeleton = ({ className = '' }: { className?: string }) => ( +
    +); + +export default Skeleton; diff --git a/src/components/common/error/index.tsx b/src/components/common/error/index.tsx index 4e00f78..9ea864a 100644 --- a/src/components/common/error/index.tsx +++ b/src/components/common/error/index.tsx @@ -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 */} {

    Đường dẫn không hợp lệ

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

    - {/* CTA */}
    - ← Về trang chủ + Về trang chủ - Xem tất cả sản phẩm + Xem danh mục PC Gaming
    diff --git a/src/components/other/Header/HeaderMid/index.tsx b/src/components/other/Header/HeaderMid/index.tsx index 7aed5f3..166d476 100644 --- a/src/components/other/Header/HeaderMid/index.tsx +++ b/src/components/other/Header/HeaderMid/index.tsx @@ -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 = () => { logo
    +
    @@ -64,6 +65,7 @@ const HeaderMid: React.FC = () => {
    +
    @@ -99,6 +101,7 @@ const HeaderMid: React.FC = () => {
    +
    diff --git a/src/components/other/Header/HeaderTop/index.tsx b/src/components/other/Header/HeaderTop/index.tsx index 84d9c62..3b3d1b4 100644 --- a/src/components/other/Header/HeaderTop/index.tsx +++ b/src/components/other/Header/HeaderTop/index.tsx @@ -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, diff --git a/src/contexts/MSWContext.tsx b/src/contexts/MSWContext.tsx new file mode 100644 index 0000000..0beffa2 --- /dev/null +++ b/src/contexts/MSWContext.tsx @@ -0,0 +1,8 @@ +'use client'; +import { createContext, useContext } from 'react'; + +export const MSWContext = createContext(true); + +export function useMSWReady() { + return useContext(MSWContext); +} diff --git a/src/features/Article/CategoryPage/index.tsx b/src/features/Article/CategoryPage/index.tsx index 657f182..bf3318e 100644 --- a/src/features/Article/CategoryPage/index.tsx +++ b/src/features/Article/CategoryPage/index.tsx @@ -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 = ({ slug }) => { ))}
    Xem tất cả diff --git a/src/features/Article/DetailPage/TocBox/index.tsx b/src/features/Article/DetailPage/TocBox/index.tsx index e454702..e5679df 100644 --- a/src/features/Article/DetailPage/TocBox/index.tsx +++ b/src/features/Article/DetailPage/TocBox/index.tsx @@ -92,18 +92,18 @@ export default function TocBox({ htmlContent }: { htmlContent: string }) { }; }, [htmlContent]); - if (!headingsTree.length) return null; - return ( <> -
    -
    - - Nội dung chính - + {headingsTree.length > 0 && ( +
    +
    + + Nội dung chính + +
    +
    {renderTree(headingsTree)}
    -
    {renderTree(headingsTree)}
    -
    + )}
    = ({ slug }) => { />
    - +

    {item.title}

    -
    +

    {item.createDate} diff --git a/src/features/Article/HomeArticlePage/index.tsx b/src/features/Article/HomeArticlePage/index.tsx index 2e27a2b..282147a 100644 --- a/src/features/Article/HomeArticlePage/index.tsx +++ b/src/features/Article/HomeArticlePage/index.tsx @@ -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 = () => ( +

    +
    + +
    + {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
    +
    +
    + +
    + {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
    +
    +
    + + {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
    +
    +
    +
    +); + 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 ; + return (
    diff --git a/src/features/Home/ArticleVideo/index.tsx b/src/features/Home/ArticleVideo/index.tsx index de20c30..47d311a 100644 --- a/src/features/Home/ArticleVideo/index.tsx +++ b/src/features/Home/ArticleVideo/index.tsx @@ -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 = () => {
    - {videos.slice(0, 4).map((item) => ( - - ))} + {isLoading + ? Array.from({ length: 4 }).map((_, i) => ( +
    + + + +
    + )) + : videos.slice(0, 4).map((item) => ( + + ))}
    ); diff --git a/src/features/Home/BoxArticle/index.tsx b/src/features/Home/BoxArticle/index.tsx index 4b2c1ce..3116359 100644 --- a/src/features/Home/BoxArticle/index.tsx +++ b/src/features/Home/BoxArticle/index.tsx @@ -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 = () => {
    - {articles.slice(0, 4).map((item) => ( - - ))} + {isLoading + ? Array.from({ length: 4 }).map((_, i) => ( +
    + + + + +
    + )) + : articles.slice(0, 4).map((item) => ( + + ))}
    ); diff --git a/src/features/Home/Category/index.tsx b/src/features/Home/Category/index.tsx index b1b86c1..ad7d91a 100644 --- a/src/features/Home/Category/index.tsx +++ b/src/features/Home/Category/index.tsx @@ -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 = () => ( +
    +
    + +
    + {Array.from({ length: 4 }).map((_, i) => ( + + ))} +
    +
    +
    + {Array.from({ length: 5 }).map((_, i) => ( +
    + + + + +
    + ))} +
    +
    +); + 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) => ( + + ))} + + ); + } + return ( <> {menuData[0].product.all_category.map((item, index) => ( diff --git a/src/features/Home/CategoryFeature/index.tsx b/src/features/Home/CategoryFeature/index.tsx index 6ad7f87..ce9fa09 100644 --- a/src/features/Home/CategoryFeature/index.tsx +++ b/src/features/Home/CategoryFeature/index.tsx @@ -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'; diff --git a/src/features/Home/Deal/index.tsx b/src/features/Home/Deal/index.tsx index 33927e9..8fa5fa7 100644 --- a/src/features/Home/Deal/index.tsx +++ b/src/features/Home/Deal/index.tsx @@ -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([]); const [expired, setExpired] = useState(false); + const { data: deals, isLoading } = useApiData(() => getDeals(), [], { + initialData: [] as TypeListProductDeal, + }); - useEffect(() => { - getDeals().then(setDeals).catch(console.error); - }, []); + if (isLoading) { + return ( +
    +
    + + +
    +
    + {Array.from({ length: 6 }).map((_, i) => ( +
    + + + + +
    + ))} +
    +
    + ); + } if (expired) return null; diff --git a/src/features/Home/ReviewCustomer/index.tsx b/src/features/Home/ReviewCustomer/index.tsx index e479dd6..cfc6542 100644 --- a/src/features/Home/ReviewCustomer/index.tsx +++ b/src/features/Home/ReviewCustomer/index.tsx @@ -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 = () => {

    Đánh giá từ khách hàng về Nguyễn Công PC

    -
    - - {reviews.map((item, index) => ( - - - + {isLoading ? ( +
    + {Array.from({ length: 3 }).map((_, i) => ( +
    +
    + +
    + + +
    +
    + + + +
    ))} - -
    +
    + ) : ( +
    + + {reviews.map((item, index) => ( + + + + ))} + +
    + )}
    ); }; diff --git a/src/features/Home/SliderHome/index.tsx b/src/features/Home/SliderHome/index.tsx index c74a8e0..b9aba57 100644 --- a/src/features/Home/SliderHome/index.tsx +++ b/src/features/Home/SliderHome/index.tsx @@ -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(null); + const { data: banners, isLoading } = useApiData(() => getBanners(), [], { + initialData: null as TemplateBanner | null, + }); - useEffect(() => { - getBanners().then(setBanners).catch(console.error); - }, []); + if (isLoading) { + return ( + <> + +
    + + +
    + + ); + } const dataSlider = banners?.homepage; diff --git a/src/features/NotFoundPage/index.tsx b/src/features/NotFoundPage/index.tsx index 72c7b67..eed51d6 100644 --- a/src/features/NotFoundPage/index.tsx +++ b/src/features/NotFoundPage/index.tsx @@ -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 */} { Bạn truy cập không tồn tại hoặc đường dẫn đã bị thay đổi.

    - {/* CTA */}
    - ← Về trang chủ + Về trang chủ
    diff --git a/src/features/Product/Category/index.tsx b/src/features/Product/Category/index.tsx index e30eb1f..a8ca1a6 100644 --- a/src/features/Product/Category/index.tsx +++ b/src/features/Product/Category/index.tsx @@ -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'; diff --git a/src/features/Product/ProductDetail/ProductReview/FormReview/index.tsx b/src/features/Product/ProductDetail/ProductReview/FormReview/index.tsx index 948ee6e..357ea3a 100644 --- a/src/features/Product/ProductDetail/ProductReview/FormReview/index.tsx +++ b/src/features/Product/ProductDetail/ProductReview/FormReview/index.tsx @@ -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 = ({ slug }) => { const [rate, setRate] = useState(5); const [content, setContent] = useState(''); const [name, setName] = useState(''); const [email, setEmail] = useState(''); const [errors, setErrors] = useState({}); 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 (

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

    ); @@ -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)} /> @@ -131,8 +158,10 @@ export const FormReview: React.FC = () => { - diff --git a/src/features/Product/ProductDetail/ProductReview/index.tsx b/src/features/Product/ProductDetail/ProductReview/index.tsx index f298317..148092a 100644 --- a/src/features/Product/ProductDetail/ProductReview/index.tsx +++ b/src/features/Product/ProductDetail/ProductReview/index.tsx @@ -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 = ({ 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 = ({ 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 (
    {rate}
    -
    +
    {total} đánh giá @@ -68,24 +68,18 @@ export const ProductReview: React.FC = ({ review, slug }) => {
    -

    Bạn đánh giá sao sản phẩm này

    - {!showForm ? ( -
    setShowForm(true)} - > - Đánh giá ngay -
    - ) : ( -
    setShowForm(false)} - > - Đóng lại -
    - )} - {showForm && } +

    Bạn đánh giá sao sản phẩm này

    + + + + {showForm && } diff --git a/src/features/Product/ProductDetail/index.tsx b/src/features/Product/ProductDetail/index.tsx index e85c836..8557212 100644 --- a/src/features/Product/ProductDetail/index.tsx +++ b/src/features/Product/ProductDetail/index.tsx @@ -81,10 +81,11 @@ const ProductDetailPage: React.FC = ({ slug }) => { return ; } - 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 ( <> diff --git a/src/features/Product/ProductHot/index.tsx b/src/features/Product/ProductHot/index.tsx index 1872e1a..1e0f81e 100644 --- a/src/features/Product/ProductHot/index.tsx +++ b/src/features/Product/ProductHot/index.tsx @@ -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 = ({ 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 = ({ 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 = ({ slug }) => { (Tổng {page.product_count} sản phẩm) +
    - +
    @@ -64,10 +70,11 @@ const ProductHotPage: React.FC = ({ slug }) => { ))}
    +
    - {page.paging_collection.map((item, index) => ( + {page.paging_collection.map((item) => ( diff --git a/src/features/Product/ProductSearch/index.tsx b/src/features/Product/ProductSearch/index.tsx index 7757195..5bca4c0 100644 --- a/src/features/Product/ProductSearch/index.tsx +++ b/src/features/Product/ProductSearch/index.tsx @@ -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 ? (
    +
    - +
    @@ -66,10 +69,11 @@ const ProductSearchPage: React.FC = () => { ))}
    +
    - {page.paging_collection.map((item, index) => ( + {page.paging_collection.map((item) => ( @@ -104,9 +108,7 @@ const ProductSearchPage: React.FC = () => {
  • Thử lại bằng các từ khóa ngắn gọn hơn
  • - - Quay lại trang chủ - + Quay lại trang chủ
    )} diff --git a/src/hooks/useApiData.ts b/src/hooks/useApiData.ts index 6fb2dd0..a63d711 100644 --- a/src/hooks/useApiData.ts +++ b/src/hooks/useApiData.ts @@ -1,6 +1,7 @@ 'use client'; import { DependencyList, useEffect, useRef, useState } from 'react'; +import { useMSWReady } from '@/contexts/MSWContext'; interface UseApiDataOptions { initialData: T; @@ -19,22 +20,25 @@ export function useApiData( options: UseApiDataOptions, ): UseApiDataState { 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(initialData); const [isLoading, setIsLoading] = useState(enabled); const [error, setError] = useState(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( .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( }; // 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 }; } diff --git a/src/lib/api/product.ts b/src/lib/api/product.ts index 02ffe37..f1936f1 100644 --- a/src/lib/api/product.ts +++ b/src/lib/api/product.ts @@ -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; +} + +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 | 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 | undefined) { if (!productList) { return []; } @@ -34,6 +70,13 @@ function normalizeProductList(productList: Product[] | Record | return Array.isArray(productList) ? productList : Object.values(productList); } +function toProductRecord(products: Product[]) { + return products.reduce>((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( + 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( - `/products/${normalizeSlug(slug)}/comments`, + return apiFetch(`/products/${normalizeSlug(slug)}/comments`).then( + normalizeComments, ); } export function getProductReviews(slug: string) { - return apiFetch( - `/products/${normalizeSlug(slug)}/reviews`, + return apiFetch(`/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(`/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(`/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( - `/products/hot-page/${normalizeSlug(slug)}`, +export function getProductHotPage(slug: string, search?: string) { + return apiFetch(`/products/hot-page/${normalizeSlug(slug)}`).then((page) => + applyListingSearchParams(page, search, page.url), ); } -export function getProductSearch(q: string) { - return apiFetch(`/products/search?q=${encodeURIComponent(q)}`); +export function getProductSearch(q: string, search?: string) { + return apiFetch(`/products/search?q=${encodeURIComponent(q)}`).then((page) => + applyListingSearchParams(page, search, SEARCH_PAGE_PATH), + ); } diff --git a/src/lib/buildpc/source.ts b/src/lib/buildpc/source.ts new file mode 100644 index 0000000..cb1f056 --- /dev/null +++ b/src/lib/buildpc/source.ts @@ -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( + /]*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(/]*>([\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( + /]*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(/]*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(url: string): Promise { + 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; +} + +export async function getBuildPcSnapshot(): Promise { + 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, + /]*>\s*(Làm mới)\s*<\/p>/i, + DEFAULT_BUILD_PC_SNAPSHOT.refreshLabel, + ), + sourceUrl: BUILD_PC_SOURCE_URL, + subtitle: extractFirstMatch( + html, + /]*>([\s\S]*?)<\/h2>/i, + DEFAULT_BUILD_PC_SNAPSHOT.subtitle, + ), + tabs: extractTabs(html), + title: extractFirstMatch( + html, + /]*>([\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(targetUrl); + return normalizeBuildPcCategoryResponse(data); +} diff --git a/src/lib/product/search/index.ts b/src/lib/product/search/index.ts index 6b1d54a..84c5f21 100644 --- a/src/lib/product/search/index.ts +++ b/src/lib/product/search/index.ts @@ -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; } diff --git a/src/mocks/handlers.ts b/src/mocks/handlers.ts index 34dd1f3..6c2901b 100644 --- a/src/mocks/handlers.ts +++ b/src/mocks/handlers.ts @@ -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); diff --git a/tsconfig.json b/tsconfig.json index e8e3e3e..8c2779e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,10 +20,8 @@ ], "paths": { "@/*": ["./src/*"], - "@components/*": ["./src/components/*"], "@types/*": ["./src/types/*"], - "@styles/*": ["./src/styles/*"], - "@Common/*": ["./src/components/Common/*"] + "@styles/*": ["./src/styles/*"] } }, "include": [