Initial commit: Mana Collection dashboards (Next.js).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-07 20:50:24 +03:30
co-authored by Cursor
commit 18b29f5b62
95 changed files with 16414 additions and 0 deletions
+423
View File
@@ -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>
);
}