Add SEO description editor on category admin rows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-09-27 20:46:17 +03:30
co-authored by Cursor
parent 2af7bfde77
commit f192085c1f
3 changed files with 78 additions and 0 deletions
+73
View File
@@ -5,6 +5,7 @@ import ClothesShell from "@/components/clothes/ClothesShell";
import IconButton, {
AddChildIcon,
EditIcon,
MetaTagsIcon,
TechnicalDataIcon,
TrashIcon,
VariantIcon,
@@ -57,6 +58,9 @@ export default function CategoriesPage() {
const [techTarget, setTechTarget] = useState<CategoryNode | null>(null);
const [draftFields, setDraftFields] = useState<TechnicalField[]>([]);
const [seoTarget, setSeoTarget] = useState<CategoryNode | null>(null);
const [draftSeoDescription, setDraftSeoDescription] = useState("");
const parentOptions = useMemo(() => flattenCategories(tree), [tree]);
const reload = useCallback(async () => {
@@ -146,6 +150,16 @@ export default function CategoriesPage() {
setDraftFields([]);
}
function openSeo(node: CategoryNode) {
setSeoTarget(node);
setDraftSeoDescription(node.seoDescription ?? "");
}
function closeSeo() {
setSeoTarget(null);
setDraftSeoDescription("");
}
function addTechnicalField() {
setDraftFields((prev) => [...prev, createTechnicalField()]);
}
@@ -316,6 +330,26 @@ export default function CategoriesPage() {
}
}
async function handleSeoSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (!seoTarget) return;
setSaving(true);
setError(null);
try {
const nextTree = await updateCategory(seoTarget.id, {
seoDescription: draftSeoDescription.trim(),
});
setTree(nextTree);
closeSeo();
} catch (err) {
setError(
err instanceof ApiError ? err.message : "ذخیره توضیحات SEO ناموفق بود.",
);
} finally {
setSaving(false);
}
}
return (
<>
<ClothesShell
@@ -356,6 +390,7 @@ export default function CategoriesPage() {
onAddChild={(id) => openCreate(id)}
onVariants={openVariants}
onTechnical={openTechnical}
onSeo={openSeo}
onDelete={handleDelete}
delay={80 + i * 60}
/>
@@ -665,6 +700,32 @@ export default function CategoriesPage() {
+ افزودن فیلد متنی
</button>
</FormModal>
<FormModal
open={Boolean(seoTarget)}
title={`SEO — ${seoTarget?.nameFa ?? ""}`}
subtitle="Category page description for users and search engines"
onClose={closeSeo}
onSubmit={handleSeoSubmit}
submitLabel={saving ? "..." : "ذخیره SEO"}
wide
>
<p className="text-xs leading-6 text-muted">
این متن در صفحه دسته در فروشگاه نمایش داده می‌شود و برای SEO استفاده
می‌شود.
</p>
<div className="clothes-field">
<label htmlFor="category-seo-description">توضیحات SEO</label>
<textarea
id="category-seo-description"
rows={8}
value={draftSeoDescription}
onChange={(e) => setDraftSeoDescription(e.target.value)}
placeholder="توضیح کامل درباره این دسته، محصولات آن و مزایای خرید..."
className="min-h-[160px] w-full resize-y"
/>
</div>
</FormModal>
</>
);
}
@@ -679,6 +740,7 @@ function CategoryBranch({
onAddChild,
onVariants,
onTechnical,
onSeo,
onDelete,
delay,
}: {
@@ -691,6 +753,7 @@ function CategoryBranch({
onAddChild: (id: string) => void;
onVariants: (node: CategoryNode) => void;
onTechnical: (node: CategoryNode) => void;
onSeo: (node: CategoryNode) => void;
onDelete: (id: string) => void;
delay: number;
}) {
@@ -701,6 +764,7 @@ function CategoryBranch({
(node.variants?.sizes.length ? 1 : 0) +
(node.variants?.packages.length ? 1 : 0);
const techCount = node.technicalFields?.length ?? 0;
const hasSeo = Boolean(node.seoDescription?.trim());
return (
<div className="space-y-2">
@@ -769,6 +833,14 @@ function CategoryBranch({
>
<TechnicalDataIcon />
</IconButton>
<IconButton
label="SEO"
variant="line"
badge={hasSeo ? "•" : null}
onClick={() => onSeo(node)}
>
<MetaTagsIcon />
</IconButton>
<IconButton
label="حذف"
variant="line"
@@ -792,6 +864,7 @@ function CategoryBranch({
onAddChild={onAddChild}
onVariants={onVariants}
onTechnical={onTechnical}
onSeo={onSeo}
onDelete={onDelete}
delay={delay + (i + 1) * 40}
/>
+1
View File
@@ -30,6 +30,7 @@ export type CategoryNode = {
children?: CategoryNode[];
variants?: CategoryVariants;
technicalFields?: TechnicalField[];
seoDescription?: string;
};
export type ProductVariantPrice = {
+4
View File
@@ -53,6 +53,7 @@ export type ApiCategory = {
sizes: string[];
packages: string[];
technicalFields?: unknown;
seoDescription?: string;
children?: ApiCategory[];
};
@@ -239,6 +240,7 @@ export async function updateCategory(
required?: boolean;
}>;
sortOrder?: number;
seoDescription?: string;
},
) {
await api(`/categories/${id}`, { method: "PATCH", body });
@@ -273,6 +275,7 @@ function nestCategories(flat: ApiCategory[]): CategoryNode[] {
packages: (node.packages ?? []) as Array<"6" | "12">,
},
technicalFields: normalizeTechnicalFields(node.technicalFields),
seoDescription: node.seoDescription ?? "",
children: build(node.id),
}));
}
@@ -293,6 +296,7 @@ export async function fetchCategoriesTree(): Promise<CategoryNode[]> {
packages: (node.packages ?? []) as Array<"6" | "12">,
},
technicalFields: normalizeTechnicalFields(node.technicalFields),
seoDescription: node.seoDescription ?? "",
children: (node.children ?? []).map(mapNode),
});
return rows.filter((r) => !r.parentId).map(mapNode);