501 lines
17 KiB
TypeScript
501 lines
17 KiB
TypeScript
"use client";
|
||
|
||
import Image from "next/image";
|
||
import Link from "next/link";
|
||
import { useRouter } from "next/navigation";
|
||
import { ChangeEvent, FormEvent, useEffect, useMemo, useRef, useState } from "react";
|
||
import ClothesShell from "@/components/clothes/ClothesShell";
|
||
import CategoryTreeSelect from "@/components/ui/CategoryTreeSelect";
|
||
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,
|
||
fetchBrands,
|
||
fetchCategoriesTree,
|
||
} from "@/lib/clothes-api";
|
||
|
||
export default function AddProductPage() {
|
||
const router = useRouter();
|
||
const coverRef = useRef<HTMLInputElement>(null);
|
||
const galleryRef = useRef<HTMLInputElement>(null);
|
||
|
||
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
|
||
const [brands, setBrands] = useState<Brand[]>([]);
|
||
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<string[]>([]);
|
||
const [tagInput, setTagInput] = useState("");
|
||
const [technicalData, setTechnicalData] = useState<TechnicalData>({});
|
||
const [coverPreview, setCoverPreview] = useState(
|
||
"/images/ahmad-ebadi-r5IzkiO08JU-unsplash.jpg",
|
||
);
|
||
const [coverFile, setCoverFile] = useState<File | null>(null);
|
||
const [galleryItems, setGalleryItems] = useState<
|
||
Array<{ preview: string; file: File }>
|
||
>([]);
|
||
const [saving, setSaving] = useState(false);
|
||
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 () => {
|
||
try {
|
||
const [cats, brandRows] = await Promise.all([
|
||
fetchCategoriesTree(),
|
||
fetchBrands(),
|
||
]);
|
||
if (cancelled) return;
|
||
setCategoryTree(cats);
|
||
setBrands(brandRows);
|
||
} catch (err) {
|
||
if (!cancelled) {
|
||
setError(
|
||
err instanceof ApiError
|
||
? err.message
|
||
: "بارگذاری دستهها و برندها ناموفق بود.",
|
||
);
|
||
}
|
||
} finally {
|
||
if (!cancelled) setLoadingMeta(false);
|
||
}
|
||
})();
|
||
return () => {
|
||
cancelled = true;
|
||
};
|
||
}, []);
|
||
|
||
function addTag() {
|
||
const next = tagInput.trim();
|
||
if (!next || tags.includes(next)) return;
|
||
setTags((prev) => [...prev, next]);
|
||
setTagInput("");
|
||
}
|
||
|
||
function handleCoverChange(event: ChangeEvent<HTMLInputElement>) {
|
||
const file = event.target.files?.[0];
|
||
if (!file) return;
|
||
setCoverFile(file);
|
||
setCoverPreview(URL.createObjectURL(file));
|
||
}
|
||
|
||
function handleGalleryChange(event: ChangeEvent<HTMLInputElement>) {
|
||
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 (!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) {
|
||
const uploaded = await uploadMedia(item.file, "gallery");
|
||
galleryUrls.push(uploaded.url);
|
||
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(),
|
||
nameEn: nameEn.trim() || undefined,
|
||
summary: abstract.trim() || undefined,
|
||
description: desc.trim() || undefined,
|
||
price: roundedPrice,
|
||
categoryId: categoryId ? Number(categoryId) : undefined,
|
||
brandId: brandId || undefined,
|
||
tags,
|
||
imageUrl,
|
||
imageKey,
|
||
galleryUrls,
|
||
galleryKeys,
|
||
published: true,
|
||
technicalData: cleanedTechnical,
|
||
...(priceTrimmed
|
||
? {
|
||
variants: [
|
||
{
|
||
colorId: "",
|
||
sizeId: "",
|
||
packageId: "",
|
||
price: roundedPrice,
|
||
},
|
||
],
|
||
}
|
||
: {}),
|
||
});
|
||
|
||
router.push("/clothes/list");
|
||
router.refresh();
|
||
} catch (err) {
|
||
setError(
|
||
err instanceof ApiError ? err.message : "ثبت محصول ناموفق بود.",
|
||
);
|
||
} finally {
|
||
setSaving(false);
|
||
}
|
||
}
|
||
|
||
return (
|
||
<ClothesShell
|
||
eyebrow="New Product"
|
||
title="افزودن لباس"
|
||
description="کاور را برش دهید، اطلاعات محصول را کامل کنید و گالری را اضافه کنید."
|
||
breadcrumbs={[
|
||
{ label: "داشبورد", href: "/" },
|
||
{ label: "لباسها", href: "/clothes" },
|
||
{ label: "فهرست", href: "/clothes/list" },
|
||
{ label: "افزودن" },
|
||
]}
|
||
>
|
||
<form
|
||
onSubmit={handleSubmit}
|
||
className="grid grid-cols-1 gap-3 lg:grid-cols-[280px_1fr]"
|
||
>
|
||
<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>
|
||
<ImageCropper
|
||
src={coverPreview}
|
||
aspect="2:3"
|
||
focusY={0}
|
||
alt="پیشنمایش کاور"
|
||
className="product-cover-cropper"
|
||
/>
|
||
<input
|
||
ref={coverRef}
|
||
type="file"
|
||
accept="image/jpeg,image/png,image/webp"
|
||
className="sr-only"
|
||
onChange={handleCoverChange}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="mt-4 w-full border border-line bg-white px-3 py-2 text-sm text-ink transition-colors hover:border-ink"
|
||
onClick={() => coverRef.current?.click()}
|
||
>
|
||
انتخاب تصویر کاور
|
||
</button>
|
||
</aside>
|
||
|
||
<div className="space-y-3 dash-enter" style={{ animationDelay: "70ms" }}>
|
||
{error ? (
|
||
<p className="rounded-lg border border-line bg-white px-4 py-3 text-sm text-ink">
|
||
{error}
|
||
</p>
|
||
) : null}
|
||
{loadingMeta ? (
|
||
<p className="text-sm text-muted">در حال بارگذاری...</p>
|
||
) : null}
|
||
|
||
<section className="grid grid-cols-1 gap-3 rounded-lg border border-line bg-surface p-4 sm:grid-cols-12">
|
||
<div className="clothes-field sm:col-span-8">
|
||
<label htmlFor="category">دستهبندی</label>
|
||
<CategoryTreeSelect
|
||
id="category"
|
||
nodes={categoryTree}
|
||
value={categoryId}
|
||
onChange={setCategoryId}
|
||
/>
|
||
</div>
|
||
<div className="clothes-field sm:col-span-4">
|
||
<label htmlFor="brand">برند</label>
|
||
<select
|
||
id="brand"
|
||
value={brandId}
|
||
onChange={(e) => setBrandId(e.target.value)}
|
||
>
|
||
<option value="">انتخاب کنید</option>
|
||
{brands.map((brand) => (
|
||
<option key={brand.id} value={brand.id}>
|
||
{brand.nameFa} / {brand.nameEn}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
<div className="clothes-field sm:col-span-6">
|
||
<label htmlFor="name-fa">نام فارسی</label>
|
||
<input
|
||
id="name-fa"
|
||
value={nameFa}
|
||
onChange={(e) => setNameFa(e.target.value)}
|
||
placeholder="مثلاً مانتو مشکی کلاسیک"
|
||
required
|
||
/>
|
||
</div>
|
||
<div className="clothes-field sm:col-span-6">
|
||
<label htmlFor="name-en">نام انگلیسی</label>
|
||
<input
|
||
id="name-en"
|
||
dir="ltr"
|
||
value={nameEn}
|
||
onChange={(e) => setNameEn(e.target.value)}
|
||
placeholder="Classic Black Manteau"
|
||
/>
|
||
</div>
|
||
<div className="clothes-field sm:col-span-6">
|
||
<label htmlFor="price">قیمت (IRT)</label>
|
||
<PriceInput
|
||
id="price"
|
||
className="font-montserrat"
|
||
value={price}
|
||
onChange={setPrice}
|
||
placeholder="1,250,000"
|
||
/>
|
||
</div>
|
||
<div className="clothes-field sm:col-span-12">
|
||
<label htmlFor="abstract">خلاصه</label>
|
||
<textarea
|
||
id="abstract"
|
||
value={abstract}
|
||
onChange={(e) => setAbstract(e.target.value)}
|
||
placeholder="توضیح کوتاه برای کارت محصول..."
|
||
className="!min-h-[4.5rem]"
|
||
/>
|
||
</div>
|
||
<div className="clothes-field sm:col-span-12">
|
||
<label htmlFor="desc">توضیحات</label>
|
||
<textarea
|
||
id="desc"
|
||
value={desc}
|
||
onChange={(e) => setDesc(e.target.value)}
|
||
placeholder="جزئیات کامل محصول..."
|
||
/>
|
||
</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>
|
||
<p className="text-sm text-ink">گالری تصاویر</p>
|
||
<p className="text-xs text-muted">چند تصویر برای جزئیات محصول</p>
|
||
</div>
|
||
<input
|
||
ref={galleryRef}
|
||
type="file"
|
||
accept="image/jpeg,image/png,image/webp"
|
||
multiple
|
||
className="sr-only"
|
||
onChange={handleGalleryChange}
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="border border-line px-3 py-1.5 text-xs text-ink hover:border-ink"
|
||
onClick={() => galleryRef.current?.click()}
|
||
>
|
||
آپلود تصویر
|
||
</button>
|
||
</div>
|
||
<div className="gallery-grid">
|
||
{galleryItems.map((item, index) => (
|
||
<div
|
||
key={item.preview}
|
||
className="relative overflow-hidden rounded-lg"
|
||
>
|
||
<Image
|
||
src={item.preview}
|
||
alt=""
|
||
width={160}
|
||
height={160}
|
||
unoptimized
|
||
className="aspect-square h-full w-full object-cover"
|
||
/>
|
||
<button
|
||
type="button"
|
||
className="absolute top-1 start-1 bg-ink/80 px-1.5 text-xs text-white"
|
||
onClick={() =>
|
||
setGalleryItems((prev) =>
|
||
prev.filter((_, i) => i !== index),
|
||
)
|
||
}
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
<button
|
||
type="button"
|
||
className="gallery-slot"
|
||
onClick={() => galleryRef.current?.click()}
|
||
>
|
||
+ افزودن
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="rounded-lg border border-line bg-surface p-4">
|
||
<p className="mb-3 text-sm text-ink">برچسبها</p>
|
||
<div className="mb-3 flex flex-wrap gap-2">
|
||
{tags.map((tag) => (
|
||
<span key={tag} className="tag-chip">
|
||
{tag}
|
||
<button
|
||
type="button"
|
||
className="text-muted hover:text-ink"
|
||
onClick={() =>
|
||
setTags((prev) => prev.filter((item) => item !== tag))
|
||
}
|
||
>
|
||
×
|
||
</button>
|
||
</span>
|
||
))}
|
||
</div>
|
||
<div className="flex gap-2">
|
||
<input
|
||
value={tagInput}
|
||
onChange={(e) => setTagInput(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
addTag();
|
||
}
|
||
}}
|
||
placeholder="برچسب جدید"
|
||
className="flex-1 border border-line px-3 py-2 text-sm outline-none focus:border-ink"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={addTag}
|
||
className="border border-ink bg-ink px-4 py-2 text-sm text-white hover:bg-white hover:text-ink"
|
||
>
|
||
افزودن
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<div className="flex justify-end gap-2">
|
||
<Link
|
||
href="/clothes/list"
|
||
className="border border-line px-5 py-2.5 text-sm text-muted hover:text-ink"
|
||
>
|
||
انصراف
|
||
</Link>
|
||
<button
|
||
type="submit"
|
||
disabled={saving || loadingMeta}
|
||
className="auth-button !mt-0 !max-w-none !w-auto px-6 disabled:opacity-60"
|
||
>
|
||
<span>{saving ? "در حال ثبت..." : "ثبت محصول"}</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</ClothesShell>
|
||
);
|
||
}
|