Files
nguyencongpc_nextjs/src/app/buildpc/ProductPickerModal.tsx
2026-03-13 17:23:37 +07:00

352 lines
15 KiB
TypeScript

'use client';
import { useMemo, useState } from 'react';
import { createPortal } from 'react-dom';
import Image from 'next/image';
import Link from 'next/link';
import { BuildPcCategory, BuildPcCategoryResponse, BuildPcProduct } from '@/lib/buildpc/source';
import { formatCurrency } from '@/lib/formatPrice';
interface ProductPickerModalProps {
activeCategory: BuildPcCategory | null;
currentRequestUrl: string | null;
error: string;
isLoading: boolean;
listing: BuildPcCategoryResponse | null;
onClose: () => void;
onLoadListing: (params: { categoryId?: number; q?: string; sourceUrl?: string }) => Promise<void>;
onSelectProduct: (product: BuildPcProduct) => void;
}
const ProductPickerModal = ({
activeCategory,
currentRequestUrl,
error,
isLoading,
listing,
onClose,
onLoadListing,
onSelectProduct,
}: ProductPickerModalProps) => {
const [searchQuery, setSearchQuery] = useState('');
const totalProducts = useMemo(() => listing?.product_list.length ?? 0, [listing]);
const portalTarget = typeof document === 'undefined' ? null : document.body;
if (!activeCategory || !portalTarget) {
return null;
}
const handleSearch = () => {
void onLoadListing({
categoryId: activeCategory.id,
q: searchQuery.trim() || undefined,
sourceUrl: currentRequestUrl ?? undefined,
});
};
const renderFilterButton = (
key: string,
label: string,
count: number,
isSelected: boolean | number,
url: string,
) => (
<button
key={key}
type="button"
onClick={() => void onLoadListing({ sourceUrl: url })}
className={`inline-flex min-h-10 items-center rounded-2xl border px-3 py-2 text-left text-sm transition ${
isSelected
? 'border-red-500 bg-red-50 text-red-700 shadow-sm'
: 'border-slate-200 bg-white text-slate-600 hover:border-red-200 hover:text-red-700'
}`}
>
<span className="break-words">{label}</span>
<span className="ml-1 shrink-0 text-slate-400">({count})</span>
</button>
);
return createPortal(
<div className="fixed inset-0 z-[2000] bg-slate-950/70 backdrop-blur-sm">
<div className="absolute left-1/2 top-1/2 flex h-[calc(100vh-16px)] w-[calc(100vw-16px)] max-w-[1320px] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-[28px] border border-white/10 bg-white shadow-[0_30px_80px_rgba(15,23,42,0.35)] md:h-[calc(100vh-48px)] md:w-[calc(100vw-48px)]">
<div className="shrink-0 border-b border-slate-200 bg-[linear-gradient(135deg,#111827_0%,#7f1d1d_100%)] px-4 py-4 text-white md:px-6 md:py-5">
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<p className="text-xs uppercase tracking-[0.24em] text-white/60">
Danh sách linh kiện
</p>
<h3 className="mt-2 border-none pb-0 text-2xl font-semibold normal-case text-white">
Chọn {activeCategory.name}
</h3>
<p className="mt-2 text-sm text-white/75">
Hiển thị {totalProducts} sản phẩm trong kết quả hiện tại.
</p>
</div>
<button
type="button"
onClick={onClose}
className="rounded-full border border-white/15 bg-white/10 px-4 py-2 text-sm font-semibold text-white transition hover:bg-white/15"
>
Đóng
</button>
</div>
<div className="mt-5 flex flex-col gap-3 xl:flex-row">
<div className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-white/10 p-2">
<div className="flex flex-col gap-2 sm:flex-row">
<input
type="text"
value={searchQuery}
onChange={(event) => setSearchQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault();
handleSearch();
}
}}
placeholder={`Tìm trong ${activeCategory.name.toLowerCase()}`}
className="h-12 min-w-0 flex-1 rounded-xl border border-transparent bg-white px-4 text-sm text-slate-900 outline-none transition focus:border-red-300"
/>
<button
type="button"
onClick={handleSearch}
className="h-12 shrink-0 rounded-xl bg-red-500 px-5 text-sm font-semibold text-white transition hover:bg-red-600 sm:min-w-[150px]"
>
Tìm linh kiện
</button>
</div>
</div>
<div className="grid grid-cols-2 gap-3 xl:w-[280px]">
<div className="rounded-2xl border border-white/10 bg-white/10 px-4 py-3">
<p className="text-xs uppercase tracking-[0.2em] text-white/55">Kết quả</p>
<p className="mt-1 text-lg font-semibold">{totalProducts}</p>
</div>
<div className="rounded-2xl border border-white/10 bg-white/10 px-4 py-3">
<p className="text-xs uppercase tracking-[0.2em] text-white/55">Bộ lọc</p>
<p className="mt-1 text-lg font-semibold">
{(listing?.brand_filter_list?.length ?? 0) +
(listing?.price_filter_list?.length ?? 0) +
(listing?.attribute_filter_list?.length ?? 0)}
</p>
</div>
</div>
</div>
</div>
<div className="grid min-h-0 flex-1 xl:grid-cols-[320px_minmax(0,1fr)]">
<aside className="min-h-0 overflow-y-auto border-b border-slate-200 bg-slate-50/80 p-4 md:p-5 xl:border-b-0 xl:border-r">
<div className="space-y-5">
{listing?.brand_filter_list?.length ? (
<section className="rounded-2xl border border-slate-200 bg-white p-4">
<h4 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-500">
Thương hiệu
</h4>
<div className="mt-3 flex flex-wrap gap-2">
{listing.brand_filter_list.map((item) =>
renderFilterButton(
`brand-${item.url}-${item.name}`,
item.name,
item.count,
item.is_selected,
item.url,
),
)}
</div>
</section>
) : null}
{listing?.price_filter_list?.length ? (
<section className="rounded-2xl border border-slate-200 bg-white p-4">
<h4 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-500">
Khoảng giá
</h4>
<div className="mt-3 flex flex-wrap gap-2">
{listing.price_filter_list.map((item) =>
renderFilterButton(
`price-${item.url}-${item.name}`,
item.name,
item.count,
item.is_selected,
item.url,
),
)}
</div>
</section>
) : null}
{(listing?.attribute_filter_list ?? []).map((filter) => (
<section
key={filter.filter_code}
className="rounded-2xl border border-slate-200 bg-white p-4"
>
<h4 className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-500">
{filter.name}
</h4>
<div className="mt-3 flex flex-wrap gap-2">
{filter.value_list.map((item) =>
renderFilterButton(
`attribute-${filter.filter_code}-${item.url}-${item.name}`,
item.name,
item.count,
item.is_selected,
item.url,
),
)}
</div>
</section>
))}
</div>
</aside>
<section className="flex min-h-0 flex-col overflow-hidden bg-white">
<div className="shrink-0 border-b border-slate-200 px-4 py-4 md:px-5">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between">
<div className="min-w-0">
<p className="text-sm font-semibold uppercase tracking-[0.18em] text-slate-400">
Sắp xếp
</p>
<div className="mt-2 flex flex-wrap gap-2">
{(listing?.sort_by_collection ?? []).map((item, index) => (
<button
key={`sort-${item.url}-${item.name}-${index}`}
type="button"
onClick={() => void onLoadListing({ sourceUrl: item.url })}
className="rounded-full border border-slate-200 px-3 py-2 text-sm font-medium text-slate-600 transition hover:border-red-200 hover:text-red-700"
>
{item.name}
</button>
))}
</div>
</div>
<p className="text-sm text-slate-500">
Chọn sản phẩm đ thay trực tiếp vào cấu hình hiện tại.
</p>
</div>
</div>
<div className="min-h-0 flex-1 overflow-y-auto p-4 md:p-5">
{isLoading ? (
<div className="grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, index) => (
<div
key={index}
className="animate-pulse rounded-[24px] border border-slate-200 bg-slate-50 p-4"
>
<div className="aspect-square rounded-2xl bg-slate-200" />
<div className="mt-4 h-4 rounded bg-slate-200" />
<div className="mt-2 h-4 w-2/3 rounded bg-slate-200" />
<div className="mt-6 h-10 rounded-xl bg-slate-200" />
</div>
))}
</div>
) : null}
{!isLoading && error ? (
<div className="rounded-[24px] border border-red-100 bg-red-50 p-5 text-sm text-red-700">
{error}
</div>
) : null}
{!isLoading && !error && listing?.product_list?.length ? (
<div className="grid gap-4 md:grid-cols-2 2xl:grid-cols-3">
{listing.product_list.map((product) => (
<article
key={product.productId}
className="group flex h-full min-w-0 flex-col rounded-[24px] border border-slate-200 bg-white p-4 shadow-sm transition hover:-translate-y-1 hover:shadow-[0_16px_40px_rgba(15,23,42,0.08)]"
>
<Link
href={product.productUrl}
target="_blank"
className="relative block aspect-square overflow-hidden rounded-2xl bg-slate-50"
>
<Image
src={product.productImage.large}
alt={product.productName}
fill
className="object-contain p-3 transition duration-300 group-hover:scale-105"
/>
</Link>
<div className="mt-4 flex min-h-0 flex-1 flex-col space-y-3">
<Link
href={product.productUrl}
target="_blank"
className="line-clamp-2 min-h-12 text-sm font-semibold leading-6 text-slate-900 transition group-hover:text-red-600"
>
{product.productName}
</Link>
<div className="grid gap-2 text-sm text-slate-500">
<p>SKU: {product.productSKU}</p>
<p>Bảo hành: {product.warranty || 'Liên hệ'}</p>
<p>
{product.quantity > 0 ? `Còn hàng: ${product.quantity}` : 'Tạm hết hàng'}
</p>
</div>
<div className="mt-auto pt-2">
{product.marketPrice > product.price ? (
<p className="text-sm text-slate-400 line-through">
{formatCurrency(product.marketPrice)}đ
</p>
) : null}
<p className="text-xl font-semibold text-red-600">
{formatCurrency(product.price)}đ
</p>
</div>
<button
type="button"
onClick={() => onSelectProduct(product)}
className="w-full rounded-xl bg-[linear-gradient(135deg,#b91c1c_0%,#ef4444_100%)] px-4 py-3 text-sm font-semibold text-white transition hover:brightness-105"
>
Thêm vào cấu hình
</button>
</div>
</article>
))}
</div>
) : null}
{!isLoading && !error && !listing?.product_list?.length ? (
<div className="rounded-[24px] border border-slate-200 bg-slate-50 p-8 text-center">
<p className="text-lg font-semibold text-slate-900">Chưa sản phẩm phù hợp</p>
<p className="mt-2 text-sm text-slate-500">
Thử đi bộ lọc hoặc tìm bằng từ khóa khác.
</p>
</div>
) : null}
</div>
{!isLoading && listing?.paging_collection?.length ? (
<div className="shrink-0 border-t border-slate-200 px-4 py-4 md:px-5">
<div className="flex flex-wrap items-center justify-center gap-2">
{listing.paging_collection.map((item, index) => (
<button
key={`paging-${item.url}-${item.name}-${index}`}
type="button"
onClick={() => void onLoadListing({ sourceUrl: item.url })}
className={`rounded-full border px-4 py-2 text-sm font-medium transition ${
item.is_active
? 'border-red-500 bg-red-50 text-red-700'
: 'border-slate-200 text-slate-600 hover:border-red-200 hover:text-red-700'
}`}
>
{item.name}
</button>
))}
</div>
</div>
) : null}
</section>
</div>
</div>
</div>,
portalTarget,
);
};
export default ProductPickerModal;