442 lines
14 KiB
TypeScript
442 lines
14 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
createSeo,
|
||
deleteSeo,
|
||
getAllSeo,
|
||
getDetailSeo,
|
||
updateSeo,
|
||
} from "@/pages/seo/lib/api";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
AlertCircle,
|
||
CheckCircle,
|
||
FileText,
|
||
Image as ImageIcon,
|
||
Loader2,
|
||
Pencil,
|
||
Trash2,
|
||
TrendingUp,
|
||
} from "lucide-react";
|
||
import { useEffect, useState, type ChangeEvent } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { toast } from "sonner";
|
||
|
||
type SeoData = {
|
||
title: string;
|
||
description: string;
|
||
keywords: string;
|
||
ogTitle: string;
|
||
ogDescription: string;
|
||
ogImage: File | null | string;
|
||
};
|
||
|
||
export default function Seo() {
|
||
const [formData, setFormData] = useState<SeoData>({
|
||
title: "",
|
||
description: "",
|
||
keywords: "",
|
||
ogTitle: "",
|
||
ogDescription: "",
|
||
ogImage: null,
|
||
});
|
||
const { t } = useTranslation();
|
||
const [edit, setEdit] = useState<number | null>(null);
|
||
const queryClient = useQueryClient();
|
||
|
||
const { mutate, isPending } = useMutation({
|
||
mutationFn: async (body: FormData) => createSeo(body),
|
||
onSuccess: () => {
|
||
toast.success(t("Ma’lumotlar muvaffaqiyatli saqlandi"), {
|
||
position: "top-center",
|
||
});
|
||
setFormData({
|
||
description: "",
|
||
keywords: "",
|
||
ogDescription: "",
|
||
ogImage: null,
|
||
ogTitle: "",
|
||
title: "",
|
||
});
|
||
queryClient.refetchQueries({ queryKey: ["seo_all"] });
|
||
queryClient.refetchQueries({ queryKey: ["seo_detail"] });
|
||
setImagePreview(null);
|
||
},
|
||
onError: () => {
|
||
toast.error(t("Xatolik yuz berdi"), {
|
||
position: "top-center",
|
||
richColors: true,
|
||
});
|
||
},
|
||
});
|
||
|
||
const { mutate: editSeo, isPending: editPending } = useMutation({
|
||
mutationFn: async ({ body, id }: { body: FormData; id: number }) =>
|
||
updateSeo({ body, id }),
|
||
onSuccess: () => {
|
||
toast.success(t("Ma’lumotlar muvaffaqiyatli saqlandi"), {
|
||
position: "top-center",
|
||
});
|
||
setEdit(null);
|
||
setFormData({
|
||
description: "",
|
||
keywords: "",
|
||
ogDescription: "",
|
||
ogImage: null,
|
||
ogTitle: "",
|
||
title: "",
|
||
});
|
||
queryClient.refetchQueries({ queryKey: ["seo_all"] });
|
||
queryClient.refetchQueries({ queryKey: ["seo_detail"] });
|
||
setImagePreview(null);
|
||
},
|
||
onError: () => {
|
||
toast.error(t("Xatolik yuz berdi"), {
|
||
position: "top-center",
|
||
richColors: true,
|
||
});
|
||
},
|
||
});
|
||
|
||
const { mutate: deleteSeoo } = useMutation({
|
||
mutationFn: (id: number) => deleteSeo({ id }),
|
||
onSuccess: () => {
|
||
setEdit(null);
|
||
setFormData({
|
||
description: "",
|
||
keywords: "",
|
||
ogDescription: "",
|
||
ogImage: null,
|
||
ogTitle: "",
|
||
title: "",
|
||
});
|
||
queryClient.refetchQueries({ queryKey: ["seo_all"] });
|
||
queryClient.refetchQueries({ queryKey: ["seo_detail"] });
|
||
setImagePreview(null);
|
||
},
|
||
onError: () => {
|
||
toast.error(t("Xatolik yuz berdi"), {
|
||
position: "top-center",
|
||
richColors: true,
|
||
});
|
||
},
|
||
});
|
||
|
||
const { data: allSeo, isLoading } = useQuery({
|
||
queryKey: ["seo_all"],
|
||
queryFn: () => getAllSeo({ page: 1, page_size: 99 }),
|
||
select(data) {
|
||
return data.data.data.results;
|
||
},
|
||
});
|
||
|
||
const { data: detailSeo } = useQuery({
|
||
queryKey: ["seo_detail", edit],
|
||
queryFn: () => getDetailSeo(edit!),
|
||
select(data) {
|
||
return data.data.data;
|
||
},
|
||
enabled: !!edit,
|
||
});
|
||
|
||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||
|
||
const handleChange = (
|
||
e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>,
|
||
) => {
|
||
const { name, value } = e.target;
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
[name]: value,
|
||
}));
|
||
};
|
||
|
||
const handleImageUpload = (e: ChangeEvent<HTMLInputElement>) => {
|
||
const file = e.target.files?.[0];
|
||
if (file) {
|
||
setFormData((prev) => ({ ...prev, ogImage: file }));
|
||
setImagePreview(URL.createObjectURL(file));
|
||
}
|
||
};
|
||
|
||
const handleEdit = (id: number) => {
|
||
setEdit(id);
|
||
};
|
||
|
||
const handleDelete = (id: number) => {
|
||
deleteSeoo(id);
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (detailSeo) {
|
||
setFormData({
|
||
description: detailSeo.description,
|
||
keywords: detailSeo.keywords,
|
||
ogDescription: detailSeo.og_description,
|
||
ogImage: detailSeo.og_image,
|
||
ogTitle: detailSeo.og_title,
|
||
title: detailSeo.title,
|
||
});
|
||
setImagePreview(detailSeo.og_image || null);
|
||
}
|
||
}, [detailSeo, edit]);
|
||
|
||
const handleSave = () => {
|
||
const form = new FormData();
|
||
form.append("title", formData.title);
|
||
form.append("description", formData.description);
|
||
form.append("keywords", formData.keywords);
|
||
form.append("og_title", formData.ogTitle);
|
||
form.append("og_description", formData.ogDescription);
|
||
|
||
// faqat File bo‘lsa qo‘shamiz
|
||
if (formData.ogImage instanceof File) {
|
||
form.append("og_image", formData.ogImage);
|
||
}
|
||
|
||
if (edit) {
|
||
editSeo({ body: form, id: edit });
|
||
} else {
|
||
mutate(form);
|
||
}
|
||
};
|
||
|
||
const getTitleLength = () => formData.title?.length ?? 0;
|
||
const getDescriptionLength = () => formData.description?.length ?? 0;
|
||
|
||
const isValidTitle = getTitleLength() > 10 && getTitleLength() <= 60;
|
||
const isValidDescription =
|
||
getDescriptionLength() > 60 && getDescriptionLength() <= 160;
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 p-8 w-full">
|
||
<div className="max-w-[90%] mx-auto">
|
||
{/* Header */}
|
||
<div className="mb-8">
|
||
<div className="flex items-center gap-3 mb-2">
|
||
<TrendingUp className="w-8 h-8 text-blue-400" />
|
||
<h1 className="text-4xl font-bold text-white">
|
||
{t("SEO Manager")}
|
||
</h1>
|
||
</div>
|
||
<p className="text-slate-400">
|
||
{t("Saytingizni qidiruv tizimida yaxshi pozitsiyaga keltiring")}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Main Form */}
|
||
<div className="grid grid-cols-1 gap-8">
|
||
<div className="bg-slate-800 rounded-lg p-6 space-y-6">
|
||
{/* Title */}
|
||
<div>
|
||
<label className="block text-sm font-semibold text-white mb-2">
|
||
<FileText className="inline w-4 h-4 mr-1" /> {t("Page Title")}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
name="title"
|
||
value={formData.title}
|
||
onChange={handleChange}
|
||
placeholder={t("Sahifa sarlavhasi (30–60 belgi)")}
|
||
className="w-full bg-slate-700 text-white px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
<div className="flex items-center justify-between mt-2">
|
||
{isValidTitle && (
|
||
<CheckCircle className="w-5 h-5 text-green-400" />
|
||
)}
|
||
{getTitleLength() > 0 && !isValidTitle && (
|
||
<AlertCircle className="w-5 h-5 text-yellow-400" />
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Description */}
|
||
<div>
|
||
<label className="block text-sm font-semibold text-white mb-2">
|
||
{t("Meta Description")}
|
||
</label>
|
||
<textarea
|
||
name="description"
|
||
value={formData.description}
|
||
onChange={handleChange}
|
||
placeholder={t("Sahifa tavsifi (120–160 belgi)")}
|
||
className="w-full bg-slate-700 text-white px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||
/>
|
||
<div className="flex items-center justify-between mt-2">
|
||
<span className="text-sm text-slate-400">
|
||
{getDescriptionLength()} / 160
|
||
</span>
|
||
{isValidDescription && (
|
||
<CheckCircle className="w-5 h-5 text-green-400" />
|
||
)}
|
||
{getDescriptionLength() > 0 && !isValidDescription && (
|
||
<AlertCircle className="w-5 h-5 text-yellow-400" />
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Keywords */}
|
||
<div>
|
||
<label className="block text-sm font-semibold text-white mb-2">
|
||
{t("Keywords")}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
name="keywords"
|
||
value={formData.keywords}
|
||
onChange={handleChange}
|
||
placeholder={t("Kalit so'zlar (vergul bilan ajratilgan)")}
|
||
className="w-full bg-slate-700 text-white px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
<p className="text-xs text-slate-400 mt-1">
|
||
{t("Masalan: Python, Web Development, Coding")}
|
||
</p>
|
||
</div>
|
||
|
||
{/* OG Tags */}
|
||
<div className="border-t border-slate-700 pt-6">
|
||
<h3 className="text-sm font-semibold text-white mb-4">
|
||
{t("Open Graph (Ijtimoiy Tarmoqlar)")}
|
||
</h3>
|
||
|
||
<div className="space-y-4">
|
||
<div>
|
||
<label className="block text-sm text-slate-300 mb-2">
|
||
{t("OG Title")}
|
||
</label>
|
||
<input
|
||
type="text"
|
||
name="ogTitle"
|
||
value={formData.ogTitle}
|
||
onChange={handleChange}
|
||
placeholder={t("Ijtimoiy tarmoqdagi sarlavha")}
|
||
className="w-full bg-slate-700 text-white px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm text-slate-300 mb-2">
|
||
{t("OG Description")}
|
||
</label>
|
||
<textarea
|
||
name="ogDescription"
|
||
value={formData.ogDescription}
|
||
onChange={handleChange}
|
||
placeholder={t("Ijtimoiy tarmoqdagi tavsif")}
|
||
className="w-full bg-slate-700 text-white px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm text-slate-300 mb-2">
|
||
<ImageIcon className="inline w-4 h-4 mr-1" />{" "}
|
||
{t("OG Image")}
|
||
</label>
|
||
<div className="space-y-3">
|
||
<input
|
||
type="file"
|
||
accept="image/*"
|
||
onChange={handleImageUpload}
|
||
className="w-full bg-slate-700 text-white px-4 py-2 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 file:bg-blue-600 file:text-white file:px-3 file:py-1 file:rounded file:border-0 file:cursor-pointer"
|
||
/>
|
||
{imagePreview && (
|
||
<div className="relative">
|
||
<img
|
||
src={imagePreview}
|
||
alt="Preview"
|
||
className="w-full h-40 object-cover rounded-lg"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setImagePreview(null);
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
ogImage: null,
|
||
}));
|
||
}}
|
||
className="mt-2 text-xs bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded"
|
||
>
|
||
{t("O'chirish")}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
onClick={handleSave}
|
||
disabled={isPending}
|
||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 rounded-lg transition-colors disabled:opacity-50"
|
||
>
|
||
{isPending || editPending ? (
|
||
<Loader2 className="animate-spin mx-auto" />
|
||
) : edit ? (
|
||
t("Tahrirlash")
|
||
) : (
|
||
t("Saqlash")
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Saved SEO Data */}
|
||
<div className="mt-8 bg-slate-700 rounded-lg p-6 text-slate-200">
|
||
<h3 className="text-lg font-semibold mb-4">
|
||
{t("Saqlangan SEO Ma’lumotlari")}
|
||
</h3>
|
||
|
||
{isLoading ? (
|
||
<p>{t("Yuklanmoqda...")}</p>
|
||
) : allSeo && allSeo.length > 0 ? (
|
||
<div className="space-y-4 max-h-[400px] overflow-y-auto">
|
||
{allSeo.map((seo) => (
|
||
<div
|
||
key={seo.id}
|
||
className="bg-slate-800 p-4 rounded-lg border border-slate-600 relative"
|
||
>
|
||
<div className="absolute top-2 right-2 flex gap-2">
|
||
<button
|
||
onClick={() => handleEdit(seo.id)}
|
||
className="p-1 text-blue-400 hover:text-blue-600"
|
||
>
|
||
<Pencil size={18} />
|
||
</button>
|
||
<button
|
||
onClick={() => handleDelete(seo.id)}
|
||
className="p-1 text-red-400 hover:text-red-600"
|
||
>
|
||
<Trash2 size={18} />
|
||
</button>
|
||
</div>
|
||
|
||
<h4 className="text-white font-semibold">{seo.title}</h4>
|
||
<p className="text-sm text-slate-300">
|
||
{seo.description?.substring(0, 150)}...
|
||
</p>
|
||
<p className="text-xs text-slate-400">
|
||
<strong>{t("Keywords")}:</strong> {seo.keywords}
|
||
</p>
|
||
{seo.og_image && (
|
||
<img
|
||
src={seo.og_image}
|
||
alt="OG"
|
||
className="w-full h-40 object-cover rounded-lg mt-2"
|
||
/>
|
||
)}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<p className="text-slate-400">
|
||
{t("Hozircha SEO ma’lumotlari mavjud emas.")}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|