Improve clothes admin UX, website slots, and featured placement.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-27 18:30:52 +03:30
co-authored by Cursor
parent 67b1943a9e
commit d8ecd57641
26 changed files with 3091 additions and 213 deletions
+1 -1
View File
@@ -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) {
+5
View File
@@ -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 ? "ذخیره" : "افزودن دسته"}
+2
View File
@@ -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 ? "..." : "ذخیره متا"}
+531
View File
@@ -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>
);
}
+5
View File
@@ -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 ? "ذخیره" : "افزودن برند"}
+232 -1
View File
@@ -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
View File
@@ -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>
</>
);
}
+98 -21
View File
@@ -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>
+8 -8
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+691
View File
@@ -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>
);
}
+101
View File
@@ -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>
);
}
+20
View File
@@ -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}
+3 -1
View File
@@ -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: "پروفایل من" },
];
+36
View File
@@ -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>
);
}
+180 -35
View File
@@ -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>
);
+16 -3
View File
@@ -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={
+37
View File
@@ -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
View File
@@ -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);
+234
View File
@@ -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;
}
+1
View File
@@ -172,6 +172,7 @@ export type MediaKind =
| "brand"
| "store"
| "avatar"
| "website"
| "temp";
export type MediaUploadResult = {
+48 -15
View File
@@ -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);
+48
View File
@@ -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" });
}