Files
dashboards/src/app/clothes/new/page.tsx
T

501 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}