diff --git a/src/app/account/orders/page.tsx b/src/app/account/orders/page.tsx
index ce5bf73..73f8828 100644
--- a/src/app/account/orders/page.tsx
+++ b/src/app/account/orders/page.tsx
@@ -18,7 +18,7 @@ const STATUS_FA: Record = {
};
function formatMoney(amount: number) {
- return new Intl.NumberFormat("fa-IR").format(amount) + " تومان";
+ return new Intl.NumberFormat("fa-IR").format(amount) + " IRT";
}
function formatDate(value: string) {
diff --git a/src/app/blog/categories/page.tsx b/src/app/blog/categories/page.tsx
index 514e618..d2573e5 100644
--- a/src/app/blog/categories/page.tsx
+++ b/src/app/blog/categories/page.tsx
@@ -183,6 +183,11 @@ export default function BlogCategoriesPage() {
setDetailPost(null)}
wide="full"
footer={
@@ -423,6 +424,7 @@ export default function BlogListPage() {
(null);
+ const galleryRef = useRef(null);
+
+ const [categoryTree, setCategoryTree] = useState([]);
+ const [brands, setBrands] = useState([]);
+ const [categoryId, setCategoryId] = useState("");
+ const [brandId, setBrandId] = useState("");
+ const [nameFa, setNameFa] = useState("");
+ const [nameEn, setNameEn] = useState("");
+ const [price, setPrice] = useState("");
+ const [abstract, setAbstract] = useState("");
+ const [desc, setDesc] = useState("");
+ const [tags, setTags] = useState([]);
+ const [tagInput, setTagInput] = useState("");
+ const [technicalData, setTechnicalData] = useState({});
+ const [techHydrated, setTechHydrated] = useState(false);
+ const [coverPreview, setCoverPreview] = useState(
+ "/images/ahmad-ebadi-r5IzkiO08JU-unsplash.jpg",
+ );
+ const [coverFile, setCoverFile] = useState(null);
+ const [galleryItems, setGalleryItems] = useState([]);
+ const [saving, setSaving] = useState(false);
+ const [error, setError] = useState(null);
+ const [loadingMeta, setLoadingMeta] = useState(true);
+
+ const technicalFields = useMemo(
+ () =>
+ categoryId
+ ? resolveCategoryTechnicalFields(categoryId, categoryTree)
+ : [],
+ [categoryId, categoryTree],
+ );
+
+ useEffect(() => {
+ setTechnicalData((prev) => {
+ const next: TechnicalData = {};
+ for (const field of technicalFields) {
+ next[field.id] = prev[field.id] ?? "";
+ }
+ return next;
+ });
+ }, [technicalFields]);
+
+ useEffect(() => {
+ if (!productId) return;
+ let cancelled = false;
+ (async () => {
+ setLoadingMeta(true);
+ setError(null);
+ try {
+ const [cats, brandRows, product] = await Promise.all([
+ fetchCategoriesTree(),
+ fetchBrands(),
+ fetchProduct(productId),
+ ]);
+ if (cancelled) return;
+ setCategoryTree(cats);
+ setBrands(brandRows);
+ setCategoryId(product.categoryId || "");
+ setBrandId(product.brandId || "");
+ setNameFa(product.nameFa);
+ setNameEn(product.nameEn || "");
+ setPrice(product.price ? String(product.price) : "");
+ setAbstract(product.summary || "");
+ setDesc(product.description || "");
+ setTags(product.tags ?? []);
+ setCoverPreview(product.image);
+ setCoverFile(null);
+ setGalleryItems(
+ (product.gallery ?? []).map((preview) => ({
+ preview,
+ file: null,
+ })),
+ );
+ setTechnicalData(product.technicalData ?? {});
+ setTechHydrated(true);
+ } catch (err) {
+ if (!cancelled) {
+ setError(
+ err instanceof ApiError
+ ? err.message
+ : "بارگذاری محصول ناموفق بود.",
+ );
+ }
+ } finally {
+ if (!cancelled) setLoadingMeta(false);
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [productId]);
+
+ useEffect(() => {
+ if (!techHydrated) return;
+ setTechnicalData((prev) => {
+ const next: TechnicalData = {};
+ for (const field of technicalFields) {
+ next[field.id] = prev[field.id] ?? "";
+ }
+ return next;
+ });
+ }, [technicalFields, techHydrated]);
+
+ function addTag() {
+ const next = tagInput.trim();
+ if (!next || tags.includes(next)) return;
+ setTags((prev) => [...prev, next]);
+ setTagInput("");
+ }
+
+ function handleCoverChange(event: ChangeEvent) {
+ const file = event.target.files?.[0];
+ if (!file) return;
+ setCoverFile(file);
+ setCoverPreview(URL.createObjectURL(file));
+ }
+
+ function handleGalleryChange(event: ChangeEvent) {
+ const files = event.target.files;
+ if (!files?.length) return;
+ const next = Array.from(files).map((file) => ({
+ preview: URL.createObjectURL(file),
+ file,
+ }));
+ setGalleryItems((prev) => [...prev, ...next]);
+ event.target.value = "";
+ }
+
+ async function handleSubmit(event: FormEvent) {
+ event.preventDefault();
+ if (!productId) return;
+ if (!nameFa.trim()) {
+ setError("نام فارسی الزامی است.");
+ return;
+ }
+
+ const priceTrimmed = parsePriceInput(price);
+ const priceNum = priceTrimmed ? Number(priceTrimmed) : 0;
+ if (priceTrimmed && (!Number.isFinite(priceNum) || priceNum < 0)) {
+ setError("قیمت معتبر وارد کنید.");
+ return;
+ }
+
+ const missingRequired = technicalFields.find(
+ (field) => field.required && !(technicalData[field.id] ?? "").trim(),
+ );
+ if (missingRequired) {
+ setError(`فیلد «${missingRequired.labelFa}» الزامی است.`);
+ return;
+ }
+
+ setSaving(true);
+ setError(null);
+ try {
+ let imageUrl: string | undefined;
+ let imageKey: string | undefined;
+ if (coverFile) {
+ const uploaded = await uploadMedia(coverFile, "main");
+ imageUrl = uploaded.url;
+ imageKey = uploaded.storageKey;
+ }
+
+ const galleryUrls: string[] = [];
+ const galleryKeys: string[] = [];
+ for (const item of galleryItems) {
+ if (item.file) {
+ const uploaded = await uploadMedia(item.file, "gallery");
+ galleryUrls.push(uploaded.url);
+ galleryKeys.push(uploaded.storageKey);
+ } else {
+ galleryUrls.push(item.preview);
+ }
+ }
+
+ const cleanedTechnical: TechnicalData = {};
+ for (const field of technicalFields) {
+ const value = (technicalData[field.id] ?? "").trim();
+ if (value) cleanedTechnical[field.id] = value;
+ }
+
+ const roundedPrice = Math.round(priceNum);
+ await updateProduct(productId, {
+ nameFa: nameFa.trim(),
+ nameEn: nameEn.trim() || "",
+ summary: abstract.trim() || "",
+ description: desc.trim() || "",
+ price: roundedPrice,
+ categoryId: categoryId ? Number(categoryId) : null,
+ brandId: brandId || null,
+ tags,
+ galleryUrls,
+ ...(galleryKeys.length ? { galleryKeys } : {}),
+ ...(imageUrl ? { imageUrl, imageKey } : {}),
+ technicalData: cleanedTechnical,
+ });
+
+ router.push("/clothes/list");
+ router.refresh();
+ } catch (err) {
+ setError(
+ err instanceof ApiError ? err.message : "ذخیره محصول ناموفق بود.",
+ );
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ return (
+
+
+
+ );
+}
diff --git a/src/app/clothes/brands/page.tsx b/src/app/clothes/brands/page.tsx
index a52da2e..d5a7637 100644
--- a/src/app/clothes/brands/page.tsx
+++ b/src/app/clothes/brands/page.tsx
@@ -221,6 +221,11 @@ export default function BrandsPage() {
(null);
+ const [draftFields, setDraftFields] = useState([]);
+
const parentOptions = useMemo(() => flattenCategories(tree), [tree]);
const reload = useCallback(async () => {
@@ -126,6 +132,52 @@ export default function CategoriesPage() {
setVariantTarget(null);
}
+ function openTechnical(node: CategoryNode) {
+ setTechTarget(node);
+ setDraftFields(
+ (node.technicalFields ?? []).map((field) =>
+ createTechnicalField(field),
+ ),
+ );
+ }
+
+ function closeTechnical() {
+ setTechTarget(null);
+ setDraftFields([]);
+ }
+
+ function addTechnicalField() {
+ setDraftFields((prev) => [...prev, createTechnicalField()]);
+ }
+
+ function updateTechnicalField(
+ id: string,
+ patch: Partial>,
+ ) {
+ setDraftFields((prev) =>
+ prev.map((field) =>
+ field.id === id ? { ...field, ...patch } : field,
+ ),
+ );
+ }
+
+ function removeTechnicalField(id: string) {
+ setDraftFields((prev) => prev.filter((field) => field.id !== id));
+ }
+
+ function moveTechnicalField(id: string, direction: -1 | 1) {
+ setDraftFields((prev) => {
+ const index = prev.findIndex((field) => field.id === id);
+ if (index < 0) return prev;
+ const nextIndex = index + direction;
+ if (nextIndex < 0 || nextIndex >= prev.length) return prev;
+ const copy = [...prev];
+ const [item] = copy.splice(index, 1);
+ copy.splice(nextIndex, 0, item);
+ return copy;
+ });
+ }
+
async function handleSubmit(event: FormEvent) {
event.preventDefault();
if (!nameFa.trim()) return;
@@ -142,7 +194,7 @@ export default function CategoriesPage() {
const next = await createCategory({
nameFa: nameFa.trim(),
nameEn: nameEn.trim() || undefined,
- parentId: parentId || undefined,
+ parentId: parentId ? Number(parentId) : undefined,
});
setTree(next);
if (parentId) setOpen((prev) => ({ ...prev, [parentId]: true }));
@@ -226,6 +278,44 @@ export default function CategoriesPage() {
}
}
+ async function handleTechnicalSubmit(event: FormEvent) {
+ event.preventDefault();
+ if (!techTarget) return;
+
+ const cleaned = draftFields
+ .map((field) => ({
+ id: field.id,
+ labelFa: field.labelFa.trim(),
+ labelEn: "",
+ type: "text" as const,
+ required: Boolean(field.required),
+ }))
+ .filter((field) => field.labelFa.length > 0);
+
+ if (cleaned.length !== draftFields.length) {
+ setError("برای هر فیلد، نام فارسی الزامی است.");
+ return;
+ }
+
+ setSaving(true);
+ setError(null);
+ try {
+ const nextTree = await updateCategory(techTarget.id, {
+ technicalFields: cleaned,
+ });
+ setTree(nextTree);
+ closeTechnical();
+ } catch (err) {
+ setError(
+ err instanceof ApiError
+ ? err.message
+ : "ذخیره مشخصات فنی ناموفق بود.",
+ );
+ } finally {
+ setSaving(false);
+ }
+ }
+
return (
<>
openCreate(id)}
onVariants={openVariants}
+ onTechnical={openTechnical}
onDelete={handleDelete}
delay={80 + i * 60}
/>
@@ -276,6 +367,11 @@ export default function CategoriesPage() {
+
+
+
+ فیلدهای متنی بسازید؛ برای هر محصول این دسته نمایش داده میشوند.
+
+
+
+ {draftFields.length === 0 ? (
+
+ هنوز فیلدی اضافه نشده است.
+
+ ) : null}
+
+ {draftFields.map((field, index) => (
+
+
+
+ #{index + 1}
+
+
+
+
+ updateTechnicalField(field.id, {
+ labelFa: e.target.value,
+ })
+ }
+ placeholder="مثلاً جنس پارچه"
+ required
+ />
+
+
+ نوع: متن
+
+
+
+ moveTechnicalField(field.id, -1)}
+ >
+
+
+ moveTechnicalField(field.id, 1)}
+ >
+
+
+ removeTechnicalField(field.id)}
+ >
+
+
+
+
+
+ ))}
+
+
+
+
>
);
}
@@ -487,6 +678,7 @@ function CategoryBranch({
onEdit,
onAddChild,
onVariants,
+ onTechnical,
onDelete,
delay,
}: {
@@ -498,6 +690,7 @@ function CategoryBranch({
onEdit: (node: CategoryNode) => void;
onAddChild: (id: string) => void;
onVariants: (node: CategoryNode) => void;
+ onTechnical: (node: CategoryNode) => void;
onDelete: (id: string) => void;
delay: number;
}) {
@@ -507,6 +700,7 @@ function CategoryBranch({
(node.variants?.colors.length ? 1 : 0) +
(node.variants?.sizes.length ? 1 : 0) +
(node.variants?.packages.length ? 1 : 0);
+ const techCount = node.technicalFields?.length ?? 0;
return (
@@ -567,6 +761,14 @@ function CategoryBranch({
>
+
onTechnical(node)}
+ >
+
+
@@ -625,3 +828,31 @@ function ChevronLeftIcon() {
);
}
+
+function ChevronUpIcon() {
+ return (
+
+ );
+}
+
+function ChevronDownSmallIcon() {
+ return (
+
+ );
+}
diff --git a/src/app/clothes/list/page.tsx b/src/app/clothes/list/page.tsx
index 4cd4ef2..8195dac 100644
--- a/src/app/clothes/list/page.tsx
+++ b/src/app/clothes/list/page.tsx
@@ -1,30 +1,47 @@
"use client";
import Image from "next/image";
-import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
+import { useRouter } from "next/navigation";
+import {
+ FormEvent,
+ useCallback,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from "react";
import ClothesShell from "@/components/clothes/ClothesShell";
import IconButton, {
ClearFilterIcon,
DiscountIcon,
EditIcon,
FilterIcon,
+ MoreIcon,
+ PlacementIcon,
SubscriptionIcon,
+ TechnicalDataIcon,
TrashIcon,
VariantIcon,
} from "@/components/ui/IconButton";
import Modal, { FormModal } from "@/components/ui/Modal";
import Pagination, { usePagination } from "@/components/ui/Pagination";
+import PriceInput from "@/components/ui/PriceInput";
import {
Brand,
CategoryNode,
+ TechnicalData,
+ TechnicalField,
discountPercent,
findCategoryNode,
flattenCategories,
formatPrice,
+ getCategoryLeafLabels,
getCategoryVariantGroups,
getVariantLabel,
+ parsePriceInput,
Product,
ProductVariantPrice,
+ resolveCategoryTechnicalFields,
} from "@/data/clothes";
import {
deleteProduct,
@@ -37,7 +54,13 @@ import {
import { ApiError } from "@/lib/api";
import { useConfirm } from "@/components/ui/ConfirmDialog";
-const PAGE_SIZE = 4;
+const PAGE_SIZE = 16;
+
+const FEATURED_PLACEMENTS = [
+ { id: "promo-1", labelFa: "جایگاه ۱", labelEn: "promo 1" },
+ { id: "promo-2", labelFa: "جایگاه ۲", labelEn: "promo 2" },
+ { id: "promo-3", labelFa: "جایگاه ۳", labelEn: "promo 3" },
+] as const;
type Filters = {
name: string;
@@ -69,6 +92,7 @@ function isBaseRow(row: ProductVariantPrice) {
}
export default function ProductListPage() {
+ const router = useRouter();
const confirmDialog = useConfirm();
const [categoryTree, setCategoryTree] = useState
([]);
const [brands, setBrands] = useState([]);
@@ -86,6 +110,8 @@ export default function ProductListPage() {
const [draft, setDraft] = useState(emptyFilters);
const [applied, setApplied] = useState(emptyFilters);
const [detailProduct, setDetailProduct] = useState(null);
+ const [menuProductId, setMenuProductId] = useState(null);
+ const menuRef = useRef(null);
const [variantProduct, setVariantProduct] = useState(null);
const [variantRows, setVariantRows] = useState([
@@ -103,6 +129,18 @@ export default function ProductListPage() {
const [vipSaving, setVipSaving] = useState(false);
const [vipError, setVipError] = useState(null);
+ const [placementProduct, setPlacementProduct] = useState(
+ null,
+ );
+ const [placementSaving, setPlacementSaving] = useState(false);
+ const [placementError, setPlacementError] = useState(null);
+
+ const [techProduct, setTechProduct] = useState(null);
+ const [techFields, setTechFields] = useState([]);
+ const [techDraft, setTechDraft] = useState({});
+ const [techSaving, setTechSaving] = useState(false);
+ const [techError, setTechError] = useState(null);
+
const reload = useCallback(async () => {
setLoading(true);
setLoadError(null);
@@ -111,8 +149,12 @@ export default function ProductListPage() {
fetchProducts({
name: applied.name.trim() || undefined,
categoryId: applied.categoryId || undefined,
- minPrice: applied.priceMin ? Number(applied.priceMin) : undefined,
- maxPrice: applied.priceMax ? Number(applied.priceMax) : undefined,
+ minPrice: applied.priceMin
+ ? Number(parsePriceInput(applied.priceMin))
+ : undefined,
+ maxPrice: applied.priceMax
+ ? Number(parsePriceInput(applied.priceMax))
+ : undefined,
}),
fetchCategoriesTree(),
fetchBrands(),
@@ -135,6 +177,21 @@ export default function ProductListPage() {
void reload();
}, [reload]);
+ useEffect(() => {
+ if (!menuProductId) return;
+ function handleClick(event: MouseEvent) {
+ if (!menuRef.current?.contains(event.target as Node)) {
+ setMenuProductId(null);
+ }
+ }
+ document.addEventListener("mousedown", handleClick);
+ return () => document.removeEventListener("mousedown", handleClick);
+ }, [menuProductId]);
+
+ useEffect(() => {
+ setMenuProductId(null);
+ }, [applied]);
+
const list = items;
const { page, setPage, pageItems, totalPages, totalItems, pageSize } =
@@ -215,6 +272,111 @@ export default function ProductListPage() {
setVipSaving(false);
}
+ function openPlacement(product: Product) {
+ setPlacementProduct(product);
+ setPlacementError(null);
+ }
+
+ function closePlacement() {
+ setPlacementProduct(null);
+ setPlacementError(null);
+ setPlacementSaving(false);
+ }
+
+ async function selectPlacement(slotId: string) {
+ if (!placementProduct || placementSaving) return;
+ setPlacementSaving(true);
+ setPlacementError(null);
+ const nextSlot =
+ placementProduct.featuredSlot === slotId ? null : slotId;
+ try {
+ const updated = await updateProduct(placementProduct.id, {
+ featuredSlot: nextSlot,
+ });
+ setItems((prev) =>
+ prev.map((row) =>
+ row.id === updated.id ? { ...row, ...updated } : row,
+ ),
+ );
+ closePlacement();
+ } catch (err) {
+ setPlacementError(
+ err instanceof ApiError
+ ? err.message
+ : "ذخیره جایگاه ویژه ناموفق بود.",
+ );
+ setPlacementSaving(false);
+ }
+ }
+
+ function featuredSlotLabel(slotId: string | null | undefined) {
+ if (!slotId) return null;
+ return FEATURED_PLACEMENTS.find((slot) => slot.id === slotId) ?? null;
+ }
+
+ function openTechnical(product: Product) {
+ const fields = product.categoryId
+ ? resolveCategoryTechnicalFields(product.categoryId, categoryTree)
+ : [];
+ const values = product.technicalData ?? {};
+ const draft: TechnicalData = {};
+ for (const field of fields) {
+ draft[field.id] = values[field.id] ?? "";
+ }
+ setTechProduct(product);
+ setTechFields(fields);
+ setTechDraft(draft);
+ setTechError(null);
+ }
+
+ function closeTechnical() {
+ setTechProduct(null);
+ setTechFields([]);
+ setTechDraft({});
+ setTechError(null);
+ setTechSaving(false);
+ }
+
+ async function saveTechnical(event: FormEvent) {
+ event.preventDefault();
+ if (!techProduct) return;
+
+ const missing = techFields.find(
+ (field) => field.required && !(techDraft[field.id] ?? "").trim(),
+ );
+ if (missing) {
+ setTechError(`فیلد «${missing.labelFa}» الزامی است.`);
+ return;
+ }
+
+ const cleaned: TechnicalData = {};
+ for (const field of techFields) {
+ const value = (techDraft[field.id] ?? "").trim();
+ if (value) cleaned[field.id] = value;
+ }
+
+ setTechSaving(true);
+ setTechError(null);
+ try {
+ const updated = await updateProduct(techProduct.id, {
+ technicalData: cleaned,
+ });
+ setItems((prev) =>
+ prev.map((item) => (item.id === updated.id ? updated : item)),
+ );
+ if (detailProduct?.id === updated.id) setDetailProduct(updated);
+ closeTechnical();
+ } catch (err) {
+ setTechError(
+ err instanceof ApiError
+ ? err.message
+ : "ذخیره مشخصات فنی ناموفق بود.",
+ );
+ } finally {
+ setTechSaving(false);
+ }
+ }
+
function updateVipPrice(id: string, value: string) {
setVipRows((prev) =>
prev.map((row) => (row.id === id ? { ...row, vipPrice: value } : row)),
@@ -238,11 +400,11 @@ export default function ProductListPage() {
if (special) {
const nextRows = vipRows.map((row) => ({ ...row }));
const priced = nextRows
- .map((row) => Number(row.vipPrice))
+ .map((row) => Number(parsePriceInput(row.vipPrice ?? "")))
.filter((n) => !Number.isNaN(n) && n > 0);
const baseVip = nextRows.find((row) => isBaseRow(row))?.vipPrice;
nextVipPrice = baseVip
- ? Number(baseVip) || null
+ ? Number(parsePriceInput(baseVip)) || null
: priced.length
? Math.min(...priced)
: null;
@@ -335,12 +497,12 @@ export default function ProductListPage() {
const nextRows = discountRows.map((row) => ({ ...row }));
const discounted = nextRows
- .map((row) => Number(row.discountPrice))
+ .map((row) => Number(parsePriceInput(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
+ ? Number(parsePriceInput(baseDiscount)) || null
: discounted.length
? Math.min(...discounted)
: null;
@@ -418,30 +580,26 @@ export default function ProductListPage() {
-
- setDraft((prev) => ({ ...prev, priceMin: e.target.value }))
+ onChange={(raw) =>
+ setDraft((prev) => ({ ...prev, priceMin: raw }))
}
- placeholder="1000000"
+ placeholder="1,000,000"
/>
-
- setDraft((prev) => ({ ...prev, priceMax: e.target.value }))
+ onChange={(raw) =>
+ setDraft((prev) => ({ ...prev, priceMax: raw }))
}
- placeholder="5000000"
+ placeholder="5,000,000"
/>
@@ -495,6 +653,17 @@ export default function ProductListPage() {
: hasDiscount
? 1
: 0;
+ const techFieldCount = product.categoryId
+ ? resolveCategoryTechnicalFields(product.categoryId, categoryTree)
+ .length
+ : 0;
+ const techFilledCount = Object.values(
+ product.technicalData ?? {},
+ ).filter((value) => value.trim()).length;
+ const featured = featuredSlotLabel(product.featuredSlot);
+ const categoryChips = product.categoryId
+ ? getCategoryLeafLabels(product.categoryId, categoryTree, 2)
+ : [];
return (
) : null}
+ {featured ? (
+
+ {featured.labelEn}
+
+ ) : null}
-
+
{product.nameFa}
{product.nameEn}
-
+ {categoryChips.length ? (
+
+
+ {categoryChips.join(" · ")}
+
+
+ ) : null}
+
{hasDiscount ? (
<>
{formatPrice(product.discountPrice!)}
- تومان
+ IRT
{formatPrice(product.price)}
- تومان
+ IRT
>
) : (
@@ -549,7 +736,7 @@ export default function ProductListPage() {
{formatPrice(product.price)}
- تومان
+ IRT
)}
@@ -559,7 +746,11 @@ export default function ProductListPage() {
className="product-card-actions"
onClick={(e) => e.stopPropagation()}
>
-
+ router.push(`/clothes/${product.id}/edit`)}
+ >
- openVip(product)}
+
-
-
+
+ setMenuProductId((prev) =>
+ prev === product.id ? null : product.id,
+ )
+ }
+ >
+
+
+ {menuProductId === product.id ? (
+
+
+
+ {techFieldCount > 0 ? (
+
+ ) : null}
+
+ ) : null}
+
);
@@ -619,6 +868,7 @@ export default function ProductListPage() {
setDetailProduct(null)}
wide="full"
footer={
@@ -708,13 +958,13 @@ export default function ProductListPage() {
{formatPrice(detailProduct.discountPrice)}
- تومان
+ IRT
{formatPrice(detailProduct.price)}
- تومان
+ IRT
>
) : (
@@ -722,7 +972,7 @@ export default function ProductListPage() {
{formatPrice(detailProduct.price)}
- تومان
+ IRT
)}
@@ -747,6 +997,45 @@ export default function ProductListPage() {
) : null}
+ {(() => {
+ const fields = detailProduct.categoryId
+ ? resolveCategoryTechnicalFields(
+ detailProduct.categoryId,
+ categoryTree,
+ )
+ : [];
+ if (!fields.length) return null;
+ const values = detailProduct.technicalData ?? {};
+ return (
+
+
مشخصات فنی
+
+ {fields.map((field) => (
+
+
-
+ {field.labelFa}
+ {field.labelEn ? (
+
+ ({field.labelEn})
+
+ ) : null}
+
+ -
+ {values[field.id]?.trim() || "—"}
+
+
+ ))}
+
+
+ );
+ })()}
+
{detailProduct.tags.length ? (
برچسبها
@@ -812,43 +1101,15 @@ export default function ProductListPage() {
-
- هر نوع تنوع (رنگ، سایز، پکیج) جداست. میتوانید خالی بگذارید.
-
-
-
- ردیفها
-
-
-
- {variantRows.map((row, index) => (
+ {variantRows.map((row) => (
-
-
- ردیف {new Intl.NumberFormat("en-US").format(index + 1)}
-
-
-
-
{variantGroups.colors.length ? (
@@ -917,33 +1178,47 @@ export default function ProductListPage() {
+
+
))}
+
+
تنوعهای موجود و قیمت فعلی را ببینید و قیمت تخفیف را وارد کنید.
@@ -968,22 +1243,18 @@ export default function ProductListPage() {
: formatPrice(currentPrice)}
{!Number.isNaN(currentPrice) && row.price ? (
- تومان
+ IRT
) : null}
-
- updateDiscountPrice(row.id, e.target.value)
- }
- placeholder="3570000"
+ onChange={(raw) => updateDiscountPrice(row.id, raw)}
+ placeholder="3,570,000"
/>
@@ -992,9 +1263,63 @@ export default function ProductListPage() {
+
+ بستن
+
+ }
+ >
+
+ این جایگاهها کاروسلهای صفحه اصلی را پر میکنند. میتوانید چند
+ محصول را به یک جایگاه اختصاص دهید تا همه در همان ردیف نمایش داده
+ شوند.
+
+ {placementError ? (
+
+ {placementError}
+
+ ) : null}
+
+ {FEATURED_PLACEMENTS.map((slot) => {
+ const active = placementProduct?.featuredSlot === slot.id;
+ return (
+
+ );
+ })}
+
+
+
void saveVip(e)}
submitLabel={vipSaving ? "..." : "ذخیره"}
@@ -1113,23 +1438,19 @@ export default function ProductListPage() {
: formatPrice(currentPrice)}
{!Number.isNaN(currentPrice) && row.price ? (
- تومان
+ IRT
) : null}
-
- updateVipPrice(row.id, e.target.value)
- }
- placeholder="3570000"
+ onChange={(raw) => updateVipPrice(row.id, raw)}
+ placeholder="3,570,000"
/>
@@ -1139,6 +1460,58 @@ export default function ProductListPage() {
) : null}
+
+
+ {techError ? (
+
+ {techError}
+
+ ) : null}
+ {techFields.length === 0 ? (
+
+ برای دستهبندی این محصول فیلد فنی تعریف نشده است.
+
+ ) : (
+
+ {techFields.map((field) => (
+
+
+
+ setTechDraft((prev) => ({
+ ...prev,
+ [field.id]: e.target.value,
+ }))
+ }
+ required={field.required}
+ placeholder={field.labelFa}
+ />
+
+ ))}
+
+ )}
+
>
);
}
diff --git a/src/app/clothes/new/page.tsx b/src/app/clothes/new/page.tsx
index c9a0a01..da03f94 100644
--- a/src/app/clothes/new/page.tsx
+++ b/src/app/clothes/new/page.tsx
@@ -3,10 +3,16 @@
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
-import { ChangeEvent, FormEvent, useEffect, useRef, useState } from "react";
+import { ChangeEvent, FormEvent, useEffect, useMemo, useRef, useState } from "react";
import ClothesShell from "@/components/clothes/ClothesShell";
import CategoryTreeSelect from "@/components/ui/CategoryTreeSelect";
-import type { Brand, CategoryNode } from "@/data/clothes";
+import ImageCropper from "@/components/ui/ImageCropper";
+import PriceInput from "@/components/ui/PriceInput";
+import type { Brand, CategoryNode, TechnicalData } from "@/data/clothes";
+import {
+ parsePriceInput,
+ resolveCategoryTechnicalFields,
+} from "@/data/clothes";
import { ApiError, uploadMedia } from "@/lib/api";
import {
createProduct,
@@ -30,6 +36,7 @@ export default function AddProductPage() {
const [desc, setDesc] = useState("");
const [tags, setTags] = useState([]);
const [tagInput, setTagInput] = useState("");
+ const [technicalData, setTechnicalData] = useState({});
const [coverPreview, setCoverPreview] = useState(
"/images/ahmad-ebadi-r5IzkiO08JU-unsplash.jpg",
);
@@ -41,6 +48,24 @@ export default function AddProductPage() {
const [error, setError] = useState(null);
const [loadingMeta, setLoadingMeta] = useState(true);
+ const technicalFields = useMemo(
+ () =>
+ categoryId
+ ? resolveCategoryTechnicalFields(categoryId, categoryTree)
+ : [],
+ [categoryId, categoryTree],
+ );
+
+ useEffect(() => {
+ setTechnicalData((prev) => {
+ const next: TechnicalData = {};
+ for (const field of technicalFields) {
+ next[field.id] = prev[field.id] ?? "";
+ }
+ return next;
+ });
+ }, [technicalFields]);
+
useEffect(() => {
let cancelled = false;
(async () => {
@@ -101,13 +126,21 @@ export default function AddProductPage() {
return;
}
- const priceTrimmed = price.trim();
+ const priceTrimmed = parsePriceInput(price);
const priceNum = priceTrimmed ? Number(priceTrimmed) : 0;
if (priceTrimmed && (!Number.isFinite(priceNum) || priceNum < 0)) {
setError("قیمت معتبر وارد کنید.");
return;
}
+ const missingRequired = technicalFields.find(
+ (field) => field.required && !(technicalData[field.id] ?? "").trim(),
+ );
+ if (missingRequired) {
+ setError(`فیلد «${missingRequired.labelFa}» الزامی است.`);
+ return;
+ }
+
setSaving(true);
setError(null);
try {
@@ -127,6 +160,12 @@ export default function AddProductPage() {
galleryKeys.push(uploaded.storageKey);
}
+ const cleanedTechnical: TechnicalData = {};
+ for (const field of technicalFields) {
+ const value = (technicalData[field.id] ?? "").trim();
+ if (value) cleanedTechnical[field.id] = value;
+ }
+
const roundedPrice = Math.round(priceNum);
await createProduct({
nameFa: nameFa.trim(),
@@ -134,7 +173,7 @@ export default function AddProductPage() {
summary: abstract.trim() || undefined,
description: desc.trim() || undefined,
price: roundedPrice,
- categoryId: categoryId || undefined,
+ categoryId: categoryId ? Number(categoryId) : undefined,
brandId: brandId || undefined,
tags,
imageUrl,
@@ -142,6 +181,7 @@ export default function AddProductPage() {
galleryUrls,
galleryKeys,
published: true,
+ technicalData: cleanedTechnical,
...(priceTrimmed
? {
variants: [
@@ -186,18 +226,15 @@ export default function AddProductPage() {