887 lines
31 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|