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, {
|
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}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user