Improve clothes admin UX, website slots, and featured placement.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
67b1943a9e
commit
d8ecd57641
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user