581 lines
18 KiB
TypeScript
581 lines
18 KiB
TypeScript
"use client";
|
|
|
|
import {
|
|
createFaq,
|
|
deleteFaq,
|
|
getAllFaq,
|
|
getAllFaqCategory,
|
|
getOneFaq,
|
|
updateFaq,
|
|
} from "@/pages/faq/lib/api";
|
|
import { Button } from "@/shared/ui/button";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/shared/ui/dialog";
|
|
import {
|
|
Form,
|
|
FormControl,
|
|
FormField,
|
|
FormItem,
|
|
FormMessage,
|
|
} from "@/shared/ui/form";
|
|
import { InfiniteScrollSelect } from "@/shared/ui/infiniteScrollSelect";
|
|
import { Input } from "@/shared/ui/input";
|
|
import { Label } from "@/shared/ui/label";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from "@/shared/ui/table";
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
|
import { Textarea } from "@/shared/ui/textarea";
|
|
import { zodResolver } from "@hookform/resolvers/zod";
|
|
import {
|
|
useInfiniteQuery,
|
|
useMutation,
|
|
useQuery,
|
|
useQueryClient,
|
|
} from "@tanstack/react-query";
|
|
import {
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
Loader2,
|
|
Pencil,
|
|
PlusCircle,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { useForm } from "react-hook-form";
|
|
import { useTranslation } from "react-i18next";
|
|
import { toast } from "sonner";
|
|
import z from "zod";
|
|
|
|
const faqForm = z.object({
|
|
categories: z.string().min(1, { message: "Majburiy maydon" }),
|
|
title: z.string().min(1, { message: "Majburiy maydon" }),
|
|
title_ru: z.string().min(1, { message: "Majburiy maydon" }),
|
|
answer: z.string().min(1, { message: "Majburiy maydon" }),
|
|
answer_ru: z.string().min(1, { message: "Majburiy maydon" }),
|
|
});
|
|
|
|
const Faq = () => {
|
|
const [activeTab, setActiveTab] = useState<string>("");
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const { t } = useTranslation();
|
|
const [openModal, setOpenModal] = useState(false);
|
|
const [editFaq, setEditFaq] = useState<number | null>(null);
|
|
const queryClient = useQueryClient();
|
|
const scrollRef = useRef<HTMLDivElement>(null);
|
|
const loaderRef = useRef<HTMLDivElement>(null);
|
|
|
|
// Infinite scroll uchun useInfiniteQuery
|
|
const {
|
|
data: categoryData,
|
|
fetchNextPage,
|
|
hasNextPage,
|
|
isFetchingNextPage,
|
|
} = useInfiniteQuery({
|
|
queryKey: ["all_faqcategory"],
|
|
queryFn: ({ pageParam = 1 }) => {
|
|
return getAllFaqCategory({ page: pageParam, page_size: 10 });
|
|
},
|
|
getNextPageParam: (lastPage) => {
|
|
const data = lastPage.data.data;
|
|
if (data.current_page < data.total_pages) {
|
|
return data.current_page + 1;
|
|
}
|
|
return undefined;
|
|
},
|
|
initialPageParam: 1,
|
|
});
|
|
|
|
useEffect(() => {
|
|
setCurrentPage(1);
|
|
}, [activeTab]);
|
|
|
|
const category =
|
|
categoryData?.pages.flatMap((page) => page.data.data.results) ?? [];
|
|
|
|
const { data: faq } = useQuery({
|
|
queryKey: ["all_faq", activeTab, currentPage],
|
|
queryFn: () => {
|
|
return getAllFaq({
|
|
page: currentPage,
|
|
page_size: 10,
|
|
category: Number(activeTab),
|
|
});
|
|
},
|
|
select(data) {
|
|
return data.data.data;
|
|
},
|
|
enabled: !!activeTab,
|
|
});
|
|
|
|
const { data: detailFaq } = useQuery({
|
|
queryKey: ["detail_faq", editFaq],
|
|
queryFn: () => {
|
|
return getOneFaq(editFaq!);
|
|
},
|
|
select(data) {
|
|
return data.data.data;
|
|
},
|
|
enabled: !!editFaq,
|
|
});
|
|
|
|
const { mutate: create, isPending } = useMutation({
|
|
mutationFn: (body: {
|
|
title: string;
|
|
title_ru: string;
|
|
text: string;
|
|
text_ru: string;
|
|
category: number;
|
|
}) => createFaq(body),
|
|
onSuccess: () => {
|
|
queryClient.refetchQueries({ queryKey: ["all_faq"] });
|
|
setOpenModal(false);
|
|
toast.success(t("Muvaffaqiyatli qo'shildi"), { position: "top-center" });
|
|
},
|
|
onError: () => {
|
|
toast.error(t("Xatolik yuz berdi"), {
|
|
position: "top-center",
|
|
richColors: true,
|
|
});
|
|
},
|
|
});
|
|
|
|
const { mutate: edit, isPending: editPending } = useMutation({
|
|
mutationFn: ({
|
|
body,
|
|
id,
|
|
}: {
|
|
id: number;
|
|
body: {
|
|
title: string;
|
|
title_ru: string;
|
|
text: string;
|
|
text_ru: string;
|
|
category?: number;
|
|
};
|
|
}) => updateFaq({ body, id }),
|
|
onSuccess: () => {
|
|
queryClient.refetchQueries({ queryKey: ["all_faq"] });
|
|
setOpenModal(false);
|
|
toast.success(t("Tahrirlandi"), { position: "top-center" });
|
|
},
|
|
onError: () => {
|
|
toast.error(t("Xatolik yuz berdi"), {
|
|
position: "top-center",
|
|
richColors: true,
|
|
});
|
|
},
|
|
});
|
|
|
|
const { mutate: deleteFaqs, isPending: deletePending } = useMutation({
|
|
mutationFn: ({ id }: { id: number }) => deleteFaq({ id }),
|
|
onSuccess: () => {
|
|
queryClient.refetchQueries({ queryKey: ["all_faq"] });
|
|
setDeleteId(null);
|
|
toast.success(t("O'chirildi"), { position: "top-center" });
|
|
},
|
|
onError: () => {
|
|
toast.error(t("Xatolik yuz berdi"), {
|
|
position: "top-center",
|
|
richColors: true,
|
|
});
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (category.length > 0 && !activeTab) {
|
|
setActiveTab(String(category[0].id));
|
|
}
|
|
}, [category, activeTab]);
|
|
|
|
// Intersection Observer for lazy loading
|
|
useEffect(() => {
|
|
if (!scrollRef.current || !loaderRef.current) return;
|
|
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
|
|
fetchNextPage();
|
|
}
|
|
},
|
|
{ root: scrollRef.current, threshold: 0.1 },
|
|
);
|
|
|
|
if (loaderRef.current) {
|
|
observer.observe(loaderRef.current);
|
|
}
|
|
|
|
return () => observer.disconnect();
|
|
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
|
|
|
const [deleteId, setDeleteId] = useState<number | null>(null);
|
|
|
|
const form = useForm<z.infer<typeof faqForm>>({
|
|
resolver: zodResolver(faqForm),
|
|
defaultValues: {
|
|
answer: "",
|
|
answer_ru: "",
|
|
categories: "",
|
|
title: "",
|
|
title_ru: "",
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
if (detailFaq) {
|
|
form.setValue("title", detailFaq.title_uz);
|
|
form.setValue("title_ru", detailFaq.title_ru);
|
|
form.setValue("answer", detailFaq.text_uz);
|
|
form.setValue("answer_ru", detailFaq.text_ru);
|
|
form.setValue("categories", activeTab);
|
|
}
|
|
}, [detailFaq, form]);
|
|
|
|
function onSubmit(value: z.infer<typeof faqForm>) {
|
|
if (editFaq === null) {
|
|
create({
|
|
category: Number(value.categories),
|
|
text: value.answer,
|
|
text_ru: value.answer_ru,
|
|
title: value.title,
|
|
title_ru: value.title_ru,
|
|
});
|
|
} else if (editFaq) {
|
|
edit({
|
|
body: {
|
|
text: value.answer,
|
|
text_ru: value.answer_ru,
|
|
title: value.title,
|
|
title_ru: value.title_ru,
|
|
},
|
|
id: editFaq,
|
|
});
|
|
}
|
|
}
|
|
|
|
const handleEdit = (faq: number) => {
|
|
setOpenModal(true);
|
|
setEditFaq(faq);
|
|
};
|
|
|
|
const handleDelete = () => {
|
|
if (deleteId) {
|
|
deleteFaqs({ id: deleteId });
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (!openModal) {
|
|
form.reset();
|
|
setEditFaq(null);
|
|
}
|
|
}, [openModal, form]);
|
|
|
|
return (
|
|
<div className="p-6 space-y-6 w-full">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<h1 className="text-2xl font-semibold">
|
|
{t("FAQ (Savol va javoblar)")}
|
|
</h1>
|
|
<Button
|
|
className="gap-2"
|
|
onClick={() => {
|
|
setEditFaq(null);
|
|
setOpenModal(true);
|
|
}}
|
|
>
|
|
<PlusCircle className="w-4 h-4" /> {t("Yangi qo'shish")}
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Tabs */}
|
|
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
|
<div className="relative">
|
|
<TabsList
|
|
ref={scrollRef}
|
|
className="flex gap-2 mb-4 overflow-x-auto pb-2 scrollbar-thin scrollbar-thumb-gray-600 scrollbar-track-gray-800"
|
|
>
|
|
{category.map((cat) => (
|
|
<TabsTrigger
|
|
key={cat.id}
|
|
value={String(cat.id)}
|
|
className="whitespace-nowrap"
|
|
>
|
|
{cat.name}
|
|
</TabsTrigger>
|
|
))}
|
|
{hasNextPage && (
|
|
<div ref={loaderRef} className="flex items-center px-4">
|
|
{isFetchingNextPage ? (
|
|
<Loader2 className="w-4 h-4 animate-spin" />
|
|
) : (
|
|
<span className="text-xs text-gray-500">
|
|
<ChevronRight className="size-4" />
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
</TabsList>
|
|
</div>
|
|
|
|
{/* Tabs content */}
|
|
{category.map((cat) => (
|
|
<TabsContent key={cat.id} value={String(cat.id)}>
|
|
{faq && faq?.results.length > 0 ? (
|
|
<div className="border rounded-md overflow-hidden shadow-sm">
|
|
<Table>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead className="w-[50px] text-center">#</TableHead>
|
|
<TableHead>{t("Savol")}</TableHead>
|
|
<TableHead>{t("Javob")}</TableHead>
|
|
<TableHead className="w-[120px] text-center">
|
|
{t("Amallar")}
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{faq.results.map((faq, index) => (
|
|
<TableRow key={faq.id}>
|
|
<TableCell className="text-center font-medium">
|
|
{index + 1}
|
|
</TableCell>
|
|
<TableCell className="font-medium">
|
|
{faq.title}
|
|
</TableCell>
|
|
<TableCell className="text-foreground">
|
|
{faq.text.length > 80
|
|
? faq.text.slice(0, 80) + "..."
|
|
: faq.text}
|
|
</TableCell>
|
|
<TableCell className="flex justify-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => handleEdit(faq.id)}
|
|
>
|
|
<Pencil className="w-4 h-4" />
|
|
</Button>
|
|
<Button
|
|
variant="destructive"
|
|
size="icon"
|
|
onClick={() => setDeleteId(faq.id)}
|
|
>
|
|
<Trash2 className="w-4 h-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
) : (
|
|
<p className="text-gray-500 text-sm mt-4 text-center">
|
|
{t("Bu bo'limda savollar yo'q")}
|
|
</p>
|
|
)}
|
|
</TabsContent>
|
|
))}
|
|
</Tabs>
|
|
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
disabled={currentPage === 1}
|
|
onClick={() => setCurrentPage((p) => Math.max(p - 1, 1))}
|
|
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:border-slate-500"
|
|
>
|
|
<ChevronLeft className="w-5 h-5" />
|
|
</button>
|
|
|
|
{[...Array(faq?.total_pages)].map((_, i) => (
|
|
<button
|
|
key={i}
|
|
onClick={() => setCurrentPage(i + 1)}
|
|
className={`px-4 py-2 rounded-lg border transition-all font-medium ${
|
|
currentPage === i + 1
|
|
? "bg-gradient-to-r from-blue-600 to-cyan-600 border-blue-500 text-white shadow-lg shadow-cyan-500/50"
|
|
: "border-slate-600 text-slate-300 hover:bg-slate-700/50 hover:border-slate-500"
|
|
}`}
|
|
>
|
|
{i + 1}
|
|
</button>
|
|
))}
|
|
|
|
<button
|
|
disabled={currentPage === faq?.total_pages}
|
|
onClick={() =>
|
|
setCurrentPage((p) => Math.min(p + 1, faq ? faq.total_pages : 0))
|
|
}
|
|
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50 disabled:cursor-not-allowed transition-all hover:border-slate-500"
|
|
>
|
|
<ChevronRight className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
|
|
<Dialog open={openModal} onOpenChange={setOpenModal}>
|
|
<DialogContent className="sm:max-w-[500px] h-[80vh] overflow-y-scroll bg-gray-900">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{editFaq ? t("FAQni tahrirlash") : t("Yangi FAQ qo'shish")}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
|
|
<Form {...form}>
|
|
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
|
|
<FormField
|
|
control={form.control}
|
|
name="categories"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<Label className="text-md">{t("Kategoriya")}</Label>
|
|
<FormControl>
|
|
<InfiniteScrollSelect
|
|
value={field.value}
|
|
onValueChange={field.onChange}
|
|
placeholder={t("Kategoriya tanlang")}
|
|
label={t("Kategoriyalar")}
|
|
data={category || []}
|
|
fetchNextPage={fetchNextPage}
|
|
renderOption={(cat) => ({
|
|
key: cat.id,
|
|
value: String(cat.id),
|
|
label: cat.name,
|
|
})}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
|
|
<FormField
|
|
control={form.control}
|
|
name="title"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<Label className="text-md">{t("Savol")}</Label>
|
|
<FormControl>
|
|
<Input
|
|
placeholder={t("Savol")}
|
|
{...field}
|
|
className="h-12 !text-md bg-gray-800 border-gray-700 text-white"
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="title_ru"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<Label className="text-md">{t("Savol")} (ru)</Label>
|
|
<FormControl>
|
|
<Input
|
|
placeholder={t("Savol")}
|
|
{...field}
|
|
className="h-12 !text-md bg-gray-800 border-gray-700 text-white"
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="answer"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<Label className="text-md">{t("Javob")}</Label>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder={t("Javob")}
|
|
{...field}
|
|
className="min-h-48 max-h-56 !text-md bg-gray-800 border-gray-700 text-white"
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<FormField
|
|
control={form.control}
|
|
name="answer_ru"
|
|
render={({ field }) => (
|
|
<FormItem>
|
|
<Label className="text-md">{t("Javob")} (ru)</Label>
|
|
<FormControl>
|
|
<Textarea
|
|
placeholder={t("Javob") + " (ru)"}
|
|
{...field}
|
|
className="min-h-48 max-h-56 !text-md bg-gray-800 border-gray-700 text-white"
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
<div className="flex justify-between">
|
|
<Button
|
|
type="button"
|
|
onClick={() => {
|
|
setOpenModal(false);
|
|
form.reset();
|
|
}}
|
|
className="bg-gray-600 px-5 py-5 hover:bg-gray-700 text-white mt-4 cursor-pointer"
|
|
>
|
|
{t("Bekor qilish")}
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
className="bg-blue-600 px-5 py-5 hover:bg-blue-700 text-white mt-4 cursor-pointer"
|
|
>
|
|
{isPending || editPending ? (
|
|
<Loader2 className="animate-spin" />
|
|
) : (
|
|
t("Qo'shish")
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Form>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<Dialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
|
<DialogContent className="sm:max-w-[400px]">
|
|
<DialogHeader>
|
|
<DialogTitle>{t("Haqiqatan ham o'chirmoqchimisiz?")}</DialogTitle>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
|
{t("Bekor qilish")}
|
|
</Button>
|
|
<Button variant="destructive" onClick={handleDelete}>
|
|
{deletePending ? (
|
|
<Loader2 className="animate-spin" />
|
|
) : (
|
|
t("O'chirish")
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default Faq;
|