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
@@ -18,7 +18,7 @@ const STATUS_FA: Record<string, string> = {
|
||||
};
|
||||
|
||||
function formatMoney(amount: number) {
|
||||
return new Intl.NumberFormat("fa-IR").format(amount) + " تومان";
|
||||
return new Intl.NumberFormat("fa-IR").format(amount) + " IRT";
|
||||
}
|
||||
|
||||
function formatDate(value: string) {
|
||||
|
||||
@@ -183,6 +183,11 @@ export default function BlogCategoriesPage() {
|
||||
<FormModal
|
||||
open={modalOpen}
|
||||
title={editingId ? "ویرایش دسته" : "دسته جدید"}
|
||||
subtitle={
|
||||
editingId
|
||||
? "Update blog category name"
|
||||
: "Create a new blog category"
|
||||
}
|
||||
onClose={closeModal}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel={saving ? "..." : editingId ? "ذخیره" : "افزودن دسته"}
|
||||
|
||||
@@ -329,6 +329,7 @@ export default function BlogListPage() {
|
||||
<Modal
|
||||
open={Boolean(detailPost)}
|
||||
title="جزئیات مطلب"
|
||||
subtitle={detailPost?.title || "Post details"}
|
||||
onClose={() => setDetailPost(null)}
|
||||
wide="full"
|
||||
footer={
|
||||
@@ -423,6 +424,7 @@ export default function BlogListPage() {
|
||||
<FormModal
|
||||
open={Boolean(metaPost)}
|
||||
title="متا تگها"
|
||||
subtitle="SEO title and description for this post"
|
||||
onClose={closeMeta}
|
||||
onSubmit={saveMeta}
|
||||
submitLabel={savingMeta ? "..." : "ذخیره متا"}
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import Link from "next/link";
|
||||
import { useParams, 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 {
|
||||
fetchBrands,
|
||||
fetchCategoriesTree,
|
||||
fetchProduct,
|
||||
updateProduct,
|
||||
} from "@/lib/clothes-api";
|
||||
|
||||
type GalleryItem = {
|
||||
preview: string;
|
||||
file: File | null;
|
||||
};
|
||||
|
||||
export default function EditProductPage() {
|
||||
const router = useRouter();
|
||||
const params = useParams();
|
||||
const productId = String(params.id ?? "");
|
||||
|
||||
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 [techHydrated, setTechHydrated] = useState(false);
|
||||
const [coverPreview, setCoverPreview] = useState(
|
||||
"/images/ahmad-ebadi-r5IzkiO08JU-unsplash.jpg",
|
||||
);
|
||||
const [coverFile, setCoverFile] = useState<File | null>(null);
|
||||
const [galleryItems, setGalleryItems] = useState<GalleryItem[]>([]);
|
||||
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(() => {
|
||||
if (!productId) return;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
setLoadingMeta(true);
|
||||
setError(null);
|
||||
try {
|
||||
const [cats, brandRows, product] = await Promise.all([
|
||||
fetchCategoriesTree(),
|
||||
fetchBrands(),
|
||||
fetchProduct(productId),
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setCategoryTree(cats);
|
||||
setBrands(brandRows);
|
||||
setCategoryId(product.categoryId || "");
|
||||
setBrandId(product.brandId || "");
|
||||
setNameFa(product.nameFa);
|
||||
setNameEn(product.nameEn || "");
|
||||
setPrice(product.price ? String(product.price) : "");
|
||||
setAbstract(product.summary || "");
|
||||
setDesc(product.description || "");
|
||||
setTags(product.tags ?? []);
|
||||
setCoverPreview(product.image);
|
||||
setCoverFile(null);
|
||||
setGalleryItems(
|
||||
(product.gallery ?? []).map((preview) => ({
|
||||
preview,
|
||||
file: null,
|
||||
})),
|
||||
);
|
||||
setTechnicalData(product.technicalData ?? {});
|
||||
setTechHydrated(true);
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: "بارگذاری محصول ناموفق بود.",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setLoadingMeta(false);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [productId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!techHydrated) return;
|
||||
setTechnicalData((prev) => {
|
||||
const next: TechnicalData = {};
|
||||
for (const field of technicalFields) {
|
||||
next[field.id] = prev[field.id] ?? "";
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [technicalFields, techHydrated]);
|
||||
|
||||
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 (!productId) return;
|
||||
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) {
|
||||
if (item.file) {
|
||||
const uploaded = await uploadMedia(item.file, "gallery");
|
||||
galleryUrls.push(uploaded.url);
|
||||
galleryKeys.push(uploaded.storageKey);
|
||||
} else {
|
||||
galleryUrls.push(item.preview);
|
||||
}
|
||||
}
|
||||
|
||||
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 updateProduct(productId, {
|
||||
nameFa: nameFa.trim(),
|
||||
nameEn: nameEn.trim() || "",
|
||||
summary: abstract.trim() || "",
|
||||
description: desc.trim() || "",
|
||||
price: roundedPrice,
|
||||
categoryId: categoryId ? Number(categoryId) : null,
|
||||
brandId: brandId || null,
|
||||
tags,
|
||||
galleryUrls,
|
||||
...(galleryKeys.length ? { galleryKeys } : {}),
|
||||
...(imageUrl ? { imageUrl, imageKey } : {}),
|
||||
technicalData: cleanedTechnical,
|
||||
});
|
||||
|
||||
router.push("/clothes/list");
|
||||
router.refresh();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : "ذخیره محصول ناموفق بود.",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<ClothesShell
|
||||
eyebrow="Edit 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 ? " *" : ""}
|
||||
</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}-${index}`}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -221,6 +221,11 @@ export default function BrandsPage() {
|
||||
<FormModal
|
||||
open={modalOpen}
|
||||
title={editingId ? "ویرایش برند" : "برند جدید"}
|
||||
subtitle={
|
||||
editingId
|
||||
? "Update brand name and image"
|
||||
: "Create a brand with name and image"
|
||||
}
|
||||
onClose={closeModal}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel={saving ? "..." : editingId ? "ذخیره" : "افزودن برند"}
|
||||
|
||||
@@ -5,6 +5,7 @@ import ClothesShell from "@/components/clothes/ClothesShell";
|
||||
import IconButton, {
|
||||
AddChildIcon,
|
||||
EditIcon,
|
||||
TechnicalDataIcon,
|
||||
TrashIcon,
|
||||
VariantIcon,
|
||||
} from "@/components/ui/IconButton";
|
||||
@@ -12,7 +13,9 @@ import { FormModal } from "@/components/ui/Modal";
|
||||
import {
|
||||
CategoryNode,
|
||||
CategoryVariants,
|
||||
TechnicalField,
|
||||
colorCatalog,
|
||||
createTechnicalField,
|
||||
emptyVariants,
|
||||
flattenCategories,
|
||||
packageCatalog,
|
||||
@@ -51,6 +54,9 @@ export default function CategoriesPage() {
|
||||
package: false,
|
||||
});
|
||||
|
||||
const [techTarget, setTechTarget] = useState<CategoryNode | null>(null);
|
||||
const [draftFields, setDraftFields] = useState<TechnicalField[]>([]);
|
||||
|
||||
const parentOptions = useMemo(() => flattenCategories(tree), [tree]);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
@@ -126,6 +132,52 @@ export default function CategoriesPage() {
|
||||
setVariantTarget(null);
|
||||
}
|
||||
|
||||
function openTechnical(node: CategoryNode) {
|
||||
setTechTarget(node);
|
||||
setDraftFields(
|
||||
(node.technicalFields ?? []).map((field) =>
|
||||
createTechnicalField(field),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function closeTechnical() {
|
||||
setTechTarget(null);
|
||||
setDraftFields([]);
|
||||
}
|
||||
|
||||
function addTechnicalField() {
|
||||
setDraftFields((prev) => [...prev, createTechnicalField()]);
|
||||
}
|
||||
|
||||
function updateTechnicalField(
|
||||
id: string,
|
||||
patch: Partial<Pick<TechnicalField, "labelFa" | "required">>,
|
||||
) {
|
||||
setDraftFields((prev) =>
|
||||
prev.map((field) =>
|
||||
field.id === id ? { ...field, ...patch } : field,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function removeTechnicalField(id: string) {
|
||||
setDraftFields((prev) => prev.filter((field) => field.id !== id));
|
||||
}
|
||||
|
||||
function moveTechnicalField(id: string, direction: -1 | 1) {
|
||||
setDraftFields((prev) => {
|
||||
const index = prev.findIndex((field) => field.id === id);
|
||||
if (index < 0) return prev;
|
||||
const nextIndex = index + direction;
|
||||
if (nextIndex < 0 || nextIndex >= prev.length) return prev;
|
||||
const copy = [...prev];
|
||||
const [item] = copy.splice(index, 1);
|
||||
copy.splice(nextIndex, 0, item);
|
||||
return copy;
|
||||
});
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!nameFa.trim()) return;
|
||||
@@ -142,7 +194,7 @@ export default function CategoriesPage() {
|
||||
const next = await createCategory({
|
||||
nameFa: nameFa.trim(),
|
||||
nameEn: nameEn.trim() || undefined,
|
||||
parentId: parentId || undefined,
|
||||
parentId: parentId ? Number(parentId) : undefined,
|
||||
});
|
||||
setTree(next);
|
||||
if (parentId) setOpen((prev) => ({ ...prev, [parentId]: true }));
|
||||
@@ -226,6 +278,44 @@ export default function CategoriesPage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function handleTechnicalSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!techTarget) return;
|
||||
|
||||
const cleaned = draftFields
|
||||
.map((field) => ({
|
||||
id: field.id,
|
||||
labelFa: field.labelFa.trim(),
|
||||
labelEn: "",
|
||||
type: "text" as const,
|
||||
required: Boolean(field.required),
|
||||
}))
|
||||
.filter((field) => field.labelFa.length > 0);
|
||||
|
||||
if (cleaned.length !== draftFields.length) {
|
||||
setError("برای هر فیلد، نام فارسی الزامی است.");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const nextTree = await updateCategory(techTarget.id, {
|
||||
technicalFields: cleaned,
|
||||
});
|
||||
setTree(nextTree);
|
||||
closeTechnical();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: "ذخیره مشخصات فنی ناموفق بود.",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ClothesShell
|
||||
@@ -265,6 +355,7 @@ export default function CategoriesPage() {
|
||||
onEdit={openEdit}
|
||||
onAddChild={(id) => openCreate(id)}
|
||||
onVariants={openVariants}
|
||||
onTechnical={openTechnical}
|
||||
onDelete={handleDelete}
|
||||
delay={80 + i * 60}
|
||||
/>
|
||||
@@ -276,6 +367,11 @@ export default function CategoriesPage() {
|
||||
<FormModal
|
||||
open={modalOpen}
|
||||
title={editingId ? "ویرایش دسته" : "دسته جدید"}
|
||||
subtitle={
|
||||
editingId
|
||||
? "Update category name and settings"
|
||||
: "Add a new category to the tree"
|
||||
}
|
||||
onClose={closeModal}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel={saving ? "..." : editingId ? "ذخیره" : "افزودن دسته"}
|
||||
@@ -322,6 +418,7 @@ export default function CategoriesPage() {
|
||||
<FormModal
|
||||
open={Boolean(variantTarget)}
|
||||
title={`تنوع — ${variantTarget?.nameFa ?? ""}`}
|
||||
subtitle="Manage variant options for this category"
|
||||
onClose={closeVariants}
|
||||
onSubmit={handleVariantsSubmit}
|
||||
submitLabel={saving ? "..." : "ذخیره تنوع"}
|
||||
@@ -474,6 +571,100 @@ export default function CategoriesPage() {
|
||||
) : null}
|
||||
</div>
|
||||
</FormModal>
|
||||
|
||||
<FormModal
|
||||
open={Boolean(techTarget)}
|
||||
title={`مشخصات فنی — ${techTarget?.nameFa ?? ""}`}
|
||||
subtitle="Technical fields shown on products in this category"
|
||||
onClose={closeTechnical}
|
||||
onSubmit={handleTechnicalSubmit}
|
||||
submitLabel={saving ? "..." : "ذخیره مشخصات"}
|
||||
wide
|
||||
>
|
||||
<p className="text-xs leading-6 text-muted">
|
||||
فیلدهای متنی بسازید؛ برای هر محصول این دسته نمایش داده میشوند.
|
||||
</p>
|
||||
|
||||
<div className="tech-field-builder">
|
||||
{draftFields.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed border-line bg-white px-4 py-6 text-center text-sm text-muted">
|
||||
هنوز فیلدی اضافه نشده است.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{draftFields.map((field, index) => (
|
||||
<div key={field.id} className="tech-field-row">
|
||||
<div className="tech-field-row-inline">
|
||||
<span className="font-montserrat shrink-0 text-xs text-muted">
|
||||
#{index + 1}
|
||||
</span>
|
||||
<div className="clothes-field min-w-0 flex-1 !mb-0">
|
||||
<label htmlFor={`tech-fa-${field.id}`}>برچسب فارسی</label>
|
||||
<input
|
||||
id={`tech-fa-${field.id}`}
|
||||
value={field.labelFa}
|
||||
onChange={(e) =>
|
||||
updateTechnicalField(field.id, {
|
||||
labelFa: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="مثلاً جنس پارچه"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<span className="shrink-0 rounded border border-line bg-white px-2 py-1 text-xs text-muted">
|
||||
نوع: متن
|
||||
</span>
|
||||
<label className="variant-type-toggle shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(field.required)}
|
||||
onChange={(e) =>
|
||||
updateTechnicalField(field.id, {
|
||||
required: e.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>الزامی</span>
|
||||
</label>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<IconButton
|
||||
label="بالا"
|
||||
variant="line"
|
||||
disabled={index === 0}
|
||||
onClick={() => moveTechnicalField(field.id, -1)}
|
||||
>
|
||||
<ChevronUpIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="پایین"
|
||||
variant="line"
|
||||
disabled={index === draftFields.length - 1}
|
||||
onClick={() => moveTechnicalField(field.id, 1)}
|
||||
>
|
||||
<ChevronDownSmallIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="حذف فیلد"
|
||||
variant="line"
|
||||
onClick={() => removeTechnicalField(field.id)}
|
||||
>
|
||||
<TrashIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="mt-1 w-full border border-dashed border-line bg-white px-3 py-2.5 text-sm text-ink transition-colors hover:border-ink"
|
||||
onClick={addTechnicalField}
|
||||
>
|
||||
+ افزودن فیلد متنی
|
||||
</button>
|
||||
</FormModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -487,6 +678,7 @@ function CategoryBranch({
|
||||
onEdit,
|
||||
onAddChild,
|
||||
onVariants,
|
||||
onTechnical,
|
||||
onDelete,
|
||||
delay,
|
||||
}: {
|
||||
@@ -498,6 +690,7 @@ function CategoryBranch({
|
||||
onEdit: (node: CategoryNode) => void;
|
||||
onAddChild: (id: string) => void;
|
||||
onVariants: (node: CategoryNode) => void;
|
||||
onTechnical: (node: CategoryNode) => void;
|
||||
onDelete: (id: string) => void;
|
||||
delay: number;
|
||||
}) {
|
||||
@@ -507,6 +700,7 @@ function CategoryBranch({
|
||||
(node.variants?.colors.length ? 1 : 0) +
|
||||
(node.variants?.sizes.length ? 1 : 0) +
|
||||
(node.variants?.packages.length ? 1 : 0);
|
||||
const techCount = node.technicalFields?.length ?? 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@@ -567,6 +761,14 @@ function CategoryBranch({
|
||||
>
|
||||
<VariantIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="مشخصات فنی"
|
||||
variant="line"
|
||||
badge={techCount || null}
|
||||
onClick={() => onTechnical(node)}
|
||||
>
|
||||
<TechnicalDataIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="حذف"
|
||||
variant="line"
|
||||
@@ -589,6 +791,7 @@ function CategoryBranch({
|
||||
onEdit={onEdit}
|
||||
onAddChild={onAddChild}
|
||||
onVariants={onVariants}
|
||||
onTechnical={onTechnical}
|
||||
onDelete={onDelete}
|
||||
delay={delay + (i + 1) * 40}
|
||||
/>
|
||||
@@ -625,3 +828,31 @@ function ChevronLeftIcon() {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronUpIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M6 15l6-6 6 6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function ChevronDownSmallIcon() {
|
||||
return (
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" aria-hidden>
|
||||
<path
|
||||
d="M6 9l6 6 6-6"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.6"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
+467
-94
@@ -1,30 +1,47 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import { FormEvent, useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
FormEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import ClothesShell from "@/components/clothes/ClothesShell";
|
||||
import IconButton, {
|
||||
ClearFilterIcon,
|
||||
DiscountIcon,
|
||||
EditIcon,
|
||||
FilterIcon,
|
||||
MoreIcon,
|
||||
PlacementIcon,
|
||||
SubscriptionIcon,
|
||||
TechnicalDataIcon,
|
||||
TrashIcon,
|
||||
VariantIcon,
|
||||
} from "@/components/ui/IconButton";
|
||||
import Modal, { FormModal } from "@/components/ui/Modal";
|
||||
import Pagination, { usePagination } from "@/components/ui/Pagination";
|
||||
import PriceInput from "@/components/ui/PriceInput";
|
||||
import {
|
||||
Brand,
|
||||
CategoryNode,
|
||||
TechnicalData,
|
||||
TechnicalField,
|
||||
discountPercent,
|
||||
findCategoryNode,
|
||||
flattenCategories,
|
||||
formatPrice,
|
||||
getCategoryLeafLabels,
|
||||
getCategoryVariantGroups,
|
||||
getVariantLabel,
|
||||
parsePriceInput,
|
||||
Product,
|
||||
ProductVariantPrice,
|
||||
resolveCategoryTechnicalFields,
|
||||
} from "@/data/clothes";
|
||||
import {
|
||||
deleteProduct,
|
||||
@@ -37,7 +54,13 @@ import {
|
||||
import { ApiError } from "@/lib/api";
|
||||
import { useConfirm } from "@/components/ui/ConfirmDialog";
|
||||
|
||||
const PAGE_SIZE = 4;
|
||||
const PAGE_SIZE = 16;
|
||||
|
||||
const FEATURED_PLACEMENTS = [
|
||||
{ id: "promo-1", labelFa: "جایگاه ۱", labelEn: "promo 1" },
|
||||
{ id: "promo-2", labelFa: "جایگاه ۲", labelEn: "promo 2" },
|
||||
{ id: "promo-3", labelFa: "جایگاه ۳", labelEn: "promo 3" },
|
||||
] as const;
|
||||
|
||||
type Filters = {
|
||||
name: string;
|
||||
@@ -69,6 +92,7 @@ function isBaseRow(row: ProductVariantPrice) {
|
||||
}
|
||||
|
||||
export default function ProductListPage() {
|
||||
const router = useRouter();
|
||||
const confirmDialog = useConfirm();
|
||||
const [categoryTree, setCategoryTree] = useState<CategoryNode[]>([]);
|
||||
const [brands, setBrands] = useState<Brand[]>([]);
|
||||
@@ -86,6 +110,8 @@ export default function ProductListPage() {
|
||||
const [draft, setDraft] = useState<Filters>(emptyFilters);
|
||||
const [applied, setApplied] = useState<Filters>(emptyFilters);
|
||||
const [detailProduct, setDetailProduct] = useState<Product | null>(null);
|
||||
const [menuProductId, setMenuProductId] = useState<string | null>(null);
|
||||
const menuRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const [variantProduct, setVariantProduct] = useState<Product | null>(null);
|
||||
const [variantRows, setVariantRows] = useState<ProductVariantPrice[]>([
|
||||
@@ -103,6 +129,18 @@ export default function ProductListPage() {
|
||||
const [vipSaving, setVipSaving] = useState(false);
|
||||
const [vipError, setVipError] = useState<string | null>(null);
|
||||
|
||||
const [placementProduct, setPlacementProduct] = useState<Product | null>(
|
||||
null,
|
||||
);
|
||||
const [placementSaving, setPlacementSaving] = useState(false);
|
||||
const [placementError, setPlacementError] = useState<string | null>(null);
|
||||
|
||||
const [techProduct, setTechProduct] = useState<Product | null>(null);
|
||||
const [techFields, setTechFields] = useState<TechnicalField[]>([]);
|
||||
const [techDraft, setTechDraft] = useState<TechnicalData>({});
|
||||
const [techSaving, setTechSaving] = useState(false);
|
||||
const [techError, setTechError] = useState<string | null>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setLoadError(null);
|
||||
@@ -111,8 +149,12 @@ export default function ProductListPage() {
|
||||
fetchProducts({
|
||||
name: applied.name.trim() || undefined,
|
||||
categoryId: applied.categoryId || undefined,
|
||||
minPrice: applied.priceMin ? Number(applied.priceMin) : undefined,
|
||||
maxPrice: applied.priceMax ? Number(applied.priceMax) : undefined,
|
||||
minPrice: applied.priceMin
|
||||
? Number(parsePriceInput(applied.priceMin))
|
||||
: undefined,
|
||||
maxPrice: applied.priceMax
|
||||
? Number(parsePriceInput(applied.priceMax))
|
||||
: undefined,
|
||||
}),
|
||||
fetchCategoriesTree(),
|
||||
fetchBrands(),
|
||||
@@ -135,6 +177,21 @@ export default function ProductListPage() {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!menuProductId) return;
|
||||
function handleClick(event: MouseEvent) {
|
||||
if (!menuRef.current?.contains(event.target as Node)) {
|
||||
setMenuProductId(null);
|
||||
}
|
||||
}
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [menuProductId]);
|
||||
|
||||
useEffect(() => {
|
||||
setMenuProductId(null);
|
||||
}, [applied]);
|
||||
|
||||
const list = items;
|
||||
|
||||
const { page, setPage, pageItems, totalPages, totalItems, pageSize } =
|
||||
@@ -215,6 +272,111 @@ export default function ProductListPage() {
|
||||
setVipSaving(false);
|
||||
}
|
||||
|
||||
function openPlacement(product: Product) {
|
||||
setPlacementProduct(product);
|
||||
setPlacementError(null);
|
||||
}
|
||||
|
||||
function closePlacement() {
|
||||
setPlacementProduct(null);
|
||||
setPlacementError(null);
|
||||
setPlacementSaving(false);
|
||||
}
|
||||
|
||||
async function selectPlacement(slotId: string) {
|
||||
if (!placementProduct || placementSaving) return;
|
||||
setPlacementSaving(true);
|
||||
setPlacementError(null);
|
||||
const nextSlot =
|
||||
placementProduct.featuredSlot === slotId ? null : slotId;
|
||||
try {
|
||||
const updated = await updateProduct(placementProduct.id, {
|
||||
featuredSlot: nextSlot,
|
||||
});
|
||||
setItems((prev) =>
|
||||
prev.map((row) =>
|
||||
row.id === updated.id ? { ...row, ...updated } : row,
|
||||
),
|
||||
);
|
||||
closePlacement();
|
||||
} catch (err) {
|
||||
setPlacementError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: "ذخیره جایگاه ویژه ناموفق بود.",
|
||||
);
|
||||
setPlacementSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function featuredSlotLabel(slotId: string | null | undefined) {
|
||||
if (!slotId) return null;
|
||||
return FEATURED_PLACEMENTS.find((slot) => slot.id === slotId) ?? null;
|
||||
}
|
||||
|
||||
function openTechnical(product: Product) {
|
||||
const fields = product.categoryId
|
||||
? resolveCategoryTechnicalFields(product.categoryId, categoryTree)
|
||||
: [];
|
||||
const values = product.technicalData ?? {};
|
||||
const draft: TechnicalData = {};
|
||||
for (const field of fields) {
|
||||
draft[field.id] = values[field.id] ?? "";
|
||||
}
|
||||
setTechProduct(product);
|
||||
setTechFields(fields);
|
||||
setTechDraft(draft);
|
||||
setTechError(null);
|
||||
}
|
||||
|
||||
function closeTechnical() {
|
||||
setTechProduct(null);
|
||||
setTechFields([]);
|
||||
setTechDraft({});
|
||||
setTechError(null);
|
||||
setTechSaving(false);
|
||||
}
|
||||
|
||||
async function saveTechnical(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!techProduct) return;
|
||||
|
||||
const missing = techFields.find(
|
||||
(field) => field.required && !(techDraft[field.id] ?? "").trim(),
|
||||
);
|
||||
if (missing) {
|
||||
setTechError(`فیلد «${missing.labelFa}» الزامی است.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const cleaned: TechnicalData = {};
|
||||
for (const field of techFields) {
|
||||
const value = (techDraft[field.id] ?? "").trim();
|
||||
if (value) cleaned[field.id] = value;
|
||||
}
|
||||
|
||||
setTechSaving(true);
|
||||
setTechError(null);
|
||||
try {
|
||||
const updated = await updateProduct(techProduct.id, {
|
||||
technicalData: cleaned,
|
||||
});
|
||||
setItems((prev) =>
|
||||
prev.map((item) => (item.id === updated.id ? updated : item)),
|
||||
);
|
||||
if (detailProduct?.id === updated.id) setDetailProduct(updated);
|
||||
closeTechnical();
|
||||
} catch (err) {
|
||||
setTechError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: "ذخیره مشخصات فنی ناموفق بود.",
|
||||
);
|
||||
} finally {
|
||||
setTechSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function updateVipPrice(id: string, value: string) {
|
||||
setVipRows((prev) =>
|
||||
prev.map((row) => (row.id === id ? { ...row, vipPrice: value } : row)),
|
||||
@@ -238,11 +400,11 @@ export default function ProductListPage() {
|
||||
if (special) {
|
||||
const nextRows = vipRows.map((row) => ({ ...row }));
|
||||
const priced = nextRows
|
||||
.map((row) => Number(row.vipPrice))
|
||||
.map((row) => Number(parsePriceInput(row.vipPrice ?? "")))
|
||||
.filter((n) => !Number.isNaN(n) && n > 0);
|
||||
const baseVip = nextRows.find((row) => isBaseRow(row))?.vipPrice;
|
||||
nextVipPrice = baseVip
|
||||
? Number(baseVip) || null
|
||||
? Number(parsePriceInput(baseVip)) || null
|
||||
: priced.length
|
||||
? Math.min(...priced)
|
||||
: null;
|
||||
@@ -335,12 +497,12 @@ export default function ProductListPage() {
|
||||
|
||||
const nextRows = discountRows.map((row) => ({ ...row }));
|
||||
const discounted = nextRows
|
||||
.map((row) => Number(row.discountPrice))
|
||||
.map((row) => Number(parsePriceInput(row.discountPrice ?? "")))
|
||||
.filter((n) => !Number.isNaN(n) && n > 0);
|
||||
const hadVariants = Boolean(discountProduct.variantPrices?.length);
|
||||
const baseDiscount = nextRows.find((row) => isBaseRow(row))?.discountPrice;
|
||||
const nextProductDiscount = baseDiscount
|
||||
? Number(baseDiscount) || null
|
||||
? Number(parsePriceInput(baseDiscount)) || null
|
||||
: discounted.length
|
||||
? Math.min(...discounted)
|
||||
: null;
|
||||
@@ -418,30 +580,26 @@ export default function ProductListPage() {
|
||||
</div>
|
||||
<div className="clothes-field">
|
||||
<label htmlFor="filter-min">حداقل قیمت</label>
|
||||
<input
|
||||
<PriceInput
|
||||
id="filter-min"
|
||||
inputMode="numeric"
|
||||
dir="ltr"
|
||||
className="font-montserrat"
|
||||
value={draft.priceMin}
|
||||
onChange={(e) =>
|
||||
setDraft((prev) => ({ ...prev, priceMin: e.target.value }))
|
||||
onChange={(raw) =>
|
||||
setDraft((prev) => ({ ...prev, priceMin: raw }))
|
||||
}
|
||||
placeholder="1000000"
|
||||
placeholder="1,000,000"
|
||||
/>
|
||||
</div>
|
||||
<div className="clothes-field">
|
||||
<label htmlFor="filter-max">حداکثر قیمت</label>
|
||||
<input
|
||||
<PriceInput
|
||||
id="filter-max"
|
||||
inputMode="numeric"
|
||||
dir="ltr"
|
||||
className="font-montserrat"
|
||||
value={draft.priceMax}
|
||||
onChange={(e) =>
|
||||
setDraft((prev) => ({ ...prev, priceMax: e.target.value }))
|
||||
onChange={(raw) =>
|
||||
setDraft((prev) => ({ ...prev, priceMax: raw }))
|
||||
}
|
||||
placeholder="5000000"
|
||||
placeholder="5,000,000"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -495,6 +653,17 @@ export default function ProductListPage() {
|
||||
: hasDiscount
|
||||
? 1
|
||||
: 0;
|
||||
const techFieldCount = product.categoryId
|
||||
? resolveCategoryTechnicalFields(product.categoryId, categoryTree)
|
||||
.length
|
||||
: 0;
|
||||
const techFilledCount = Object.values(
|
||||
product.technicalData ?? {},
|
||||
).filter((value) => value.trim()).length;
|
||||
const featured = featuredSlotLabel(product.featuredSlot);
|
||||
const categoryChips = product.categoryId
|
||||
? getCategoryLeafLabels(product.categoryId, categoryTree, 2)
|
||||
: [];
|
||||
|
||||
return (
|
||||
<article
|
||||
@@ -516,32 +685,50 @@ export default function ProductListPage() {
|
||||
{percent}%-
|
||||
</span>
|
||||
) : null}
|
||||
{featured ? (
|
||||
<span
|
||||
dir="ltr"
|
||||
className="absolute top-2 end-2 rounded-[3px] bg-ink px-2 py-0.5 font-montserrat text-[0.65rem] uppercase tracking-[0.12em] text-white"
|
||||
>
|
||||
{featured.labelEn}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1 p-3 text-start">
|
||||
<div className="flex flex-1 flex-col p-3 text-start">
|
||||
<h2 className="line-clamp-1 text-sm text-ink">
|
||||
{product.nameFa}
|
||||
</h2>
|
||||
<p
|
||||
dir="ltr"
|
||||
className="text-end font-display text-xs tracking-[0.06em] text-muted"
|
||||
className="mt-0.5 text-end font-display text-xs tracking-[0.06em] text-muted"
|
||||
>
|
||||
{product.nameEn}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-baseline gap-x-2 gap-y-1">
|
||||
{categoryChips.length ? (
|
||||
<div className="mt-1">
|
||||
<span className="inline-block rounded-[3px] bg-[#ecece8] px-1.5 py-0.5 text-[0.65rem] leading-none text-muted">
|
||||
{categoryChips.join(" · ")}
|
||||
</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className="mt-auto pt-2 flex flex-wrap items-baseline justify-start gap-x-2 gap-y-1 text-left"
|
||||
dir="ltr"
|
||||
>
|
||||
{hasDiscount ? (
|
||||
<>
|
||||
<span className="text-sm text-ink">
|
||||
<span className="font-montserrat">
|
||||
{formatPrice(product.discountPrice!)}
|
||||
</span>
|
||||
<span className="ms-1">تومان</span>
|
||||
<span className="ms-1">IRT</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted line-through">
|
||||
<span className="font-montserrat">
|
||||
{formatPrice(product.price)}
|
||||
</span>
|
||||
<span className="ms-1">تومان</span>
|
||||
<span className="ms-1">IRT</span>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
@@ -549,7 +736,7 @@ export default function ProductListPage() {
|
||||
<span className="font-montserrat">
|
||||
{formatPrice(product.price)}
|
||||
</span>
|
||||
<span className="ms-1">تومان</span>
|
||||
<span className="ms-1">IRT</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -559,7 +746,11 @@ export default function ProductListPage() {
|
||||
className="product-card-actions"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<IconButton label="ویرایش" variant="ghost">
|
||||
<IconButton
|
||||
label="ویرایش"
|
||||
variant="ghost"
|
||||
onClick={() => router.push(`/clothes/${product.id}/edit`)}
|
||||
>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
@@ -585,16 +776,74 @@ export default function ProductListPage() {
|
||||
>
|
||||
<DiscountIcon />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="دسترسی VIP"
|
||||
variant="ghost"
|
||||
className={
|
||||
product.vipEnabled ? "customer-sub-premium" : undefined
|
||||
}
|
||||
onClick={() => openVip(product)}
|
||||
<div
|
||||
className="product-card-more"
|
||||
ref={menuProductId === product.id ? menuRef : undefined}
|
||||
>
|
||||
<SubscriptionIcon premium={Boolean(product.vipEnabled)} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
label="بیشتر"
|
||||
variant="ghost"
|
||||
className={
|
||||
product.vipEnabled ? "customer-sub-premium" : undefined
|
||||
}
|
||||
onClick={() =>
|
||||
setMenuProductId((prev) =>
|
||||
prev === product.id ? null : product.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<MoreIcon />
|
||||
</IconButton>
|
||||
{menuProductId === product.id ? (
|
||||
<div role="menu" className="product-card-more-panel">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="dash-menu-item w-full"
|
||||
onClick={() => {
|
||||
setMenuProductId(null);
|
||||
openVip(product);
|
||||
}}
|
||||
>
|
||||
<SubscriptionIcon
|
||||
premium={Boolean(product.vipEnabled)}
|
||||
/>
|
||||
دسترسی VIP
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="dash-menu-item w-full"
|
||||
onClick={() => {
|
||||
setMenuProductId(null);
|
||||
openPlacement(product);
|
||||
}}
|
||||
>
|
||||
<PlacementIcon />
|
||||
جایگاه ویژه
|
||||
</button>
|
||||
{techFieldCount > 0 ? (
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="dash-menu-item w-full"
|
||||
onClick={() => {
|
||||
setMenuProductId(null);
|
||||
openTechnical(product);
|
||||
}}
|
||||
>
|
||||
<TechnicalDataIcon />
|
||||
مشخصات فنی
|
||||
{techFilledCount ? (
|
||||
<span className="ms-auto font-montserrat text-[0.7rem] text-muted">
|
||||
{techFilledCount}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
@@ -619,6 +868,7 @@ export default function ProductListPage() {
|
||||
<Modal
|
||||
open={Boolean(detailProduct)}
|
||||
title="جزئیات محصول"
|
||||
subtitle={detailProduct?.nameEn || detailProduct?.nameFa || "Product details"}
|
||||
onClose={() => setDetailProduct(null)}
|
||||
wide="full"
|
||||
footer={
|
||||
@@ -708,13 +958,13 @@ export default function ProductListPage() {
|
||||
<span className="font-montserrat">
|
||||
{formatPrice(detailProduct.discountPrice)}
|
||||
</span>
|
||||
<span className="ms-1">تومان</span>
|
||||
<span className="ms-1">IRT</span>
|
||||
</span>
|
||||
<span className="text-xs text-muted line-through">
|
||||
<span className="font-montserrat">
|
||||
{formatPrice(detailProduct.price)}
|
||||
</span>
|
||||
<span className="ms-1">تومان</span>
|
||||
<span className="ms-1">IRT</span>
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
@@ -722,7 +972,7 @@ export default function ProductListPage() {
|
||||
<span className="font-montserrat">
|
||||
{formatPrice(detailProduct.price)}
|
||||
</span>
|
||||
<span className="ms-1">تومان</span>
|
||||
<span className="ms-1">IRT</span>
|
||||
</span>
|
||||
)}
|
||||
</dd>
|
||||
@@ -747,6 +997,45 @@ export default function ProductListPage() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{(() => {
|
||||
const fields = detailProduct.categoryId
|
||||
? resolveCategoryTechnicalFields(
|
||||
detailProduct.categoryId,
|
||||
categoryTree,
|
||||
)
|
||||
: [];
|
||||
if (!fields.length) return null;
|
||||
const values = detailProduct.technicalData ?? {};
|
||||
return (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs text-muted">مشخصات فنی</p>
|
||||
<dl className="space-y-2">
|
||||
{fields.map((field) => (
|
||||
<div
|
||||
key={field.id}
|
||||
className="flex flex-wrap items-baseline justify-between gap-2 border border-line px-2.5 py-2 text-sm"
|
||||
>
|
||||
<dt className="text-muted">
|
||||
{field.labelFa}
|
||||
{field.labelEn ? (
|
||||
<span
|
||||
dir="ltr"
|
||||
className="ms-1 font-montserrat text-[0.7rem]"
|
||||
>
|
||||
({field.labelEn})
|
||||
</span>
|
||||
) : null}
|
||||
</dt>
|
||||
<dd className="text-ink">
|
||||
{values[field.id]?.trim() || "—"}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
|
||||
{detailProduct.tags.length ? (
|
||||
<div>
|
||||
<p className="mb-1.5 text-xs text-muted">برچسبها</p>
|
||||
@@ -812,43 +1101,15 @@ export default function ProductListPage() {
|
||||
<FormModal
|
||||
open={Boolean(variantProduct)}
|
||||
title={`تنوع و قیمت — ${variantProduct?.nameFa ?? ""}`}
|
||||
subtitle="Color, size, and package rows for this product"
|
||||
onClose={closeVariants}
|
||||
onSubmit={saveVariants}
|
||||
submitLabel="ذخیره"
|
||||
wide="xl"
|
||||
>
|
||||
<p className="text-xs leading-6 text-muted">
|
||||
هر نوع تنوع (رنگ، سایز، پکیج) جداست. میتوانید خالی بگذارید.
|
||||
</p>
|
||||
|
||||
<div className="mb-1 flex items-center justify-between gap-2">
|
||||
<span className="text-sm text-ink">ردیفها</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRow}
|
||||
className="border border-ink bg-ink px-3 py-1.5 text-xs text-white transition-colors hover:bg-white hover:text-ink"
|
||||
>
|
||||
+ ردیف جدید
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{variantRows.map((row, index) => (
|
||||
{variantRows.map((row) => (
|
||||
<div key={row.id} className="variant-price-row">
|
||||
<div className="mb-2 flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-muted">
|
||||
ردیف {new Intl.NumberFormat("en-US").format(index + 1)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted hover:text-ink disabled:opacity-30"
|
||||
disabled={variantRows.length <= 1}
|
||||
onClick={() => removeRow(row.id)}
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="variant-price-grid">
|
||||
<div className="variant-price-fields">
|
||||
{variantGroups.colors.length ? (
|
||||
@@ -917,33 +1178,47 @@ export default function ProductListPage() {
|
||||
|
||||
<div className="variant-price-amount">
|
||||
<div className="clothes-field">
|
||||
<label htmlFor={`vp-price-${row.id}`}>قیمت</label>
|
||||
<input
|
||||
<PriceInput
|
||||
id={`vp-price-${row.id}`}
|
||||
dir="ltr"
|
||||
inputMode="numeric"
|
||||
aria-label="قیمت"
|
||||
className="!text-end font-montserrat"
|
||||
value={row.price}
|
||||
onChange={(e) =>
|
||||
updateRow(row.id, "price", e.target.value)
|
||||
}
|
||||
placeholder="3570000"
|
||||
onChange={(raw) => updateRow(row.id, "price", raw)}
|
||||
placeholder="3,570,000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="variant-price-remove text-xs text-muted hover:text-ink disabled:opacity-30"
|
||||
disabled={variantRows.length <= 1}
|
||||
onClick={() => removeRow(row.id)}
|
||||
>
|
||||
حذف
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRow}
|
||||
className="w-full border border-line bg-white px-3 py-2.5 text-sm text-ink transition-colors hover:border-ink"
|
||||
>
|
||||
+ ردیف جدید
|
||||
</button>
|
||||
</div>
|
||||
</FormModal>
|
||||
|
||||
<FormModal
|
||||
open={Boolean(discountProduct)}
|
||||
title={`تخفیف — ${discountProduct?.nameFa ?? ""}`}
|
||||
subtitle="Set percent or amount discount for this product"
|
||||
onClose={closeDiscount}
|
||||
onSubmit={saveDiscount}
|
||||
submitLabel="ذخیره تخفیف"
|
||||
wide
|
||||
wide="xl"
|
||||
>
|
||||
<p className="text-xs leading-6 text-muted">
|
||||
تنوعهای موجود و قیمت فعلی را ببینید و قیمت تخفیف را وارد کنید.
|
||||
@@ -968,22 +1243,18 @@ export default function ProductListPage() {
|
||||
: formatPrice(currentPrice)}
|
||||
</span>
|
||||
{!Number.isNaN(currentPrice) && row.price ? (
|
||||
<span className="ms-1">تومان</span>
|
||||
<span className="ms-1">IRT</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<div className="clothes-field">
|
||||
<label htmlFor={`disc-new-${row.id}`}>قیمت تخفیف</label>
|
||||
<input
|
||||
<PriceInput
|
||||
id={`disc-new-${row.id}`}
|
||||
dir="ltr"
|
||||
inputMode="numeric"
|
||||
className="!text-end font-montserrat"
|
||||
value={row.discountPrice ?? ""}
|
||||
onChange={(e) =>
|
||||
updateDiscountPrice(row.id, e.target.value)
|
||||
}
|
||||
placeholder="3570000"
|
||||
onChange={(raw) => updateDiscountPrice(row.id, raw)}
|
||||
placeholder="3,570,000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -992,9 +1263,63 @@ export default function ProductListPage() {
|
||||
</div>
|
||||
</FormModal>
|
||||
|
||||
<Modal
|
||||
open={Boolean(placementProduct)}
|
||||
title={`جایگاه ویژه — ${placementProduct?.nameFa ?? ""}`}
|
||||
subtitle="Featured placement"
|
||||
onClose={closePlacement}
|
||||
footer={
|
||||
<button
|
||||
type="button"
|
||||
className="modal-btn-ghost"
|
||||
onClick={closePlacement}
|
||||
>
|
||||
بستن
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<p className="mb-4 text-sm leading-7 text-muted">
|
||||
این جایگاهها کاروسلهای صفحه اصلی را پر میکنند. میتوانید چند
|
||||
محصول را به یک جایگاه اختصاص دهید تا همه در همان ردیف نمایش داده
|
||||
شوند.
|
||||
</p>
|
||||
{placementError ? (
|
||||
<p className="mb-3 text-sm text-red-600" role="alert">
|
||||
{placementError}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="space-y-2">
|
||||
{FEATURED_PLACEMENTS.map((slot) => {
|
||||
const active = placementProduct?.featuredSlot === slot.id;
|
||||
return (
|
||||
<button
|
||||
key={slot.id}
|
||||
type="button"
|
||||
disabled={placementSaving}
|
||||
className={`flex w-full flex-col items-start gap-1 border px-4 py-3 text-start transition-colors ${
|
||||
active
|
||||
? "border-ink bg-[#f4f4f2]"
|
||||
: "border-line bg-white hover:border-ink"
|
||||
} disabled:opacity-60`}
|
||||
onClick={() => void selectPlacement(slot.id)}
|
||||
>
|
||||
<span className="text-sm text-ink">{slot.labelFa}</span>
|
||||
<span
|
||||
dir="ltr"
|
||||
className="font-montserrat text-[10px] uppercase tracking-[0.16em] text-muted"
|
||||
>
|
||||
{slot.labelEn}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<FormModal
|
||||
open={Boolean(vipProduct)}
|
||||
title={`دسترسی VIP — ${vipProduct?.nameFa ?? ""}`}
|
||||
subtitle="VIP access level and purchase limits"
|
||||
onClose={closeVip}
|
||||
onSubmit={(e) => void saveVip(e)}
|
||||
submitLabel={vipSaving ? "..." : "ذخیره"}
|
||||
@@ -1113,23 +1438,19 @@ export default function ProductListPage() {
|
||||
: formatPrice(currentPrice)}
|
||||
</span>
|
||||
{!Number.isNaN(currentPrice) && row.price ? (
|
||||
<span className="ms-1">تومان</span>
|
||||
<span className="ms-1">IRT</span>
|
||||
) : null}
|
||||
</p>
|
||||
</div>
|
||||
<div className="clothes-field">
|
||||
<label htmlFor={`vip-price-${row.id}`}>قیمت VIP</label>
|
||||
<input
|
||||
<PriceInput
|
||||
id={`vip-price-${row.id}`}
|
||||
dir="ltr"
|
||||
inputMode="numeric"
|
||||
className="!text-end font-montserrat"
|
||||
value={row.vipPrice ?? ""}
|
||||
disabled={!vipEnabled}
|
||||
onChange={(e) =>
|
||||
updateVipPrice(row.id, e.target.value)
|
||||
}
|
||||
placeholder="3570000"
|
||||
onChange={(raw) => updateVipPrice(row.id, raw)}
|
||||
placeholder="3,570,000"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1139,6 +1460,58 @@ export default function ProductListPage() {
|
||||
) : null}
|
||||
</div>
|
||||
</FormModal>
|
||||
|
||||
<FormModal
|
||||
open={Boolean(techProduct)}
|
||||
title={`مشخصات فنی — ${techProduct?.nameFa ?? ""}`}
|
||||
subtitle="Technical fields defined on this product’s category"
|
||||
onClose={closeTechnical}
|
||||
onSubmit={saveTechnical}
|
||||
submitLabel={techSaving ? "..." : "ذخیره مشخصات"}
|
||||
wide
|
||||
>
|
||||
{techError ? (
|
||||
<p className="rounded-lg border border-line bg-white px-3 py-2 text-sm text-ink">
|
||||
{techError}
|
||||
</p>
|
||||
) : null}
|
||||
{techFields.length === 0 ? (
|
||||
<p className="text-sm text-muted">
|
||||
برای دستهبندی این محصول فیلد فنی تعریف نشده است.
|
||||
</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
{techFields.map((field) => (
|
||||
<div key={field.id} className="clothes-field">
|
||||
<label htmlFor={`list-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={`list-tech-${field.id}`}
|
||||
value={techDraft[field.id] ?? ""}
|
||||
onChange={(e) =>
|
||||
setTechDraft((prev) => ({
|
||||
...prev,
|
||||
[field.id]: e.target.value,
|
||||
}))
|
||||
}
|
||||
required={field.required}
|
||||
placeholder={field.labelFa}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</FormModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -77,7 +77,7 @@ function ClothesIcon() {
|
||||
<path
|
||||
d="M8.5 4.5 12 7l3.5-2.5L19 6l-2 4.5V19H7V10.5L5 6l3.5-1.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.15"
|
||||
strokeWidth="0.85"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
@@ -90,7 +90,7 @@ function AddClothIcon() {
|
||||
<path
|
||||
d="M12 7v10M7 12h10"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.15"
|
||||
strokeWidth="0.85"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
<rect
|
||||
@@ -100,7 +100,7 @@ function AddClothIcon() {
|
||||
height="15"
|
||||
rx="1.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.15"
|
||||
strokeWidth="0.85"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -109,10 +109,10 @@ function AddClothIcon() {
|
||||
function CategoriesIcon() {
|
||||
return (
|
||||
<svg {...iconProps()}>
|
||||
<rect x="4.5" y="4.5" width="6.5" height="6.5" stroke="currentColor" strokeWidth="1.15" />
|
||||
<rect x="13" y="4.5" width="6.5" height="6.5" stroke="currentColor" strokeWidth="1.15" />
|
||||
<rect x="4.5" y="13" width="6.5" height="6.5" stroke="currentColor" strokeWidth="1.15" />
|
||||
<rect x="13" y="13" width="6.5" height="6.5" stroke="currentColor" strokeWidth="1.15" />
|
||||
<rect x="4.5" y="4.5" width="6.5" height="6.5" stroke="currentColor" strokeWidth="0.85" />
|
||||
<rect x="13" y="4.5" width="6.5" height="6.5" stroke="currentColor" strokeWidth="0.85" />
|
||||
<rect x="4.5" y="13" width="6.5" height="6.5" stroke="currentColor" strokeWidth="0.85" />
|
||||
<rect x="13" y="13" width="6.5" height="6.5" stroke="currentColor" strokeWidth="0.85" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -123,7 +123,7 @@ function BrandsIcon() {
|
||||
<path
|
||||
d="M5 6.5h8.5L19 12l-5.5 5.5H5V6.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.15"
|
||||
strokeWidth="0.85"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="9" cy="12" r="1.2" fill="currentColor" />
|
||||
|
||||
+156
-8
@@ -568,6 +568,27 @@ body {
|
||||
border-inline-start: none;
|
||||
}
|
||||
|
||||
.product-card-more {
|
||||
position: relative;
|
||||
border-inline-start: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.product-card-more .icon-btn {
|
||||
border-inline-start: none;
|
||||
}
|
||||
|
||||
.product-card-more-panel {
|
||||
position: absolute;
|
||||
bottom: calc(100% + 0.35rem);
|
||||
inset-inline-end: 0;
|
||||
z-index: 30;
|
||||
min-width: 11.5rem;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.08);
|
||||
animation: auth-fade 0.18s ease both;
|
||||
}
|
||||
|
||||
.tree-box {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--surface);
|
||||
@@ -1073,6 +1094,18 @@ body {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.website-slot-cropper .crop-frame {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: min(36dvh, 16rem);
|
||||
margin-inline: 0;
|
||||
}
|
||||
|
||||
.website-slot-cropper .crop-zoom-bar {
|
||||
width: 100%;
|
||||
max-width: 20rem;
|
||||
}
|
||||
|
||||
@keyframes modal-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
@@ -1085,24 +1118,71 @@ body {
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.85rem 1rem 0.65rem;
|
||||
padding: 1rem 1.1rem 0.95rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
background-color: #f3f3f1;
|
||||
background-image: radial-gradient(
|
||||
circle,
|
||||
rgba(10, 10, 10, 0.12) 0.7px,
|
||||
transparent 1px
|
||||
);
|
||||
background-size: 12px 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header-copy {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
font-weight: 500;
|
||||
line-height: 1.35;
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.modal-title {
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-subtitle {
|
||||
margin: 0;
|
||||
max-width: 36rem;
|
||||
font-family: var(--font-montserrat), ui-sans-serif, system-ui, sans-serif;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.04em;
|
||||
line-height: 1.45;
|
||||
color: var(--muted);
|
||||
direction: ltr;
|
||||
text-align: start;
|
||||
unicode-bidi: isolate;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
width: 2.75rem;
|
||||
height: 2.75rem;
|
||||
flex-shrink: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
font-size: 1.35rem;
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
border-radius: var(--radius-card);
|
||||
@@ -1110,7 +1190,7 @@ body {
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--ink);
|
||||
background: #f4f4f2;
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
@@ -1382,6 +1462,14 @@ body {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.product-cover-cropper .crop-frame-brand {
|
||||
width: min(100%, 280px);
|
||||
}
|
||||
|
||||
.product-cover-cropper .crop-zoom-bar {
|
||||
width: min(100%, 280px);
|
||||
}
|
||||
|
||||
.profile-avatar-cropper .crop-frame-brand {
|
||||
width: min(100%, 200px);
|
||||
}
|
||||
@@ -1410,6 +1498,36 @@ body {
|
||||
background: #fafaf8;
|
||||
}
|
||||
|
||||
.tech-field-builder {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tech-field-row {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
background: #fafaf8;
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.tech-field-row-inline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: end;
|
||||
gap: 0.65rem 0.75rem;
|
||||
}
|
||||
|
||||
.tech-field-row-inline .clothes-field label {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.tech-field-row-inline {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.variant-type-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1520,7 +1638,7 @@ body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.65rem;
|
||||
align-items: end;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
@@ -1537,7 +1655,7 @@ body {
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.variant-price-fields {
|
||||
grid-column: span 8;
|
||||
grid-column: span 7;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -1555,6 +1673,18 @@ body {
|
||||
}
|
||||
}
|
||||
|
||||
.variant-price-remove {
|
||||
justify-self: start;
|
||||
padding: 0.35rem 0.15rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.variant-price-remove {
|
||||
grid-column: span 1;
|
||||
justify-self: center;
|
||||
}
|
||||
}
|
||||
|
||||
.crop-frame.crop-ratio-1-1 {
|
||||
aspect-ratio: 1 / 1;
|
||||
}
|
||||
@@ -1572,6 +1702,7 @@ body {
|
||||
inset: 0;
|
||||
transform-origin: center center;
|
||||
will-change: transform;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.crop-frame::after {
|
||||
@@ -1591,6 +1722,7 @@ body {
|
||||
gap: 0.55rem;
|
||||
width: min(100%, 220px);
|
||||
margin-inline: auto;
|
||||
direction: ltr !important;
|
||||
}
|
||||
|
||||
.crop-zoom-btn {
|
||||
@@ -1599,6 +1731,7 @@ body {
|
||||
justify-content: center;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
background: #fff;
|
||||
@@ -1626,6 +1759,21 @@ body {
|
||||
min-width: 0;
|
||||
accent-color: var(--ink);
|
||||
cursor: pointer;
|
||||
direction: ltr !important;
|
||||
}
|
||||
|
||||
.crop-zoom-value {
|
||||
min-width: 2.75rem;
|
||||
font-family: var(--font-montserrat), system-ui, sans-serif;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
text-align: end;
|
||||
direction: ltr;
|
||||
}
|
||||
|
||||
.website-slot-cropper .crop-frame::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.gallery-grid {
|
||||
|
||||
+33
-11
@@ -34,6 +34,13 @@ const tiles: DashTileItem[] = [
|
||||
description: "پیگیری ارسال و پرداخت",
|
||||
icon: <OrdersIcon />,
|
||||
},
|
||||
{
|
||||
href: "/website",
|
||||
title: "مدیریت وبسایت",
|
||||
titleEn: "Website",
|
||||
description: "تصاویر و درخواستها",
|
||||
icon: <WebsiteIcon />,
|
||||
},
|
||||
{
|
||||
href: "/profile",
|
||||
title: "پروفایل من",
|
||||
@@ -67,7 +74,8 @@ export default function HomePage() {
|
||||
</span>
|
||||
</h1>
|
||||
<p className="mt-3 max-w-xl text-sm leading-7 text-muted sm:text-base">
|
||||
از اینجا لباسها، مشتریان، بلاگ، سفارشها و پروفایل فروشگاه را مدیریت کنید.
|
||||
از اینجا لباسها، مشتریان، بلاگ، سفارشها، وبسایت و پروفایل فروشگاه را
|
||||
مدیریت کنید.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -103,7 +111,7 @@ function ClothesIcon() {
|
||||
<path
|
||||
d="M8.5 4.5 12 7l3.5-2.5L19 6l-2 4.5V19H7V10.5L5 6l3.5-1.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.15"
|
||||
strokeWidth="0.85"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
@@ -113,12 +121,12 @@ function ClothesIcon() {
|
||||
function CustomersIcon() {
|
||||
return (
|
||||
<svg {...iconProps()}>
|
||||
<circle cx="9" cy="8" r="2.75" stroke="currentColor" strokeWidth="1.15" />
|
||||
<circle cx="16.5" cy="9.5" r="2.1" stroke="currentColor" strokeWidth="1.15" />
|
||||
<circle cx="9" cy="8" r="2.75" stroke="currentColor" strokeWidth="0.85" />
|
||||
<circle cx="16.5" cy="9.5" r="2.1" stroke="currentColor" strokeWidth="0.85" />
|
||||
<path
|
||||
d="M3.5 18.5c1.2-2.6 3.1-3.9 5.5-3.9s4.3 1.3 5.5 3.9M13.2 14.8c1.1-.5 2.4-.7 3.8-.4 1.7.4 3 1.5 3.9 3.1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.15"
|
||||
strokeWidth="0.85"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
@@ -131,13 +139,13 @@ function BlogIcon() {
|
||||
<path
|
||||
d="M6 5.5h12v13H6V5.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.15"
|
||||
strokeWidth="0.85"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9 9h6M9 12.5h6M9 16h4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.15"
|
||||
strokeWidth="0.85"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
@@ -150,13 +158,13 @@ function OrdersIcon() {
|
||||
<path
|
||||
d="M6 7h12l-.8 11.2a1 1 0 0 1-1 .8H7.8a1 1 0 0 1-1-.8L6 7Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.15"
|
||||
strokeWidth="0.85"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9 7a3 3 0 0 1 6 0"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.15"
|
||||
strokeWidth="0.85"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
@@ -166,11 +174,25 @@ function OrdersIcon() {
|
||||
function ProfileIcon() {
|
||||
return (
|
||||
<svg {...iconProps()}>
|
||||
<circle cx="12" cy="8" r="3" stroke="currentColor" strokeWidth="1.15" />
|
||||
<circle cx="12" cy="8" r="3" stroke="currentColor" strokeWidth="0.85" />
|
||||
<path
|
||||
d="M5 19c1.5-3.2 3.8-4.8 7-4.8s5.5 1.6 7 4.8"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.15"
|
||||
strokeWidth="0.85"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function WebsiteIcon() {
|
||||
return (
|
||||
<svg {...iconProps()}>
|
||||
<circle cx="12" cy="12" r="7.5" stroke="currentColor" strokeWidth="0.85" />
|
||||
<path
|
||||
d="M4.5 12h15M12 4.5c2.2 2.4 3.3 4.9 3.3 7.5S14.2 17.1 12 19.5M12 4.5C9.8 6.9 8.7 9.4 8.7 12s1.1 5.1 3.3 7.5"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.85"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
@@ -0,0 +1,691 @@
|
||||
"use client";
|
||||
|
||||
import Image from "next/image";
|
||||
import {
|
||||
ChangeEvent,
|
||||
FormEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import ClothesShell from "@/components/clothes/ClothesShell";
|
||||
import IconButton, { EditIcon, TrashIcon } from "@/components/ui/IconButton";
|
||||
import ImageCropper from "@/components/ui/ImageCropper";
|
||||
import { FormModal } from "@/components/ui/Modal";
|
||||
import { useConfirm } from "@/components/ui/ConfirmDialog";
|
||||
import {
|
||||
WEBSITE_SLOT_DEFS,
|
||||
groupSlotSections,
|
||||
heroPreviewObjectPosition,
|
||||
normalizeSlotDef,
|
||||
type WebsiteSlotDef,
|
||||
type WebsiteSlotItem,
|
||||
} from "@/data/website-slots";
|
||||
import { ApiError, uploadMedia } from "@/lib/api";
|
||||
import {
|
||||
createWebsiteSlotItem,
|
||||
deleteWebsiteSlotItem,
|
||||
fetchWebsiteSlots,
|
||||
updateWebsiteSlotItem,
|
||||
} from "@/lib/website-slots-api";
|
||||
|
||||
type FormState = {
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
subtext: string;
|
||||
linkText: string;
|
||||
link: string;
|
||||
imagePreview: string;
|
||||
imageFile: File | null;
|
||||
};
|
||||
|
||||
const emptyForm = (): FormState => ({
|
||||
nameFa: "",
|
||||
nameEn: "",
|
||||
subtext: "",
|
||||
linkText: "",
|
||||
link: "",
|
||||
imagePreview: "",
|
||||
imageFile: null,
|
||||
});
|
||||
|
||||
export default function WebsiteImagesPage() {
|
||||
const confirmDialog = useConfirm();
|
||||
const fileRef = useRef<HTMLInputElement>(null);
|
||||
const [catalog, setCatalog] = useState<WebsiteSlotDef[]>(WEBSITE_SLOT_DEFS);
|
||||
const [items, setItems] = useState<WebsiteSlotItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [slotSlug, setSlotSlug] = useState("");
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [targetSortOrder, setTargetSortOrder] = useState<number | undefined>();
|
||||
const [form, setForm] = useState<FormState>(emptyForm());
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchWebsiteSlots();
|
||||
const nextCatalog = (data.catalog?.length ? data.catalog : WEBSITE_SLOT_DEFS)
|
||||
.map((row) => normalizeSlotDef(row as WebsiteSlotDef & { key?: string }))
|
||||
.filter((row) => row.slug && row.slug !== "payment-badges");
|
||||
setCatalog(nextCatalog.length ? nextCatalog : WEBSITE_SLOT_DEFS);
|
||||
setItems(data.items ?? []);
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError
|
||||
? err.message
|
||||
: "بارگذاری اسلاتهای وبسایت ناموفق بود.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const itemsBySlot = useMemo(() => {
|
||||
const map = new Map<string, WebsiteSlotItem[]>();
|
||||
for (const item of items) {
|
||||
const list = map.get(item.slotKey) ?? [];
|
||||
list.push(item);
|
||||
map.set(item.slotKey, list);
|
||||
}
|
||||
for (const [, list] of map) {
|
||||
list.sort((a, b) => a.sortOrder - b.sortOrder);
|
||||
}
|
||||
return map;
|
||||
}, [items]);
|
||||
|
||||
const sections = useMemo(() => groupSlotSections(catalog), [catalog]);
|
||||
|
||||
const activeDef = catalog.find((d) => d.slug === slotSlug);
|
||||
const activeCell =
|
||||
targetSortOrder !== undefined
|
||||
? activeDef?.cellLabels?.[targetSortOrder]
|
||||
: undefined;
|
||||
const activeAspectRatio =
|
||||
activeCell?.aspectRatio ?? activeDef?.aspectRatio ?? 1;
|
||||
const activeAspectLabel =
|
||||
activeCell?.aspectLabel ?? activeDef?.aspectLabel ?? "";
|
||||
|
||||
const activePreviewObjectPosition = useMemo(() => {
|
||||
if (!activeDef) return undefined;
|
||||
if (activeDef.slug === "hero-slider") {
|
||||
const list = itemsBySlot.get(activeDef.slug) ?? [];
|
||||
const index = editingId
|
||||
? list.findIndex((row) => row.id === editingId)
|
||||
: list.length;
|
||||
return heroPreviewObjectPosition(Math.max(0, index));
|
||||
}
|
||||
return (
|
||||
activeCell?.previewObjectPosition ?? activeDef.previewObjectPosition
|
||||
);
|
||||
}, [activeDef, activeCell, editingId, itemsBySlot]);
|
||||
|
||||
const activeFocusY = objectPositionFocusY(activePreviewObjectPosition);
|
||||
|
||||
function closeModal() {
|
||||
setModalOpen(false);
|
||||
setEditingId(null);
|
||||
setSlotSlug("");
|
||||
setTargetSortOrder(undefined);
|
||||
setForm(emptyForm());
|
||||
if (fileRef.current) fileRef.current.value = "";
|
||||
}
|
||||
|
||||
function openCreate(def: WebsiteSlotDef, sortOrder?: number) {
|
||||
setEditingId(null);
|
||||
setSlotSlug(def.slug);
|
||||
setTargetSortOrder(sortOrder);
|
||||
const cell =
|
||||
sortOrder !== undefined ? def.cellLabels?.[sortOrder] : undefined;
|
||||
setForm({
|
||||
...emptyForm(),
|
||||
nameFa: cell?.labelFa ?? "",
|
||||
nameEn: cell?.labelEn ?? "",
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function openEdit(def: WebsiteSlotDef, item: WebsiteSlotItem) {
|
||||
setEditingId(item.id);
|
||||
setSlotSlug(def.slug);
|
||||
setTargetSortOrder(item.sortOrder);
|
||||
setForm({
|
||||
nameFa: item.nameFa,
|
||||
nameEn: item.nameEn,
|
||||
subtext: item.subtext,
|
||||
linkText: item.linkText,
|
||||
link: item.link,
|
||||
imagePreview: item.imageUrl ?? "",
|
||||
imageFile: null,
|
||||
});
|
||||
setModalOpen(true);
|
||||
}
|
||||
|
||||
function handleImageChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
imageFile: file,
|
||||
imagePreview: URL.createObjectURL(file),
|
||||
}));
|
||||
}
|
||||
|
||||
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (!slotSlug) return;
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
let imageUrl: string | undefined;
|
||||
let imageKey: string | undefined;
|
||||
if (form.imageFile) {
|
||||
const uploaded = await uploadMedia(form.imageFile, "website");
|
||||
imageUrl = uploaded.url;
|
||||
imageKey = uploaded.storageKey;
|
||||
}
|
||||
|
||||
const payload = {
|
||||
nameFa: form.nameFa.trim(),
|
||||
nameEn: form.nameEn.trim(),
|
||||
subtext: form.subtext.trim(),
|
||||
linkText: form.linkText.trim(),
|
||||
link: form.link.trim(),
|
||||
...(imageUrl ? { imageUrl, imageKey } : {}),
|
||||
};
|
||||
|
||||
if (editingId) {
|
||||
await updateWebsiteSlotItem(editingId, payload);
|
||||
} else {
|
||||
await createWebsiteSlotItem({
|
||||
slotKey: slotSlug,
|
||||
...payload,
|
||||
...(targetSortOrder !== undefined
|
||||
? { sortOrder: targetSortOrder }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
await reload();
|
||||
closeModal();
|
||||
} catch (err) {
|
||||
setError(
|
||||
err instanceof ApiError ? err.message : "ذخیره اسلات ناموفق بود.",
|
||||
);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete(id: string) {
|
||||
if (!(await confirmDialog("این آیتم حذف شود؟"))) return;
|
||||
setError(null);
|
||||
try {
|
||||
await deleteWebsiteSlotItem(id);
|
||||
setItems((prev) => prev.filter((item) => item.id !== id));
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : "حذف ناموفق بود.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<ClothesShell
|
||||
eyebrow="Images"
|
||||
title="مدیریت تصاویر"
|
||||
description="اسلاتهای صفحه اصلی با نسبت تصویر واقعی هر جایگاه."
|
||||
breadcrumbs={[
|
||||
{ label: "داشبورد", href: "/" },
|
||||
{ label: "وبسایت", href: "/website" },
|
||||
{ label: "تصاویر" },
|
||||
]}
|
||||
>
|
||||
{error ? (
|
||||
<p className="mb-3 rounded-lg border border-line bg-surface px-4 py-3 text-sm text-ink">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted">در حال بارگذاری...</p>
|
||||
) : (
|
||||
<div>
|
||||
{sections.map((section, sectionIndex) => {
|
||||
const primary = section.defs[0];
|
||||
const slugLine =
|
||||
section.defs.length > 1
|
||||
? section.defs.map((d) => d.slug).join(" · ")
|
||||
: primary.slug;
|
||||
return (
|
||||
<section
|
||||
key={section.key}
|
||||
className={`space-y-3 ${
|
||||
sectionIndex > 0 ? "border-t border-line pt-8" : ""
|
||||
} ${
|
||||
sectionIndex < sections.length - 1 ? "pb-8" : ""
|
||||
}`}
|
||||
>
|
||||
<div>
|
||||
<h2 className="text-base text-ink">{section.labelFa}</h2>
|
||||
<p
|
||||
dir="ltr"
|
||||
className="mt-1 font-montserrat text-sm tracking-wide text-muted"
|
||||
>
|
||||
<span className="text-ink/70">{slugLine}</span>
|
||||
<span className="mx-2 text-line">·</span>
|
||||
<span>{formatImageSize(section.aspectLabel)}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{section.defs.map((def) => {
|
||||
const slotItems = itemsBySlot.get(def.slug) ?? [];
|
||||
|
||||
if (def.kind === "collection") {
|
||||
return (
|
||||
<div
|
||||
key={def.slug}
|
||||
className="contents"
|
||||
>
|
||||
{slotItems.map((item, index) => (
|
||||
<SlotThumb
|
||||
key={item.id}
|
||||
aspectRatio={def.aspectRatio}
|
||||
imageUrl={item.imageUrl}
|
||||
objectPosition={
|
||||
def.slug === "hero-slider"
|
||||
? heroPreviewObjectPosition(index)
|
||||
: def.previewObjectPosition
|
||||
}
|
||||
title={item.nameFa || "بدون نام"}
|
||||
subtitle={formatImageSize(def.aspectLabel)}
|
||||
onEdit={() => openEdit(def, item)}
|
||||
onDelete={() => void handleDelete(item.id)}
|
||||
/>
|
||||
))}
|
||||
<PlusThumb
|
||||
aspectRatio={def.aspectRatio}
|
||||
label={formatImageSize(def.aspectLabel)}
|
||||
onClick={() => openCreate(def)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (def.kind === "fixed-set") {
|
||||
return (
|
||||
<div key={def.slug} className="contents">
|
||||
{Array.from({
|
||||
length: def.itemCount ?? 0,
|
||||
}).map((_, index) => {
|
||||
const item = slotItems.find(
|
||||
(row) => row.sortOrder === index,
|
||||
);
|
||||
const cell = def.cellLabels?.[index];
|
||||
const cellAr =
|
||||
cell?.aspectRatio ?? def.aspectRatio;
|
||||
const sizeLabel = formatImageSize(
|
||||
cell?.aspectLabel ?? def.aspectLabel,
|
||||
);
|
||||
const widthRem = cell?.previewWidthRem;
|
||||
if (item) {
|
||||
return (
|
||||
<SlotThumb
|
||||
key={item.id}
|
||||
aspectRatio={cellAr}
|
||||
widthRem={widthRem}
|
||||
imageUrl={item.imageUrl}
|
||||
objectPosition={
|
||||
cell?.previewObjectPosition ??
|
||||
def.previewObjectPosition
|
||||
}
|
||||
title={
|
||||
item.nameFa ||
|
||||
cell?.labelEn ||
|
||||
`${def.slug}-${index + 1}`
|
||||
}
|
||||
subtitle={sizeLabel}
|
||||
onEdit={() => openEdit(def, item)}
|
||||
onDelete={() => void handleDelete(item.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PlusThumb
|
||||
key={`${def.slug}-${index}`}
|
||||
aspectRatio={cellAr}
|
||||
widthRem={widthRem}
|
||||
label={sizeLabel}
|
||||
onClick={() => openCreate(def, index)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// fixed
|
||||
const item = slotItems[0];
|
||||
const sizeLabel = formatImageSize(def.aspectLabel);
|
||||
if (item) {
|
||||
return (
|
||||
<SlotThumb
|
||||
key={item.id}
|
||||
aspectRatio={def.aspectRatio}
|
||||
imageUrl={item.imageUrl}
|
||||
objectPosition={def.previewObjectPosition}
|
||||
title={item.nameFa || def.slug}
|
||||
subtitle={sizeLabel}
|
||||
onEdit={() => openEdit(def, item)}
|
||||
onDelete={() => void handleDelete(item.id)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<PlusThumb
|
||||
key={def.slug}
|
||||
aspectRatio={def.aspectRatio}
|
||||
label={sizeLabel}
|
||||
onClick={() => openCreate(def)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</ClothesShell>
|
||||
|
||||
<FormModal
|
||||
open={modalOpen}
|
||||
title={
|
||||
editingId
|
||||
? `ویرایش — ${activeDef?.labelFa ?? ""}`
|
||||
: `جدید — ${activeDef?.labelFa ?? ""}`
|
||||
}
|
||||
subtitle={
|
||||
activeDef
|
||||
? `${activeDef.slug} · ${formatImageSize(activeAspectLabel)}`
|
||||
: "Website image slot"
|
||||
}
|
||||
onClose={closeModal}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel={saving ? "..." : "ذخیره"}
|
||||
wide="xl"
|
||||
>
|
||||
<div className="space-y-5">
|
||||
<div>
|
||||
<div className="mb-2 flex flex-wrap items-baseline justify-between gap-2">
|
||||
<p className="text-sm text-ink">تصویر</p>
|
||||
<p
|
||||
dir="ltr"
|
||||
className="font-montserrat text-sm tracking-wide text-muted"
|
||||
>
|
||||
{formatImageSize(activeAspectLabel)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{form.imagePreview ? (
|
||||
<div
|
||||
className={`mx-auto w-full ${
|
||||
activeAspectRatio >= 1.2 ? "max-w-md" : "max-w-[9.5rem]"
|
||||
}`}
|
||||
>
|
||||
<ImageCropper
|
||||
src={form.imagePreview}
|
||||
aspectRatio={activeAspectRatio}
|
||||
focusY={activeFocusY}
|
||||
alt={activeDef?.labelFa ?? "پیشنمایش"}
|
||||
className="website-slot-cropper"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="mt-2 w-full text-center text-xs text-muted underline-offset-2 hover:text-ink hover:underline"
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
تعویض تصویر
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className={`relative mx-auto flex w-full items-center justify-center overflow-hidden border border-dashed border-line bg-white text-muted transition-colors hover:border-ink hover:text-ink ${
|
||||
activeAspectRatio >= 1.2 ? "max-w-md" : "max-w-[9.5rem]"
|
||||
}`}
|
||||
style={{ aspectRatio: String(activeAspectRatio) }}
|
||||
onClick={() => fileRef.current?.click()}
|
||||
>
|
||||
<span className="flex flex-col items-center gap-2 px-3">
|
||||
<span className="text-4xl leading-none">+</span>
|
||||
<span dir="ltr" className="font-montserrat text-sm">
|
||||
{formatImageSize(activeAspectLabel)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
className="sr-only"
|
||||
onChange={handleImageChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
|
||||
<div className="clothes-field">
|
||||
<label htmlFor="slot-name-fa">نام فارسی</label>
|
||||
<input
|
||||
id="slot-name-fa"
|
||||
value={form.nameFa}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, nameFa: e.target.value }))
|
||||
}
|
||||
placeholder="نام را کاربر تعریف میکند"
|
||||
/>
|
||||
</div>
|
||||
<div className="clothes-field">
|
||||
<label htmlFor="slot-name-en">نام انگلیسی</label>
|
||||
<input
|
||||
id="slot-name-en"
|
||||
dir="ltr"
|
||||
value={form.nameEn}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, nameEn: e.target.value }))
|
||||
}
|
||||
placeholder="Optional English name"
|
||||
/>
|
||||
</div>
|
||||
<div className="clothes-field sm:col-span-2">
|
||||
<label htmlFor="slot-subtext">زیرنویس</label>
|
||||
<textarea
|
||||
id="slot-subtext"
|
||||
value={form.subtext}
|
||||
rows={4}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, subtext: e.target.value }))
|
||||
}
|
||||
className="!min-h-[6rem]"
|
||||
/>
|
||||
</div>
|
||||
<div className="clothes-field">
|
||||
<label htmlFor="slot-link-text">متن لینک</label>
|
||||
<input
|
||||
id="slot-link-text"
|
||||
value={form.linkText}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({
|
||||
...prev,
|
||||
linkText: e.target.value,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="clothes-field">
|
||||
<label htmlFor="slot-link">لینک</label>
|
||||
<input
|
||||
id="slot-link"
|
||||
dir="ltr"
|
||||
value={form.link}
|
||||
onChange={(e) =>
|
||||
setForm((prev) => ({ ...prev, link: e.target.value }))
|
||||
}
|
||||
placeholder="/products"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</FormModal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function formatImageSize(label: string) {
|
||||
return label
|
||||
.replace(/(\d+)\s*[x×]\s*(\d+)/gi, "$1 × $2")
|
||||
.replace(/\s*·\s*/g, " · ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Map Tailwind object-position class → vertical focus 0..1 for ImageCropper */
|
||||
function objectPositionFocusY(objectPosition?: string) {
|
||||
if (!objectPosition) return 0.5;
|
||||
if (/\bobject-top\b/.test(objectPosition)) return 0;
|
||||
if (/\bobject-(?:left-)?bottom\b/.test(objectPosition)) return 1;
|
||||
const match = objectPosition.match(/_(\d+(?:\.\d+)?)%/);
|
||||
if (match) return Number(match[1]) / 100;
|
||||
return 0.5;
|
||||
}
|
||||
|
||||
function thumbWidthClass(aspectRatio: number, widthRem?: number) {
|
||||
if (widthRem) return undefined;
|
||||
if (aspectRatio >= 4) return "w-full max-w-md";
|
||||
if (aspectRatio >= 1.5) return "w-[min(100%,18rem)] sm:w-[22rem]";
|
||||
if (aspectRatio >= 1) return "w-[9rem] sm:w-[10rem]";
|
||||
return "w-[7.5rem] sm:w-[8.5rem]";
|
||||
}
|
||||
|
||||
function plusSizeClass(aspectRatio: number, widthRem?: number) {
|
||||
const scale = widthRem ?? (aspectRatio >= 1.5 ? 22 : aspectRatio >= 1 ? 10 : 8);
|
||||
if (scale >= 12 || aspectRatio >= 1.5) return "text-5xl sm:text-6xl";
|
||||
if (scale >= 9 || aspectRatio >= 1) return "text-3xl";
|
||||
return "text-2xl";
|
||||
}
|
||||
|
||||
function PlusThumb({
|
||||
aspectRatio,
|
||||
widthRem,
|
||||
label,
|
||||
onClick,
|
||||
}: {
|
||||
aspectRatio: number;
|
||||
widthRem?: number;
|
||||
label?: string;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={label || "افزودن"}
|
||||
className={`group flex flex-col gap-2 text-start ${thumbWidthClass(aspectRatio, widthRem) ?? ""}`}
|
||||
style={widthRem ? { width: `${widthRem}rem` } : undefined}
|
||||
>
|
||||
<span
|
||||
className="relative flex w-full items-center justify-center border border-dashed border-line bg-white text-muted transition-colors group-hover:border-ink group-hover:text-ink"
|
||||
style={{ aspectRatio: String(aspectRatio) }}
|
||||
>
|
||||
<span className={`${plusSizeClass(aspectRatio, widthRem)} leading-none`}>
|
||||
+
|
||||
</span>
|
||||
</span>
|
||||
{label ? (
|
||||
<span
|
||||
dir="ltr"
|
||||
className="line-clamp-2 text-end font-montserrat text-xs text-muted"
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SlotThumb({
|
||||
aspectRatio,
|
||||
widthRem,
|
||||
imageUrl,
|
||||
objectPosition,
|
||||
title,
|
||||
subtitle,
|
||||
onEdit,
|
||||
onDelete,
|
||||
}: {
|
||||
aspectRatio: number;
|
||||
widthRem?: number;
|
||||
imageUrl: string | null;
|
||||
/** Tailwind object-* class matching storefront framing */
|
||||
objectPosition?: string;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-2 ${thumbWidthClass(aspectRatio, widthRem) ?? ""}`}
|
||||
style={widthRem ? { width: `${widthRem}rem` } : undefined}
|
||||
>
|
||||
<div
|
||||
className="relative w-full overflow-hidden border border-line bg-white"
|
||||
style={{ aspectRatio: String(aspectRatio) }}
|
||||
>
|
||||
{imageUrl ? (
|
||||
<Image
|
||||
src={imageUrl}
|
||||
alt=""
|
||||
fill
|
||||
unoptimized
|
||||
className={`object-cover ${objectPosition ?? "object-center"}`}
|
||||
sizes="360px"
|
||||
/>
|
||||
) : (
|
||||
<span className="absolute inset-0 flex items-center justify-center text-[0.65rem] text-muted">
|
||||
بدون تصویر
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-xs text-ink">{title}</p>
|
||||
{subtitle ? (
|
||||
<p
|
||||
dir="ltr"
|
||||
className="truncate text-end font-montserrat text-xs text-muted"
|
||||
>
|
||||
{subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<IconButton label="ویرایش" variant="line" onClick={onEdit}>
|
||||
<EditIcon />
|
||||
</IconButton>
|
||||
<IconButton label="حذف" variant="line" onClick={onDelete}>
|
||||
<TrashIcon />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import DashboardBanner from "@/components/dashboard/DashboardBanner";
|
||||
import DashTileGrid, {
|
||||
DashTileItem,
|
||||
} from "@/components/dashboard/DashTileGrid";
|
||||
import ClothesShell from "@/components/clothes/ClothesShell";
|
||||
|
||||
const websiteTiles: DashTileItem[] = [
|
||||
{
|
||||
href: "/website/images",
|
||||
title: "مدیریت تصاویر",
|
||||
titleEn: "Images",
|
||||
description: "بنرها و تصاویر صفحه اصلی",
|
||||
icon: <ImagesIcon />,
|
||||
},
|
||||
{
|
||||
href: "/website/requests",
|
||||
title: "درخواستها",
|
||||
titleEn: "Requests",
|
||||
description: "پیامها و درخواستهای کاربران",
|
||||
icon: <RequestsIcon />,
|
||||
},
|
||||
];
|
||||
|
||||
export default function WebsitePage() {
|
||||
const bannerDelay = 80 + websiteTiles.length * 70;
|
||||
|
||||
return (
|
||||
<ClothesShell
|
||||
eyebrow="Website"
|
||||
title="مدیریت وبسایت"
|
||||
description="تصاویر فروشگاه و درخواستهای کاربران را از اینجا مدیریت کنید."
|
||||
breadcrumbs={[
|
||||
{ label: "داشبورد", href: "/" },
|
||||
{ label: "وبسایت" },
|
||||
]}
|
||||
>
|
||||
<DashTileGrid tiles={websiteTiles} />
|
||||
|
||||
<DashboardBanner
|
||||
src="/images/image4.jpg"
|
||||
alt="نمای وبسایت مانا"
|
||||
caption="Quiet elegance."
|
||||
priority
|
||||
style={{ animationDelay: `${bannerDelay}ms` }}
|
||||
/>
|
||||
</ClothesShell>
|
||||
);
|
||||
}
|
||||
|
||||
function iconProps() {
|
||||
return {
|
||||
width: 56,
|
||||
height: 56,
|
||||
viewBox: "0 0 24 24",
|
||||
fill: "none",
|
||||
"aria-hidden": true as const,
|
||||
};
|
||||
}
|
||||
|
||||
function ImagesIcon() {
|
||||
return (
|
||||
<svg {...iconProps()}>
|
||||
<rect
|
||||
x="4.5"
|
||||
y="5.5"
|
||||
width="15"
|
||||
height="13"
|
||||
rx="1"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.85"
|
||||
/>
|
||||
<circle cx="9" cy="10" r="1.6" stroke="currentColor" strokeWidth="0.85" />
|
||||
<path
|
||||
d="M5.5 16.5 9.5 13l2.5 2.2 2.3-2.7 4.2 4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.85"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function RequestsIcon() {
|
||||
return (
|
||||
<svg {...iconProps()}>
|
||||
<path
|
||||
d="M5.5 6.5h13v9.5H11l-3.5 2.5V16H5.5V6.5Z"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.85"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M9 10h6M9 13h4"
|
||||
stroke="currentColor"
|
||||
strokeWidth="0.85"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import ClothesShell from "@/components/clothes/ClothesShell";
|
||||
|
||||
export default function WebsiteRequestsPage() {
|
||||
return (
|
||||
<ClothesShell
|
||||
eyebrow="Requests"
|
||||
title="درخواستها"
|
||||
description="پیامها و درخواستهای ارسالشده از وبسایت."
|
||||
breadcrumbs={[
|
||||
{ label: "داشبورد", href: "/" },
|
||||
{ label: "وبسایت", href: "/website" },
|
||||
{ label: "درخواستها" },
|
||||
]}
|
||||
>
|
||||
<p className="rounded-lg border border-line bg-surface px-4 py-10 text-center text-sm text-muted">
|
||||
بهزودی درخواستهای کاربران اینجا نمایش داده میشود.
|
||||
</p>
|
||||
</ClothesShell>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user