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, { import IconButton, {
AddChildIcon, AddChildIcon,
EditIcon, EditIcon,
MetaTagsIcon,
TechnicalDataIcon, TechnicalDataIcon,
TrashIcon, TrashIcon,
VariantIcon, VariantIcon,
@@ -57,6 +58,9 @@ export default function CategoriesPage() {
const [techTarget, setTechTarget] = useState<CategoryNode | null>(null); const [techTarget, setTechTarget] = useState<CategoryNode | null>(null);
const [draftFields, setDraftFields] = useState<TechnicalField[]>([]); const [draftFields, setDraftFields] = useState<TechnicalField[]>([]);
const [seoTarget, setSeoTarget] = useState<CategoryNode | null>(null);
const [draftSeoDescription, setDraftSeoDescription] = useState("");
const parentOptions = useMemo(() => flattenCategories(tree), [tree]); const parentOptions = useMemo(() => flattenCategories(tree), [tree]);
const reload = useCallback(async () => { const reload = useCallback(async () => {
@@ -146,6 +150,16 @@ export default function CategoriesPage() {
setDraftFields([]); setDraftFields([]);
} }
function openSeo(node: CategoryNode) {
setSeoTarget(node);
setDraftSeoDescription(node.seoDescription ?? "");
}
function closeSeo() {
setSeoTarget(null);
setDraftSeoDescription("");
}
function addTechnicalField() { function addTechnicalField() {
setDraftFields((prev) => [...prev, createTechnicalField()]); 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 ( return (
<> <>
<ClothesShell <ClothesShell
@@ -356,6 +390,7 @@ export default function CategoriesPage() {
onAddChild={(id) => openCreate(id)} onAddChild={(id) => openCreate(id)}
onVariants={openVariants} onVariants={openVariants}
onTechnical={openTechnical} onTechnical={openTechnical}
onSeo={openSeo}
onDelete={handleDelete} onDelete={handleDelete}
delay={80 + i * 60} delay={80 + i * 60}
/> />
@@ -665,6 +700,32 @@ export default function CategoriesPage() {
+ افزودن فیلد متنی + افزودن فیلد متنی
</button> </button>
</FormModal> </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, onAddChild,
onVariants, onVariants,
onTechnical, onTechnical,
onSeo,
onDelete, onDelete,
delay, delay,
}: { }: {
@@ -691,6 +753,7 @@ function CategoryBranch({
onAddChild: (id: string) => void; onAddChild: (id: string) => void;
onVariants: (node: CategoryNode) => void; onVariants: (node: CategoryNode) => void;
onTechnical: (node: CategoryNode) => void; onTechnical: (node: CategoryNode) => void;
onSeo: (node: CategoryNode) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
delay: number; delay: number;
}) { }) {
@@ -701,6 +764,7 @@ function CategoryBranch({
(node.variants?.sizes.length ? 1 : 0) + (node.variants?.sizes.length ? 1 : 0) +
(node.variants?.packages.length ? 1 : 0); (node.variants?.packages.length ? 1 : 0);
const techCount = node.technicalFields?.length ?? 0; const techCount = node.technicalFields?.length ?? 0;
const hasSeo = Boolean(node.seoDescription?.trim());
return ( return (
<div className="space-y-2"> <div className="space-y-2">
@@ -769,6 +833,14 @@ function CategoryBranch({
> >
<TechnicalDataIcon /> <TechnicalDataIcon />
</IconButton> </IconButton>
<IconButton
label="SEO"
variant="line"
badge={hasSeo ? "•" : null}
onClick={() => onSeo(node)}
>
<MetaTagsIcon />
</IconButton>
<IconButton <IconButton
label="حذف" label="حذف"
variant="line" variant="line"
@@ -792,6 +864,7 @@ function CategoryBranch({
onAddChild={onAddChild} onAddChild={onAddChild}
onVariants={onVariants} onVariants={onVariants}
onTechnical={onTechnical} onTechnical={onTechnical}
onSeo={onSeo}
onDelete={onDelete} onDelete={onDelete}
delay={delay + (i + 1) * 40} delay={delay + (i + 1) * 40}
/> />
+1
View File
@@ -30,6 +30,7 @@ export type CategoryNode = {
children?: CategoryNode[]; children?: CategoryNode[];
variants?: CategoryVariants; variants?: CategoryVariants;
technicalFields?: TechnicalField[]; technicalFields?: TechnicalField[];
seoDescription?: string;
}; };
export type ProductVariantPrice = { export type ProductVariantPrice = {
+4
View File
@@ -53,6 +53,7 @@ export type ApiCategory = {
sizes: string[]; sizes: string[];
packages: string[]; packages: string[];
technicalFields?: unknown; technicalFields?: unknown;
seoDescription?: string;
children?: ApiCategory[]; children?: ApiCategory[];
}; };
@@ -239,6 +240,7 @@ export async function updateCategory(
required?: boolean; required?: boolean;
}>; }>;
sortOrder?: number; sortOrder?: number;
seoDescription?: string;
}, },
) { ) {
await api(`/categories/${id}`, { method: "PATCH", body }); await api(`/categories/${id}`, { method: "PATCH", body });
@@ -273,6 +275,7 @@ function nestCategories(flat: ApiCategory[]): CategoryNode[] {
packages: (node.packages ?? []) as Array<"6" | "12">, packages: (node.packages ?? []) as Array<"6" | "12">,
}, },
technicalFields: normalizeTechnicalFields(node.technicalFields), technicalFields: normalizeTechnicalFields(node.technicalFields),
seoDescription: node.seoDescription ?? "",
children: build(node.id), children: build(node.id),
})); }));
} }
@@ -293,6 +296,7 @@ export async function fetchCategoriesTree(): Promise<CategoryNode[]> {
packages: (node.packages ?? []) as Array<"6" | "12">, packages: (node.packages ?? []) as Array<"6" | "12">,
}, },
technicalFields: normalizeTechnicalFields(node.technicalFields), technicalFields: normalizeTechnicalFields(node.technicalFields),
seoDescription: node.seoDescription ?? "",
children: (node.children ?? []).map(mapNode), children: (node.children ?? []).map(mapNode),
}); });
return rows.filter((r) => !r.parentId).map(mapNode); return rows.filter((r) => !r.parentId).map(mapNode);