Initial commit: Mana Collection dashboards (Next.js).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,886 @@
|
||||
"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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user