352 lines
15 KiB
TypeScript
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 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;
|