595 lines
19 KiB
TypeScript
595 lines
19 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
bannerDelete,
|
||
createBanner,
|
||
getBanner,
|
||
getBannerDetail,
|
||
updateBanner,
|
||
} from "@/pages/site-banner/lib/api";
|
||
import TicketsImagesModel from "@/pages/tours/ui/TicketsImagesModel";
|
||
import { Badge } from "@/shared/ui/badge";
|
||
import { Button } from "@/shared/ui/button";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@/shared/ui/dialog";
|
||
import {
|
||
Form,
|
||
FormControl,
|
||
FormField,
|
||
FormItem,
|
||
FormLabel,
|
||
FormMessage,
|
||
} from "@/shared/ui/form";
|
||
import { Input } from "@/shared/ui/input";
|
||
import {
|
||
Select,
|
||
SelectContent,
|
||
SelectItem,
|
||
SelectTrigger,
|
||
SelectValue,
|
||
} from "@/shared/ui/select";
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from "@/shared/ui/table";
|
||
import { Textarea } from "@/shared/ui/textarea";
|
||
import { zodResolver } from "@hookform/resolvers/zod";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
AlertTriangle,
|
||
ChevronLeft,
|
||
ChevronRight,
|
||
Edit2,
|
||
Loader2,
|
||
Plus,
|
||
Trash2,
|
||
} from "lucide-react";
|
||
import { useEffect, useState } from "react";
|
||
import { useForm } from "react-hook-form";
|
||
import { useTranslation } from "react-i18next";
|
||
import { useSearchParams } from "react-router-dom";
|
||
import { toast } from "sonner";
|
||
import { z } from "zod";
|
||
|
||
const fileSchema = z.union([
|
||
z.instanceof(File, { message: "Rasm faylini yuklang" }),
|
||
z.string().min(1, { message: "Rasm faylini yuklang" }),
|
||
]);
|
||
|
||
const bannerSchema = z.object({
|
||
title: z
|
||
.string()
|
||
.min(3, "Sarlavha kamida 3 ta belgidan iborat bo‘lishi kerak"),
|
||
title_ru: z
|
||
.string()
|
||
.min(3, "Sarlavha kamida 3 ta belgidan iborat bo‘lishi kerak"),
|
||
description: z
|
||
.string()
|
||
.min(5, "Tavsif kamida 5 ta belgidan iborat bo‘lishi kerak"),
|
||
description_ru: z
|
||
.string()
|
||
.min(5, "Tavsif kamida 5 ta belgidan iborat bo‘lishi kerak"),
|
||
image: fileSchema,
|
||
link: z.string().url("Yaroqli havola URL manzili kiriting"),
|
||
position: z.string().min(1, "Pozitsiyani tanlang"),
|
||
});
|
||
|
||
type BannerFormData = z.infer<typeof bannerSchema>;
|
||
|
||
const positions = [
|
||
{ value: "banner1", label: "Asosiy" },
|
||
{ value: "banner2", label: "Kun taklifi" },
|
||
{ value: "banner3", label: "Mashhur yo‘nalishlar" },
|
||
{ value: "banner4", label: "Reytingi baland turlar" },
|
||
];
|
||
|
||
const SiteBannerAdmin = () => {
|
||
const [searchParams, setSearchParams] = useSearchParams();
|
||
|
||
const initialPage = Number(searchParams.get("page")) || 1;
|
||
const [page, setPage] = useState(initialPage);
|
||
|
||
const updatePage = (newPage: number) => {
|
||
setPage(newPage);
|
||
setSearchParams({ page: newPage.toString() });
|
||
};
|
||
|
||
const {
|
||
data: banner,
|
||
isLoading,
|
||
isError,
|
||
refetch,
|
||
} = useQuery({
|
||
queryKey: ["all_banner", page],
|
||
queryFn: () => getBanner({ page, page_size: 20 }),
|
||
select(data) {
|
||
return data.data.data;
|
||
},
|
||
});
|
||
const { t } = useTranslation();
|
||
const queryClient = useQueryClient();
|
||
const [open, setOpen] = useState(false);
|
||
const [editingBanner, setEditingBanner] = useState<number | null>(null);
|
||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||
|
||
const form = useForm<BannerFormData>({
|
||
resolver: zodResolver(bannerSchema),
|
||
defaultValues: {
|
||
title: "",
|
||
description: "",
|
||
image: "",
|
||
link: "",
|
||
position: "banner1",
|
||
},
|
||
});
|
||
|
||
const { data: bannerDetail } = useQuery({
|
||
queryKey: ["detail_banner", editingBanner],
|
||
queryFn: () => getBannerDetail(editingBanner!),
|
||
select(data) {
|
||
return data.data.data;
|
||
},
|
||
enabled: !!editingBanner,
|
||
});
|
||
|
||
useEffect(() => {
|
||
if (editingBanner && bannerDetail) {
|
||
form.setValue("title", bannerDetail.title_uz);
|
||
form.setValue("title_ru", bannerDetail.title_ru);
|
||
form.setValue("description", bannerDetail.description_uz);
|
||
form.setValue("description_ru", bannerDetail.description_ru);
|
||
form.setValue("image", bannerDetail.image);
|
||
form.setValue("link", bannerDetail.link);
|
||
form.setValue("position", bannerDetail.position);
|
||
}
|
||
}, [bannerDetail, editingBanner]);
|
||
|
||
const { mutate: create, isPending } = useMutation({
|
||
mutationFn: (body: FormData) => createBanner(body),
|
||
onSuccess: () => {
|
||
queryClient.refetchQueries({ queryKey: ["all_banner"] });
|
||
queryClient.refetchQueries({ queryKey: ["detail_banner"] });
|
||
setOpen(false);
|
||
form.reset();
|
||
},
|
||
onError: () => {
|
||
toast.error(t("Xatolik yuz berdi"), {
|
||
richColors: true,
|
||
position: "top-center",
|
||
});
|
||
},
|
||
});
|
||
|
||
const { mutate: update, isPending: updatePending } = useMutation({
|
||
mutationFn: ({ body, id }: { id: number; body: FormData }) =>
|
||
updateBanner({ body, id }),
|
||
onSuccess: () => {
|
||
queryClient.refetchQueries({ queryKey: ["all_banner"] });
|
||
queryClient.refetchQueries({ queryKey: ["detail_banner"] });
|
||
setOpen(false);
|
||
form.reset();
|
||
},
|
||
onError: () => {
|
||
toast.error(t("Xatolik yuz berdi"), {
|
||
richColors: true,
|
||
position: "top-center",
|
||
});
|
||
},
|
||
});
|
||
|
||
const { mutate: deletBanner, isPending: deletePending } = useMutation({
|
||
mutationFn: ({ id }: { id: number }) => bannerDelete(id),
|
||
onSuccess: () => {
|
||
queryClient.refetchQueries({ queryKey: ["all_banner"] });
|
||
queryClient.refetchQueries({ queryKey: ["detail_banner"] });
|
||
},
|
||
onError: () => {
|
||
toast.error(t("Xatolik yuz berdi"), {
|
||
richColors: true,
|
||
position: "top-center",
|
||
});
|
||
},
|
||
});
|
||
|
||
const handleOpen = (banner: number) => {
|
||
setOpen(true);
|
||
setEditingBanner(banner);
|
||
};
|
||
|
||
const onSubmit = (values: BannerFormData) => {
|
||
const formData = new FormData();
|
||
formData.append("title", values.title);
|
||
formData.append("title_ru", values.title_ru);
|
||
formData.append("description", values.description);
|
||
formData.append("description_ru", values.description_ru);
|
||
if (values.image instanceof File) {
|
||
formData.append("image", values.image);
|
||
}
|
||
formData.append("link", values.link);
|
||
formData.append("position", values.position);
|
||
|
||
if (editingBanner) {
|
||
update({
|
||
body: formData,
|
||
id: editingBanner,
|
||
});
|
||
} else {
|
||
create(formData);
|
||
}
|
||
};
|
||
|
||
const handleDelete = (id: number) => {
|
||
deletBanner({ id });
|
||
setDeleteId(id);
|
||
};
|
||
|
||
if (isLoading) {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white gap-4 w-full">
|
||
<Loader2 className="w-10 h-10 animate-spin text-cyan-400" />
|
||
<p className="text-slate-400">{t("Ma'lumotlar yuklanmoqda...")}</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (isError) {
|
||
return (
|
||
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 w-full text-center text-white gap-4">
|
||
<AlertTriangle className="w-10 h-10 text-red-500" />
|
||
<p className="text-lg">
|
||
{t("Ma'lumotlarni yuklashda xatolik yuz berdi.")}
|
||
</p>
|
||
<Button
|
||
onClick={() => refetch()}
|
||
className="bg-gradient-to-r from-blue-600 to-cyan-600 text-white rounded-lg px-5 py-2 hover:opacity-90"
|
||
>
|
||
{t("Qayta urinish")}
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<div className="min-h-screen w-full bg-gray-900 text-white p-8">
|
||
<div className="max-w-full mx-auto">
|
||
<div className="flex items-center justify-between mb-6">
|
||
<div>
|
||
<h1 className="text-3xl font-bold text-white">
|
||
{t("Sayt Bannerlari")}
|
||
</h1>
|
||
<p className="text-gray-400 mt-2">{t("Bannerlarni boshqarish")}</p>
|
||
</div>
|
||
<Button
|
||
className="gap-2 bg-blue-600 hover:bg-blue-700 text-white"
|
||
onClick={() => {
|
||
setOpen(true);
|
||
setEditingBanner(null);
|
||
form.reset();
|
||
}}
|
||
>
|
||
<Plus size={18} /> {t("Qo'shish")}
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="bg-gray-800 rounded-lg shadow-md border border-gray-700">
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow className="border-b border-gray-700 bg-gray-800/60">
|
||
<TableHead className="text-gray-300 font-medium">ID</TableHead>
|
||
<TableHead className="text-gray-300 font-medium">
|
||
{t("Rasm")}
|
||
</TableHead>
|
||
<TableHead className="text-gray-300 font-medium">
|
||
{t("Sarlavha")}
|
||
</TableHead>
|
||
<TableHead className="text-gray-300 font-medium">
|
||
{t("Tavsif")}
|
||
</TableHead>
|
||
<TableHead className="text-gray-300 font-medium">
|
||
{t("Joylashuvi")}
|
||
</TableHead>
|
||
<TableHead className="text-gray-300 font-medium text-right">
|
||
{t("Amallar")}
|
||
</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
|
||
<TableBody>
|
||
{banner && banner.results.length === 0 ? (
|
||
<TableRow>
|
||
<TableCell
|
||
colSpan={6}
|
||
className="py-10 text-center text-gray-400"
|
||
>
|
||
<div className="flex flex-col items-center justify-center gap-3">
|
||
<AlertTriangle className="w-8 h-8 text-yellow-500" />
|
||
<p className="text-lg">
|
||
{t("Hozircha bannerlar mavjud emas")}
|
||
</p>
|
||
<Button
|
||
onClick={() => setOpen(true)}
|
||
className="bg-blue-600 hover:bg-blue-700 text-white mt-2"
|
||
>
|
||
<Plus size={16} className="mr-2" />
|
||
{t("Qo'shish")}
|
||
</Button>
|
||
</div>
|
||
</TableCell>
|
||
</TableRow>
|
||
) : (
|
||
banner &&
|
||
banner.results.map((item) => (
|
||
<TableRow
|
||
key={item.id}
|
||
className="border-b border-gray-700 hover:bg-gray-700/30 transition-colors"
|
||
>
|
||
<TableCell className="text-gray-300">{item.id}</TableCell>
|
||
|
||
<TableCell>
|
||
<img
|
||
src={item.image}
|
||
alt={item.title}
|
||
className="w-24 h-16 object-cover rounded-md border border-gray-600"
|
||
/>
|
||
</TableCell>
|
||
|
||
<TableCell className="font-medium text-white">
|
||
{item.title}
|
||
<div className="text-blue-400 text-sm truncate max-w-[180px]">
|
||
<a href={item.link} target="_blank" rel="noreferrer">
|
||
{item.link}
|
||
</a>
|
||
</div>
|
||
</TableCell>
|
||
|
||
<TableCell className="text-gray-300 truncate max-w-xs">
|
||
{item.description}
|
||
</TableCell>
|
||
|
||
<TableCell>
|
||
<Badge
|
||
variant="outline"
|
||
className="bg-blue-900 text-blue-300"
|
||
>
|
||
{t(
|
||
positions.find((p) => p.value === item.position)
|
||
?.label ?? "",
|
||
)}
|
||
</Badge>
|
||
</TableCell>
|
||
|
||
<TableCell className="text-right flex justify-end items-center gap-2">
|
||
<Button
|
||
variant="outline"
|
||
size="icon"
|
||
className="border-gray-600 text-blue-400 hover:text-blue-200"
|
||
onClick={() => handleOpen(item.id)}
|
||
>
|
||
<Edit2 size={16} />
|
||
</Button>
|
||
<Button
|
||
variant="destructive"
|
||
size="icon"
|
||
onClick={() => handleDelete(item.id)}
|
||
>
|
||
{deleteId === item.id && deletePending ? (
|
||
<Loader2 className="animate-spin" size={16} />
|
||
) : (
|
||
<Trash2 size={16} />
|
||
)}
|
||
</Button>
|
||
</TableCell>
|
||
</TableRow>
|
||
))
|
||
)}
|
||
</TableBody>
|
||
</Table>
|
||
</div>
|
||
|
||
<div className="flex justify-end mt-10 gap-3">
|
||
<button
|
||
disabled={page === 1}
|
||
onClick={() => updatePage(Math.max(page - 1, 1))}
|
||
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50"
|
||
>
|
||
<ChevronLeft className="w-5 h-5" />
|
||
</button>
|
||
|
||
{[...Array(banner?.total_pages)].map((_, i) => {
|
||
const pageNum = i + 1;
|
||
return (
|
||
<button
|
||
key={i}
|
||
onClick={() => updatePage(pageNum)}
|
||
className={`px-4 py-2 rounded-lg border font-medium transition-all ${
|
||
page === pageNum
|
||
? "bg-gradient-to-r from-blue-600 to-cyan-600 text-white border-blue-500"
|
||
: "border-slate-600 text-slate-300 hover:bg-slate-700/50"
|
||
}`}
|
||
>
|
||
{pageNum}
|
||
</button>
|
||
);
|
||
})}
|
||
|
||
<button
|
||
disabled={page === banner?.total_pages}
|
||
onClick={() =>
|
||
updatePage(Math.min(page + 1, banner?.total_pages ?? 1))
|
||
}
|
||
className="p-2 rounded-lg border border-slate-600 text-slate-300 hover:bg-slate-700/50 disabled:opacity-50"
|
||
>
|
||
<ChevronRight className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
<Dialog open={open} onOpenChange={setOpen}>
|
||
<DialogContent className="sm:max-w-[600px] max-h-[80vh] overflow-y-scroll bg-gray-800 border border-gray-700 text-white">
|
||
<DialogHeader>
|
||
<DialogTitle>
|
||
{editingBanner
|
||
? t("Bannerni tahrirlash")
|
||
: t("Yangi banner qo'shish")}
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
|
||
<Form {...form}>
|
||
<form
|
||
onSubmit={form.handleSubmit(onSubmit)}
|
||
className="space-y-4"
|
||
>
|
||
<FormField
|
||
control={form.control}
|
||
name="title"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>{t("Sarlavha")}</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
className="bg-gray-700 border-gray-600 text-white"
|
||
{...field}
|
||
/>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="title_ru"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>{t("Sarlavha")} (ru)</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
className="bg-gray-700 border-gray-600 text-white"
|
||
{...field}
|
||
/>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="description"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>{t("Tavsif")}</FormLabel>
|
||
<FormControl>
|
||
<Textarea
|
||
className="bg-gray-700 border-gray-600 text-white"
|
||
rows={3}
|
||
{...field}
|
||
/>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="description_ru"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>{t("Tavsif")} (ru)</FormLabel>
|
||
<FormControl>
|
||
<Textarea
|
||
className="bg-gray-700 border-gray-600 text-white"
|
||
rows={3}
|
||
{...field}
|
||
/>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<TicketsImagesModel
|
||
form={form}
|
||
name="image"
|
||
multiple={false}
|
||
label={t("Banner")}
|
||
imageUrl={bannerDetail?.image}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="link"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>{t("Havola URL")}</FormLabel>
|
||
<FormControl>
|
||
<Input
|
||
className="bg-gray-700 border-gray-600 text-white"
|
||
{...field}
|
||
/>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<FormField
|
||
control={form.control}
|
||
name="position"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>{t("Joylashuvi")}</FormLabel>
|
||
<Select
|
||
onValueChange={field.onChange}
|
||
value={field.value}
|
||
>
|
||
<FormControl>
|
||
<SelectTrigger className="w-full bg-gray-700 border-gray-600 text-white">
|
||
<SelectValue placeholder="Pozitsiyani tanlang" />
|
||
</SelectTrigger>
|
||
</FormControl>
|
||
<SelectContent className="bg-gray-800 text-white border-gray-700">
|
||
{positions.map((pos) => (
|
||
<SelectItem key={pos.value} value={pos.value}>
|
||
{t(pos.label)}
|
||
</SelectItem>
|
||
))}
|
||
</SelectContent>
|
||
</Select>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
|
||
<DialogFooter>
|
||
<Button
|
||
type="submit"
|
||
className="w-full bg-blue-600 hover:bg-blue-700 text-white"
|
||
>
|
||
{isPending || updatePending ? (
|
||
<Loader2 className="animate-spin" />
|
||
) : (
|
||
<>{editingBanner ? "Saqlash" : "Qo‘shish"}</>
|
||
)}
|
||
</Button>
|
||
</DialogFooter>
|
||
</form>
|
||
</Form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default SiteBannerAdmin;
|