Improve clothes admin UX, website slots, and featured placement.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-27 18:30:52 +03:30
co-authored by Cursor
parent 67b1943a9e
commit d8ecd57641
26 changed files with 3091 additions and 213 deletions
+98 -21
View File
@@ -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<string[]>([]);
const [tagInput, setTagInput] = useState("");
const [technicalData, setTechnicalData] = useState<TechnicalData>({});
const [coverPreview, setCoverPreview] = useState(
"/images/ahmad-ebadi-r5IzkiO08JU-unsplash.jpg",
);
@@ -41,6 +48,24 @@ export default function AddProductPage() {
const [error, setError] = useState<string | null>(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() {
<aside className="dash-enter rounded-lg border border-line bg-surface p-4">
<p className="mb-3 text-sm text-ink">برش تصویر کاور</p>
<p className="mb-4 text-xs leading-6 text-muted">
نسبت ۲:۳ — قاب داخلی محدوده برش نهایی را نشان می‌دهد.
نسبت ۲:۳ — زوم را کم کنید تا کل سوژه در قاب بماند؛ قاب داخلی محدوده نمایش است.
</p>
<div className="crop-frame">
<Image
src={coverPreview}
alt="پیش‌نمایش کاور"
fill
unoptimized
className="object-cover"
sizes="280px"
/>
</div>
<ImageCropper
src={coverPreview}
aspect="2:3"
focusY={0}
alt="پیش‌نمایش کاور"
className="product-cover-cropper"
/>
<input
ref={coverRef}
type="file"
@@ -270,14 +307,13 @@ export default function AddProductPage() {
/>
</div>
<div className="clothes-field sm:col-span-6">
<label htmlFor="price">قیمت (تومان)</label>
<input
<label htmlFor="price">قیمت (IRT)</label>
<PriceInput
id="price"
dir="ltr"
inputMode="numeric"
className="font-montserrat"
value={price}
onChange={(e) => setPrice(e.target.value)}
placeholder="1250000"
onChange={setPrice}
placeholder="1,250,000"
/>
</div>
<div className="clothes-field sm:col-span-12">
@@ -301,6 +337,47 @@ export default function AddProductPage() {
</div>
</section>
{technicalFields.length > 0 ? (
<section className="rounded-lg border border-line bg-surface p-4">
<div className="mb-3">
<p className="text-sm text-ink">مشخصات فنی</p>
<p className="text-xs text-muted">
فیلدهای تعریف‌شده برای این دسته‌بندی
</p>
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{technicalFields.map((field) => (
<div key={field.id} className="clothes-field">
<label htmlFor={`tech-${field.id}`}>
{field.labelFa}
{field.required ? " *" : ""}
{field.labelEn ? (
<span
dir="ltr"
className="ms-1 font-montserrat text-[0.7rem] text-muted"
>
({field.labelEn})
</span>
) : null}
</label>
<input
id={`tech-${field.id}`}
value={technicalData[field.id] ?? ""}
onChange={(e) =>
setTechnicalData((prev) => ({
...prev,
[field.id]: e.target.value,
}))
}
required={field.required}
placeholder={field.labelFa}
/>
</div>
))}
</div>
</section>
) : null}
<section className="rounded-lg border border-line bg-surface p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div>