Files
dashboards/src/app/clothes/list/page.tsx
T

887 lines
31 KiB
TypeScript

"use client";
import Image from "next/image";
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
import ClothesShell from "@/components/clothes/ClothesShell";
import IconButton, {
ClearFilterIcon,
DiscountIcon,
EditIcon,
FilterIcon,
TrashIcon,
VariantIcon,
} from "@/components/ui/IconButton";
import Modal, { FormModal } from "@/components/ui/Modal";
import Pagination, { usePagination } from "@/components/ui/Pagination";
import {
Brand,
CategoryNode,
discountPercent,
findCategoryNode,
flattenCategories,
formatPrice,
getCategoryVariantGroups,
getVariantLabel,
Product,
ProductVariantPrice,
} from "@/data/clothes";
import {
deleteProduct,
fetchBrands,
fetchCategoriesTree,
fetchProducts,
toVariantPayload,
updateProduct,
} from "@/lib/clothes-api";
import { ApiError } from "@/lib/api";
import { useConfirm } from "@/components/ui/ConfirmDialog";
const PAGE_SIZE = 4;
type Filters = {
name: string;
categoryId: string;
priceMin: string;
priceMax: string;
};
const emptyFilters: Filters = {
name: "",
categoryId: "",
priceMin: "",
priceMax: "",
};
function emptyVariantRow(price = ""): ProductVariantPrice {
return {
id: `vp-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
colorId: "",
sizeId: "",
packageId: "",
price,
discountPrice: "",
};
}
function isBaseRow(row: ProductVariantPrice) {
return !row.colorId && !row.sizeId && !row.packageId;
}
export default function ProductListPage() {
const confirmDialog = useConfirm();
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
const [brands, setBrands] = useState<Brand[]>([]);
const categoryOptions = useMemo(
() => flattenCategories(categoryTree),
[categoryTree],
);
const brandById = useMemo(
() => new Map(brands.map((b) => [b.id, b])),
[brands],
);
const [items, setItems] = useState<Product[]>([]);
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null);
const [draft, setDraft] = useState<Filters>(emptyFilters);
const [applied, setApplied] = useState<Filters>(emptyFilters);
const [detailProduct, setDetailProduct] = useState<Product | null>(null);
const [variantProduct, setVariantProduct] = useState<Product | null>(null);
const [variantRows, setVariantRows] = useState<ProductVariantPrice[]>([
emptyVariantRow(),
]);
const [discountProduct, setDiscountProduct] = useState<Product | null>(null);
const [discountRows, setDiscountRows] = useState<ProductVariantPrice[]>([]);
const reload = useCallback(async () => {
setLoading(true);
setLoadError(null);
try {
const [products, cats, brandRows] = await Promise.all([
fetchProducts({
name: applied.name.trim() || undefined,
categoryId: applied.categoryId || undefined,
minPrice: applied.priceMin ? Number(applied.priceMin) : undefined,
maxPrice: applied.priceMax ? Number(applied.priceMax) : undefined,
}),
fetchCategoriesTree(),
fetchBrands(),
]);
setItems(products);
setCategoryTree(cats);
setBrands(brandRows);
} catch (err) {
setLoadError(
err instanceof ApiError
? err.message
: "بارگذاری محصولات ناموفق بود. آیا API روشن است؟",
);
} finally {
setLoading(false);
}
}, [applied]);
useEffect(() => {
void reload();
}, [reload]);
const list = items;
const { page, setPage, pageItems, totalPages, totalItems, pageSize } =
usePagination(list, PAGE_SIZE, JSON.stringify(applied));
const variantGroups = useMemo(
() =>
variantProduct
? getCategoryVariantGroups(variantProduct.categoryId, categoryTree)
: { colors: [], sizes: [], packages: [] },
[variantProduct, categoryTree],
);
const hasAnyVariantOption =
variantGroups.colors.length > 0 ||
variantGroups.sizes.length > 0 ||
variantGroups.packages.length > 0;
function openVariants(product: Product) {
setVariantProduct(product);
setVariantRows(
product.variantPrices?.length
? product.variantPrices.map((row) => ({ ...row }))
: [emptyVariantRow(String(product.price))],
);
}
function closeVariants() {
setVariantProduct(null);
}
function openDiscount(product: Product) {
setDiscountProduct(product);
if (product.variantPrices?.length) {
setDiscountRows(product.variantPrices.map((row) => ({ ...row })));
} else {
setDiscountRows([
{
...emptyVariantRow(String(product.price)),
discountPrice:
typeof product.discountPrice === "number"
? String(product.discountPrice)
: "",
},
]);
}
}
function closeDiscount() {
setDiscountProduct(null);
}
function updateRow(
id: string,
field: keyof Omit<ProductVariantPrice, "id">,
value: string,
) {
setVariantRows((prev) =>
prev.map((row) => (row.id === id ? { ...row, [field]: value } : row)),
);
}
function updateDiscountPrice(id: string, value: string) {
setDiscountRows((prev) =>
prev.map((row) =>
row.id === id ? { ...row, discountPrice: value } : row,
),
);
}
function addRow() {
setVariantRows((prev) => [
...prev,
emptyVariantRow(variantProduct ? String(variantProduct.price) : ""),
]);
}
function removeRow(id: string) {
setVariantRows((prev) =>
prev.length <= 1 ? prev : prev.filter((row) => row.id !== id),
);
}
async function saveVariants(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!variantProduct) return;
try {
const updated = await updateProduct(variantProduct.id, {
variants: toVariantPayload(variantRows),
});
setItems((prev) =>
prev.map((product) =>
product.id === updated.id ? updated : product,
),
);
closeVariants();
} catch (err) {
alert(err instanceof ApiError ? err.message : "ذخیره تنوع ناموفق بود");
}
}
async function saveDiscount(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!discountProduct) return;
const nextRows = discountRows.map((row) => ({ ...row }));
const discounted = nextRows
.map((row) => Number(row.discountPrice))
.filter((n) => !Number.isNaN(n) && n > 0);
const hadVariants = Boolean(discountProduct.variantPrices?.length);
const baseDiscount = nextRows.find((row) => isBaseRow(row))?.discountPrice;
const nextProductDiscount = baseDiscount
? Number(baseDiscount) || null
: discounted.length
? Math.min(...discounted)
: null;
try {
const updated = await updateProduct(discountProduct.id, {
discountPrice: nextProductDiscount,
...(hadVariants
? { variants: toVariantPayload(nextRows) }
: {}),
});
setItems((prev) =>
prev.map((product) =>
product.id === updated.id ? updated : product,
),
);
closeDiscount();
} catch (err) {
alert(err instanceof ApiError ? err.message : "ذخیره تخفیف ناموفق بود");
}
}
async function handleDelete(productId: string) {
if (!(await confirmDialog("این محصول حذف شود؟"))) return;
try {
await deleteProduct(productId);
setItems((prev) => prev.filter((p) => p.id !== productId));
} catch (err) {
alert(err instanceof ApiError ? err.message : "حذف ناموفق بود");
}
}
return (
<>
<ClothesShell
eyebrow="Products"
title="فهرست لباس‌ها"
description="محصولات را فیلتر کنید و کارت هر لباس را مدیریت کنید."
breadcrumbs={[
{ label: "داشبورد", href: "/" },
{ label: "لباس‌ها", href: "/clothes" },
{ label: "فهرست" },
]}
plusHref="/clothes/new"
plusLabel="افزودن لباس"
>
<section className="clothes-filter-bar mb-4 dash-enter">
<div className="clothes-field">
<label htmlFor="filter-name">نام</label>
<input
id="filter-name"
value={draft.name}
onChange={(e) =>
setDraft((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="جستجو..."
/>
</div>
<div className="clothes-field">
<label htmlFor="filter-category">دسته‌بندی</label>
<select
id="filter-category"
value={draft.categoryId}
onChange={(e) =>
setDraft((prev) => ({ ...prev, categoryId: e.target.value }))
}
>
<option value="">همه</option>
{categoryOptions.map((opt) => (
<option key={opt.id} value={opt.id}>
{opt.label}
</option>
))}
</select>
</div>
<div className="clothes-field">
<label htmlFor="filter-min">حداقل قیمت</label>
<input
id="filter-min"
inputMode="numeric"
dir="ltr"
className="font-montserrat"
value={draft.priceMin}
onChange={(e) =>
setDraft((prev) => ({ ...prev, priceMin: e.target.value }))
}
placeholder="1000000"
/>
</div>
<div className="clothes-field">
<label htmlFor="filter-max">حداکثر قیمت</label>
<input
id="filter-max"
inputMode="numeric"
dir="ltr"
className="font-montserrat"
value={draft.priceMax}
onChange={(e) =>
setDraft((prev) => ({ ...prev, priceMax: e.target.value }))
}
placeholder="5000000"
/>
</div>
<div className="clothes-filter-actions">
<IconButton
label="اعمال فیلتر"
variant="solid"
onClick={() => setApplied(draft)}
>
<FilterIcon />
</IconButton>
<IconButton
label="حذف فیلتر"
variant="line"
onClick={() => {
setDraft(emptyFilters);
setApplied(emptyFilters);
}}
>
<ClearFilterIcon />
</IconButton>
</div>
</section>
{loadError ? (
<p className="mb-4 text-sm text-red-600">{loadError}</p>
) : null}
{loading ? (
<p className="mb-4 text-sm text-muted">در حال بارگذاری محصولات…</p>
) : null}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 lg:grid-cols-4">
{!loading && pageItems.length === 0 ? (
<p className="col-span-full text-sm text-muted">
محصولی یافت نشد.
</p>
) : null}
{pageItems.map((product, i) => {
const hasDiscount =
typeof product.discountPrice === "number" &&
product.discountPrice < product.price;
const percent = hasDiscount
? discountPercent(product.price, product.discountPrice!)
: null;
const variantCount = product.variantPrices?.length ?? 0;
const discountCount = product.variantPrices?.length
? product.variantPrices.filter((row) => {
const n = Number(row.discountPrice);
return row.discountPrice && !Number.isNaN(n) && n > 0;
}).length
: hasDiscount
? 1
: 0;
return (
<article
key={product.id}
className="product-card dash-enter cursor-pointer"
style={{ animationDelay: `${80 + i * 50}ms` }}
onClick={() => setDetailProduct(product)}
>
<div className="relative aspect-[2/3] overflow-hidden bg-[#f3f3f1]">
<Image
src={product.image}
alt={product.nameFa}
fill
sizes="(max-width: 768px) 50vw, 25vw"
className="object-cover"
/>
{percent ? (
<span className="absolute top-2 start-2 bg-ink px-2 py-0.5 font-montserrat text-[0.7rem] text-white">
{percent}%-
</span>
) : null}
</div>
<div className="flex flex-1 flex-col gap-1 p-3 text-start">
<h2 className="line-clamp-1 text-sm text-ink">
{product.nameFa}
</h2>
<p
dir="ltr"
className="text-end font-display text-xs tracking-[0.06em] text-muted"
>
{product.nameEn}
</p>
<div className="mt-2 flex flex-wrap items-baseline gap-x-2 gap-y-1">
{hasDiscount ? (
<>
<span className="text-sm text-ink">
<span className="font-montserrat">
{formatPrice(product.discountPrice!)}
</span>
<span className="ms-1">تومان</span>
</span>
<span className="text-xs text-muted line-through">
<span className="font-montserrat">
{formatPrice(product.price)}
</span>
<span className="ms-1">تومان</span>
</span>
</>
) : (
<span className="text-sm text-ink">
<span className="font-montserrat">
{formatPrice(product.price)}
</span>
<span className="ms-1">تومان</span>
</span>
)}
</div>
</div>
<div
className="product-card-actions"
onClick={(e) => e.stopPropagation()}
>
<IconButton label="ویرایش" variant="ghost">
<EditIcon />
</IconButton>
<IconButton
label="حذف"
variant="ghost"
onClick={() => void handleDelete(product.id)}
>
<TrashIcon />
</IconButton>
<IconButton
label="تنوع و قیمت"
variant="ghost"
badge={variantCount || null}
onClick={() => openVariants(product)}
>
<VariantIcon />
</IconButton>
<IconButton
label="تخفیف"
variant="ghost"
badge={discountCount || null}
onClick={() => openDiscount(product)}
>
<DiscountIcon />
</IconButton>
</div>
</article>
);
})}
</div>
{list.length === 0 ? (
<p className="mt-8 text-center text-sm text-muted">
محصولی با این فیلترها پیدا نشد.
</p>
) : (
<Pagination
page={page}
totalPages={totalPages}
totalItems={totalItems}
pageSize={pageSize}
onChange={setPage}
/>
)}
</ClothesShell>
<Modal
open={Boolean(detailProduct)}
title="جزئیات محصول"
onClose={() => setDetailProduct(null)}
wide="full"
footer={
<button
type="button"
className="modal-btn-ghost"
onClick={() => setDetailProduct(null)}
>
بستن
</button>
}
>
{detailProduct ? (
<div className="grid grid-cols-1 gap-5 lg:grid-cols-[280px_1fr]">
<div className="space-y-3">
<div className="relative mx-auto aspect-[2/3] w-full max-w-[280px] overflow-hidden rounded-md bg-[#f3f3f1] lg:max-w-none">
<Image
src={detailProduct.image}
alt={detailProduct.nameFa}
fill
unoptimized
sizes="280px"
className="object-cover"
/>
</div>
{(detailProduct.gallery?.length ?? 0) > 0 ? (
<div>
<p className="mb-2 text-xs text-muted">گالری</p>
<div className="grid grid-cols-3 gap-1.5 sm:grid-cols-4 lg:grid-cols-3">
{detailProduct.gallery!.map((src) => (
<div
key={src}
className="relative aspect-square overflow-hidden rounded-md bg-[#f3f3f1]"
>
<Image
src={src}
alt=""
fill
unoptimized
sizes="90px"
className="object-cover"
/>
</div>
))}
</div>
</div>
) : null}
</div>
<div className="space-y-4 text-start">
<div>
<h3 className="text-xl text-ink sm:text-2xl">
{detailProduct.nameFa}
</h3>
{detailProduct.nameEn ? (
<p
dir="ltr"
className="mt-1 text-end font-montserrat text-sm text-muted sm:text-base"
>
{detailProduct.nameEn}
</p>
) : null}
</div>
<dl className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
<div>
<dt className="text-xs text-muted">دسته‌بندی</dt>
<dd className="mt-0.5 text-ink">
{findCategoryNode(categoryTree, detailProduct.categoryId)
?.nameFa || "—"}
</dd>
</div>
<div>
<dt className="text-xs text-muted">برند</dt>
<dd className="mt-0.5 text-ink">
{brandById.get(detailProduct.brandId)?.nameFa || "—"}
</dd>
</div>
<div className="sm:col-span-2">
<dt className="text-xs text-muted">قیمت</dt>
<dd className="mt-0.5 flex flex-wrap items-baseline gap-x-2 text-ink">
{typeof detailProduct.discountPrice === "number" &&
detailProduct.discountPrice < detailProduct.price ? (
<>
<span>
<span className="font-montserrat">
{formatPrice(detailProduct.discountPrice)}
</span>
<span className="ms-1">تومان</span>
</span>
<span className="text-xs text-muted line-through">
<span className="font-montserrat">
{formatPrice(detailProduct.price)}
</span>
<span className="ms-1">تومان</span>
</span>
</>
) : (
<span>
<span className="font-montserrat">
{formatPrice(detailProduct.price)}
</span>
<span className="ms-1">تومان</span>
</span>
)}
</dd>
</div>
</dl>
{detailProduct.summary ? (
<div>
<p className="mb-1 text-xs text-muted">خلاصه</p>
<p className="text-sm leading-7 text-ink/90 whitespace-pre-wrap">
{detailProduct.summary}
</p>
</div>
) : null}
{detailProduct.description ? (
<div>
<p className="mb-1 text-xs text-muted">توضیحات</p>
<p className="text-sm leading-7 text-ink/90 whitespace-pre-wrap">
{detailProduct.description}
</p>
</div>
) : null}
{detailProduct.tags.length ? (
<div>
<p className="mb-1.5 text-xs text-muted">برچسب‌ها</p>
<div className="flex flex-wrap gap-1.5">
{detailProduct.tags.map((tag) => (
<span key={tag} className="tag-chip !rounded-md !py-0.5">
{tag}
</span>
))}
</div>
</div>
) : null}
<div>
<p className="mb-1.5 text-xs text-muted">تنوع‌ها</p>
{detailProduct.variantPrices?.length ? (
<ul className="space-y-1.5">
{detailProduct.variantPrices.map((row) => {
const rowDiscount = Number(row.discountPrice);
const hasRowDiscount =
row.discountPrice &&
!Number.isNaN(rowDiscount) &&
rowDiscount > 0;
return (
<li
key={row.id}
className="flex flex-wrap items-baseline justify-between gap-2 border border-line px-2.5 py-2 text-sm"
>
<span className="text-ink">
{getVariantLabel(
row,
detailProduct.categoryId,
categoryTree,
)}
</span>
<span className="font-montserrat text-muted">
{hasRowDiscount ? (
<>
<span className="text-ink">
{formatPrice(rowDiscount)}
</span>
<span className="ms-2 line-through">
{formatPrice(Number(row.price) || 0)}
</span>
</>
) : (
formatPrice(Number(row.price) || 0)
)}
</span>
</li>
);
})}
</ul>
) : (
<p className="text-sm text-muted">تنوعی ثبت نشده است.</p>
)}
</div>
</div>
</div>
) : null}
</Modal>
<FormModal
open={Boolean(variantProduct)}
title={`تنوع و قیمت — ${variantProduct?.nameFa ?? ""}`}
onClose={closeVariants}
onSubmit={saveVariants}
submitLabel="ذخیره"
wide="xl"
>
<p className="text-xs leading-6 text-muted">
هر نوع تنوع (رنگ، سایز، پکیج) جداست. می‌توانید خالی بگذارید.
</p>
<div className="mb-1 flex items-center justify-between gap-2">
<span className="text-sm text-ink">ردیف‌ها</span>
<button
type="button"
onClick={addRow}
className="border border-ink bg-ink px-3 py-1.5 text-xs text-white transition-colors hover:bg-white hover:text-ink"
>
+ ردیف جدید
</button>
</div>
<div className="space-y-2">
{variantRows.map((row, index) => (
<div key={row.id} className="variant-price-row">
<div className="mb-2 flex items-center justify-between gap-2">
<span className="text-xs text-muted">
ردیف {new Intl.NumberFormat("en-US").format(index + 1)}
</span>
<button
type="button"
className="text-xs text-muted hover:text-ink disabled:opacity-30"
disabled={variantRows.length <= 1}
onClick={() => removeRow(row.id)}
>
حذف
</button>
</div>
<div className="variant-price-grid">
<div className="variant-price-fields">
{variantGroups.colors.length ? (
<div className="clothes-field">
<label htmlFor={`vp-color-${row.id}`}>رنگ</label>
<select
id={`vp-color-${row.id}`}
value={row.colorId}
onChange={(e) =>
updateRow(row.id, "colorId", e.target.value)
}
>
<option value="">—</option>
{variantGroups.colors.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
) : null}
{variantGroups.sizes.length ? (
<div className="clothes-field">
<label htmlFor={`vp-size-${row.id}`}>سایز</label>
<select
id={`vp-size-${row.id}`}
value={row.sizeId}
onChange={(e) =>
updateRow(row.id, "sizeId", e.target.value)
}
>
<option value="">—</option>
{variantGroups.sizes.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
) : null}
{variantGroups.packages.length ? (
<div className="clothes-field">
<label htmlFor={`vp-pack-${row.id}`}>پکیج</label>
<select
id={`vp-pack-${row.id}`}
value={row.packageId}
onChange={(e) =>
updateRow(row.id, "packageId", e.target.value)
}
>
<option value="">—</option>
{variantGroups.packages.map((opt) => (
<option key={opt.value} value={opt.value}>
{opt.label}
</option>
))}
</select>
</div>
) : null}
{!hasAnyVariantOption ? (
<p className="text-xs text-muted sm:col-span-3">
تنوعی برای این دسته تعریف نشده.
</p>
) : null}
</div>
<div className="variant-price-amount">
<div className="clothes-field">
<label htmlFor={`vp-price-${row.id}`}>قیمت</label>
<input
id={`vp-price-${row.id}`}
dir="ltr"
inputMode="numeric"
className="!text-end font-montserrat"
value={row.price}
onChange={(e) =>
updateRow(row.id, "price", e.target.value)
}
placeholder="3570000"
/>
</div>
</div>
</div>
</div>
))}
</div>
</FormModal>
<FormModal
open={Boolean(discountProduct)}
title={`تخفیف — ${discountProduct?.nameFa ?? ""}`}
onClose={closeDiscount}
onSubmit={saveDiscount}
submitLabel="ذخیره تخفیف"
wide
>
<p className="text-xs leading-6 text-muted">
تنوع‌های موجود و قیمت فعلی را ببینید و قیمت تخفیف را وارد کنید.
</p>
<div className="space-y-2">
{discountRows.map((row) => {
const currentPrice = Number(row.price);
const label = discountProduct
? getVariantLabel(row, discountProduct.categoryId, categoryTree)
: "بدون تنوع";
return (
<div key={row.id} className="variant-price-row">
<div className="mb-2 text-sm text-ink">{label}</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div className="clothes-field">
<span className="block text-xs text-muted">قیمت فعلی</span>
<p className="mt-1.5 text-sm text-ink">
<span className="font-montserrat">
{Number.isNaN(currentPrice)
? row.price || "—"
: formatPrice(currentPrice)}
</span>
{!Number.isNaN(currentPrice) && row.price ? (
<span className="ms-1">تومان</span>
) : null}
</p>
</div>
<div className="clothes-field">
<label htmlFor={`disc-new-${row.id}`}>قیمت تخفیف</label>
<input
id={`disc-new-${row.id}`}
dir="ltr"
inputMode="numeric"
className="!text-end font-montserrat"
value={row.discountPrice ?? ""}
onChange={(e) =>
updateDiscountPrice(row.id, e.target.value)
}
placeholder="3570000"
/>
</div>
</div>
</div>
);
})}
</div>
</FormModal>
</>
);
}