Initial commit: Mana Collection dashboards (Next.js).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { ChangeEvent, FormEvent, useEffect, useRef, useState } from "react";
|
||||
import ClothesShell from "@/components/clothes/ClothesShell";
|
||||
import CategoryTreeSelect from "@/components/ui/CategoryTreeSelect";
|
||||
import type { Brand, CategoryNode } 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 [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);
|
||||
|
||||
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 = price.trim();
|
||||
const priceNum = priceTrimmed ? Number(priceTrimmed) : 0;
|
||||
if (priceTrimmed && (!Number.isFinite(priceNum) || priceNum < 0)) {
|
||||
setError("قیمت معتبر وارد کنید.");
|
||||
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 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 || undefined,
|
||||
brandId: brandId || undefined,
|
||||
tags,
|
||||
imageUrl,
|
||||
imageKey,
|
||||
galleryUrls,
|
||||
galleryKeys,
|
||||
published: true,
|
||||
...(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>
|
||||
<div className="crop-frame">
|
||||
<Image
|
||||
src={coverPreview}
|
||||
alt="پیشنمایش کاور"
|
||||
fill
|
||||
unoptimized
|
||||
className="object-cover"
|
||||
sizes="280px"
|
||||
/>
|
||||
</div>
|
||||
<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">قیمت (تومان)</label>
|
||||
<input
|
||||
id="price"
|
||||
dir="ltr"
|
||||
inputMode="numeric"
|
||||
value={price}
|
||||
onChange={(e) => setPrice(e.target.value)}
|
||||
placeholder="1250000"
|
||||
/>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user