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>
|
||||
);
|
||||
}
|
||||
@@ -61,13 +61,11 @@ export default function ChangePasswordModal({
|
||||
<FormModal
|
||||
open={open}
|
||||
title="تغییر رمز عبور"
|
||||
subtitle={userName ? `Account · ${userName}` : "Set a new password for this account"}
|
||||
onClose={onClose}
|
||||
onSubmit={handleSubmit}
|
||||
submitLabel={saving ? "..." : "ذخیره"}
|
||||
>
|
||||
{userName ? (
|
||||
<p className="text-sm text-muted">{userName}</p>
|
||||
) : null}
|
||||
{error ? (
|
||||
<p className="text-sm text-red-600" role="alert">
|
||||
{error}
|
||||
|
||||
@@ -27,7 +27,9 @@ export default function DashTileGrid({
|
||||
? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3"
|
||||
: tiles.length === 4
|
||||
? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-4"
|
||||
: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-5";
|
||||
: tiles.length === 5
|
||||
? "grid-cols-1 sm:grid-cols-2 lg:grid-cols-5"
|
||||
: "grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6";
|
||||
|
||||
return (
|
||||
<div className={`grid gap-2 ${gridCols}`}>
|
||||
|
||||
@@ -15,6 +15,7 @@ const ADMIN_LINKS: NavLink[] = [
|
||||
{ href: "/blog", label: "بلاگ من" },
|
||||
{ href: "/blog/categories", label: "دستهبندی بلاگ" },
|
||||
{ href: "/orders", label: "سفارشهای من" },
|
||||
{ href: "/website", label: "مدیریت وبسایت" },
|
||||
{ href: "/profile", label: "پروفایل من" },
|
||||
];
|
||||
|
||||
|
||||
@@ -139,3 +139,39 @@ export function MetaTagsIcon() {
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function TechnicalDataIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden>
|
||||
<rect {...stroke} x="5" y="4.5" width="14" height="15" rx="1" />
|
||||
<path {...stroke} d="M8 9h8M8 12.5h8M8 16h5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function MoreIcon() {
|
||||
return (
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" aria-hidden>
|
||||
<circle cx="12" cy="6" r="1.15" fill="currentColor" />
|
||||
<circle cx="12" cy="12" r="1.15" fill="currentColor" />
|
||||
<circle cx="12" cy="18" r="1.15" fill="currentColor" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PlacementIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M5 4.5h14v15H5z" />
|
||||
<path d="M5 9.5h14M5 14.5h14M9.5 4.5v15" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,37 +5,90 @@ import {
|
||||
PointerEvent,
|
||||
WheelEvent,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
|
||||
type AspectRatio = "1:1" | "3:2" | "2:3";
|
||||
type AspectPreset = "1:1" | "3:2" | "2:3";
|
||||
|
||||
type ImageCropperProps = {
|
||||
src: string;
|
||||
aspect: AspectRatio;
|
||||
/** Preset ratios used by older screens */
|
||||
aspect?: AspectPreset;
|
||||
/** Arbitrary width/height ratio (e.g. 1920/1080) */
|
||||
aspectRatio?: number;
|
||||
/**
|
||||
* Vertical focus matching CSS object-position Y (0 = top, 0.5 = center).
|
||||
* Used as the initial pan so the preview matches the storefront.
|
||||
*/
|
||||
focusY?: number;
|
||||
alt?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
const MIN_ZOOM = 1;
|
||||
/** 1 = cover (fills frame). Below 1 = unzoom toward contain. */
|
||||
const COVER_ZOOM = 1;
|
||||
const MAX_ZOOM = 3;
|
||||
const ZOOM_STEP = 0.15;
|
||||
|
||||
const aspectClass: Record<AspectRatio, string> = {
|
||||
const aspectClass: Record<AspectPreset, string> = {
|
||||
"1:1": "crop-ratio-1-1",
|
||||
"3:2": "crop-ratio-3-2",
|
||||
"2:3": "crop-ratio-2-3",
|
||||
};
|
||||
|
||||
type Size = { width: number; height: number };
|
||||
|
||||
function clamp(value: number, min: number, max: number) {
|
||||
if (max < min) return 0;
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
/** Zoom 1 = cover. Zoom contain/cover = full image visible. */
|
||||
function coverSize(frame: Size, image: Size, zoom: number): Size {
|
||||
const cover = Math.max(frame.width / image.width, frame.height / image.height);
|
||||
return {
|
||||
width: image.width * cover * zoom,
|
||||
height: image.height * cover * zoom,
|
||||
};
|
||||
}
|
||||
|
||||
function minZoomFor(frame: Size, image: Size) {
|
||||
if (frame.width <= 0 || frame.height <= 0 || image.width <= 0 || image.height <= 0) {
|
||||
return COVER_ZOOM;
|
||||
}
|
||||
const cover = Math.max(frame.width / image.width, frame.height / image.height);
|
||||
const contain = Math.min(frame.width / image.width, frame.height / image.height);
|
||||
if (cover <= 0) return COVER_ZOOM;
|
||||
// Floor so the full image can sit inside the frame (unzoom).
|
||||
return Math.min(COVER_ZOOM, Number((contain / cover).toFixed(3)));
|
||||
}
|
||||
|
||||
function clampOffset(offset: { x: number; y: number }, frame: Size, media: Size) {
|
||||
const maxX = Math.max(0, (media.width - frame.width) / 2);
|
||||
const maxY = Math.max(0, (media.height - frame.height) / 2);
|
||||
return {
|
||||
x: clamp(offset.x, -maxX, maxX),
|
||||
y: clamp(offset.y, -maxY, maxY),
|
||||
};
|
||||
}
|
||||
|
||||
export default function ImageCropper({
|
||||
src,
|
||||
aspect,
|
||||
aspect = "1:1",
|
||||
aspectRatio,
|
||||
focusY = 0.5,
|
||||
alt = "پیشنمایش برش",
|
||||
className = "",
|
||||
}: ImageCropperProps) {
|
||||
const [zoom, setZoom] = useState(MIN_ZOOM);
|
||||
const [zoom, setZoom] = useState(COVER_ZOOM);
|
||||
const [offset, setOffset] = useState({ x: 0, y: 0 });
|
||||
const [frameSize, setFrameSize] = useState<Size>({ width: 0, height: 0 });
|
||||
const [imageSize, setImageSize] = useState<Size | null>(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
const frameRef = useRef<HTMLDivElement>(null);
|
||||
const dragRef = useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
@@ -43,32 +96,95 @@ export default function ImageCropper({
|
||||
originX: number;
|
||||
originY: number;
|
||||
} | null>(null);
|
||||
const focusYRef = useRef(focusY);
|
||||
focusYRef.current = focusY;
|
||||
const userPannedRef = useRef(false);
|
||||
|
||||
const minZoom = useMemo(() => {
|
||||
if (!imageSize || frameSize.width <= 0) return COVER_ZOOM;
|
||||
return minZoomFor(frameSize, imageSize);
|
||||
}, [frameSize, imageSize]);
|
||||
|
||||
useEffect(() => {
|
||||
setZoom(MIN_ZOOM);
|
||||
setZoom(COVER_ZOOM);
|
||||
setOffset({ x: 0, y: 0 });
|
||||
}, [src, aspect]);
|
||||
setImageSize(null);
|
||||
userPannedRef.current = false;
|
||||
}, [src, aspect, aspectRatio, focusY]);
|
||||
|
||||
useEffect(() => {
|
||||
const frame = frameRef.current;
|
||||
if (!frame) return;
|
||||
|
||||
const update = () => {
|
||||
const rect = frame.getBoundingClientRect();
|
||||
setFrameSize({ width: rect.width, height: rect.height });
|
||||
};
|
||||
|
||||
update();
|
||||
const observer = new ResizeObserver(update);
|
||||
observer.observe(frame);
|
||||
return () => observer.disconnect();
|
||||
}, [aspect, aspectRatio]);
|
||||
|
||||
const media =
|
||||
imageSize && frameSize.width > 0
|
||||
? coverSize(frameSize, imageSize, zoom)
|
||||
: null;
|
||||
|
||||
const canPan =
|
||||
!!media &&
|
||||
(media.width > frameSize.width + 0.5 || media.height > frameSize.height + 0.5);
|
||||
|
||||
function focusOffset(frame: Size, mediaSize: Size, yFocus: number) {
|
||||
const y = (frame.height - mediaSize.height) * (yFocus - 0.5);
|
||||
return clampOffset({ x: 0, y }, frame, mediaSize);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!imageSize || frameSize.width <= 0) return;
|
||||
const floor = minZoomFor(frameSize, imageSize);
|
||||
if (zoom < floor) {
|
||||
setZoom(floor);
|
||||
return;
|
||||
}
|
||||
const nextMedia = coverSize(frameSize, imageSize, zoom);
|
||||
setOffset((prev) => {
|
||||
if (userPannedRef.current) {
|
||||
const next = clampOffset(prev, frameSize, nextMedia);
|
||||
if (next.x === prev.x && next.y === prev.y) return prev;
|
||||
return next;
|
||||
}
|
||||
return focusOffset(frameSize, nextMedia, focusYRef.current);
|
||||
});
|
||||
}, [frameSize, imageSize, zoom]);
|
||||
|
||||
function clampZoom(value: number) {
|
||||
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Number(value.toFixed(2))));
|
||||
return Math.min(MAX_ZOOM, Math.max(minZoom, Number(value.toFixed(2))));
|
||||
}
|
||||
|
||||
function setZoomAndClamp(nextZoom: number) {
|
||||
const next = clampZoom(nextZoom);
|
||||
setZoom(next);
|
||||
if (imageSize && frameSize.width > 0) {
|
||||
const nextMedia = coverSize(frameSize, imageSize, next);
|
||||
setOffset((prev) => clampOffset(prev, frameSize, nextMedia));
|
||||
}
|
||||
}
|
||||
|
||||
function zoomBy(delta: number) {
|
||||
setZoom((prev) => clampZoom(prev + delta));
|
||||
setZoomAndClamp(zoom + delta);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (zoom <= MIN_ZOOM) setOffset({ x: 0, y: 0 });
|
||||
}, [zoom]);
|
||||
|
||||
function onWheel(event: WheelEvent<HTMLDivElement>) {
|
||||
event.preventDefault();
|
||||
zoomBy(event.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP);
|
||||
}
|
||||
|
||||
function onPointerDown(event: PointerEvent<HTMLDivElement>) {
|
||||
if (zoom <= MIN_ZOOM) return;
|
||||
if (!canPan) return;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
setDragging(true);
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
@@ -80,36 +196,58 @@ export default function ImageCropper({
|
||||
|
||||
function onPointerMove(event: PointerEvent<HTMLDivElement>) {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
setOffset({
|
||||
x: drag.originX + (event.clientX - drag.startX),
|
||||
y: drag.originY + (event.clientY - drag.startY),
|
||||
});
|
||||
if (!drag || drag.pointerId !== event.pointerId || !media) return;
|
||||
userPannedRef.current = true;
|
||||
setOffset(
|
||||
clampOffset(
|
||||
{
|
||||
x: drag.originX + (event.clientX - drag.startX),
|
||||
y: drag.originY + (event.clientY - drag.startY),
|
||||
},
|
||||
frameSize,
|
||||
media,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function onPointerUp(event: PointerEvent<HTMLDivElement>) {
|
||||
if (dragRef.current?.pointerId === event.pointerId) {
|
||||
dragRef.current = null;
|
||||
setDragging(false);
|
||||
}
|
||||
}
|
||||
|
||||
const unoptimized = src.startsWith("blob:");
|
||||
const ratioStyle =
|
||||
aspectRatio && aspectRatio > 0
|
||||
? { aspectRatio: String(aspectRatio) }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div className={`image-cropper ${className}`}>
|
||||
<div
|
||||
className={`crop-frame crop-frame-brand ${aspectClass[aspect]}`}
|
||||
ref={frameRef}
|
||||
className={`crop-frame crop-frame-brand ${aspectRatio ? "" : aspectClass[aspect]}`}
|
||||
style={ratioStyle}
|
||||
onWheel={onWheel}
|
||||
onPointerDown={onPointerDown}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onPointerCancel={onPointerUp}
|
||||
style={{ cursor: zoom > MIN_ZOOM ? "grab" : "default" }}
|
||||
>
|
||||
<div
|
||||
className="crop-frame-media"
|
||||
style={{
|
||||
transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`,
|
||||
width: media ? `${media.width}px` : "100%",
|
||||
height: media ? `${media.height}px` : "100%",
|
||||
left: media ? "50%" : 0,
|
||||
top: media ? "50%" : 0,
|
||||
right: "auto",
|
||||
bottom: "auto",
|
||||
transform: media
|
||||
? `translate(calc(-50% + ${offset.x}px), calc(-50% + ${offset.y}px))`
|
||||
: undefined,
|
||||
cursor: canPan ? (dragging ? "grabbing" : "grab") : "default",
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
@@ -117,19 +255,26 @@ export default function ImageCropper({
|
||||
alt={alt}
|
||||
fill
|
||||
className="object-cover"
|
||||
sizes="220px"
|
||||
sizes="(max-width: 768px) 90vw, 640px"
|
||||
unoptimized={unoptimized}
|
||||
draggable={false}
|
||||
onLoad={(event) => {
|
||||
const img = event.currentTarget;
|
||||
setImageSize({
|
||||
width: img.naturalWidth,
|
||||
height: img.naturalHeight,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="crop-zoom-bar">
|
||||
<div className="crop-zoom-bar" dir="ltr">
|
||||
<button
|
||||
type="button"
|
||||
className="crop-zoom-btn"
|
||||
aria-label="کوچکنمایی"
|
||||
disabled={zoom <= MIN_ZOOM}
|
||||
aria-label="Zoom out"
|
||||
disabled={zoom <= minZoom + 0.001}
|
||||
onClick={() => zoomBy(-ZOOM_STEP)}
|
||||
>
|
||||
−
|
||||
@@ -137,26 +282,26 @@ export default function ImageCropper({
|
||||
<input
|
||||
type="range"
|
||||
className="crop-zoom-range"
|
||||
min={MIN_ZOOM}
|
||||
min={minZoom}
|
||||
max={MAX_ZOOM}
|
||||
step={0.01}
|
||||
value={zoom}
|
||||
aria-label="بزرگنمایی"
|
||||
onChange={(e) => {
|
||||
const next = clampZoom(Number(e.target.value));
|
||||
setZoom(next);
|
||||
if (next <= MIN_ZOOM) setOffset({ x: 0, y: 0 });
|
||||
}}
|
||||
aria-label="Zoom"
|
||||
dir="ltr"
|
||||
onChange={(e) => setZoomAndClamp(Number(e.target.value))}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="crop-zoom-btn"
|
||||
aria-label="بزرگنمایی"
|
||||
aria-label="Zoom in"
|
||||
disabled={zoom >= MAX_ZOOM}
|
||||
onClick={() => zoomBy(ZOOM_STEP)}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<span className="crop-zoom-value" aria-hidden>
|
||||
{Math.round(zoom * 100)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { FormEvent, ReactNode, useEffect } from "react";
|
||||
type ModalProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
footer?: ReactNode;
|
||||
@@ -14,6 +15,7 @@ type ModalProps = {
|
||||
export default function Modal({
|
||||
open,
|
||||
title,
|
||||
subtitle,
|
||||
onClose,
|
||||
children,
|
||||
footer,
|
||||
@@ -56,12 +58,20 @@ export default function Modal({
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="modal-title"
|
||||
aria-describedby={subtitle ? "modal-subtitle" : undefined}
|
||||
className={panelClass}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h2 id="modal-title" className="text-base text-ink sm:text-lg">
|
||||
{title}
|
||||
</h2>
|
||||
<div className="modal-header-copy">
|
||||
<h2 id="modal-title" className="modal-title font-doran">
|
||||
{title}
|
||||
</h2>
|
||||
{subtitle ? (
|
||||
<p id="modal-subtitle" className="modal-subtitle" dir="ltr">
|
||||
{subtitle}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
@@ -81,6 +91,7 @@ export default function Modal({
|
||||
type FormModalProps = {
|
||||
open: boolean;
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
onClose: () => void;
|
||||
onSubmit: (event: FormEvent<HTMLFormElement>) => void;
|
||||
submitLabel?: string;
|
||||
@@ -91,6 +102,7 @@ type FormModalProps = {
|
||||
export function FormModal({
|
||||
open,
|
||||
title,
|
||||
subtitle,
|
||||
onClose,
|
||||
onSubmit,
|
||||
submitLabel = "ذخیره",
|
||||
@@ -101,6 +113,7 @@ export function FormModal({
|
||||
<Modal
|
||||
open={open}
|
||||
title={title}
|
||||
subtitle={subtitle}
|
||||
onClose={onClose}
|
||||
wide={wide}
|
||||
footer={
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { ChangeEvent, InputHTMLAttributes } from "react";
|
||||
import { formatPriceInput, parsePriceInput } from "@/data/clothes";
|
||||
|
||||
type PriceInputProps = Omit<
|
||||
InputHTMLAttributes<HTMLInputElement>,
|
||||
"value" | "onChange" | "type" | "inputMode"
|
||||
> & {
|
||||
value: string;
|
||||
/** Receives digits-only string */
|
||||
onChange: (raw: string) => void;
|
||||
};
|
||||
|
||||
export default function PriceInput({
|
||||
value,
|
||||
onChange,
|
||||
className,
|
||||
dir = "ltr",
|
||||
...rest
|
||||
}: PriceInputProps) {
|
||||
function handleChange(event: ChangeEvent<HTMLInputElement>) {
|
||||
onChange(parsePriceInput(event.target.value));
|
||||
}
|
||||
|
||||
return (
|
||||
<input
|
||||
{...rest}
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
dir={dir}
|
||||
className={className}
|
||||
value={formatPriceInput(value)}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+136
-12
@@ -11,12 +11,25 @@ export type CategoryVariants = {
|
||||
packages: Array<"6" | "12">;
|
||||
};
|
||||
|
||||
export type CategoryNode = {
|
||||
export type TechnicalFieldType = "text";
|
||||
|
||||
export type TechnicalField = {
|
||||
id: string;
|
||||
labelFa: string;
|
||||
labelEn: string;
|
||||
type: TechnicalFieldType;
|
||||
required?: boolean;
|
||||
};
|
||||
|
||||
export type TechnicalData = Record<string, string>;
|
||||
|
||||
export type CategoryNode = {
|
||||
id: number | string;
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
children?: CategoryNode[];
|
||||
variants?: CategoryVariants;
|
||||
technicalFields?: TechnicalField[];
|
||||
};
|
||||
|
||||
export type ProductVariantPrice = {
|
||||
@@ -30,12 +43,12 @@ export type ProductVariantPrice = {
|
||||
};
|
||||
|
||||
export type Product = {
|
||||
id: string;
|
||||
id: number | string;
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
categoryId: string;
|
||||
categoryId: number | string;
|
||||
brandId: string;
|
||||
price: number;
|
||||
discountPrice?: number;
|
||||
@@ -46,6 +59,9 @@ export type Product = {
|
||||
vipOnly?: boolean;
|
||||
vipSpecialPrice?: boolean;
|
||||
vipPrice?: number;
|
||||
/** Home carousel: promo-1 | promo-2 | promo-3 (multiple products per slot) */
|
||||
featuredSlot?: string | null;
|
||||
technicalData?: TechnicalData;
|
||||
variantPrices?: ProductVariantPrice[];
|
||||
};
|
||||
|
||||
@@ -88,6 +104,55 @@ export const emptyVariants = (): CategoryVariants => ({
|
||||
packages: [],
|
||||
});
|
||||
|
||||
export function createTechnicalField(
|
||||
partial?: Partial<TechnicalField>,
|
||||
): TechnicalField {
|
||||
return {
|
||||
id:
|
||||
partial?.id ??
|
||||
(typeof crypto !== "undefined" && "randomUUID" in crypto
|
||||
? crypto.randomUUID()
|
||||
: `tf-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`),
|
||||
labelFa: partial?.labelFa ?? "",
|
||||
labelEn: partial?.labelEn ?? "",
|
||||
type: partial?.type ?? "text",
|
||||
required: partial?.required ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
/** Normalize API / stored JSON into technical field defs */
|
||||
export function normalizeTechnicalFields(raw: unknown): TechnicalField[] {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const row = item as Record<string, unknown>;
|
||||
const id = typeof row.id === "string" ? row.id.trim() : "";
|
||||
const labelFa =
|
||||
typeof row.labelFa === "string" ? row.labelFa.trim() : "";
|
||||
if (!id || !labelFa) return null;
|
||||
return {
|
||||
id,
|
||||
labelFa,
|
||||
labelEn: typeof row.labelEn === "string" ? row.labelEn : "",
|
||||
type: "text" as const,
|
||||
required: Boolean(row.required),
|
||||
};
|
||||
})
|
||||
.filter(Boolean) as TechnicalField[];
|
||||
}
|
||||
|
||||
export function normalizeTechnicalData(raw: unknown): TechnicalData {
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
|
||||
const out: TechnicalData = {};
|
||||
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
|
||||
if (typeof value === "string") out[key] = value;
|
||||
else if (value == null) out[key] = "";
|
||||
else out[key] = String(value);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export const brands: Brand[] = [
|
||||
{
|
||||
id: "b1",
|
||||
@@ -268,6 +333,18 @@ export function formatPrice(value: number) {
|
||||
return new Intl.NumberFormat("en-US").format(value);
|
||||
}
|
||||
|
||||
/** Digits-only string from a typed/formatted price field */
|
||||
export function parsePriceInput(value: string) {
|
||||
return value.replace(/\D/g, "");
|
||||
}
|
||||
|
||||
/** Display thousand separators while editing (keeps empty string empty) */
|
||||
export function formatPriceInput(value: string) {
|
||||
const digits = parsePriceInput(value);
|
||||
if (!digits) return "";
|
||||
return formatPrice(Number(digits));
|
||||
}
|
||||
|
||||
export function discountPercent(price: number, discountPrice: number) {
|
||||
return Math.round(((price - discountPrice) / price) * 100);
|
||||
}
|
||||
@@ -279,7 +356,7 @@ export function flattenCategories(
|
||||
return nodes.flatMap((node) => {
|
||||
const label = prefix ? `${prefix} / ${node.nameFa}` : node.nameFa;
|
||||
return [
|
||||
{ id: node.id, label },
|
||||
{ id: String(node.id), label },
|
||||
...(node.children ? flattenCategories(node.children, label) : []),
|
||||
];
|
||||
});
|
||||
@@ -287,10 +364,11 @@ export function flattenCategories(
|
||||
|
||||
export function findCategoryNode(
|
||||
nodes: CategoryNode[],
|
||||
id: string,
|
||||
id: string | number,
|
||||
): CategoryNode | null {
|
||||
const target = String(id);
|
||||
for (const node of nodes) {
|
||||
if (node.id === id) return node;
|
||||
if (String(node.id) === target) return node;
|
||||
if (node.children?.length) {
|
||||
const found = findCategoryNode(node.children, id);
|
||||
if (found) return found;
|
||||
@@ -301,7 +379,7 @@ export function findCategoryNode(
|
||||
|
||||
/** Resolve variants from category or nearest parent that has them */
|
||||
export function resolveCategoryVariants(
|
||||
categoryId: string,
|
||||
categoryId: string | number,
|
||||
nodes: CategoryNode[] = categories,
|
||||
): CategoryVariants | null {
|
||||
let current = findCategoryNode(nodes, categoryId);
|
||||
@@ -321,12 +399,30 @@ export function resolveCategoryVariants(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Resolve technical fields from category or nearest parent that has them */
|
||||
export function resolveCategoryTechnicalFields(
|
||||
categoryId: string | number,
|
||||
nodes: CategoryNode[] = categories,
|
||||
): TechnicalField[] {
|
||||
let current = findCategoryNode(nodes, categoryId);
|
||||
while (current) {
|
||||
if (current.technicalFields?.length) {
|
||||
return current.technicalFields;
|
||||
}
|
||||
const parent = findParentCategory(nodes, current.id);
|
||||
if (!parent) break;
|
||||
current = parent;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function findParentCategory(
|
||||
nodes: CategoryNode[],
|
||||
childId: string,
|
||||
childId: string | number,
|
||||
): CategoryNode | null {
|
||||
const target = String(childId);
|
||||
for (const node of nodes) {
|
||||
if (node.children?.some((c) => c.id === childId)) return node;
|
||||
if (node.children?.some((c) => String(c.id) === target)) return node;
|
||||
if (node.children?.length) {
|
||||
const found = findParentCategory(node.children, childId);
|
||||
if (found) return found;
|
||||
@@ -335,8 +431,36 @@ function findParentCategory(
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Root → leaf breadcrumb for a category id */
|
||||
export function getCategoryPath(
|
||||
categoryId: string | number,
|
||||
nodes: CategoryNode[] = categories,
|
||||
): CategoryNode[] {
|
||||
const path: CategoryNode[] = [];
|
||||
let current = findCategoryNode(nodes, categoryId);
|
||||
while (current) {
|
||||
path.unshift(current);
|
||||
const parent = findParentCategory(nodes, current.id);
|
||||
if (!parent) break;
|
||||
current = parent;
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
/** Last up to `count` category levels (leaf last) */
|
||||
export function getCategoryLeafLabels(
|
||||
categoryId: string | number,
|
||||
nodes: CategoryNode[] = categories,
|
||||
count = 2,
|
||||
): string[] {
|
||||
return getCategoryPath(categoryId, nodes)
|
||||
.map((node) => node.nameFa)
|
||||
.filter(Boolean)
|
||||
.slice(-count);
|
||||
}
|
||||
|
||||
export function getCategoryVariantOptions(
|
||||
categoryId: string,
|
||||
categoryId: string | number,
|
||||
nodes: CategoryNode[] = categories,
|
||||
) {
|
||||
const groups = getCategoryVariantGroups(categoryId, nodes);
|
||||
@@ -357,7 +481,7 @@ export function getCategoryVariantOptions(
|
||||
}
|
||||
|
||||
export function getCategoryVariantGroups(
|
||||
categoryId: string,
|
||||
categoryId: string | number,
|
||||
nodes: CategoryNode[] = categories,
|
||||
) {
|
||||
const variants = resolveCategoryVariants(categoryId, nodes);
|
||||
@@ -394,7 +518,7 @@ export function getCategoryVariantGroups(
|
||||
|
||||
export function getVariantLabel(
|
||||
row: Pick<ProductVariantPrice, "colorId" | "sizeId" | "packageId">,
|
||||
categoryId: string,
|
||||
categoryId: string | number,
|
||||
nodes: CategoryNode[] = categories,
|
||||
) {
|
||||
const groups = getCategoryVariantGroups(categoryId, nodes);
|
||||
|
||||
@@ -0,0 +1,234 @@
|
||||
export type WebsiteSlotKind = "collection" | "fixed" | "fixed-set";
|
||||
|
||||
export type WebsiteSlotDef = {
|
||||
slug: string;
|
||||
labelFa: string;
|
||||
labelEn: string;
|
||||
kind: WebsiteSlotKind;
|
||||
/** Group several fixed slots under one admin section */
|
||||
sectionKey?: string;
|
||||
sectionLabelFa?: string;
|
||||
sectionLabelEn?: string;
|
||||
itemCount?: number;
|
||||
aspectRatio: number;
|
||||
aspectLabel: string;
|
||||
/**
|
||||
* Tailwind object-position class matching the storefront
|
||||
* (preview only — uploaded file is full-bleed cover).
|
||||
*/
|
||||
previewObjectPosition?: string;
|
||||
cellLabels?: Array<{
|
||||
labelFa: string;
|
||||
labelEn: string;
|
||||
aspectRatio?: number;
|
||||
aspectLabel?: string;
|
||||
/** Relative preview width in rem (matches on-site scale) */
|
||||
previewWidthRem?: number;
|
||||
previewObjectPosition?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type WebsiteSlotItem = {
|
||||
id: string;
|
||||
slotKey: string;
|
||||
sortOrder: number;
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
subtext: string;
|
||||
linkText: string;
|
||||
link: string;
|
||||
imageUrl: string | null;
|
||||
imageKey?: string | null;
|
||||
};
|
||||
|
||||
/** Back-compat while API still may send `key` */
|
||||
export type WebsiteSlotDefApi = WebsiteSlotDef & { key?: string };
|
||||
|
||||
export type WebsiteSlotSection = {
|
||||
key: string;
|
||||
labelFa: string;
|
||||
labelEn: string;
|
||||
aspectLabel: string;
|
||||
defs: WebsiteSlotDef[];
|
||||
};
|
||||
|
||||
export const WEBSITE_SLOT_DEFS: WebsiteSlotDef[] = [
|
||||
{
|
||||
slug: "hero-slider",
|
||||
labelFa: "اسلایدر",
|
||||
labelEn: "Hero slider",
|
||||
kind: "collection",
|
||||
aspectRatio: 1920 / 1080,
|
||||
aspectLabel: "1920×1080",
|
||||
// Matches website HeroSlider / map-home-slots first slide
|
||||
previewObjectPosition: "object-[center_20%]",
|
||||
},
|
||||
{
|
||||
slug: "category-strip",
|
||||
labelFa: "دستهبندیها",
|
||||
labelEn: "Categories",
|
||||
kind: "collection",
|
||||
aspectRatio: 68 / 80,
|
||||
aspectLabel: "68×80",
|
||||
},
|
||||
{
|
||||
slug: "promo-1",
|
||||
labelFa: "پرومو ۱",
|
||||
labelEn: "Promo 1",
|
||||
kind: "fixed",
|
||||
sectionKey: "promos",
|
||||
sectionLabelFa: "پروموها",
|
||||
sectionLabelEn: "Promos",
|
||||
aspectRatio: 300 / 491,
|
||||
aspectLabel: "300×491",
|
||||
},
|
||||
{
|
||||
slug: "promo-2",
|
||||
labelFa: "پرومو ۲",
|
||||
labelEn: "Promo 2",
|
||||
kind: "fixed",
|
||||
sectionKey: "promos",
|
||||
sectionLabelFa: "پروموها",
|
||||
sectionLabelEn: "Promos",
|
||||
aspectRatio: 300 / 491,
|
||||
aspectLabel: "300×491",
|
||||
},
|
||||
{
|
||||
slug: "promo-3",
|
||||
labelFa: "پرومو ۳",
|
||||
labelEn: "Promo 3",
|
||||
kind: "fixed",
|
||||
sectionKey: "promos",
|
||||
sectionLabelFa: "پروموها",
|
||||
sectionLabelEn: "Promos",
|
||||
aspectRatio: 300 / 491,
|
||||
aspectLabel: "300×491",
|
||||
},
|
||||
{
|
||||
slug: "leather-banner",
|
||||
labelFa: "بنر چرم",
|
||||
labelEn: "Leather banner",
|
||||
kind: "fixed",
|
||||
aspectRatio: 1920 / 585,
|
||||
aspectLabel: "1920×585",
|
||||
previewObjectPosition: "object-left-bottom",
|
||||
},
|
||||
{
|
||||
slug: "four-portrait-banner",
|
||||
labelFa: "کالکشن مشکی",
|
||||
labelEn: "Black collection",
|
||||
kind: "fixed-set",
|
||||
itemCount: 4,
|
||||
aspectRatio: 410 / 717,
|
||||
aspectLabel: "410×717",
|
||||
cellLabels: [
|
||||
{ labelFa: "اسلات ۱", labelEn: "Slot 1" },
|
||||
{ labelFa: "اسلات ۲", labelEn: "Slot 2" },
|
||||
{ labelFa: "اسلات ۳", labelEn: "Slot 3" },
|
||||
{ labelFa: "اسلات ۴", labelEn: "Slot 4" },
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "about",
|
||||
labelFa: "درباره ما",
|
||||
labelEn: "About",
|
||||
kind: "fixed-set",
|
||||
itemCount: 2,
|
||||
aspectRatio: 4 / 5,
|
||||
aspectLabel: "4×5 · 3×4",
|
||||
cellLabels: [
|
||||
{
|
||||
labelFa: "تصویر اصلی",
|
||||
labelEn: "Primary",
|
||||
aspectRatio: 4 / 5,
|
||||
aspectLabel: "4×5",
|
||||
// On site: w-[78%] vs secondary w-[48%] ≈ 1.625×
|
||||
previewWidthRem: 13,
|
||||
previewObjectPosition: "object-[center_18%]",
|
||||
},
|
||||
{
|
||||
labelFa: "تصویر دوم",
|
||||
labelEn: "Secondary",
|
||||
aspectRatio: 3 / 4,
|
||||
aspectLabel: "3×4",
|
||||
previewWidthRem: 8,
|
||||
previewObjectPosition: "object-top",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
slug: "mega-menu",
|
||||
labelFa: "مگامنو",
|
||||
labelEn: "Mega menu",
|
||||
kind: "fixed-set",
|
||||
itemCount: 6,
|
||||
aspectRatio: 160 / 220,
|
||||
aspectLabel: "160×220",
|
||||
cellLabels: [
|
||||
{ labelFa: "پالتو و کت", labelEn: "Coats" },
|
||||
{ labelFa: "پیراهن", labelEn: "Shirts" },
|
||||
{ labelFa: "شلوار", labelEn: "Pants" },
|
||||
{ labelFa: "دامن", labelEn: "Skirts" },
|
||||
{ labelFa: "کیف", labelEn: "Bags" },
|
||||
{ labelFa: "چرم", labelEn: "Leather" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export function normalizeSlotDef(raw: WebsiteSlotDefApi): WebsiteSlotDef {
|
||||
const slug = raw.slug || raw.key || "";
|
||||
const local = WEBSITE_SLOT_DEFS.find((d) => d.slug === slug);
|
||||
return {
|
||||
slug,
|
||||
labelFa: raw.labelFa,
|
||||
labelEn: raw.labelEn,
|
||||
kind:
|
||||
raw.kind ??
|
||||
((raw as { duplicatable?: boolean }).duplicatable
|
||||
? "collection"
|
||||
: "fixed"),
|
||||
sectionKey: raw.sectionKey,
|
||||
sectionLabelFa: raw.sectionLabelFa,
|
||||
sectionLabelEn: raw.sectionLabelEn,
|
||||
itemCount: raw.itemCount,
|
||||
aspectRatio: raw.aspectRatio || 1,
|
||||
aspectLabel: raw.aspectLabel || "",
|
||||
previewObjectPosition:
|
||||
raw.previewObjectPosition ?? local?.previewObjectPosition,
|
||||
cellLabels: (raw.cellLabels ?? local?.cellLabels)?.map((cell, index) => ({
|
||||
...cell,
|
||||
previewObjectPosition:
|
||||
cell.previewObjectPosition ??
|
||||
local?.cellLabels?.[index]?.previewObjectPosition,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/** Same focal points as website `map-home-slots` hero slides */
|
||||
export function heroPreviewObjectPosition(index: number) {
|
||||
if (index === 0) return "object-[center_20%]";
|
||||
if (index === 1) return "object-[center_40%]";
|
||||
return "object-[center_55%]";
|
||||
}
|
||||
|
||||
export function groupSlotSections(
|
||||
catalog: WebsiteSlotDef[],
|
||||
): WebsiteSlotSection[] {
|
||||
const sections: WebsiteSlotSection[] = [];
|
||||
for (const def of catalog) {
|
||||
const key = def.sectionKey ?? def.slug;
|
||||
const last = sections[sections.length - 1];
|
||||
if (last && last.key === key) {
|
||||
last.defs.push(def);
|
||||
continue;
|
||||
}
|
||||
sections.push({
|
||||
key,
|
||||
labelFa: def.sectionLabelFa ?? def.labelFa,
|
||||
labelEn: def.sectionLabelEn ?? def.labelEn,
|
||||
aspectLabel: def.aspectLabel,
|
||||
defs: [def],
|
||||
});
|
||||
}
|
||||
return sections;
|
||||
}
|
||||
@@ -172,6 +172,7 @@ export type MediaKind =
|
||||
| "brand"
|
||||
| "store"
|
||||
| "avatar"
|
||||
| "website"
|
||||
| "temp";
|
||||
|
||||
export type MediaUploadResult = {
|
||||
|
||||
+48
-15
@@ -5,6 +5,11 @@ import type {
|
||||
Product,
|
||||
ProductVariantPrice,
|
||||
} from "@/data/clothes";
|
||||
import {
|
||||
normalizeTechnicalData,
|
||||
normalizeTechnicalFields,
|
||||
parsePriceInput,
|
||||
} from "@/data/clothes";
|
||||
|
||||
export type ApiProductVariant = {
|
||||
id: string;
|
||||
@@ -17,12 +22,12 @@ export type ApiProductVariant = {
|
||||
};
|
||||
|
||||
export type ApiProduct = {
|
||||
id: string;
|
||||
id: number;
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
categoryId: string | null;
|
||||
categoryId: number | null;
|
||||
brandId: string | null;
|
||||
price: number;
|
||||
discountPrice: number | null;
|
||||
@@ -33,18 +38,21 @@ export type ApiProduct = {
|
||||
vipOnly?: boolean;
|
||||
vipSpecialPrice?: boolean;
|
||||
vipPrice?: number | null;
|
||||
featuredSlot?: string | null;
|
||||
technicalData?: unknown;
|
||||
variants: ApiProductVariant[];
|
||||
};
|
||||
|
||||
export type ApiCategory = {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
id: number;
|
||||
parentId: number | null;
|
||||
nameFa: string;
|
||||
nameEn: string;
|
||||
sortOrder: number;
|
||||
colors: string[];
|
||||
sizes: string[];
|
||||
packages: string[];
|
||||
technicalFields?: unknown;
|
||||
children?: ApiCategory[];
|
||||
};
|
||||
|
||||
@@ -79,6 +87,8 @@ export function mapProduct(p: ApiProduct): Product {
|
||||
vipOnly: Boolean(p.vipOnly),
|
||||
vipSpecialPrice: Boolean(p.vipSpecialPrice),
|
||||
vipPrice: p.vipPrice ?? undefined,
|
||||
featuredSlot: p.featuredSlot ?? null,
|
||||
technicalData: normalizeTechnicalData(p.technicalData),
|
||||
variantPrices: (p.variants ?? []).map(mapVariant),
|
||||
};
|
||||
}
|
||||
@@ -89,11 +99,13 @@ export function toVariantPayload(rows: ProductVariantPrice[]) {
|
||||
colorId: row.colorId || "",
|
||||
sizeId: row.sizeId || "",
|
||||
packageId: row.packageId || "",
|
||||
price: Number(row.price) || 0,
|
||||
price: Number(parsePriceInput(row.price)) || 0,
|
||||
discountPrice: row.discountPrice
|
||||
? Number(row.discountPrice) || undefined
|
||||
? Number(parsePriceInput(row.discountPrice)) || undefined
|
||||
: undefined,
|
||||
vipPrice: row.vipPrice
|
||||
? Number(parsePriceInput(row.vipPrice)) || undefined
|
||||
: undefined,
|
||||
vipPrice: row.vipPrice ? Number(row.vipPrice) || undefined : undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -113,8 +125,13 @@ export async function fetchProducts(params?: {
|
||||
return rows.map(mapProduct);
|
||||
}
|
||||
|
||||
export async function fetchProduct(id: string | number) {
|
||||
const row = await api<ApiProduct>(`/products/${id}`);
|
||||
return mapProduct(row);
|
||||
}
|
||||
|
||||
export async function updateProduct(
|
||||
id: string,
|
||||
id: string | number,
|
||||
body: Record<string, unknown>,
|
||||
) {
|
||||
const row = await api<ApiProduct>(`/products/${id}`, {
|
||||
@@ -124,7 +141,7 @@ export async function updateProduct(
|
||||
return mapProduct(row);
|
||||
}
|
||||
|
||||
export async function deleteProduct(id: string) {
|
||||
export async function deleteProduct(id: string | number) {
|
||||
await api(`/products/${id}`, { method: "DELETE" });
|
||||
}
|
||||
|
||||
@@ -188,10 +205,17 @@ export async function deleteBrand(id: string) {
|
||||
export async function createCategory(body: {
|
||||
nameFa: string;
|
||||
nameEn?: string;
|
||||
parentId?: string;
|
||||
parentId?: number;
|
||||
colors?: string[];
|
||||
sizes?: string[];
|
||||
packages?: string[];
|
||||
technicalFields?: Array<{
|
||||
id: string;
|
||||
labelFa: string;
|
||||
labelEn?: string;
|
||||
type: "text";
|
||||
required?: boolean;
|
||||
}>;
|
||||
sortOrder?: number;
|
||||
}) {
|
||||
await api("/categories", { method: "POST", body });
|
||||
@@ -199,14 +223,21 @@ export async function createCategory(body: {
|
||||
}
|
||||
|
||||
export async function updateCategory(
|
||||
id: string,
|
||||
id: number | string,
|
||||
body: {
|
||||
nameFa?: string;
|
||||
nameEn?: string;
|
||||
parentId?: string | null;
|
||||
parentId?: number | null;
|
||||
colors?: string[];
|
||||
sizes?: string[];
|
||||
packages?: string[];
|
||||
technicalFields?: Array<{
|
||||
id: string;
|
||||
labelFa: string;
|
||||
labelEn?: string;
|
||||
type: "text";
|
||||
required?: boolean;
|
||||
}>;
|
||||
sortOrder?: number;
|
||||
},
|
||||
) {
|
||||
@@ -214,13 +245,13 @@ export async function updateCategory(
|
||||
return fetchCategoriesTree();
|
||||
}
|
||||
|
||||
export async function deleteCategory(id: string) {
|
||||
export async function deleteCategory(id: number | string) {
|
||||
await api(`/categories/${id}`, { method: "DELETE" });
|
||||
return fetchCategoriesTree();
|
||||
}
|
||||
|
||||
function nestCategories(flat: ApiCategory[]): CategoryNode[] {
|
||||
const byParent = new Map<string | null, ApiCategory[]>();
|
||||
const byParent = new Map<number | null, ApiCategory[]>();
|
||||
for (const cat of flat) {
|
||||
const key = cat.parentId;
|
||||
const list = byParent.get(key) ?? [];
|
||||
@@ -228,7 +259,7 @@ function nestCategories(flat: ApiCategory[]): CategoryNode[] {
|
||||
byParent.set(key, list);
|
||||
}
|
||||
|
||||
function build(parentId: string | null): CategoryNode[] {
|
||||
function build(parentId: number | null): CategoryNode[] {
|
||||
const nodes = (byParent.get(parentId) ?? []).sort(
|
||||
(a, b) => a.sortOrder - b.sortOrder,
|
||||
);
|
||||
@@ -241,6 +272,7 @@ function nestCategories(flat: ApiCategory[]): CategoryNode[] {
|
||||
sizes: node.sizes ?? [],
|
||||
packages: (node.packages ?? []) as Array<"6" | "12">,
|
||||
},
|
||||
technicalFields: normalizeTechnicalFields(node.technicalFields),
|
||||
children: build(node.id),
|
||||
}));
|
||||
}
|
||||
@@ -260,6 +292,7 @@ export async function fetchCategoriesTree(): Promise<CategoryNode[]> {
|
||||
sizes: node.sizes ?? [],
|
||||
packages: (node.packages ?? []) as Array<"6" | "12">,
|
||||
},
|
||||
technicalFields: normalizeTechnicalFields(node.technicalFields),
|
||||
children: (node.children ?? []).map(mapNode),
|
||||
});
|
||||
return rows.filter((r) => !r.parentId).map(mapNode);
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import { api } from "@/lib/api";
|
||||
import type { WebsiteSlotDef, WebsiteSlotItem } from "@/data/website-slots";
|
||||
|
||||
export type WebsiteSlotsResponse = {
|
||||
catalog: WebsiteSlotDef[];
|
||||
items: WebsiteSlotItem[];
|
||||
};
|
||||
|
||||
export async function fetchWebsiteSlots() {
|
||||
return api<WebsiteSlotsResponse>("/website-slots");
|
||||
}
|
||||
|
||||
export async function createWebsiteSlotItem(body: {
|
||||
slotKey: string;
|
||||
nameFa?: string;
|
||||
nameEn?: string;
|
||||
subtext?: string;
|
||||
linkText?: string;
|
||||
link?: string;
|
||||
imageUrl?: string;
|
||||
imageKey?: string;
|
||||
sortOrder?: number;
|
||||
}) {
|
||||
return api<WebsiteSlotItem>("/website-slots", { method: "POST", body });
|
||||
}
|
||||
|
||||
export async function updateWebsiteSlotItem(
|
||||
id: string,
|
||||
body: {
|
||||
nameFa?: string;
|
||||
nameEn?: string;
|
||||
subtext?: string;
|
||||
linkText?: string;
|
||||
link?: string;
|
||||
imageUrl?: string | null;
|
||||
imageKey?: string | null;
|
||||
sortOrder?: number;
|
||||
},
|
||||
) {
|
||||
return api<WebsiteSlotItem>(`/website-slots/${id}`, {
|
||||
method: "PATCH",
|
||||
body,
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteWebsiteSlotItem(id: string) {
|
||||
await api(`/website-slots/${id}`, { method: "DELETE" });
|
||||
}
|
||||
Reference in New Issue
Block a user