Add SEO description editor on category admin rows.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
co-authored by
Cursor
parent
2af7bfde77
commit
f192085c1f
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -30,6 +30,7 @@ export type CategoryNode = {
|
||||
children?: CategoryNode[];
|
||||
variants?: CategoryVariants;
|
||||
technicalFields?: TechnicalField[];
|
||||
seoDescription?: string;
|
||||
};
|
||||
|
||||
export type ProductVariantPrice = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user