api ulandi
This commit is contained in:
@@ -1,4 +1,9 @@
|
||||
import type { Faq, FaqCategory, FaqCategoryDetail } from "@/pages/faq/lib/type";
|
||||
import type {
|
||||
Faq,
|
||||
FaqCategory,
|
||||
FaqCategoryDetail,
|
||||
FaqOne,
|
||||
} from "@/pages/faq/lib/type";
|
||||
import httpClient from "@/shared/config/api/httpClient";
|
||||
import { FAQ, FAQ_CATEGORIES } from "@/shared/config/api/URLs";
|
||||
import type { AxiosResponse } from "axios";
|
||||
@@ -6,11 +11,50 @@ import type { AxiosResponse } from "axios";
|
||||
const getAllFaq = async (params: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
category: number;
|
||||
}): Promise<AxiosResponse<Faq>> => {
|
||||
const res = await httpClient.get(FAQ, { params });
|
||||
return res;
|
||||
};
|
||||
|
||||
const getOneFaq = async (id: number): Promise<AxiosResponse<FaqOne>> => {
|
||||
const res = await httpClient.get(`${FAQ}${id}/`);
|
||||
return res;
|
||||
};
|
||||
|
||||
const createFaq = async (body: {
|
||||
title: string;
|
||||
title_ru: string;
|
||||
text: string;
|
||||
text_ru: string;
|
||||
category: number;
|
||||
}) => {
|
||||
const res = await httpClient.post(FAQ, body);
|
||||
return res;
|
||||
};
|
||||
|
||||
const updateFaq = async ({
|
||||
body,
|
||||
id,
|
||||
}: {
|
||||
id: number;
|
||||
body: {
|
||||
title: string;
|
||||
title_ru: string;
|
||||
text: string;
|
||||
text_ru: string;
|
||||
category?: number;
|
||||
};
|
||||
}) => {
|
||||
const res = await httpClient.patch(`${FAQ}${id}/`, body);
|
||||
return res;
|
||||
};
|
||||
|
||||
const deleteFaq = async ({ id }: { id: number }) => {
|
||||
const res = await httpClient.delete(`${FAQ}${id}/`);
|
||||
return res;
|
||||
};
|
||||
|
||||
const getAllFaqCategory = async (params: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
@@ -48,10 +92,14 @@ const deleteFaqCategory = async (id: number) => {
|
||||
};
|
||||
|
||||
export {
|
||||
createFaq,
|
||||
createFaqCategory,
|
||||
deleteFaq,
|
||||
deleteFaqCategory,
|
||||
getAllFaq,
|
||||
getAllFaqCategory,
|
||||
getDetailFaqCategory,
|
||||
getOneFaq,
|
||||
updateFaq,
|
||||
updateFaqCategory,
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface FaqCategoryDetail {
|
||||
data: {
|
||||
id: number;
|
||||
name: string;
|
||||
name_uz: string;
|
||||
name_ru: string;
|
||||
};
|
||||
}
|
||||
@@ -48,3 +49,16 @@ export interface Faq {
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface FaqOne {
|
||||
status: boolean;
|
||||
data: {
|
||||
id: number;
|
||||
title: string;
|
||||
title_uz: string;
|
||||
title_ru: string;
|
||||
text: string;
|
||||
text_ru: string;
|
||||
text_uz: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { getAllFaq, getAllFaqCategory } from "@/pages/faq/lib/api";
|
||||
import {
|
||||
createFaq,
|
||||
deleteFaq,
|
||||
getAllFaq,
|
||||
getAllFaqCategory,
|
||||
getOneFaq,
|
||||
updateFaq,
|
||||
} from "@/pages/faq/lib/api";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -16,16 +23,9 @@ import {
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -37,70 +37,231 @@ import {
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Pencil, PlusCircle, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
useInfiniteQuery,
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
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 { t } = useTranslation();
|
||||
const { data: category } = useQuery({
|
||||
queryKey: ["all_faqcategory"],
|
||||
queryFn: () => {
|
||||
return getAllFaqCategory({ page: 1, page_size: 99 });
|
||||
},
|
||||
select(data) {
|
||||
return data.data.data.results;
|
||||
},
|
||||
});
|
||||
const { data: faq } = useQuery({
|
||||
queryKey: ["all_faq"],
|
||||
queryFn: () => {
|
||||
return getAllFaq({ page: 1, page_size: 99 });
|
||||
},
|
||||
select(data) {
|
||||
return data.data.data.results;
|
||||
},
|
||||
});
|
||||
const [activeTab, setActiveTab] = useState<string>("");
|
||||
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,
|
||||
});
|
||||
|
||||
// Barcha kategoriyalarni birlashtirib olish
|
||||
const category =
|
||||
categoryData?.pages.flatMap((page) => page.data.data.results) ?? [];
|
||||
|
||||
const { data: faq } = useQuery({
|
||||
queryKey: ["all_faq", activeTab],
|
||||
queryFn: () => {
|
||||
return getAllFaq({ page: 1, page_size: 10, category: Number(activeTab) });
|
||||
},
|
||||
select(data) {
|
||||
return data.data.data.results;
|
||||
},
|
||||
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) {
|
||||
if (category.length > 0 && !activeTab) {
|
||||
setActiveTab(String(category[0].id));
|
||||
}
|
||||
}, [category]);
|
||||
}, [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 [editFaq, setEditFaq] = useState<any | null>(null);
|
||||
const [openModal, setOpenModal] = useState(false);
|
||||
|
||||
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>) {
|
||||
console.log(value);
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -125,22 +286,42 @@ const Faq = () => {
|
||||
setOpenModal(true);
|
||||
}}
|
||||
>
|
||||
<PlusCircle className="w-4 h-4" /> {t("Yangi qo‘shish")}
|
||||
<PlusCircle className="w-4 h-4" /> {t("Yangi qo'shish")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="flex flex-wrap gap-2 mb-4">
|
||||
{category?.map((cat) => (
|
||||
<TabsTrigger key={cat.id} value={String(cat.id)}>
|
||||
{cat.name}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
<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) => (
|
||||
{category.map((cat) => (
|
||||
<TabsContent key={cat.id} value={String(cat.id)}>
|
||||
{faq && faq?.length > 0 ? (
|
||||
<div className="border rounded-md overflow-hidden shadow-sm">
|
||||
@@ -192,7 +373,7 @@ const Faq = () => {
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-gray-500 text-sm mt-4 text-center">
|
||||
{t("Bu bo‘limda savollar yo‘q.")}
|
||||
{t("Bu bo'limda savollar yo'q")}
|
||||
</p>
|
||||
)}
|
||||
</TabsContent>
|
||||
@@ -200,10 +381,10 @@ const Faq = () => {
|
||||
</Tabs>
|
||||
|
||||
<Dialog open={openModal} onOpenChange={setOpenModal}>
|
||||
<DialogContent className="sm:max-w-[500px] bg-gray-900">
|
||||
<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")}
|
||||
{editFaq ? t("FAQni tahrirlash") : t("Yangi FAQ qo'shish")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -216,24 +397,37 @@ const Faq = () => {
|
||||
<FormItem>
|
||||
<Label className="text-md">{t("Kategoriya")}</Label>
|
||||
<FormControl>
|
||||
<Select
|
||||
onValueChange={field.onChange}
|
||||
<InfiniteScrollSelect
|
||||
value={field.value}
|
||||
>
|
||||
<SelectTrigger className="w-full !h-12 border-gray-700 text-white">
|
||||
<SelectValue placeholder={t("Kategoriya tanlang")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="border-gray-700 text-white">
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t("Kategoriyalar")}</SelectLabel>
|
||||
{/* {categories.map((cat) => (
|
||||
<SelectItem key={cat.value} value={cat.value}>
|
||||
{cat.label}
|
||||
</SelectItem>
|
||||
))} */}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
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>
|
||||
@@ -241,10 +435,10 @@ const Faq = () => {
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
name="title_ru"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label className="text-md">{t("Savol")}</Label>
|
||||
<Label className="text-md">{t("Savol")} (ru)</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder={t("Savol")}
|
||||
@@ -273,6 +467,23 @@ const Faq = () => {
|
||||
</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"
|
||||
@@ -288,7 +499,11 @@ const Faq = () => {
|
||||
type="submit"
|
||||
className="bg-blue-600 px-5 py-5 hover:bg-blue-700 text-white mt-4 cursor-pointer"
|
||||
>
|
||||
{t("Qo'shish")}
|
||||
{isPending || editPending ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("Qo'shish")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -299,14 +514,18 @@ const Faq = () => {
|
||||
<Dialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("Haqiqatan ham o‘chirmoqchimisiz?")}</DialogTitle>
|
||||
<DialogTitle>{t("Haqiqatan ham o'chirmoqchimisiz?")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||
{t("Bekor qilish")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
{t("O'chirish")}
|
||||
{deletePending ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("O'chirish")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -34,7 +34,14 @@ import {
|
||||
} from "@/shared/ui/table";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Loader, Pencil, PlusCircle, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Loader,
|
||||
Pencil,
|
||||
PlusCircle,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -42,21 +49,22 @@ import { toast } from "sonner";
|
||||
import z from "zod";
|
||||
|
||||
const categoryFormSchema = z.object({
|
||||
name: z.string().min(1, { message: "Kategoriya nomi majburiy" }),
|
||||
name_ru: z.string().min(1, { message: "Kategoriya nomi majburiy" }),
|
||||
name: z.string().min(1, { message: "Majburiy maydon" }),
|
||||
name_ru: z.string().min(1, { message: "Majburiy maydon" }),
|
||||
});
|
||||
|
||||
const FaqCategory = () => {
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [categories, setCategories] = useState<number | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const { data: category } = useQuery({
|
||||
queryKey: ["all_faqcategory"],
|
||||
queryKey: ["all_faqcategory", currentPage],
|
||||
queryFn: () => {
|
||||
return getAllFaqCategory({ page: 1, page_size: 99 });
|
||||
return getAllFaqCategory({ page: currentPage, page_size: 10 });
|
||||
},
|
||||
select(data) {
|
||||
return data.data.data.results;
|
||||
return data.data.data;
|
||||
},
|
||||
});
|
||||
const { data: oneCategory } = useQuery({
|
||||
@@ -135,7 +143,7 @@ const FaqCategory = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (oneCategory && categories) {
|
||||
form.setValue("name", oneCategory.name);
|
||||
form.setValue("name", oneCategory.name_uz);
|
||||
form.setValue("name_ru", oneCategory.name_ru);
|
||||
}
|
||||
}, [oneCategory, categories]);
|
||||
@@ -186,7 +194,7 @@ const FaqCategory = () => {
|
||||
setOpenModal(true);
|
||||
}}
|
||||
>
|
||||
<PlusCircle className="w-4 h-4" /> {t("Yangi kategoriya")}
|
||||
<PlusCircle className="w-4 h-4" /> {t("Qo'shish")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -196,15 +204,17 @@ const FaqCategory = () => {
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[50px] text-center">#</TableHead>
|
||||
<TableHead>Kategoriya nomi</TableHead>
|
||||
<TableHead>{t("Kategoriya nomi")}</TableHead>
|
||||
{/* <TableHead className="text-center">Savollar soni</TableHead> */}
|
||||
<TableHead className="w-[120px] text-center">Amallar</TableHead>
|
||||
<TableHead className="w-[120px] text-center">
|
||||
{t("Amallar")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
|
||||
<TableBody>
|
||||
{category && category.length > 0 ? (
|
||||
category.map((cat, index) => (
|
||||
{category && category.results.length > 0 ? (
|
||||
category.results.map((cat, index) => (
|
||||
<TableRow key={cat.id}>
|
||||
<TableCell className="text-center font-medium">
|
||||
{index + 1}
|
||||
@@ -245,12 +255,48 @@ const FaqCategory = () => {
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<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(category?.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 === category?.total_pages}
|
||||
onClick={() =>
|
||||
setCurrentPage((p) =>
|
||||
Math.min(p + 1, category ? category.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>
|
||||
|
||||
{/* Modal */}
|
||||
<Dialog open={openModal} onOpenChange={setOpenModal}>
|
||||
<DialogContent className="sm:max-w-[400px] bg-gray-900">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{categories ? "Kategoriyani tahrirlash" : "Yangi kategoriya"}
|
||||
{categories ? t("Tahrirlash") : t("Qo'shish")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -261,10 +307,10 @@ const FaqCategory = () => {
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Kategoriya nomi</Label>
|
||||
<Label className="text-md">{t("Kategoriya nomi")}</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Masalan: umumiy"
|
||||
placeholder={t("Kategoriya nomi")}
|
||||
{...field}
|
||||
className="h-12 bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
@@ -279,10 +325,12 @@ const FaqCategory = () => {
|
||||
name="name_ru"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<Label className="text-md">Kategoriya nomi (ru)</Label>
|
||||
<Label className="text-md">
|
||||
{t("Kategoriya nomi")} (ru)
|
||||
</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Masalan: umumiy"
|
||||
placeholder={t("Kategoriya nomi") + " (ru)"}
|
||||
{...field}
|
||||
className="h-12 bg-gray-800 border-gray-700 text-white"
|
||||
/>
|
||||
@@ -298,7 +346,7 @@ const FaqCategory = () => {
|
||||
onClick={() => setOpenModal(false)}
|
||||
className="bg-gray-600 hover:bg-gray-700 text-white"
|
||||
>
|
||||
Bekor qilish
|
||||
{t("Bekor qilish")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
@@ -307,9 +355,9 @@ const FaqCategory = () => {
|
||||
{createPending || updatePending ? (
|
||||
<Loader className="animate-spin" />
|
||||
) : categories ? (
|
||||
"Saqlash"
|
||||
t("Saqlash")
|
||||
) : (
|
||||
"Qo‘shish"
|
||||
t("Qo‘shish")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -322,17 +370,17 @@ const FaqCategory = () => {
|
||||
<Dialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
|
||||
<DialogContent className="sm:max-w-[400px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Haqiqatan ham o‘chirmoqchimisiz?</DialogTitle>
|
||||
<DialogTitle>{t("Haqiqatan ham o‘chirmoqchimisiz?")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteId(null)}>
|
||||
Bekor qilish
|
||||
{t("Bekor qilish")}
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
{deletePending ? (
|
||||
<Loader className="animate-spin" />
|
||||
) : (
|
||||
t("O‘chirish")
|
||||
t("O'chirish")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
42
src/pages/finance/lib/api.ts
Normal file
42
src/pages/finance/lib/api.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type {
|
||||
UserOrderData,
|
||||
UserOrderDetailData,
|
||||
} from "@/pages/finance/lib/type";
|
||||
import httpClient from "@/shared/config/api/httpClient";
|
||||
import { USER_ORDERS } from "@/shared/config/api/URLs";
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
const getAllOrder = async (params: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
}): Promise<AxiosResponse<UserOrderData>> => {
|
||||
const res = await httpClient.get(USER_ORDERS, { params });
|
||||
return res;
|
||||
};
|
||||
|
||||
const getDetailOrder = async (
|
||||
id: number,
|
||||
): Promise<AxiosResponse<UserOrderDetailData>> => {
|
||||
const res = await httpClient.get(`${USER_ORDERS}${id}/`);
|
||||
return res;
|
||||
};
|
||||
|
||||
const updateDetailOrder = async ({
|
||||
id,
|
||||
body,
|
||||
}: {
|
||||
id: number;
|
||||
body: {
|
||||
order_status:
|
||||
| "pending_payment"
|
||||
| "pending_confirmation"
|
||||
| "cancelled"
|
||||
| "confirmed"
|
||||
| "completed";
|
||||
};
|
||||
}) => {
|
||||
const res = await httpClient.patch(`${USER_ORDERS}${id}/`, body);
|
||||
return res;
|
||||
};
|
||||
|
||||
export { getAllOrder, getDetailOrder, updateDetailOrder };
|
||||
106
src/pages/finance/lib/type.ts
Normal file
106
src/pages/finance/lib/type.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
export interface UserOrderData {
|
||||
status: boolean;
|
||||
data: {
|
||||
links: {
|
||||
previous: string;
|
||||
next: string;
|
||||
};
|
||||
total_items: number;
|
||||
total_pages: number;
|
||||
page_size: number;
|
||||
current_page: number;
|
||||
results: {
|
||||
orders: {
|
||||
id: number;
|
||||
user: {
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
contact: string;
|
||||
};
|
||||
destination: string;
|
||||
total_price: number;
|
||||
order_status:
|
||||
| "pending_payment"
|
||||
| "pending_confirmation"
|
||||
| "cancelled"
|
||||
| "confirmed"
|
||||
| "completed";
|
||||
tour_name: string;
|
||||
}[];
|
||||
total_income: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface OrderStatus {
|
||||
order_status:
|
||||
| "pending_payment"
|
||||
| "pending_confirmation"
|
||||
| "cancelled"
|
||||
| "confirmed"
|
||||
| "completed";
|
||||
}
|
||||
|
||||
export interface UserOrderDetailData {
|
||||
status: boolean;
|
||||
data: {
|
||||
id: number;
|
||||
user: {
|
||||
id: number;
|
||||
last_login: string;
|
||||
is_superuser: true;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
is_staff: true;
|
||||
is_active: true;
|
||||
date_joined: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
username: string;
|
||||
avatar: string;
|
||||
validated_at: string;
|
||||
role: string;
|
||||
total_spent: number;
|
||||
travel_agency: number;
|
||||
};
|
||||
departure: string;
|
||||
destination: string;
|
||||
departure_date: string;
|
||||
arrival_time: string;
|
||||
participant: [
|
||||
{
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
birth_date: string;
|
||||
phone_number: string;
|
||||
gender: "male" | "female";
|
||||
participant_pasport_image: [
|
||||
{
|
||||
image: string;
|
||||
},
|
||||
];
|
||||
},
|
||||
];
|
||||
ticket: number;
|
||||
tariff: string;
|
||||
transport: string;
|
||||
extra_service: [
|
||||
{
|
||||
name: string;
|
||||
},
|
||||
];
|
||||
extra_paid_service: [
|
||||
{
|
||||
name: string;
|
||||
price: number;
|
||||
},
|
||||
];
|
||||
order_status:
|
||||
| "pending_payment"
|
||||
| "pending_confirmation"
|
||||
| "cancelled"
|
||||
| "confirmed"
|
||||
| "completed";
|
||||
total_price: number;
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { getAllOrder } from "@/pages/finance/lib/api";
|
||||
import type { OrderStatus } from "@/pages/finance/lib/type";
|
||||
import formatPhone from "@/shared/lib/formatPhone";
|
||||
import formatPrice from "@/shared/lib/formatPrice";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
AlertTriangle,
|
||||
CreditCard,
|
||||
DollarSign,
|
||||
Eye,
|
||||
Hotel,
|
||||
Loader2,
|
||||
MapPin,
|
||||
Plane,
|
||||
TrendingUp,
|
||||
@@ -15,94 +22,6 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
type Purchase = {
|
||||
id: number;
|
||||
userName: string;
|
||||
userPhone: string;
|
||||
tourName: string;
|
||||
tourId: number;
|
||||
agencyName: string;
|
||||
agencyId: number;
|
||||
destination: string;
|
||||
travelDate: string;
|
||||
amount: number;
|
||||
paymentStatus: "paid" | "pending" | "cancelled" | "refunded";
|
||||
purchaseDate: string;
|
||||
};
|
||||
|
||||
const mockPurchases: Purchase[] = [
|
||||
{
|
||||
id: 1,
|
||||
userName: "Aziza Karimova",
|
||||
userPhone: "+998 90 123 45 67",
|
||||
tourName: "Dubai Luxury Tour",
|
||||
tourId: 1,
|
||||
agencyName: "Silk Road Travel",
|
||||
agencyId: 1,
|
||||
destination: "Dubai, UAE",
|
||||
travelDate: "2025-11-10",
|
||||
amount: 1500000,
|
||||
paymentStatus: "paid",
|
||||
purchaseDate: "2025-10-10",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
userName: "Sardor Rahimov",
|
||||
userPhone: "+998 91 234 56 78",
|
||||
tourName: "Bali Adventure Package",
|
||||
tourId: 2,
|
||||
agencyName: "Silk Road Travel",
|
||||
agencyId: 1,
|
||||
destination: "Bali, Indonesia",
|
||||
travelDate: "2025-11-15",
|
||||
amount: 1800000,
|
||||
paymentStatus: "paid",
|
||||
purchaseDate: "2025-10-12",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
userName: "Nilufar Toshmatova",
|
||||
userPhone: "+998 93 345 67 89",
|
||||
tourName: "Dubai Luxury Tour",
|
||||
tourId: 1,
|
||||
agencyName: "Silk Road Travel",
|
||||
agencyId: 1,
|
||||
destination: "Dubai, UAE",
|
||||
travelDate: "2025-11-20",
|
||||
amount: 1500000,
|
||||
paymentStatus: "pending",
|
||||
purchaseDate: "2025-10-14",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
userName: "Jamshid Alimov",
|
||||
userPhone: "+998 94 456 78 90",
|
||||
tourName: "Istanbul Express Tour",
|
||||
tourId: 3,
|
||||
agencyName: "Orient Express",
|
||||
agencyId: 3,
|
||||
destination: "Istanbul, Turkey",
|
||||
travelDate: "2025-11-05",
|
||||
amount: 1200000,
|
||||
paymentStatus: "cancelled",
|
||||
purchaseDate: "2025-10-08",
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
userName: "Madina Yusupova",
|
||||
userPhone: "+998 97 567 89 01",
|
||||
tourName: "Paris Romantic Getaway",
|
||||
tourId: 4,
|
||||
agencyName: "Euro Travels",
|
||||
agencyId: 2,
|
||||
destination: "Paris, France",
|
||||
travelDate: "2025-12-01",
|
||||
amount: 2200000,
|
||||
paymentStatus: "paid",
|
||||
purchaseDate: "2025-10-16",
|
||||
},
|
||||
];
|
||||
|
||||
export default function FinancePage() {
|
||||
const { t } = useTranslation();
|
||||
const [tab, setTab] = useState<"bookings" | "agencies">("bookings");
|
||||
@@ -110,28 +29,56 @@ export default function FinancePage() {
|
||||
"all" | "paid" | "pending" | "cancelled" | "refunded"
|
||||
>("all");
|
||||
|
||||
const getStatusBadge = (status: Purchase["paymentStatus"]) => {
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["list_order_user"],
|
||||
queryFn: () => getAllOrder({ page: 1, page_size: 10 }),
|
||||
});
|
||||
|
||||
const getStatusBadge = (status: OrderStatus["order_status"]) => {
|
||||
const base =
|
||||
"px-3 py-1 rounded-full text-sm font-medium inline-flex items-center gap-2";
|
||||
|
||||
switch (status) {
|
||||
case "paid":
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-green-900 text-green-400 border border-green-700`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-green-400"></div>
|
||||
{t("Paid")}
|
||||
</span>
|
||||
);
|
||||
case "pending":
|
||||
case "pending_payment":
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-yellow-900 text-yellow-400 border border-yellow-700`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-400"></div>
|
||||
{t("Pending")}
|
||||
{t("Pending Payment")}
|
||||
</span>
|
||||
);
|
||||
|
||||
case "pending_confirmation":
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-orange-900 text-orange-400 border border-orange-700`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-orange-400"></div>
|
||||
{t("Pending Confirmation")}
|
||||
</span>
|
||||
);
|
||||
|
||||
case "confirmed":
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-blue-900 text-blue-400 border border-blue-700`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-blue-400"></div>
|
||||
{t("Confirmed")}
|
||||
</span>
|
||||
);
|
||||
|
||||
case "completed":
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-green-900 text-green-400 border border-green-700`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-green-400"></div>
|
||||
{t("Completed")}
|
||||
</span>
|
||||
);
|
||||
|
||||
case "cancelled":
|
||||
return (
|
||||
<span
|
||||
@@ -141,49 +88,37 @@ export default function FinancePage() {
|
||||
{t("Cancelled")}
|
||||
</span>
|
||||
);
|
||||
case "refunded":
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-blue-900 text-blue-400 border border-blue-700`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-blue-400"></div>
|
||||
Refunded
|
||||
</span>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredPurchases =
|
||||
filterStatus === "all"
|
||||
? mockPurchases
|
||||
: mockPurchases.filter((p) => p.paymentStatus === filterStatus);
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
const totalRevenue = filteredPurchases
|
||||
.filter((p) => p.paymentStatus === "paid")
|
||||
.reduce((sum, p) => sum + p.amount, 0);
|
||||
const pendingRevenue = filteredPurchases
|
||||
.filter((p) => p.paymentStatus === "pending")
|
||||
.reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
const agencies = Array.from(
|
||||
new Set(mockPurchases.map((p) => p.agencyId)),
|
||||
).map((id) => {
|
||||
const agencyPurchases = mockPurchases.filter((p) => p.agencyId === id);
|
||||
return {
|
||||
id,
|
||||
name: agencyPurchases[0].agencyName,
|
||||
totalPaid: agencyPurchases
|
||||
.filter((p) => p.paymentStatus === "paid")
|
||||
.reduce((sum, p) => sum + p.amount, 0),
|
||||
pending: agencyPurchases
|
||||
.filter((p) => p.paymentStatus === "pending")
|
||||
.reduce((sum, p) => sum + p.amount, 0),
|
||||
purchaseCount: agencyPurchases.length,
|
||||
destinations: Array.from(
|
||||
new Set(agencyPurchases.map((p) => p.destination)),
|
||||
),
|
||||
};
|
||||
});
|
||||
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-gray-100">
|
||||
@@ -275,7 +210,7 @@ export default function FinancePage() {
|
||||
<DollarSign className="text-green-400 w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-green-400 mt-3">
|
||||
{formatPrice(totalRevenue, true)}
|
||||
{/* {formatPrice(totalRevenue, true)} */}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{t("Yakunlangan bandlovlardan")}
|
||||
@@ -289,7 +224,7 @@ export default function FinancePage() {
|
||||
<TrendingUp className="text-yellow-400 w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-yellow-400 mt-3">
|
||||
{formatPrice(pendingRevenue, true)}
|
||||
{/* {formatPrice(pendingRevenue, true)} */}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{t("Tasdiqlash kutilmoqda")}
|
||||
@@ -303,10 +238,10 @@ export default function FinancePage() {
|
||||
<CreditCard className="text-blue-400 w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-blue-400 mt-3">
|
||||
{
|
||||
{/* {
|
||||
filteredPurchases.filter((p) => p.paymentStatus === "paid")
|
||||
.length
|
||||
}
|
||||
} */}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{t("Tasdiqlangan bandlovlar")}
|
||||
@@ -320,11 +255,11 @@ export default function FinancePage() {
|
||||
<Hotel className="text-purple-400 w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-purple-400 mt-3">
|
||||
{
|
||||
{/* {
|
||||
filteredPurchases.filter(
|
||||
(p) => p.paymentStatus === "pending",
|
||||
).length
|
||||
}
|
||||
} */}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{t("Kutilayotgan to‘lovlar")}
|
||||
@@ -335,46 +270,50 @@ export default function FinancePage() {
|
||||
{/* Booking Cards */}
|
||||
<h2 className="text-xl font-bold mb-4">{t("Oxirgi bandlovlar")}</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredPurchases.map((p) => (
|
||||
{data?.data.data.results.orders.map((p, index) => (
|
||||
<div
|
||||
key={p.id}
|
||||
key={index}
|
||||
className="bg-gray-800 p-5 rounded-xl shadow hover:shadow-md transition-all flex flex-col justify-between"
|
||||
>
|
||||
<div>
|
||||
<div className="flex justify-between items-start mb-3">
|
||||
<h2 className="text-lg font-bold text-gray-100">
|
||||
{p.userName}
|
||||
{p.user.first_name} {p.user.last_name}
|
||||
</h2>
|
||||
</div>
|
||||
<p className="text-gray-400 text-sm">{p.userPhone}</p>
|
||||
<p className="text-gray-400 text-sm">
|
||||
{p.user.contact.includes("gmail.com")
|
||||
? p.user.contact
|
||||
: formatPhone(p.user.contact)}
|
||||
</p>
|
||||
<p className="mt-3 font-semibold text-gray-200">
|
||||
{p.tourName}
|
||||
{p.tour_name}
|
||||
</p>
|
||||
<div className="flex items-center gap-1 mt-1 text-gray-400">
|
||||
<MapPin size={14} />
|
||||
<p className="text-sm">{p.destination}</p>
|
||||
</div>
|
||||
<div className="flex justify-between mt-3">
|
||||
<div>
|
||||
<div className="flex justify-between items-center mt-3">
|
||||
{/* <div>
|
||||
<p className="text-gray-500 text-sm">
|
||||
{t("Sayohat sanasi")}
|
||||
</p>
|
||||
<p className="text-gray-100 font-medium">
|
||||
{p.travelDate}
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
</div> */}
|
||||
<div className="text-start">
|
||||
<p className="text-gray-500 text-sm">{t("Miqdor")}</p>
|
||||
<p className="text-green-400 font-bold">
|
||||
{formatPrice(p.amount, true)}
|
||||
{formatPrice(p.total_price, true)}
|
||||
</p>
|
||||
</div>
|
||||
<div>{getStatusBadge(p.order_status)}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-between items-center">
|
||||
{getStatusBadge(p.paymentStatus)}
|
||||
<div className="mt-4 items-center">
|
||||
<Link to={`/bookings/${p.id}`}>
|
||||
<button className="bg-blue-600 text-white px-3 py-2 rounded-lg hover:bg-blue-700 flex items-center gap-1 transition-colors">
|
||||
<button className="bg-blue-600 text-white px-3 py-2 w-full justify-center cursor-pointer rounded-lg hover:bg-blue-700 flex items-center gap-1 transition-colors">
|
||||
<Eye className="w-4 h-4" /> {t("Ko'rish")}
|
||||
</button>
|
||||
</Link>
|
||||
@@ -385,7 +324,7 @@ export default function FinancePage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "agencies" && (
|
||||
{/* {tab === "agencies" && (
|
||||
<>
|
||||
<h2 className="text-xl font-bold mb-6">Partner Agencies</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
@@ -441,7 +380,7 @@ export default function FinancePage() {
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
)} */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
|
||||
type TourPurchase = {
|
||||
id: number;
|
||||
@@ -110,6 +110,14 @@ export default function FinanceDetailTour() {
|
||||
const [activeTab, setActiveTab] = useState<
|
||||
"overview" | "bookings" | "reviews"
|
||||
>("overview");
|
||||
const params = useParams();
|
||||
|
||||
console.log(params);
|
||||
|
||||
// const {} = useQuery({
|
||||
// queryKey: ["detail_order"],
|
||||
// queryFn: () => getDetailOrder()
|
||||
// })
|
||||
|
||||
const getStatusBadge = (status: TourPurchase["paymentStatus"]) => {
|
||||
const base =
|
||||
|
||||
@@ -1,7 +1,17 @@
|
||||
"use client";
|
||||
|
||||
import { getDetailOrder, updateDetailOrder } from "@/pages/finance/lib/api";
|
||||
import type { OrderStatus } from "@/pages/finance/lib/type";
|
||||
import formatPhone from "@/shared/lib/formatPhone";
|
||||
import formatPrice from "@/shared/lib/formatPrice";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Calendar,
|
||||
@@ -10,129 +20,80 @@ import {
|
||||
Mail,
|
||||
MapPin,
|
||||
Phone,
|
||||
TrendingUp,
|
||||
User,
|
||||
UsersIcon,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
type UserPurchase = {
|
||||
id: number;
|
||||
userName: string;
|
||||
userPhone: string;
|
||||
userEmail: string;
|
||||
tourName: string;
|
||||
tourId: number;
|
||||
agencyName: string;
|
||||
agencyId: number;
|
||||
destination: string;
|
||||
travelDate: string;
|
||||
returnDate: string;
|
||||
amount: number;
|
||||
paymentStatus: "paid" | "pending" | "cancelled" | "refunded";
|
||||
paymentMethod: "credit_card" | "paypal" | "bank_transfer" | "crypto";
|
||||
purchaseDate: string;
|
||||
travelers: number;
|
||||
bookingReference: string;
|
||||
};
|
||||
|
||||
const mockUserData = {
|
||||
id: 1,
|
||||
userName: "Aziza Karimova",
|
||||
userPhone: "+998 90 123 45 67",
|
||||
userEmail: "aziza.karimova@example.com",
|
||||
joinDate: "2024-01-15",
|
||||
totalSpent: 4500000,
|
||||
totalBookings: 3,
|
||||
memberLevel: "Gold",
|
||||
};
|
||||
|
||||
const mockUserPurchases: UserPurchase[] = [
|
||||
{
|
||||
id: 1,
|
||||
userName: "Aziza Karimova",
|
||||
userPhone: "+998 90 123 45 67",
|
||||
userEmail: "aziza.karimova@example.com",
|
||||
tourName: "Dubai Luxury Tour",
|
||||
tourId: 1,
|
||||
agencyName: "Silk Road Travel",
|
||||
agencyId: 1,
|
||||
destination: "Dubai, UAE",
|
||||
travelDate: "2025-11-10",
|
||||
returnDate: "2025-11-17",
|
||||
amount: 1500000,
|
||||
paymentStatus: "paid",
|
||||
paymentMethod: "credit_card",
|
||||
purchaseDate: "2025-10-10",
|
||||
travelers: 2,
|
||||
bookingReference: "TRV-DXB-001",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
userName: "Aziza Karimova",
|
||||
userPhone: "+998 90 123 45 67",
|
||||
userEmail: "aziza.karimova@example.com",
|
||||
tourName: "Paris Romantic Getaway",
|
||||
tourId: 4,
|
||||
agencyName: "Euro Travels",
|
||||
agencyId: 2,
|
||||
destination: "Paris, France",
|
||||
travelDate: "2025-12-01",
|
||||
returnDate: "2025-12-08",
|
||||
amount: 2200000,
|
||||
paymentStatus: "paid",
|
||||
paymentMethod: "paypal",
|
||||
purchaseDate: "2025-10-16",
|
||||
travelers: 2,
|
||||
bookingReference: "TRV-PAR-002",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
userName: "Aziza Karimova",
|
||||
userPhone: "+998 90 123 45 67",
|
||||
userEmail: "aziza.karimova@example.com",
|
||||
tourName: "Bali Adventure Package",
|
||||
tourId: 2,
|
||||
agencyName: "Silk Road Travel",
|
||||
agencyId: 1,
|
||||
destination: "Bali, Indonesia",
|
||||
travelDate: "2025-11-15",
|
||||
returnDate: "2025-11-22",
|
||||
amount: 1800000,
|
||||
paymentStatus: "pending",
|
||||
paymentMethod: "bank_transfer",
|
||||
purchaseDate: "2025-10-12",
|
||||
travelers: 1,
|
||||
bookingReference: "TRV-BAL-003",
|
||||
},
|
||||
];
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function FinanceDetailUser() {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [activeTab, setActiveTab] = useState<"bookings" | "details">(
|
||||
"bookings",
|
||||
);
|
||||
|
||||
const getStatusBadge = (status: UserPurchase["paymentStatus"]) => {
|
||||
const params = useParams();
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["detail_order"],
|
||||
queryFn: () => getDetailOrder(Number(params.id)),
|
||||
select(data) {
|
||||
return data.data.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
body,
|
||||
}: {
|
||||
id: number;
|
||||
body: {
|
||||
order_status:
|
||||
| "pending_payment"
|
||||
| "pending_confirmation"
|
||||
| "cancelled"
|
||||
| "confirmed"
|
||||
| "completed";
|
||||
};
|
||||
}) => updateDetailOrder({ body, id }),
|
||||
onSuccess: () => {
|
||||
toast.success(t("Status muvaffaqiyatli yangilandi"), {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["detail_order"] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("Statusni yangilashda xatolik yuz berdi"), {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const getStatusBadge = (status: OrderStatus["order_status"]) => {
|
||||
const base =
|
||||
"px-3 py-1 rounded-full text-sm font-medium inline-flex items-center gap-2";
|
||||
switch (status) {
|
||||
case "paid":
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-green-900 text-green-400 border border-green-700`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-green-400"></div>
|
||||
{t("Paid")}
|
||||
</span>
|
||||
);
|
||||
case "pending":
|
||||
case "pending_confirmation":
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-yellow-900 text-yellow-400 border border-yellow-700`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-yellow-400"></div>
|
||||
{t("Paid")}
|
||||
</span>
|
||||
);
|
||||
case "pending_payment":
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-green-900 text-green-400 border border-green-700`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-green-400"></div>
|
||||
{t("Pending")}
|
||||
</span>
|
||||
);
|
||||
@@ -145,39 +106,18 @@ export default function FinanceDetailUser() {
|
||||
{t("Cancelled")}
|
||||
</span>
|
||||
);
|
||||
case "refunded":
|
||||
case "confirmed":
|
||||
return (
|
||||
<span
|
||||
className={`${base} bg-blue-900 text-blue-400 border border-blue-700`}
|
||||
>
|
||||
<div className="w-2 h-2 rounded-full bg-blue-400"></div>
|
||||
Refunded
|
||||
{t("Refunded")}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getPaymentMethod = (method: UserPurchase["paymentMethod"]) => {
|
||||
switch (method) {
|
||||
case "credit_card":
|
||||
return "Credit Card";
|
||||
case "paypal":
|
||||
return "PayPal";
|
||||
case "bank_transfer":
|
||||
return "Bank Transfer";
|
||||
case "crypto":
|
||||
return "Cryptocurrency";
|
||||
}
|
||||
};
|
||||
|
||||
const totalSpent = mockUserPurchases
|
||||
.filter((p) => p.paymentStatus === "paid")
|
||||
.reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
const pendingAmount = mockUserPurchases
|
||||
.filter((p) => p.paymentStatus === "pending")
|
||||
.reduce((sum, p) => sum + p.amount, 0);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full bg-gray-900 text-gray-100">
|
||||
<div className="w-[90%] mx-auto py-6">
|
||||
@@ -195,67 +135,12 @@ export default function FinanceDetailUser() {
|
||||
{t("Foydalanuvchi moliyaviy tafsilotlari")}
|
||||
</h1>
|
||||
<p className="text-gray-400 mt-1">
|
||||
{mockUserData.userName} {t("uchun batafsil moliyaviy sharh")}
|
||||
{data?.user.username} {t("uchun batafsil moliyaviy sharh")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* User Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-8">
|
||||
<div className="bg-gray-800 p-6 rounded-xl shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-gray-400 font-medium">{t("Total Spent")}</p>
|
||||
<DollarSign className="text-green-400 w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-green-400 mt-3">
|
||||
{formatPrice(totalSpent, true)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{t("All completed bookings")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 p-6 rounded-xl shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-gray-400 font-medium">
|
||||
{t("Pending Payments")}
|
||||
</p>
|
||||
<TrendingUp className="text-yellow-400 w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-yellow-400 mt-3">
|
||||
{formatPrice(pendingAmount, true)}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{t("Awaiting confirmation")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-800 p-6 rounded-xl shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-gray-400 font-medium">{t("Total Bookings")}</p>
|
||||
<CreditCard className="text-blue-400 w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-blue-400 mt-3">
|
||||
{mockUserData.totalBookings}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{t("All time bookings")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* <div className="bg-gray-800 p-6 rounded-xl shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-gray-400 font-medium">{t("Member Level")}</p>
|
||||
<User className="text-purple-400 w-6 h-6" />
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-purple-400 mt-3">
|
||||
{mockUserData.memberLevel}
|
||||
</p>
|
||||
<p className="text-sm text-gray-500 mt-1">{t("Loyalty status")}</p>
|
||||
</div> */}
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="bg-gray-800 rounded-xl shadow">
|
||||
{/* Tabs */}
|
||||
@@ -290,82 +175,114 @@ export default function FinanceDetailUser() {
|
||||
<h2 className="text-xl font-bold mb-4">
|
||||
{t("Booking History")}
|
||||
</h2>
|
||||
{mockUserPurchases.map((purchase) => (
|
||||
<div
|
||||
key={purchase.id}
|
||||
className="bg-gray-700 p-6 rounded-lg hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div className="bg-gray-700 p-6 rounded-lg hover:bg-gray-600 transition-colors">
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-100">
|
||||
{data?.departure}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{data && getStatusBadge(data?.order_status)}
|
||||
{data && (
|
||||
<Select
|
||||
onValueChange={(
|
||||
value:
|
||||
| "pending_payment"
|
||||
| "pending_confirmation"
|
||||
| "cancelled"
|
||||
| "confirmed"
|
||||
| "completed",
|
||||
) =>
|
||||
mutate({
|
||||
id: data.id,
|
||||
body: { order_status: value },
|
||||
})
|
||||
}
|
||||
value={data.order_status}
|
||||
>
|
||||
<SelectTrigger className="w-[180px] bg-gray-800 border-gray-700 text-gray-200">
|
||||
<SelectValue placeholder={t("Statusni tanlang")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-800 text-gray-100 border-gray-700">
|
||||
<SelectItem value="pending_payment">
|
||||
{t("Pending")}
|
||||
</SelectItem>
|
||||
<SelectItem value="pending_confirmation">
|
||||
{t("Paid")}
|
||||
</SelectItem>
|
||||
<SelectItem value="confirmed">
|
||||
{t("Refunded")}
|
||||
</SelectItem>
|
||||
<SelectItem value="completed">
|
||||
{t("Completed")}
|
||||
</SelectItem>
|
||||
<SelectItem value="cancelled">
|
||||
{t("Cancelled")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-gray-400" />
|
||||
<div>
|
||||
<h3 className="text-lg font-bold text-gray-100">
|
||||
{purchase.tourName}
|
||||
</h3>
|
||||
<p className="text-gray-400 text-sm">
|
||||
{t("Booking Ref")}: {purchase.bookingReference}
|
||||
<p className="text-sm text-gray-400">
|
||||
{t("Destination")}
|
||||
</p>
|
||||
<p className="text-gray-100">{data?.destination}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">
|
||||
{t("Travel Dates")}
|
||||
</p>
|
||||
<p className="text-gray-100">
|
||||
{data?.departure_date} - {data?.arrival_time}
|
||||
</p>
|
||||
</div>
|
||||
{getStatusBadge(purchase.paymentStatus)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">
|
||||
{t("Destination")}
|
||||
</p>
|
||||
<p className="text-gray-100">
|
||||
{purchase.destination}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">
|
||||
{t("Travel Dates")}
|
||||
</p>
|
||||
<p className="text-gray-100">
|
||||
{purchase.travelDate} - {purchase.returnDate}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">
|
||||
{t("Travelers")}
|
||||
</p>
|
||||
<p className="text-gray-100">{purchase.travelers}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">{t("Amount")}</p>
|
||||
<p className="text-green-400 font-bold">
|
||||
{formatPrice(purchase.amount, true)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">
|
||||
{t("Travelers")}
|
||||
</p>
|
||||
<p className="text-gray-100">
|
||||
{data?.participant.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center pt-4 border-t border-gray-600">
|
||||
<div className="text-sm text-gray-400">
|
||||
{t("Booked on")} {purchase.purchaseDate}{" "}
|
||||
{getPaymentMethod(purchase.paymentMethod)}
|
||||
<div className="flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4 text-gray-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">{t("Amount")}</p>
|
||||
<p className="text-green-400 font-bold">
|
||||
{data && formatPrice(data?.total_price, true)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<div className="flex justify-between items-center pt-4 border-t border-gray-600">
|
||||
<div className="text-sm text-gray-400">
|
||||
{t("Booked on")} {data?.departure_date}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === "details" && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
<div className="grid grid-cols-1 gap-8">
|
||||
{/* Personal Information */}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold mb-4">
|
||||
@@ -378,7 +295,9 @@ export default function FinanceDetailUser() {
|
||||
<p className="text-sm text-gray-400">
|
||||
{t("Full Name")}
|
||||
</p>
|
||||
<p className="text-gray-100">{mockUserData.userName}</p>
|
||||
<p className="text-gray-100">
|
||||
{data?.user.first_name} {data?.user.last_name}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -389,71 +308,113 @@ export default function FinanceDetailUser() {
|
||||
{t("Phone Number")}
|
||||
</p>
|
||||
<p className="text-gray-100">
|
||||
{formatPhone(mockUserData.userPhone)}
|
||||
{data && formatPhone(data?.user.phone)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
|
||||
<Mail className="w-5 h-5 text-yellow-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">
|
||||
{t("Email Address")}
|
||||
</p>
|
||||
<p className="text-gray-100">
|
||||
{mockUserData.userEmail}
|
||||
</p>
|
||||
{data?.user.email && (
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
|
||||
<Mail className="w-5 h-5 text-yellow-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">
|
||||
{t("Email Address")}
|
||||
</p>
|
||||
<p className="text-gray-100">{data?.user.email}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
|
||||
<Calendar className="w-5 h-5 text-purple-400" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-400">
|
||||
{t("Member Since")}
|
||||
</p>
|
||||
<p className="text-gray-100">{mockUserData.joinDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Travel Preferences */}
|
||||
<div>
|
||||
<h3 className="text-lg font-bold mb-4">
|
||||
{t("Travel Statistics")}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 bg-gray-700 rounded-lg">
|
||||
<p className="text-sm text-gray-400 mb-2">
|
||||
{t("Favorite Destination")}
|
||||
<div className="bg-slate-900/50 backdrop-blur-sm rounded-2xl shadow-xl border border-slate-800/50 p-6">
|
||||
<h2 className="text-lg font-semibold text-slate-100 mb-4 flex items-center gap-2">
|
||||
<UsersIcon className="w-5 h-5 text-purple-400" />
|
||||
{t("Hamrohlar")}
|
||||
<span className="ml-auto text-sm font-normal text-slate-500">
|
||||
{data?.participant.length} ta
|
||||
</span>
|
||||
</h2>
|
||||
<div className="space-y-4 max-h-[300px] overflow-y-auto pr-2 scrollbar-thin scrollbar-thumb-slate-700 hover:scrollbar-thumb-slate-600 scrollbar-track-transparent">
|
||||
{data && data?.participant.length > 0 ? (
|
||||
data?.participant.map((companion, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="p-4 bg-slate-800/50 rounded-xl border border-slate-700/50"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="w-14 h-14 rounded-lg bg-gradient-to-br from-purple-600 to-pink-600 flex items-center justify-center flex-shrink-0 shadow-lg shadow-purple-500/20">
|
||||
<User className="w-7 h-7 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 space-y-3">
|
||||
<div>
|
||||
<h3 className="font-semibold text-slate-100 text-lg">
|
||||
{companion.first_name} {companion.last_name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<span
|
||||
className={`px-2 py-1 text-xs rounded-full border ${
|
||||
companion.gender === "male"
|
||||
? "bg-blue-500/20 text-blue-400 border-blue-500/30"
|
||||
: "bg-pink-500/20 text-pink-400 border-pink-500/30"
|
||||
}`}
|
||||
>
|
||||
{companion.gender === "male"
|
||||
? t("Erkak")
|
||||
: t("Ayol")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("Tug'ilgan sana")}
|
||||
</p>
|
||||
<p className="text-slate-200 font-medium">
|
||||
{companion.birth_date}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-slate-500">
|
||||
{t("Telefon raqami")}
|
||||
</p>
|
||||
<p className="text-slate-200 font-medium">
|
||||
{formatPhone(companion.phone_number)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{companion.participant_pasport_image.length >
|
||||
0 && (
|
||||
<div>
|
||||
<p className="text-xs text-slate-500 mb-2">
|
||||
{t("Passport rasmlari")}:
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
{companion.participant_pasport_image.map(
|
||||
(img) => (
|
||||
<div
|
||||
key={index}
|
||||
className="w-20 h-20 rounded-lg bg-slate-700/50 flex items-center justify-center overflow-hidden border border-slate-600/50"
|
||||
>
|
||||
<img
|
||||
alt="passport image"
|
||||
src={img.image}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<p className="text-sm text-slate-500 text-center py-4">
|
||||
{t("Hozircha hamrohlar qo'shilmagan")}
|
||||
</p>
|
||||
<p className="text-gray-100 font-medium">Dubai, UAE</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
2 {t("bookings")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-700 rounded-lg">
|
||||
<p className="text-sm text-gray-400 mb-2">
|
||||
{t("Preferred Agency")}
|
||||
</p>
|
||||
<p className="text-gray-100 font-medium">
|
||||
Silk Road Travel
|
||||
</p>
|
||||
<p className="text-sm text-gray-400 mt-1">
|
||||
2 {t("out of")} 3 {t("bookings")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-700 rounded-lg">
|
||||
<p className="text-sm text-gray-400 mb-2">
|
||||
{t("Average Booking Value")}
|
||||
</p>
|
||||
<p className="text-green-400 font-bold">
|
||||
{formatPrice(totalSpent, true)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import type {
|
||||
GetAllNewsCategory,
|
||||
GetDetailNewsCategory,
|
||||
NewsAll,
|
||||
NewsDetail,
|
||||
} from "@/pages/news/lib/type";
|
||||
import httpClient from "@/shared/config/api/httpClient";
|
||||
import { NEWS, NEWS_CATEGORY } from "@/shared/config/api/URLs";
|
||||
@@ -18,6 +19,13 @@ const getAllNews = async ({
|
||||
return response;
|
||||
};
|
||||
|
||||
const getDetailNews = async (
|
||||
id: number,
|
||||
): Promise<AxiosResponse<NewsDetail>> => {
|
||||
const response = await httpClient.get(`${NEWS}${id}/`);
|
||||
return response;
|
||||
};
|
||||
|
||||
const addNews = async (body: FormData) => {
|
||||
const response = await httpClient.post(NEWS, body, {
|
||||
headers: {
|
||||
@@ -27,7 +35,20 @@ const addNews = async (body: FormData) => {
|
||||
return response;
|
||||
};
|
||||
|
||||
// category news
|
||||
const updateNews = async ({ body, id }: { id: number; body: FormData }) => {
|
||||
const response = await httpClient.patch(`${NEWS}${id}/`, body, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
const deleteNews = async (id: number) => {
|
||||
const response = await httpClient.delete(`${NEWS}${id}/`);
|
||||
return response;
|
||||
};
|
||||
|
||||
const getAllNewsCategory = async (params: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
@@ -67,9 +88,12 @@ const deleteNewsCategory = async (id: number) => {
|
||||
export {
|
||||
addNews,
|
||||
addNewsCategory,
|
||||
deleteNews,
|
||||
deleteNewsCategory,
|
||||
getAllNews,
|
||||
getAllNewsCategory,
|
||||
getDetailNews,
|
||||
getDetailNewsCategory,
|
||||
updateNews,
|
||||
updateNewsCategory,
|
||||
};
|
||||
|
||||
@@ -7,7 +7,7 @@ interface NewsData {
|
||||
title_ru: string;
|
||||
desc_ru: string;
|
||||
category: string;
|
||||
banner: File | undefined;
|
||||
banner: File | undefined | string;
|
||||
}
|
||||
|
||||
interface NewsStore {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import z from "zod";
|
||||
|
||||
const fileSchema = z.instanceof(File, { message: "Rasm faylini yuklang" });
|
||||
const fileSchema = z.union([
|
||||
z.instanceof(File, { message: "Rasm faylini yuklang" }),
|
||||
z.string().min(1, { message: "Rasm faylini yuklang" }),
|
||||
]);
|
||||
|
||||
export const newsForm = z.object({
|
||||
title: z.string().min(2, {
|
||||
|
||||
@@ -82,5 +82,42 @@ export interface GetDetailNewsCategory {
|
||||
id: number;
|
||||
name: string;
|
||||
name_ru: string;
|
||||
name_uz: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface NewsDetail {
|
||||
status: boolean;
|
||||
data: {
|
||||
id: number;
|
||||
title: string;
|
||||
title_ru: string;
|
||||
title_uz: string;
|
||||
image: string;
|
||||
text: string;
|
||||
text_ru: string;
|
||||
text_uz: string;
|
||||
is_public: boolean;
|
||||
category: {
|
||||
name: string;
|
||||
name_ru: string;
|
||||
name_uz: string;
|
||||
};
|
||||
tag: [
|
||||
{
|
||||
id: number;
|
||||
name: string;
|
||||
name_ru: string;
|
||||
name_uz: string;
|
||||
},
|
||||
];
|
||||
post_images: [
|
||||
{
|
||||
image: string;
|
||||
text: string;
|
||||
text_ru: string;
|
||||
text_uz: string;
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
import { getDetailNews } from "@/pages/news/lib/api";
|
||||
import StepOne from "@/pages/news/ui/StepOne";
|
||||
import StepTwo from "@/pages/news/ui/StepTwo";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -11,6 +13,14 @@ const AddNews = () => {
|
||||
const isEditMode = useMemo(() => !!id, [id]);
|
||||
const [step, setStep] = useState(1);
|
||||
const { t } = useTranslation();
|
||||
const { data } = useQuery({
|
||||
queryKey: ["news_detail", id],
|
||||
queryFn: () => getDetailNews(Number(id)),
|
||||
select(data) {
|
||||
return data.data.data;
|
||||
},
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="p-8 w-full mx-auto bg-gray-900 text-white rounded-2xl shadow-lg">
|
||||
@@ -30,8 +40,10 @@ const AddNews = () => {
|
||||
2. {t("Yangilik ma'lumotlari")}
|
||||
</div>
|
||||
</div>
|
||||
{step === 1 && <StepOne isEditMode={isEditMode} setStep={setStep} />}
|
||||
{step === 2 && <StepTwo />}
|
||||
{step === 1 && (
|
||||
<StepOne isEditMode={isEditMode} setStep={setStep} data={data!} />
|
||||
)}
|
||||
{step === 2 && <StepTwo data={data!} isEditMode={isEditMode} id={id!} />}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"use client";
|
||||
import { getAllNews } from "@/pages/news/lib/api";
|
||||
import { deleteNews, getAllNews } from "@/pages/news/lib/api";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card } from "@/shared/ui/card";
|
||||
@@ -10,27 +10,56 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import clsx from "clsx";
|
||||
import { Edit, FolderOpen, PlusCircle, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Edit,
|
||||
FolderOpen,
|
||||
Loader2,
|
||||
PlusCircle,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const News = () => {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const { t } = useTranslation();
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
data: allNews,
|
||||
isLoading,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: ["all_news"],
|
||||
queryFn: () => getAllNews({ page: 1, page_size: 2 }),
|
||||
queryKey: ["all_news", currentPage],
|
||||
queryFn: () => getAllNews({ page: currentPage, page_size: 10 }),
|
||||
});
|
||||
|
||||
const confirmDelete = () => {};
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: (id: number) => deleteNews(id),
|
||||
onSuccess: () => {
|
||||
setDeleteId(null);
|
||||
queryClient.refetchQueries({ queryKey: ["all_news"] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("Xatolik yuz berdi"), {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const confirmDelete = () => {
|
||||
if (deleteId) {
|
||||
mutate(deleteId);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -157,7 +186,7 @@ const News = () => {
|
||||
{/* Actions */}
|
||||
<div className="flex justify-end gap-2 pt-3">
|
||||
<Button
|
||||
onClick={() => navigate(`/news/add`)}
|
||||
onClick={() => navigate(`/news/edit/${item.id}`)}
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="hover:bg-neutral-700 hover:text-blue-400"
|
||||
@@ -201,11 +230,51 @@ const News = () => {
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDelete}>
|
||||
<Trash2 className="w-4 h-4 mr-2" />
|
||||
{t("O'chirish")}
|
||||
{isPending ? (
|
||||
<Loader2 className="animate-spin" />
|
||||
) : (
|
||||
t("O'chirish")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<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(allNews?.data.data.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 === allNews?.data.data.total_pages}
|
||||
onClick={() =>
|
||||
setCurrentPage((p) =>
|
||||
Math.min(p + 1, allNews ? allNews?.data.data.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>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -86,7 +86,7 @@ const NewsCategory = () => {
|
||||
|
||||
useEffect(() => {
|
||||
if (detail) {
|
||||
form.setValue("title", detail.data.data.name);
|
||||
form.setValue("title", detail.data.data.name_uz);
|
||||
form.setValue("title_ru", detail.data.data.name_ru);
|
||||
}
|
||||
}, [editItem, form, detail]);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
"use client";
|
||||
|
||||
import { getAllNewsCategory } from "@/pages/news/lib/api";
|
||||
import { useNewsStore } from "@/pages/news/lib/data";
|
||||
import { newsForm } from "@/pages/news/lib/form";
|
||||
@@ -9,40 +11,68 @@ import {
|
||||
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 {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import { Textarea } from "@/shared/ui/textarea";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useInfiniteQuery } from "@tanstack/react-query";
|
||||
import { XIcon } from "lucide-react";
|
||||
import { type Dispatch, type SetStateAction, useEffect } from "react";
|
||||
import { useEffect, useRef, type Dispatch, type SetStateAction } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type z from "zod";
|
||||
|
||||
interface Data {
|
||||
id: number;
|
||||
title: string;
|
||||
title_ru: string;
|
||||
title_uz: string;
|
||||
image: string;
|
||||
text: string;
|
||||
text_ru: string;
|
||||
text_uz: string;
|
||||
is_public: boolean;
|
||||
category: {
|
||||
name: string;
|
||||
name_ru: string;
|
||||
name_uz: string;
|
||||
};
|
||||
tag: [
|
||||
{
|
||||
id: number;
|
||||
name: string;
|
||||
name_ru: string;
|
||||
name_uz: string;
|
||||
},
|
||||
];
|
||||
post_images: [
|
||||
{
|
||||
image: string;
|
||||
text: string;
|
||||
text_ru: string;
|
||||
text_uz: string;
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const StepOne = ({
|
||||
setStep,
|
||||
data: detail,
|
||||
}: {
|
||||
setStep: Dispatch<SetStateAction<number>>;
|
||||
isEditMode: boolean;
|
||||
data: Data;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { setStepOneData, stepOneData } = useNewsStore();
|
||||
const hasReset = useRef(false); // 👈 infinite loopni oldini olish uchun
|
||||
|
||||
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
||||
useInfiniteQuery({
|
||||
queryKey: ["news_category"],
|
||||
queryKey: ["news_category", detail],
|
||||
queryFn: ({ pageParam = 1 }) =>
|
||||
getAllNewsCategory({ page: pageParam, page_size: 5 }),
|
||||
getAllNewsCategory({ page: pageParam, page_size: 10 }),
|
||||
getNextPageParam: (lastPage) => {
|
||||
const currentPage = lastPage.data.data.current_page;
|
||||
const totalPages = lastPage.data.data.total_pages;
|
||||
@@ -66,42 +96,39 @@ const StepOne = ({
|
||||
},
|
||||
});
|
||||
|
||||
// ✅ Haqiqiy scroll elementni topish va scroll eventni qo‘shish
|
||||
// ✅ reset faqat bir marta, ma'lumot tayyor bo'lganda ishlaydi
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => {
|
||||
const viewport = document.querySelector(
|
||||
"[data-radix-select-viewport]",
|
||||
) as HTMLDivElement | null;
|
||||
if (
|
||||
detail &&
|
||||
allCategories.length > 0 &&
|
||||
!hasReset.current // faqat bir marta
|
||||
) {
|
||||
const foundCategory = allCategories.find(
|
||||
(cat) => cat.name === detail.category.name,
|
||||
);
|
||||
|
||||
if (viewport) {
|
||||
const handleScroll = () => {
|
||||
const { scrollTop, scrollHeight, clientHeight } = viewport;
|
||||
if (
|
||||
scrollHeight - scrollTop - clientHeight < 50 &&
|
||||
hasNextPage &&
|
||||
!isFetchingNextPage
|
||||
) {
|
||||
fetchNextPage();
|
||||
}
|
||||
};
|
||||
form.reset({
|
||||
banner: detail.image as any,
|
||||
category: foundCategory ? String(foundCategory.id) : "",
|
||||
title: detail.title_uz,
|
||||
title_ru: detail.title_ru,
|
||||
desc: detail.text_uz,
|
||||
desc_ru: detail.text_ru,
|
||||
});
|
||||
|
||||
viewport.addEventListener("scroll", handleScroll);
|
||||
clearInterval(interval);
|
||||
|
||||
return () => {
|
||||
viewport.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}
|
||||
}, 200);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
|
||||
hasReset.current = true; // ✅ qayta reset bo‘lmasin
|
||||
}
|
||||
}, [detail, allCategories, form]);
|
||||
|
||||
function onSubmit(values: z.infer<typeof newsForm>) {
|
||||
setStepOneData(values);
|
||||
setStep(2);
|
||||
}
|
||||
|
||||
const banner = form.watch("banner");
|
||||
const bannerSrc =
|
||||
banner instanceof File ? URL.createObjectURL(banner) : String(banner);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
@@ -127,6 +154,7 @@ const StepOne = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* title_ru */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title_ru"
|
||||
@@ -164,6 +192,7 @@ const StepOne = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* desc_ru */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="desc_ru"
|
||||
@@ -182,6 +211,7 @@ const StepOne = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* category */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="category"
|
||||
@@ -189,38 +219,28 @@ const StepOne = ({
|
||||
<FormItem>
|
||||
<Label className="text-md">{t("Kategoriya")}</Label>
|
||||
<FormControl>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<SelectTrigger className="w-full !h-12 bg-gray-800 border-gray-700 text-white">
|
||||
<SelectValue placeholder={t("Kategoriya tanlang")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="bg-gray-800 border-gray-700 text-white max-h-[180px] overflow-y-auto">
|
||||
<SelectGroup>
|
||||
<SelectLabel>{t("Kategoriyalar")}</SelectLabel>
|
||||
{allCategories.map((cat) => (
|
||||
<SelectItem key={cat.id} value={String(cat.id)}>
|
||||
{cat.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
{isFetchingNextPage && (
|
||||
<div className="text-center py-2 text-gray-400 text-sm">
|
||||
{t("Yuklanmoqda...")}
|
||||
</div>
|
||||
)}
|
||||
{!hasNextPage && allCategories.length > 0 && (
|
||||
<div className="text-center py-2 text-gray-500 text-xs">
|
||||
{t("Barcha kategoriyalar yuklandi")}
|
||||
</div>
|
||||
)}
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<InfiniteScrollSelect
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
placeholder={t("Kategoriya tanlang")}
|
||||
label={t("Kategoriyalar")}
|
||||
data={allCategories}
|
||||
hasNextPage={hasNextPage}
|
||||
isFetchingNextPage={isFetchingNextPage}
|
||||
fetchNextPage={fetchNextPage}
|
||||
renderOption={(cat) => ({
|
||||
key: cat.id,
|
||||
value: String(cat.id),
|
||||
label: cat.name,
|
||||
})}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* Banner */}
|
||||
{/* banner */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="banner"
|
||||
@@ -254,11 +274,10 @@ const StepOne = ({
|
||||
</p>
|
||||
</label>
|
||||
|
||||
{/* ✅ Preview (URL.createObjectURL bilan) */}
|
||||
{form.watch("banner") instanceof File && (
|
||||
{form.watch("banner") && (
|
||||
<div className="relative w-32 h-32 rounded-md overflow-hidden border border-gray-600">
|
||||
<img
|
||||
src={URL.createObjectURL(form.watch("banner"))}
|
||||
src={bannerSrc}
|
||||
alt="Banner preview"
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { addNews } from "@/pages/news/lib/api";
|
||||
import { addNews, updateNews } from "@/pages/news/lib/api";
|
||||
import { useNewsStore } from "@/pages/news/lib/data";
|
||||
import { newsPostForm, type NewsPostFormType } from "@/pages/news/lib/form";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
@@ -17,15 +17,57 @@ import { Textarea } from "@/shared/ui/textarea";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { ImagePlus, PlusCircle, Trash2 } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useFieldArray, useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const StepTwo = () => {
|
||||
interface Data {
|
||||
id: number;
|
||||
title: string;
|
||||
title_ru: string;
|
||||
title_uz: string;
|
||||
image: string;
|
||||
text: string;
|
||||
text_ru: string;
|
||||
text_uz: string;
|
||||
is_public: boolean;
|
||||
category: {
|
||||
name: string;
|
||||
name_ru: string;
|
||||
name_uz: string;
|
||||
};
|
||||
tag: [
|
||||
{
|
||||
id: number;
|
||||
name: string;
|
||||
name_ru: string;
|
||||
name_uz: string;
|
||||
},
|
||||
];
|
||||
post_images: [
|
||||
{
|
||||
image: string;
|
||||
text: string;
|
||||
text_ru: string;
|
||||
text_uz: string;
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const StepTwo = ({
|
||||
data: detail,
|
||||
id,
|
||||
}: {
|
||||
isEditMode: boolean;
|
||||
id: string;
|
||||
data: Data;
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const hasReset = useRef(false);
|
||||
const navigate = useNavigate();
|
||||
const { stepOneData } = useNewsStore();
|
||||
const { stepOneData, resetStepOneData } = useNewsStore();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = useForm<NewsPostFormType>({
|
||||
@@ -39,6 +81,33 @@ const StepTwo = () => {
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (detail && !hasReset.current) {
|
||||
// 🧠 xavfsiz map qilish
|
||||
const mappedSections =
|
||||
detail.post_images?.map((img) => ({
|
||||
image: img.image,
|
||||
text: img.text_uz,
|
||||
text_ru: img.text_ru,
|
||||
})) ?? [];
|
||||
|
||||
const mappedTags = detail.tag?.map((t) => t.name_uz) ?? [];
|
||||
|
||||
form.reset({
|
||||
desc: detail.text_uz || "",
|
||||
desc_ru: detail.text_ru || "",
|
||||
is_public: detail.is_public ? "yes" : "no",
|
||||
post_tags: mappedTags.length > 0 ? mappedTags : [""],
|
||||
sections:
|
||||
mappedSections.length > 0
|
||||
? mappedSections
|
||||
: [{ image: "", text: "", text_ru: "" }],
|
||||
});
|
||||
|
||||
hasReset.current = true;
|
||||
}
|
||||
}, [detail, form]);
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control: form.control,
|
||||
name: "sections",
|
||||
@@ -56,7 +125,26 @@ const StepTwo = () => {
|
||||
mutationFn: (body: FormData) => addNews(body),
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({ queryKey: ["all_news"] });
|
||||
queryClient.refetchQueries({ queryKey: ["news_detail"] });
|
||||
navigate("/news");
|
||||
resetStepOneData();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("Xatolik yuz berdi"), {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: update } = useMutation({
|
||||
mutationFn: ({ body, id }: { id: number; body: FormData }) =>
|
||||
updateNews({ id: id, body }),
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({ queryKey: ["news_detail"] });
|
||||
queryClient.refetchQueries({ queryKey: ["all_news"] });
|
||||
navigate("/news");
|
||||
resetStepOneData();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("Xatolik yuz berdi"), {
|
||||
@@ -79,14 +167,20 @@ const StepTwo = () => {
|
||||
|
||||
formData.append("title", stepOneData.title);
|
||||
formData.append("title_ru", stepOneData.title_ru);
|
||||
formData.append("image", stepOneData.banner ?? "");
|
||||
formData.append("text", stepOneData.desc);
|
||||
formData.append("text_ru", stepOneData.desc_ru);
|
||||
formData.append("is_public", values.is_public === "no" ? "false" : "true");
|
||||
formData.append("category", stepOneData.category);
|
||||
|
||||
if (stepOneData.banner instanceof File) {
|
||||
formData.append("image", stepOneData.banner);
|
||||
}
|
||||
|
||||
// 🔥 sections
|
||||
values.sections.forEach((section, i) => {
|
||||
formData.append(`post_images[${i}]`, section.image);
|
||||
if (section.image instanceof File) {
|
||||
formData.append(`post_images[${i}]`, section.image);
|
||||
}
|
||||
formData.append(`post_text[${i}]`, section.text);
|
||||
formData.append(`post_text_ru[${i}]`, section.text_ru);
|
||||
});
|
||||
@@ -94,8 +188,14 @@ const StepTwo = () => {
|
||||
values.post_tags.forEach((tag, i) => {
|
||||
formData.append(`post_tags[${i}]`, tag);
|
||||
});
|
||||
|
||||
added(formData);
|
||||
if (id) {
|
||||
update({
|
||||
body: formData,
|
||||
id: Number(id),
|
||||
});
|
||||
} else {
|
||||
added(formData);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -201,9 +301,13 @@ const StepTwo = () => {
|
||||
{form.watch(`sections.${index}.image`) ? (
|
||||
<div className="relative mt-2 w-48">
|
||||
<img
|
||||
src={URL.createObjectURL(
|
||||
form.watch(`sections.${index}.image`),
|
||||
)}
|
||||
src={
|
||||
form.watch(`sections.${index}.image`) instanceof File
|
||||
? URL.createObjectURL(
|
||||
form.watch(`sections.${index}.image`) as File,
|
||||
)
|
||||
: String(form.watch(`sections.${index}.image`) || "")
|
||||
}
|
||||
alt="preview"
|
||||
className="rounded-lg border border-gray-700 object-cover h-40 w-full"
|
||||
/>
|
||||
|
||||
40
src/pages/seo/lib/api.ts
Normal file
40
src/pages/seo/lib/api.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import type { AllSeoData, DetailSeoData } from "@/pages/seo/lib/types";
|
||||
import httpClient from "@/shared/config/api/httpClient";
|
||||
import { SITE_SEO } from "@/shared/config/api/URLs";
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
const createSeo = async (body: FormData) => {
|
||||
const res = await httpClient.post(SITE_SEO, body, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return res;
|
||||
};
|
||||
|
||||
const getAllSeo = async (params: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
}): Promise<AxiosResponse<AllSeoData>> => {
|
||||
const res = await httpClient.get(SITE_SEO, { params });
|
||||
return res;
|
||||
};
|
||||
|
||||
const getDetailSeo = async (
|
||||
id: number,
|
||||
): Promise<AxiosResponse<DetailSeoData>> => {
|
||||
const res = await httpClient.get(`${SITE_SEO}${id}/`);
|
||||
return res;
|
||||
};
|
||||
|
||||
const updateSeo = async ({ body, id }: { id: number; body: FormData }) => {
|
||||
const res = await httpClient.patch(`${SITE_SEO}${id}/`, body, {
|
||||
headers: { "Content-Type": "multipart/form-data" },
|
||||
});
|
||||
return res;
|
||||
};
|
||||
|
||||
const deleteSeo = async ({ id }: { id: number }) => {
|
||||
const res = await httpClient.delete(`${SITE_SEO}${id}/`);
|
||||
return res;
|
||||
};
|
||||
|
||||
export { createSeo, deleteSeo, getAllSeo, getDetailSeo, updateSeo };
|
||||
37
src/pages/seo/lib/types.ts
Normal file
37
src/pages/seo/lib/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface AllSeoData {
|
||||
status: boolean;
|
||||
data: {
|
||||
links: {
|
||||
previous: string;
|
||||
next: string;
|
||||
};
|
||||
total_items: number;
|
||||
total_pages: number;
|
||||
page_size: number;
|
||||
current_page: number;
|
||||
results: [
|
||||
{
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
keywords: string;
|
||||
og_title: string;
|
||||
og_description: string;
|
||||
og_image: string;
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
export interface DetailSeoData {
|
||||
status: boolean;
|
||||
data: {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
keywords: string;
|
||||
og_title: string;
|
||||
og_description: string;
|
||||
og_image: string;
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,26 @@
|
||||
"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 { useState, type ChangeEvent } from "react";
|
||||
import { useEffect, useState, type ChangeEvent } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type SeoData = {
|
||||
title: string;
|
||||
@@ -15,7 +28,7 @@ type SeoData = {
|
||||
keywords: string;
|
||||
ogTitle: string;
|
||||
ogDescription: string;
|
||||
ogImage: string;
|
||||
ogImage: File | null | string;
|
||||
};
|
||||
|
||||
export default function Seo() {
|
||||
@@ -25,10 +38,107 @@ export default function Seo() {
|
||||
keywords: "",
|
||||
ogTitle: "",
|
||||
ogDescription: "",
|
||||
ogImage: "",
|
||||
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 [savedSeo, setSavedSeo] = useState<SeoData | null>(null);
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null);
|
||||
|
||||
const handleChange = (
|
||||
@@ -44,37 +154,59 @@ export default function Seo() {
|
||||
const handleImageUpload = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
const result = event.target?.result as string;
|
||||
setImagePreview(result);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
ogImage: result,
|
||||
}));
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
setFormData((prev) => ({ ...prev, ogImage: file }));
|
||||
setImagePreview(URL.createObjectURL(file));
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
setSavedSeo(formData);
|
||||
setFormData({
|
||||
description: "",
|
||||
keywords: "",
|
||||
ogDescription: "",
|
||||
ogImage: "",
|
||||
ogTitle: "",
|
||||
title: "",
|
||||
});
|
||||
const handleEdit = (id: number) => {
|
||||
setEdit(id);
|
||||
};
|
||||
|
||||
const getTitleLength = () => formData.title.length;
|
||||
const getDescriptionLength = () => formData.description.length;
|
||||
const handleDelete = (id: number) => {
|
||||
deleteSeoo(id);
|
||||
};
|
||||
|
||||
const isValidTitle = getTitleLength() > 30 && getTitleLength() <= 60;
|
||||
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() > 120 && getDescriptionLength() <= 160;
|
||||
getDescriptionLength() > 60 && getDescriptionLength() <= 160;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 p-8 w-full">
|
||||
@@ -83,33 +215,32 @@ export default function Seo() {
|
||||
<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">SEO Manager</h1>
|
||||
<h1 className="text-4xl font-bold text-white">
|
||||
{t("SEO Manager")}
|
||||
</h1>
|
||||
</div>
|
||||
<p className="text-slate-400">
|
||||
Saytingizni qidiruv tizimida yaxshi pozitsiyaga keltiring
|
||||
{t("Saytingizni qidiruv tizimida yaxshi pozitsiyaga keltiring")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
{/* 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" /> Page Title
|
||||
<FileText className="inline w-4 h-4 mr-1" /> {t("Page Title")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="title"
|
||||
value={formData.title}
|
||||
onChange={handleChange}
|
||||
placeholder="Sahifa sarlavhasi (30–60 belgi)"
|
||||
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">
|
||||
<span className="text-sm text-slate-400">
|
||||
{getTitleLength()} / 60
|
||||
</span>
|
||||
{isValidTitle && (
|
||||
<CheckCircle className="w-5 h-5 text-green-400" />
|
||||
)}
|
||||
@@ -122,13 +253,13 @@ export default function Seo() {
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-white mb-2">
|
||||
Meta Description
|
||||
{t("Meta Description")}
|
||||
</label>
|
||||
<textarea
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleChange}
|
||||
placeholder="Sahifa tavsifi (120–160 belgi)"
|
||||
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">
|
||||
@@ -147,58 +278,59 @@ export default function Seo() {
|
||||
{/* Keywords */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-white mb-2">
|
||||
Keywords
|
||||
{t("Keywords")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="keywords"
|
||||
value={formData.keywords}
|
||||
onChange={handleChange}
|
||||
placeholder="Kalit so'zlar (vergul bilan ajratilgan)"
|
||||
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">
|
||||
Masalan: Python, Web Development, Coding
|
||||
{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">
|
||||
Open Graph (Ijtimoiy Tarmoqlar)
|
||||
{t("Open Graph (Ijtimoiy Tarmoqlar)")}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-slate-300 mb-2">
|
||||
OG Title
|
||||
{t("OG Title")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="ogTitle"
|
||||
value={formData.ogTitle}
|
||||
onChange={handleChange}
|
||||
placeholder="Ijtimoiy tarmoqdagi sarlavha"
|
||||
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">
|
||||
OG Description
|
||||
{t("OG Description")}
|
||||
</label>
|
||||
<textarea
|
||||
name="ogDescription"
|
||||
value={formData.ogDescription}
|
||||
onChange={handleChange}
|
||||
placeholder="Ijtimoiy tarmoqdagi tavsif"
|
||||
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" /> OG Image
|
||||
<ImageIcon className="inline w-4 h-4 mr-1" />{" "}
|
||||
{t("OG Image")}
|
||||
</label>
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
@@ -220,12 +352,12 @@ export default function Seo() {
|
||||
setImagePreview(null);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
ogImage: "",
|
||||
ogImage: null,
|
||||
}));
|
||||
}}
|
||||
className="mt-2 text-xs bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded"
|
||||
>
|
||||
O‘chirish
|
||||
{t("O'chirish")}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@@ -236,33 +368,73 @@ export default function Seo() {
|
||||
|
||||
<button
|
||||
onClick={handleSave}
|
||||
className="w-full bg-blue-600 hover:bg-blue-700 text-white font-semibold py-3 rounded-lg transition-colors"
|
||||
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"
|
||||
>
|
||||
Saqlash
|
||||
{isPending || editPending ? (
|
||||
<Loader2 className="animate-spin mx-auto" />
|
||||
) : edit ? (
|
||||
t("Tahrirlash")
|
||||
) : (
|
||||
t("Saqlash")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Saved SEO Data (Preview) */}
|
||||
{savedSeo && (
|
||||
<div className="mt-8 bg-slate-700 rounded-lg p-6 text-slate-200">
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
Saqlangan SEO Ma’lumotlari
|
||||
</h3>
|
||||
<pre className="bg-slate-800 p-4 rounded text-xs overflow-auto">
|
||||
{JSON.stringify(
|
||||
{
|
||||
...savedSeo,
|
||||
ogImage: savedSeo.ogImage
|
||||
? savedSeo.ogImage.substring(0, 100) + "..."
|
||||
: "",
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}
|
||||
</pre>
|
||||
</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>
|
||||
);
|
||||
|
||||
47
src/pages/site-banner/lib/api.ts
Normal file
47
src/pages/site-banner/lib/api.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
AllGetBanner,
|
||||
DetailGetBanner,
|
||||
} from "@/pages/site-banner/lib/types";
|
||||
import httpClient from "@/shared/config/api/httpClient";
|
||||
import { BANNER } from "@/shared/config/api/URLs";
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
const getBanner = async (params: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
}): Promise<AxiosResponse<AllGetBanner>> => {
|
||||
const res = await httpClient.get(BANNER, { params });
|
||||
return res;
|
||||
};
|
||||
|
||||
const getBannerDetail = async (
|
||||
id: number,
|
||||
): Promise<AxiosResponse<DetailGetBanner>> => {
|
||||
const res = await httpClient.get(`${BANNER}${id}/`);
|
||||
return res;
|
||||
};
|
||||
|
||||
const bannerDelete = async (id: number) => {
|
||||
const res = await httpClient.delete(`${BANNER}${id}/`);
|
||||
return res;
|
||||
};
|
||||
|
||||
const createBanner = async (body: FormData) => {
|
||||
const res = await httpClient.post(BANNER, body, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
return res;
|
||||
};
|
||||
|
||||
const updateBanner = async ({ body, id }: { id: number; body: FormData }) => {
|
||||
const res = await httpClient.patch(`${BANNER}${id}/`, body, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
return res;
|
||||
};
|
||||
|
||||
export { bannerDelete, createBanner, getBanner, getBannerDetail, updateBanner };
|
||||
37
src/pages/site-banner/lib/types.ts
Normal file
37
src/pages/site-banner/lib/types.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export interface DetailGetBanner {
|
||||
status: boolean;
|
||||
data: {
|
||||
id: number;
|
||||
title: string;
|
||||
title_ru: string;
|
||||
title_uz: string;
|
||||
description: string;
|
||||
description_ru: string;
|
||||
description_uz: string;
|
||||
image: string;
|
||||
link: string;
|
||||
position: "banner1" | "banner2" | "banner3" | "banner4";
|
||||
};
|
||||
}
|
||||
|
||||
export interface AllGetBanner {
|
||||
status: boolean;
|
||||
data: {
|
||||
links: {
|
||||
previous: string;
|
||||
next: string;
|
||||
};
|
||||
total_items: number;
|
||||
total_pages: number;
|
||||
page_size: number;
|
||||
current_page: number;
|
||||
results: {
|
||||
id: number;
|
||||
title: string;
|
||||
description: string;
|
||||
image: string;
|
||||
link: string;
|
||||
position: "banner1" | "banner2" | "banner3" | "banner4";
|
||||
}[];
|
||||
};
|
||||
}
|
||||
583
src/pages/site-banner/ui/Banner.tsx
Normal file
583
src/pages/site-banner/ui/Banner.tsx
Normal file
@@ -0,0 +1,583 @@
|
||||
"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 { 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 [currentPage, setCurrentPage] = useState(1);
|
||||
const {
|
||||
data: banner,
|
||||
isLoading,
|
||||
isError,
|
||||
refetch,
|
||||
} = useQuery({
|
||||
queryKey: ["all_banner", currentPage],
|
||||
queryFn: () => getBanner({ page: currentPage, page_size: 10 }),
|
||||
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 gap-2 mt-5">
|
||||
<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(banner?.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 === banner?.total_pages}
|
||||
onClick={() =>
|
||||
setCurrentPage((p) =>
|
||||
Math.min(p + 1, banner ? banner?.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={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;
|
||||
112
src/pages/site-banner/ui/BannerCarousel.tsx
Normal file
112
src/pages/site-banner/ui/BannerCarousel.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { getBanner } from "@/pages/site-banner/lib/api";
|
||||
import { Card } from "@/shared/ui/card";
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
CarouselNext,
|
||||
CarouselPrevious,
|
||||
} from "@/shared/ui/carousel";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Loader2, MoveRightIcon } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const BannerCarousel = () => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 🧠 Bannerlarni backenddan olish
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["all_banner"],
|
||||
queryFn: () => getBanner(),
|
||||
select: (res) =>
|
||||
res.data.data.results.filter((b) => b.position === "banner1"),
|
||||
});
|
||||
|
||||
const colors = ["#EDF5C7", "#F5DCC7"];
|
||||
|
||||
if (isLoading)
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loader2 className="animate-spin text-blue-500 w-8 h-8" />
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isError)
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] text-white">
|
||||
<AlertTriangle className="text-red-500 w-8 h-8 mb-2" />
|
||||
<p>{t("Ma'lumotlarni yuklashda xatolik yuz berdi.")}</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="mt-3 bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-lg"
|
||||
>
|
||||
{t("Qayta urinish")}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!data || data.length === 0)
|
||||
return (
|
||||
<div className="flex items-center justify-center text-gray-400 min-h-[400px]">
|
||||
{t("Hozircha bannerlar mavjud emas")}
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mt-10 max-lg:hidden custom-container">
|
||||
<Carousel opts={{ loop: true, align: "start" }}>
|
||||
<CarouselContent>
|
||||
{data.map((banner, index) => (
|
||||
<CarouselItem
|
||||
key={banner.id}
|
||||
className="basis-full md:basis-[80%] shrink-0"
|
||||
>
|
||||
<div className="h-[500px]">
|
||||
<Card className="h-full !rounded-[50px] flex border-none items-center justify-start relative overflow-hidden">
|
||||
{/* <BannerCircle
|
||||
color={colors[index % colors.length]}
|
||||
className="w-[60%] h-full absolute z-10"
|
||||
/> */}
|
||||
|
||||
{/* Matn qismi */}
|
||||
<div className="flex flex-col gap-6 w-96 z-20 absolute left-14 top-1/2 -translate-y-1/2">
|
||||
<p className="text-4xl font-semibold text-[#232325]">
|
||||
{banner.title}
|
||||
</p>
|
||||
<p className="text-[#212122] font-medium">
|
||||
{banner.description}
|
||||
</p>
|
||||
<Link
|
||||
to={banner.link || "#"}
|
||||
className="bg-white text-[#212122] font-semibold flex gap-4 px-8 py-4 shadow-sm !rounded-4xl w-fit"
|
||||
>
|
||||
<p>{t("Batafsil")}</p>
|
||||
<MoveRightIcon />
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Rasm qismi */}
|
||||
<div className="absolute right-0 w-[50%] h-full">
|
||||
<img
|
||||
src={banner.image}
|
||||
alt={banner.title}
|
||||
className="object-cover w-full h-full"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
|
||||
<CarouselPrevious className="absolute left-2 top-1/2 -translate-y-1/2 bg-white/80 hover:bg-white p-2 size-10 rounded-full shadow z-10" />
|
||||
<CarouselNext className="absolute right-2 top-1/2 -translate-y-1/2 bg-white/80 hover:bg-white p-2 rounded-full size-10 shadow z-10" />
|
||||
</Carousel>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BannerCarousel;
|
||||
122
src/pages/site-page/lib/api.ts
Normal file
122
src/pages/site-page/lib/api.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import type {
|
||||
GetAllHelpPage,
|
||||
GetAllOfferta,
|
||||
GetDetailHelpPage,
|
||||
GetDetailOfferta,
|
||||
} from "@/pages/site-page/lib/types";
|
||||
import httpClient from "@/shared/config/api/httpClient";
|
||||
import { HELP_PAGE, OFFERTA } from "@/shared/config/api/URLs";
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
const createOfferta = async ({
|
||||
body,
|
||||
}: {
|
||||
body: {
|
||||
title: string;
|
||||
content: string;
|
||||
person_type: "individual" | "legal_entity";
|
||||
is_active: boolean;
|
||||
};
|
||||
}) => {
|
||||
const res = await httpClient.post(OFFERTA, body);
|
||||
return res;
|
||||
};
|
||||
|
||||
const updateOfferta = async ({
|
||||
body,
|
||||
id,
|
||||
}: {
|
||||
id: number;
|
||||
body: {
|
||||
title?: string;
|
||||
content?: string;
|
||||
person_type?: "individual" | "legal_entity";
|
||||
is_active?: boolean;
|
||||
};
|
||||
}) => {
|
||||
const res = await httpClient.patch(`${OFFERTA}${id}/`, body);
|
||||
return res;
|
||||
};
|
||||
|
||||
const getAllOfferta = async (params: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
}): Promise<AxiosResponse<GetAllOfferta>> => {
|
||||
const res = await httpClient.get(OFFERTA, { params });
|
||||
return res;
|
||||
};
|
||||
|
||||
const getOneOfferta = async (
|
||||
id: number,
|
||||
): Promise<AxiosResponse<GetDetailOfferta>> => {
|
||||
const res = await httpClient.get(`${OFFERTA}${id}/`);
|
||||
return res;
|
||||
};
|
||||
|
||||
const deleteOfferta = async (id: number) => {
|
||||
const res = await httpClient.delete(`${OFFERTA}${id}/`);
|
||||
return res;
|
||||
};
|
||||
|
||||
const createHelpPage = async ({
|
||||
body,
|
||||
}: {
|
||||
body: {
|
||||
title: string;
|
||||
content: string;
|
||||
page_type: "privacy_policy" | "user_agreement";
|
||||
is_active: boolean;
|
||||
};
|
||||
}) => {
|
||||
const res = await httpClient.post(HELP_PAGE, body);
|
||||
return res;
|
||||
};
|
||||
|
||||
const getAllHelpPage = async (params: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
}): Promise<AxiosResponse<GetAllHelpPage>> => {
|
||||
const res = await httpClient.get(HELP_PAGE, { params });
|
||||
return res;
|
||||
};
|
||||
|
||||
const getDetailHelpPage = async (
|
||||
id: number,
|
||||
): Promise<AxiosResponse<GetDetailHelpPage>> => {
|
||||
const res = await httpClient.get(`${HELP_PAGE}${id}/`);
|
||||
return res;
|
||||
};
|
||||
|
||||
const updateHelpPage = async ({
|
||||
body,
|
||||
id,
|
||||
}: {
|
||||
id: number;
|
||||
body: {
|
||||
title?: string;
|
||||
content?: string;
|
||||
page_type?: "privacy_policy" | "user_agreement";
|
||||
is_active?: boolean;
|
||||
};
|
||||
}) => {
|
||||
const res = await httpClient.patch(`${HELP_PAGE}${id}/`, body);
|
||||
return res;
|
||||
};
|
||||
|
||||
const deleteHelpPage = async (id: number) => {
|
||||
const res = await httpClient.delete(`${HELP_PAGE}${id}/`);
|
||||
return res;
|
||||
};
|
||||
|
||||
export {
|
||||
createHelpPage,
|
||||
createOfferta,
|
||||
deleteHelpPage,
|
||||
deleteOfferta,
|
||||
getAllHelpPage,
|
||||
getAllOfferta,
|
||||
getDetailHelpPage,
|
||||
getOneOfferta,
|
||||
updateHelpPage,
|
||||
updateOfferta,
|
||||
};
|
||||
63
src/pages/site-page/lib/types.ts
Normal file
63
src/pages/site-page/lib/types.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
export interface GetAllOfferta {
|
||||
status: boolean;
|
||||
data: {
|
||||
links: {
|
||||
previous: string;
|
||||
next: string;
|
||||
};
|
||||
total_items: number;
|
||||
total_pages: number;
|
||||
page_size: number;
|
||||
current_page: number;
|
||||
results: {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
person_type: "individual" | "legal_entity";
|
||||
is_active: boolean;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface GetDetailOfferta {
|
||||
status: boolean;
|
||||
data: {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
person_type: "individual" | "legal_entity";
|
||||
is_active: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GetAllHelpPage {
|
||||
status: boolean;
|
||||
data: {
|
||||
links: {
|
||||
previous: string;
|
||||
next: string;
|
||||
};
|
||||
total_items: number;
|
||||
total_pages: number;
|
||||
page_size: number;
|
||||
current_page: number;
|
||||
results: {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
page_type: "privacy_policy" | "user_agreement";
|
||||
is_active: boolean;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface GetDetailHelpPage {
|
||||
status: true;
|
||||
data: {
|
||||
id: number;
|
||||
title: string;
|
||||
content: string;
|
||||
page_type: "privacy_policy" | "user_agreement";
|
||||
is_active: true;
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,10 @@
|
||||
import {
|
||||
createHelpPage,
|
||||
deleteHelpPage,
|
||||
getAllHelpPage,
|
||||
getDetailHelpPage,
|
||||
updateHelpPage,
|
||||
} from "@/pages/site-page/lib/api";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
|
||||
import { Checkbox } from "@/shared/ui/checkbox";
|
||||
@@ -16,84 +23,150 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Edit2, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ReactQuill from "react-quill-new";
|
||||
import "react-quill-new/dist/quill.snow.css";
|
||||
|
||||
type Offer = {
|
||||
id: string;
|
||||
title: string;
|
||||
audience: "Foydalanuvchi qo‘llanmasi" | "Maxfiylik siyosati";
|
||||
content: string;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const FAKE_DATA: Offer[] = [
|
||||
{
|
||||
id: "of-1",
|
||||
title: "Ommaviy oferta - Standart shartlar",
|
||||
audience: "Foydalanuvchi qo‘llanmasi",
|
||||
content:
|
||||
"Bu hujjat kompaniya va xizmatlardan foydalanish bo'yicha umumiy shartlarni o'z ichiga oladi.",
|
||||
active: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "of-2",
|
||||
title: "Foydalanuvchi qo‘llanmasi uchun oferta",
|
||||
audience: "Foydalanuvchi qo‘llanmasi",
|
||||
content: "Foydalanuvchi qo‘llanmasi uchun maxsus shartlar va kafolatlar.",
|
||||
active: false,
|
||||
createdAt: new Date(Date.now() - 86400000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
const STORAGE_KEY = "ommaviy_oferta_v1";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function PolicyCrud() {
|
||||
const [items, setItems] = useState<Offer[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [editing, setEditing] = useState<Offer | null>(null);
|
||||
const [form, setForm] = useState<Partial<Offer>>({
|
||||
title: "",
|
||||
audience: "Foydalanuvchi qo‘llanmasi",
|
||||
content: "",
|
||||
active: true,
|
||||
const { t } = useTranslation();
|
||||
const [deleteOpen, setDeleteOpen] = useState<boolean>(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { data: items } = useQuery({
|
||||
queryKey: ["help_page"],
|
||||
queryFn: () => {
|
||||
return getAllHelpPage({ page: 1, page_size: 99 });
|
||||
},
|
||||
select(data) {
|
||||
return data.data.data.results;
|
||||
},
|
||||
});
|
||||
|
||||
const [editing, setEditing] = useState<number | null>(null);
|
||||
|
||||
const { data: detail } = useQuery({
|
||||
queryKey: ["help_page_detail", editing],
|
||||
queryFn: () => {
|
||||
return getDetailHelpPage(editing!);
|
||||
},
|
||||
select(data) {
|
||||
return data.data.data;
|
||||
},
|
||||
enabled: !!editing,
|
||||
});
|
||||
|
||||
const [form, setForm] = useState<{
|
||||
title: string;
|
||||
content: string;
|
||||
page_type: "privacy_policy" | "user_agreement";
|
||||
is_active: boolean;
|
||||
}>({
|
||||
title: "",
|
||||
content: "",
|
||||
page_type: "privacy_policy",
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Offer[];
|
||||
setItems(parsed);
|
||||
} catch {
|
||||
setItems(FAKE_DATA);
|
||||
}
|
||||
} else {
|
||||
setItems(FAKE_DATA);
|
||||
}
|
||||
}, []);
|
||||
const { mutate: create } = useMutation({
|
||||
mutationFn: ({
|
||||
body,
|
||||
}: {
|
||||
body: {
|
||||
title: string;
|
||||
content: string;
|
||||
page_type: "privacy_policy" | "user_agreement";
|
||||
is_active: boolean;
|
||||
};
|
||||
}) => createHelpPage({ body }),
|
||||
onSuccess: () => {
|
||||
resetForm();
|
||||
queryClient.refetchQueries({ queryKey: ["help_page"] });
|
||||
queryClient.refetchQueries({ queryKey: ["help_page_detail"] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("Xatolik yuz berdi"), {
|
||||
position: "top-center",
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||
}, [items]);
|
||||
const { mutate: update } = useMutation({
|
||||
mutationFn: ({
|
||||
body,
|
||||
id,
|
||||
}: {
|
||||
id: number;
|
||||
body: {
|
||||
title?: string;
|
||||
content?: string;
|
||||
page_type?: "privacy_policy" | "user_agreement";
|
||||
is_active?: boolean;
|
||||
};
|
||||
}) => updateHelpPage({ body, id }),
|
||||
onSuccess: () => {
|
||||
resetForm();
|
||||
queryClient.refetchQueries({ queryKey: ["help_page"] });
|
||||
queryClient.refetchQueries({ queryKey: ["help_page_detail"] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("Xatolik yuz berdi"), {
|
||||
position: "top-center",
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: deleteHelp } = useMutation({
|
||||
mutationFn: (id: number) => deleteHelpPage(id),
|
||||
onSuccess: () => {
|
||||
resetForm();
|
||||
queryClient.refetchQueries({ queryKey: ["help_page"] });
|
||||
queryClient.refetchQueries({ queryKey: ["help_page_detail"] });
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("Xatolik yuz berdi"), {
|
||||
position: "top-center",
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function resetForm() {
|
||||
setForm({
|
||||
title: "",
|
||||
audience: "Foydalanuvchi qo‘llanmasi",
|
||||
content: "",
|
||||
active: true,
|
||||
is_active: true,
|
||||
page_type: "privacy_policy",
|
||||
});
|
||||
setErrors({});
|
||||
setEditing(null);
|
||||
}
|
||||
|
||||
function validate(f: Partial<Offer>) {
|
||||
useEffect(() => {
|
||||
if (detail && editing) {
|
||||
setForm({
|
||||
content: detail.content,
|
||||
is_active: detail.is_active,
|
||||
page_type: detail.page_type,
|
||||
title: detail.title,
|
||||
});
|
||||
}
|
||||
}, [detail, editing]);
|
||||
|
||||
function validate(
|
||||
f: Partial<{
|
||||
title: string;
|
||||
content: string;
|
||||
page_type: "privacy_policy" | "user_agreement";
|
||||
is_active: boolean;
|
||||
}>,
|
||||
) {
|
||||
const e: Record<string, string> = {};
|
||||
if (!f.title || f.title.trim().length < 3)
|
||||
e.title = "Sarlavha kamida 3 ta belgidan iborat bo'lishi kerak";
|
||||
@@ -110,83 +183,78 @@ export default function PolicyCrud() {
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.id === editing.id ? { ...it, ...(form as Offer) } : it,
|
||||
),
|
||||
);
|
||||
resetForm();
|
||||
update({
|
||||
body: {
|
||||
content: form.content,
|
||||
is_active: form.is_active,
|
||||
page_type: form.page_type,
|
||||
title: form.title,
|
||||
},
|
||||
id: editing,
|
||||
});
|
||||
} else {
|
||||
const newItem: Offer = {
|
||||
id: `of-${Date.now()}`,
|
||||
title: (form.title || "Untitled").trim(),
|
||||
audience: (form.audience as Offer["audience"]) || "Barcha",
|
||||
content: (form.content || "").trim(),
|
||||
active: form.active ?? true,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setItems((prev) => [newItem, ...prev]);
|
||||
resetForm();
|
||||
create({
|
||||
body: {
|
||||
content: form.content,
|
||||
is_active: form.is_active,
|
||||
page_type: form.page_type,
|
||||
title: form.title,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(item: Offer) {
|
||||
function startEdit(item: number) {
|
||||
setEditing(item);
|
||||
setForm({ ...item });
|
||||
setErrors({});
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
setItems((prev) => prev.filter((p) => p.id !== id));
|
||||
function removeItem(id: number) {
|
||||
deleteHelp(id);
|
||||
}
|
||||
|
||||
function toggleActive(id: string) {
|
||||
setItems((prev) =>
|
||||
prev.map((p) => (p.id === id ? { ...p, active: !p.active } : p)),
|
||||
);
|
||||
function toggleActive(id: number, currentStatus: boolean) {
|
||||
update({
|
||||
id: id,
|
||||
body: {
|
||||
is_active: !currentStatus,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const filtered = items.filter((it) => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return (
|
||||
it.title.toLowerCase().includes(q) ||
|
||||
it.content.toLowerCase().includes(q) ||
|
||||
it.audience.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full p-6 bg-gray-900">
|
||||
<div className="max-w-[90%] mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold">Ommaviy oferta</h1>
|
||||
<h1 className="text-3xl font-bold">
|
||||
{t("Yordam sahifalari boshqaruvi")}
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<Card className="bg-gray-900">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{editing ? "Tahrirish" : "Yangi oferta yaratish"}
|
||||
{editing ? t("Tahrirlash") : t("Yangi yordam sahifasi yaratish")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Sarlavha</label>
|
||||
<label className="text-sm font-medium">{t("Sarlavha")}</label>
|
||||
<Input
|
||||
value={form.title || ""}
|
||||
onChange={(e) =>
|
||||
setForm((s) => ({ ...s, title: e.target.value }))
|
||||
}
|
||||
placeholder="Ommaviy oferta sarlavhasi"
|
||||
placeholder={t("Yordam sahifasi sarlavhasi")}
|
||||
className="mt-1"
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="text-destructive text-sm mt-1">{errors.title}</p>
|
||||
<p className="text-destructive text-sm mt-1">
|
||||
{t(errors.title)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-full w-[100%]">
|
||||
<label className="text-sm font-medium">Kontent</label>
|
||||
<div className="h-[280px] w-[100%]">
|
||||
<label className="text-sm font-medium">{t("Kontent")}</label>
|
||||
<div className="mt-1">
|
||||
<ReactQuill
|
||||
value={form.content || ""}
|
||||
@@ -194,37 +262,39 @@ export default function PolicyCrud() {
|
||||
setForm((s) => ({ ...s, content: value }))
|
||||
}
|
||||
className="bg-gray-900 h-48"
|
||||
placeholder="Oferta matnini kiriting..."
|
||||
placeholder={t("Yordam matnini kiriting...")}
|
||||
/>
|
||||
</div>
|
||||
{errors.content && (
|
||||
<p className="text-destructive text-sm mt-1">
|
||||
{errors.content}
|
||||
<p className="text-destructive text-sm mt-12">
|
||||
{t(errors.content)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 mt-24">
|
||||
<div className="grid grid-cols-1 gap-4 mt-5">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Kimlar uchun</label>
|
||||
<label className="text-sm font-medium">
|
||||
{t("Sahifa turi")}
|
||||
</label>
|
||||
<Select
|
||||
value={form.audience || "Barcha"}
|
||||
value={form.page_type}
|
||||
onValueChange={(value) =>
|
||||
setForm((s) => ({
|
||||
...s,
|
||||
audience: value as Offer["audience"],
|
||||
page_type: value as "privacy_policy" | "user_agreement",
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 w-full !h-12">
|
||||
<SelectValue />
|
||||
<SelectValue placeholder={t("Sahifa turini tanlang")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Foydalanuvchi qo‘llanmasi">
|
||||
Foydalanuvchi qo‘llanmasi
|
||||
<SelectItem value="user_agreement">
|
||||
{t("Qo‘llanma")}
|
||||
</SelectItem>
|
||||
<SelectItem value="Maxfiylik siyosati">
|
||||
Maxfiylik siyosati
|
||||
<SelectItem value="privacy_policy">
|
||||
{t("Maxfiylik siyosati")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
@@ -233,12 +303,12 @@ export default function PolicyCrud() {
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={!!form.active}
|
||||
checked={!!form.is_active}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((s) => ({ ...s, active: checked ? true : false }))
|
||||
}
|
||||
/>
|
||||
<span>Faol</span>
|
||||
<span>{t("Faol")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -248,92 +318,87 @@ export default function PolicyCrud() {
|
||||
onClick={handleCreateOrUpdate}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{editing ? "Saqlash" : "Yaratish"}
|
||||
{editing ? t("Saqlash") : t("Yaratish")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={resetForm}>
|
||||
Bekor qilish
|
||||
{t("Bekor qilish")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Qidirish sarlavha, kontent yoki auditoriya bo'yicha..."
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<div className="flex gap-4 text-sm text-muted-foreground">
|
||||
<span>Natija: {filtered.length}</span>
|
||||
<span>Barcha: {items.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{filtered.length === 0 && (
|
||||
{items?.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-muted-foreground text-center">
|
||||
Natija topilmadi.
|
||||
{t("Natija topilmadi.")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{filtered.map((it) => (
|
||||
{items?.map((it) => (
|
||||
<Card key={it.id} className="overflow-hidden">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg">{it.title}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{it.audience} • {new Date(it.createdAt).toLocaleString()}
|
||||
</p>
|
||||
<p className="mt-3 text-sm line-clamp-3">{it.content}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center flex-wrap md:flex-col md:flex-nowrap">
|
||||
<Button
|
||||
onClick={() => startEdit(it)}
|
||||
onClick={() => startEdit(it.id)}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
size="sm"
|
||||
>
|
||||
<Edit2 className="w-4 h-4 mr-1" />
|
||||
Tahrirlash
|
||||
{t("Tahrirlash")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => toggleActive(it.id)}
|
||||
variant={it.active ? "default" : "outline"}
|
||||
onClick={() => toggleActive(it.id, it.is_active)}
|
||||
variant={it.is_active ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={
|
||||
it.active ? "bg-green-600 hover:bg-green-700" : ""
|
||||
it.is_active
|
||||
? "bg-green-600 hover:bg-green-700 w-full"
|
||||
: "w-full"
|
||||
}
|
||||
>
|
||||
{it.active ? "Faol" : "Faol emas"}
|
||||
{it.is_active ? t("Faol") : t("Faol emas")}
|
||||
</Button>
|
||||
|
||||
<Dialog>
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
O'chirish
|
||||
{t("O'chirish")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogTitle>O'chirish tasdiqlash</DialogTitle>
|
||||
<DialogTitle>{t("O‘chirishni tasdiqlash")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Haqiqatan ham bu ofertani o'chirmoqchimisiz? Bu amalni
|
||||
bekor qilib bo'lmaydi.
|
||||
{t(
|
||||
"Haqiqatan ham bu yordam sahifasini o‘chirmoqchimisiz? Bu amalni bekor qilib bo‘lmaydi.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
<div className="flex gap-3 justify-end pt-4">
|
||||
<Button>Bekor qilish</Button>
|
||||
<Button onClick={() => setDeleteOpen(false)}>
|
||||
{t("Bekor qilish")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
onClick={() => removeItem(it.id)}
|
||||
>
|
||||
O'chirish
|
||||
{t("O'chirish")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import {
|
||||
createOfferta,
|
||||
deleteOfferta,
|
||||
getAllOfferta,
|
||||
getOneOfferta,
|
||||
updateOfferta,
|
||||
} from "@/pages/site-page/lib/api";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
|
||||
import { Checkbox } from "@/shared/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -16,47 +22,25 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Edit2, Trash2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import ReactQuill from "react-quill-new";
|
||||
import "react-quill-new/dist/quill.snow.css";
|
||||
|
||||
type Offer = {
|
||||
id: string;
|
||||
title: string;
|
||||
audience: "Jismoniy shaxslar" | "Yuridik shaxslar";
|
||||
content: string;
|
||||
active: boolean;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
const FAKE_DATA: Offer[] = [
|
||||
{
|
||||
id: "of-1",
|
||||
title: "Ommaviy oferta - Standart shartlar",
|
||||
audience: "Jismoniy shaxslar",
|
||||
content:
|
||||
"Bu hujjat kompaniya va xizmatlardan foydalanish bo'yicha umumiy shartlarni o'z ichiga oladi.",
|
||||
active: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: "of-2",
|
||||
title: "Yuridik shaxslar uchun oferta",
|
||||
audience: "Yuridik shaxslar",
|
||||
content: "Yuridik shaxslar uchun maxsus shartlar va kafolatlar.",
|
||||
active: false,
|
||||
createdAt: new Date(Date.now() - 86400000).toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
const STORAGE_KEY = "ommaviy_oferta_v1";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export default function OmmaviyOfertaCRUD() {
|
||||
const [items, setItems] = useState<Offer[]>([]);
|
||||
const [query, setQuery] = useState("");
|
||||
const [editing, setEditing] = useState<Offer | null>(null);
|
||||
const [form, setForm] = useState<Partial<Offer>>({
|
||||
const { t } = useTranslation();
|
||||
const [deleteOpen, setDeleteOpen] = useState<boolean>(false);
|
||||
const queryClient = useQueryClient();
|
||||
const [editing, setEditing] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<{
|
||||
title: string;
|
||||
content: string;
|
||||
audience: string;
|
||||
active: boolean;
|
||||
}>({
|
||||
title: "",
|
||||
audience: "Jismoniy shaxslar",
|
||||
content: "",
|
||||
@@ -64,24 +48,6 @@ export default function OmmaviyOfertaCRUD() {
|
||||
});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
useEffect(() => {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) {
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Offer[];
|
||||
setItems(parsed);
|
||||
} catch {
|
||||
setItems(FAKE_DATA);
|
||||
}
|
||||
} else {
|
||||
setItems(FAKE_DATA);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(items));
|
||||
}, [items]);
|
||||
|
||||
function resetForm() {
|
||||
setForm({
|
||||
title: "",
|
||||
@@ -93,100 +59,214 @@ export default function OmmaviyOfertaCRUD() {
|
||||
setEditing(null);
|
||||
}
|
||||
|
||||
function validate(f: Partial<Offer>) {
|
||||
const e: Record<string, string> = {};
|
||||
if (!f.title || f.title.trim().length < 3)
|
||||
e.title = "Sarlavha kamida 3 ta belgidan iborat bo'lishi kerak";
|
||||
if (!f.content || f.content.trim().length < 10)
|
||||
e.content = "Kontent kamida 10 ta belgidan iborat bo'lishi kerak";
|
||||
return e;
|
||||
}
|
||||
const { mutate: create } = useMutation({
|
||||
mutationFn: (body: {
|
||||
title: string;
|
||||
content: string;
|
||||
person_type: "individual" | "legal_entity";
|
||||
is_active: boolean;
|
||||
}) => createOfferta({ body }),
|
||||
onSuccess: () => {
|
||||
resetForm();
|
||||
queryClient.refetchQueries({ queryKey: ["all_offerta"] });
|
||||
queryClient.refetchQueries({ queryKey: ["detail_offerta"] });
|
||||
toast.success(t("Muvaffaqiyatli yaratildi"), {
|
||||
position: "top-center",
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("Xatolik yuz berdi"), {
|
||||
position: "top-center",
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: update } = useMutation({
|
||||
mutationFn: ({
|
||||
body,
|
||||
id,
|
||||
}: {
|
||||
body: {
|
||||
title?: string;
|
||||
content?: string;
|
||||
person_type?: "individual" | "legal_entity";
|
||||
is_active?: boolean;
|
||||
};
|
||||
id: number;
|
||||
}) => updateOfferta({ body, id }),
|
||||
onSuccess: () => {
|
||||
resetForm();
|
||||
setEditing(null);
|
||||
queryClient.refetchQueries({ queryKey: ["all_offerta"] });
|
||||
queryClient.refetchQueries({ queryKey: ["detail_offerta"] });
|
||||
toast.success(t("Muvaffaqiyatli yangilandi"), {
|
||||
position: "top-center",
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("Xatolik yuz berdi"), {
|
||||
position: "top-center",
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: removeOfferta } = useMutation({
|
||||
mutationFn: ({ id }: { id: number }) => deleteOfferta(id),
|
||||
onSuccess: () => {
|
||||
resetForm();
|
||||
queryClient.refetchQueries({ queryKey: ["all_offerta"] });
|
||||
queryClient.refetchQueries({ queryKey: ["detail_offerta"] });
|
||||
toast.success(t("Muvaffaqiyatli o'chirildi"), {
|
||||
position: "top-center",
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("Xatolik yuz berdi"), {
|
||||
position: "top-center",
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { data: allOfferta } = useQuery({
|
||||
queryKey: ["all_offerta"],
|
||||
queryFn: () => {
|
||||
return getAllOfferta({ page: 1, page_size: 99 });
|
||||
},
|
||||
select(data) {
|
||||
return data.data.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: detailOfferta } = useQuery({
|
||||
queryKey: ["detail_offerta", editing],
|
||||
queryFn: () => {
|
||||
return getOneOfferta(editing!);
|
||||
},
|
||||
select(data) {
|
||||
return data.data.data;
|
||||
},
|
||||
enabled: !!editing,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (editing && detailOfferta) {
|
||||
setForm({
|
||||
active: detailOfferta.is_active,
|
||||
audience:
|
||||
detailOfferta.person_type === "individual"
|
||||
? "Jismoniy shaxslar"
|
||||
: "Yuridik shaxslar",
|
||||
content: detailOfferta.content,
|
||||
title: detailOfferta.title,
|
||||
});
|
||||
}
|
||||
}, [detailOfferta, editing]);
|
||||
|
||||
function handleCreateOrUpdate() {
|
||||
const validation = validate(form);
|
||||
if (Object.keys(validation).length) {
|
||||
setErrors(validation);
|
||||
const newErrors: Record<string, string> = {};
|
||||
if (!form.title.trim()) {
|
||||
newErrors.title = "Sarlavha kiritish majburiy";
|
||||
} else if (form.title.trim().length < 3) {
|
||||
newErrors.title = "Sarlavha kamida 3 ta belgidan iborat bo'lishi kerak";
|
||||
}
|
||||
|
||||
const plainText = form.content.replace(/<[^>]+>/g, "").trim();
|
||||
if (!plainText) {
|
||||
newErrors.content = "Kontent kiritish majburiy";
|
||||
} else if (plainText.length < 10) {
|
||||
newErrors.content = "Kontent kamida 10 ta belgidan iborat bo'lishi kerak";
|
||||
}
|
||||
|
||||
if (!form.audience) {
|
||||
newErrors.audience = "Kimlar uchun degan maydonni tanlang";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
toast.error(t("Iltimos, barcha majburiy maydonlarni to'ldiring"), {
|
||||
position: "top-center",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
setItems((prev) =>
|
||||
prev.map((it) =>
|
||||
it.id === editing.id ? { ...it, ...(form as Offer) } : it,
|
||||
),
|
||||
);
|
||||
resetForm();
|
||||
} else {
|
||||
const newItem: Offer = {
|
||||
id: `of-${Date.now()}`,
|
||||
title: (form.title || "Untitled").trim(),
|
||||
audience: (form.audience as Offer["audience"]) || "Barcha",
|
||||
content: (form.content || "").trim(),
|
||||
active: form.active ?? true,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
setItems((prev) => [newItem, ...prev]);
|
||||
resetForm();
|
||||
if (editing === null) {
|
||||
create({
|
||||
content: form.content,
|
||||
is_active: form.active,
|
||||
person_type:
|
||||
form.audience === "Jismoniy shaxslar" ? "individual" : "legal_entity",
|
||||
title: form.title,
|
||||
});
|
||||
} else if (editing) {
|
||||
update({
|
||||
body: {
|
||||
content: form.content,
|
||||
is_active: form.active,
|
||||
person_type:
|
||||
form.audience === "Jismoniy shaxslar"
|
||||
? "individual"
|
||||
: "legal_entity",
|
||||
title: form.title,
|
||||
},
|
||||
id: editing,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function startEdit(item: Offer) {
|
||||
function startEdit(item: number) {
|
||||
setEditing(item);
|
||||
setForm({ ...item });
|
||||
setErrors({});
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
|
||||
function removeItem(id: string) {
|
||||
setItems((prev) => prev.filter((p) => p.id !== id));
|
||||
function removeItem(id: number) {
|
||||
removeOfferta({ id });
|
||||
}
|
||||
|
||||
function toggleActive(id: string) {
|
||||
setItems((prev) =>
|
||||
prev.map((p) => (p.id === id ? { ...p, active: !p.active } : p)),
|
||||
);
|
||||
function toggleActive(id: number, currentStatus: boolean) {
|
||||
update({
|
||||
id: id,
|
||||
body: {
|
||||
is_active: !currentStatus,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const filtered = items.filter((it) => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return true;
|
||||
return (
|
||||
it.title.toLowerCase().includes(q) ||
|
||||
it.content.toLowerCase().includes(q) ||
|
||||
it.audience.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="min-h-screen w-full p-6 bg-gray-900">
|
||||
<div className="max-w-[90%] mx-auto space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-3xl font-bold">Ommaviy oferta</h1>
|
||||
<h1 className="text-3xl font-bold">{t("Ommaviy oferta")}</h1>
|
||||
</div>
|
||||
|
||||
<Card className="bg-gray-900">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{editing ? "Tahrirish" : "Yangi oferta yaratish"}
|
||||
{editing ? t("Tahrirlash") : t("Yangi oferta yaratish")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Sarlavha</label>
|
||||
<label className="text-sm font-medium">{t("Sarlavha")}</label>
|
||||
<Input
|
||||
value={form.title || ""}
|
||||
onChange={(e) =>
|
||||
setForm((s) => ({ ...s, title: e.target.value }))
|
||||
}
|
||||
placeholder="Ommaviy oferta sarlavhasi"
|
||||
placeholder={t("Ommaviy oferta sarlavhasi")}
|
||||
className="mt-1"
|
||||
/>
|
||||
{errors.title && (
|
||||
<p className="text-destructive text-sm mt-1">{errors.title}</p>
|
||||
<p className="text-destructive text-sm mt-1">
|
||||
{t(errors.title)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="h-full w-[100%]">
|
||||
<label className="text-sm font-medium">Kontent</label>
|
||||
<div className="h-[280px] w-[100%]">
|
||||
<label className="text-sm font-medium">{t("Kontent")}</label>
|
||||
<div className="mt-1">
|
||||
<ReactQuill
|
||||
value={form.content || ""}
|
||||
@@ -194,54 +274,41 @@ export default function OmmaviyOfertaCRUD() {
|
||||
setForm((s) => ({ ...s, content: value }))
|
||||
}
|
||||
className="bg-gray-900 h-48"
|
||||
placeholder="Oferta matnini kiriting..."
|
||||
placeholder={t("Oferta matnini kiriting...")}
|
||||
/>
|
||||
</div>
|
||||
{errors.content && (
|
||||
<p className="text-destructive text-sm mt-1">
|
||||
{errors.content}
|
||||
<p className="text-destructive text-sm mt-12">
|
||||
{t(errors.content)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-4 mt-24">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Kimlar uchun</label>
|
||||
<Select
|
||||
value={form.audience || "Barcha"}
|
||||
onValueChange={(value) =>
|
||||
setForm((s) => ({
|
||||
...s,
|
||||
audience: value as Offer["audience"],
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 w-full !h-12">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Barcha">Barcha</SelectItem>
|
||||
<SelectItem value="Jismoniy shaxslar">
|
||||
Jismoniy shaxslar uchun
|
||||
</SelectItem>
|
||||
<SelectItem value="Yuridik shaxslar">
|
||||
Yuridik shaxslar uchun
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox
|
||||
checked={!!form.active}
|
||||
onCheckedChange={(checked) =>
|
||||
setForm((s) => ({ ...s, active: checked ? true : false }))
|
||||
}
|
||||
/>
|
||||
<span>Faol</span>
|
||||
</label>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">{t("Kimlar uchun")}</label>
|
||||
<Select
|
||||
value={form.audience || t("Barcha")}
|
||||
onValueChange={(value) =>
|
||||
setForm((s) => ({ ...s, audience: value }))
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="mt-1 w-full !h-12">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="Jismoniy shaxslar">
|
||||
{t("Jismoniy shaxslar uchun")}
|
||||
</SelectItem>
|
||||
<SelectItem value="Yuridik shaxslar">
|
||||
{t("Yuridik shaxslar uchun")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{errors.audience && (
|
||||
<p className="text-destructive text-sm mt-1">
|
||||
{t(errors.audience)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-4">
|
||||
@@ -249,92 +316,91 @@ export default function OmmaviyOfertaCRUD() {
|
||||
onClick={handleCreateOrUpdate}
|
||||
className="bg-blue-600 hover:bg-blue-700"
|
||||
>
|
||||
{editing ? "Saqlash" : "Yaratish"}
|
||||
{editing ? t("Saqlash") : t("Qo'shish")}
|
||||
</Button>
|
||||
<Button variant="outline" onClick={resetForm}>
|
||||
Bekor qilish
|
||||
{t("Bekor qilish")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
|
||||
<Input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Qidirish sarlavha, kontent yoki auditoriya bo'yicha..."
|
||||
className="max-w-sm"
|
||||
/>
|
||||
<div className="flex gap-4 text-sm text-muted-foreground">
|
||||
<span>Natija: {filtered.length}</span>
|
||||
<span>Barcha: {items.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{filtered.length === 0 && (
|
||||
{allOfferta && allOfferta?.results.length === 0 && (
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<p className="text-muted-foreground text-center">
|
||||
Natija topilmadi.
|
||||
{t("Natija topilmadi.")}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{filtered.map((it) => (
|
||||
{allOfferta?.results.map((it) => (
|
||||
<Card key={it.id} className="overflow-hidden">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold text-lg">{it.title}</h3>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{it.audience} • {new Date(it.createdAt).toLocaleString()}
|
||||
{it.person_type == "individual"
|
||||
? t("Jismoniy shaxslar uchun")
|
||||
: t("Yuridik shaxslar uchun")}
|
||||
</p>
|
||||
<p className="mt-3 text-sm line-clamp-3">{it.content}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center flex-wrap md:flex-col md:flex-nowrap">
|
||||
<Button
|
||||
onClick={() => startEdit(it)}
|
||||
onClick={() => startEdit(it.id)}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<Edit2 className="w-4 h-4 mr-1" />
|
||||
Tahrirlash
|
||||
{t("Tahrirlash")}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onClick={() => toggleActive(it.id)}
|
||||
variant={it.active ? "default" : "outline"}
|
||||
onClick={() => toggleActive(it.id, it.is_active)}
|
||||
variant={it.is_active ? "default" : "outline"}
|
||||
size="sm"
|
||||
className={
|
||||
it.active ? "bg-green-600 hover:bg-green-700" : ""
|
||||
it.is_active
|
||||
? "w-full bg-green-600 hover:bg-green-700"
|
||||
: "w-full"
|
||||
}
|
||||
>
|
||||
{it.active ? "Faol" : "Faol emas"}
|
||||
{it.is_active ? t("Faol") : t("Faol emas")}
|
||||
</Button>
|
||||
|
||||
<Dialog>
|
||||
<Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm">
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4 mr-1" />
|
||||
O'chirish
|
||||
{t("O'chirish")}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogTitle>O'chirish tasdiqlash</DialogTitle>
|
||||
<DialogTitle>{t("O'chirish tasdiqlash")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Haqiqatan ham bu ofertani o'chirmoqchimisiz? Bu amalni
|
||||
bekor qilib bo'lmaydi.
|
||||
{t(
|
||||
"Haqiqatan ham bu ofertani o'chirmoqchimisiz? Bu amalni bekor qilib bo'lmaydi.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
<div className="flex gap-3 justify-end pt-4">
|
||||
<Button>Bekor qilish</Button>
|
||||
<Button onClick={() => setDeleteOpen(false)}>
|
||||
{t("Bekor qilish")}
|
||||
</Button>
|
||||
<Button
|
||||
variant={"destructive"}
|
||||
onClick={() => removeItem(it.id)}
|
||||
>
|
||||
O'chirish
|
||||
{t("O'chirish")}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
|
||||
64
src/pages/support/lib/api.ts
Normal file
64
src/pages/support/lib/api.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type {
|
||||
GetSupportAgency,
|
||||
GetSupportUser,
|
||||
} from "@/pages/support/lib/types";
|
||||
import httpClient from "@/shared/config/api/httpClient";
|
||||
import { SUPPORT_AGENCY, SUPPORT_USER } from "@/shared/config/api/URLs";
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
const getSupportUser = async (params: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
status: "pending" | "done" | "failed" | "";
|
||||
}): Promise<AxiosResponse<GetSupportUser>> => {
|
||||
const res = await httpClient.get(SUPPORT_USER, { params });
|
||||
return res;
|
||||
};
|
||||
|
||||
const updateSupportUser = async ({
|
||||
body,
|
||||
id,
|
||||
}: {
|
||||
id: number;
|
||||
body: {
|
||||
name: string;
|
||||
phone_number: string;
|
||||
travel_agency: number | null;
|
||||
status: "pending" | "done" | "failed";
|
||||
};
|
||||
}) => {
|
||||
const res = await httpClient.patch(`${SUPPORT_USER}${id}/`, body);
|
||||
return res;
|
||||
};
|
||||
|
||||
const deleteSupportUser = async ({ id }: { id: number }) => {
|
||||
const res = await httpClient.delete(`${SUPPORT_USER}${id}/`);
|
||||
return res;
|
||||
};
|
||||
|
||||
//support for agency
|
||||
|
||||
const getSupportAgency = async (params: {
|
||||
page: number;
|
||||
page_size: number;
|
||||
search: string;
|
||||
status: "pending" | "approved" | "cancelled" | "";
|
||||
}): Promise<AxiosResponse<GetSupportAgency>> => {
|
||||
const res = await httpClient.get(SUPPORT_AGENCY, { params });
|
||||
return res;
|
||||
};
|
||||
|
||||
const getSupportAgencyDetail = async (
|
||||
id: number,
|
||||
): Promise<AxiosResponse<any>> => {
|
||||
const res = await httpClient.get(`${SUPPORT_AGENCY}${id}/`);
|
||||
return res;
|
||||
};
|
||||
|
||||
export {
|
||||
deleteSupportUser,
|
||||
getSupportAgency,
|
||||
getSupportAgencyDetail,
|
||||
getSupportUser,
|
||||
updateSupportUser,
|
||||
};
|
||||
80
src/pages/support/lib/types.ts
Normal file
80
src/pages/support/lib/types.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
export interface GetSupportUser {
|
||||
status: boolean;
|
||||
data: {
|
||||
links: {
|
||||
previous: null | string;
|
||||
next: null | string;
|
||||
};
|
||||
total_items: number;
|
||||
total_pages: number;
|
||||
page_size: number;
|
||||
current_page: number;
|
||||
results: GetSupportUserRes[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface GetSupportUserRes {
|
||||
id: number;
|
||||
name: string;
|
||||
phone_number: string;
|
||||
travel_agency: null | number;
|
||||
status: "pending" | "done" | "failed";
|
||||
}
|
||||
|
||||
export interface GetSupportAgency {
|
||||
status: boolean;
|
||||
data: {
|
||||
links: {
|
||||
previous: null | string;
|
||||
next: null | string;
|
||||
};
|
||||
total_items: number;
|
||||
total_pages: number;
|
||||
page_size: number;
|
||||
current_page: number;
|
||||
results: GetSupportAgencyRes[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface GetSupportAgencyRes {
|
||||
id: number;
|
||||
status: "pending" | "approved" | "cancelled";
|
||||
name: string;
|
||||
addres: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
web_site: string;
|
||||
travel_agency_documents: [
|
||||
{
|
||||
file: string;
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export interface GetSupportAgencyDetail {
|
||||
status: boolean;
|
||||
data: {
|
||||
links: {
|
||||
previous: null | string;
|
||||
next: null | string;
|
||||
};
|
||||
total_items: number;
|
||||
total_pages: number;
|
||||
page_size: number;
|
||||
current_page: number;
|
||||
results: {
|
||||
id: number;
|
||||
status: "pending" | "approved" | "cancelled";
|
||||
name: string;
|
||||
addres: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
web_site: string;
|
||||
travel_agency_documents: [
|
||||
{
|
||||
file: string;
|
||||
},
|
||||
];
|
||||
}[];
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,11 @@
|
||||
import { XIcon } from "lucide-react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { getSupportAgency } from "@/pages/support/lib/api";
|
||||
import type { GetSupportAgencyRes } from "@/pages/support/lib/types";
|
||||
import formatPhone from "@/shared/lib/formatPhone";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Loader2, XIcon } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
interface Data {
|
||||
@@ -43,110 +49,118 @@ const sampleData: Data[] = [
|
||||
|
||||
const SupportAgency = ({ requests = sampleData }) => {
|
||||
const [query, setQuery] = useState("");
|
||||
const [selected, setSelected] = useState<Data | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const [selected, setSelected] = useState<GetSupportAgencyRes | null>(null);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!query.trim()) return requests;
|
||||
const q = query.toLowerCase();
|
||||
return requests.filter(
|
||||
(r) =>
|
||||
(r.name && r.name.toLowerCase().includes(q)) ||
|
||||
(r.email && r.email.toLowerCase().includes(q)) ||
|
||||
(r.phone && r.phone.toLowerCase().includes(q)),
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["support_agency"],
|
||||
queryFn: () =>
|
||||
getSupportAgency({ page: 1, page_size: 10, search: "", status: "" }),
|
||||
});
|
||||
|
||||
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>
|
||||
);
|
||||
}, [requests, query]);
|
||||
}
|
||||
|
||||
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="p-4 w-full mx-auto">
|
||||
<h2 className="text-2xl font-semibold mb-4">Agentlik soʻrovlari</h2>
|
||||
<h2 className="text-2xl font-semibold mb-4">
|
||||
{t("Agentlik so'rovlari")}
|
||||
</h2>
|
||||
|
||||
<div className="flex gap-3 mb-6">
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Qidiruv (ism, email yoki telefon)..."
|
||||
placeholder={t("Qidiruv (ism, email yoki telefon)...")}
|
||||
className="flex-1 p-2 border rounded-md focus:outline-none focus:ring"
|
||||
/>
|
||||
<button
|
||||
onClick={() => setQuery("")}
|
||||
className="px-4 py-2 bg-gray-500 rounded-md hover:bg-gray-300 transition"
|
||||
>
|
||||
Tozalash
|
||||
{t("Tozalash")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{filtered.length === 0 ? (
|
||||
<div className="text-center text-gray-500">Soʻrov topilmadi.</div>
|
||||
{data && data.data.data.results.length === 0 ? (
|
||||
<div className="text-center text-gray-500">{t("So'rov topilmadi")}</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="border rounded-lg p-4 shadow-sm hover:shadow-md transition bg-gray-800 text-white"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">{r.name}</h3>
|
||||
<p className="text-md">{r.address}</p>
|
||||
{data &&
|
||||
data.data.data.results.map((r) => (
|
||||
<div
|
||||
key={r.id}
|
||||
className="border rounded-lg p-4 shadow-sm hover:shadow-md transition bg-gray-800 text-white"
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">{r.name}</h3>
|
||||
<p className="text-md">{r.addres}</p>
|
||||
</div>
|
||||
<div className="text-md">{formatPhone(r.phone)}</div>
|
||||
</div>
|
||||
<div className="text-md">{r.phone}</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 text-sm text-white">
|
||||
<div>
|
||||
<strong>Email:</strong>{" "}
|
||||
<Link to={`mailto:${r.email}`} className="text-white">
|
||||
{r.email}
|
||||
<div className="mt-3 text-sm text-white">
|
||||
<div>
|
||||
<strong>{t("Email")}:</strong>{" "}
|
||||
<Link to={`mailto:${r.email}`} className="text-white">
|
||||
{r.email}
|
||||
</Link>
|
||||
</div>
|
||||
{r.web_site && (
|
||||
<div>
|
||||
<strong>{t("Veb-sayt")}:</strong>{" "}
|
||||
<a
|
||||
href={r.web_site}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-white hover:underline"
|
||||
>
|
||||
{r.web_site.replace(/^https?:\/\//, "")}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
onClick={() => setSelected(r)}
|
||||
className="px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition"
|
||||
>
|
||||
{t("Tafsilotlar")}
|
||||
</button>
|
||||
<Link
|
||||
to={`mailto:${r.email}`}
|
||||
className="px-3 py-1 rounded border text-sm transition"
|
||||
>
|
||||
{t("Javob yozish")}
|
||||
</Link>
|
||||
</div>
|
||||
{r.instagram && (
|
||||
<div>
|
||||
<strong>Instagram:</strong>{" "}
|
||||
<a
|
||||
href={r.instagram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-white hover:underline"
|
||||
>
|
||||
@
|
||||
{r.instagram.replace(
|
||||
/^https?:\/\/(www\.)?instagram\.com\/?/,
|
||||
"",
|
||||
)}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{r.web_site && (
|
||||
<div>
|
||||
<strong>Website:</strong>{" "}
|
||||
<a
|
||||
href={r.web_site}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-white hover:underline"
|
||||
>
|
||||
{r.web_site.replace(/^https?:\/\//, "")}
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex gap-2">
|
||||
<button
|
||||
onClick={() => setSelected(r)}
|
||||
className="px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition"
|
||||
>
|
||||
Tafsilotlar
|
||||
</button>
|
||||
<Link
|
||||
to={`mailto:${r.email}`}
|
||||
className="px-3 py-1 rounded border text-sm transition"
|
||||
>
|
||||
Javob yozish
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -167,11 +181,11 @@ const SupportAgency = ({ requests = sampleData }) => {
|
||||
</button>
|
||||
|
||||
<h3 className="text-xl font-semibold mb-2">{selected.name}</h3>
|
||||
<p className="text-md text-white mb-4">{selected.address}</p>
|
||||
<p className="text-md text-white mb-4">{selected.addres}</p>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<div className="text-md text-white">Email</div>
|
||||
<div className="text-md text-white">{t("Email")}</div>
|
||||
<a
|
||||
href={`mailto:${selected.email}`}
|
||||
className="block text-white hover:underline"
|
||||
@@ -181,28 +195,12 @@ const SupportAgency = ({ requests = sampleData }) => {
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-md text-white">Telefon</div>
|
||||
<div>{selected.phone}</div>
|
||||
<div className="text-md text-white">{t("Telefon raqam")}</div>
|
||||
<div>{formatPhone(selected.phone)}</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-md text-white">Instagram</div>
|
||||
{selected.instagram ? (
|
||||
<a
|
||||
href={selected.instagram}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="block text-white hover:underline"
|
||||
>
|
||||
{selected.instagram}
|
||||
</a>
|
||||
) : (
|
||||
<div className="text-white">—</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="text-xs text-white">Website</div>
|
||||
<div className="text-xs text-white">{t("Veb-sayt")}</div>
|
||||
{selected.web_site ? (
|
||||
<a
|
||||
href={selected.web_site}
|
||||
@@ -219,26 +217,27 @@ const SupportAgency = ({ requests = sampleData }) => {
|
||||
</div>
|
||||
|
||||
<div className="mt-5">
|
||||
<div className="text-sm font-medium mb-2">Hujjatlar</div>
|
||||
{selected.documents && selected.documents.length > 0 ? (
|
||||
<div className="text-sm font-medium mb-2">{t("Hujjatlar")}</div>
|
||||
{selected.travel_agency_documents &&
|
||||
selected.travel_agency_documents.length > 0 ? (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||
{selected.documents.map((doc, i) => (
|
||||
{selected.travel_agency_documents.map((doc, i) => (
|
||||
<a
|
||||
key={i}
|
||||
href={doc}
|
||||
href={doc.file}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="group relative aspect-square border-2 border-gray-200 rounded-lg overflow-hidden hover:border-blue-500 transition"
|
||||
>
|
||||
<img
|
||||
src={doc}
|
||||
src={doc.file}
|
||||
alt={`Hujjat ${i + 1}`}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/10 transition flex items-end">
|
||||
<div className="w-full bg-gradient-to-t from-black/70 to-transparent p-2">
|
||||
<span className="text-white text-xs font-medium">
|
||||
Hujjat {i + 1}
|
||||
{t("Hujjat")} {i + 1}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -246,7 +245,7 @@ const SupportAgency = ({ requests = sampleData }) => {
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-500">Hujjat topilmadi</div>
|
||||
<div className="text-gray-500">{t("Hujjat topilmadi")}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -258,7 +257,7 @@ const SupportAgency = ({ requests = sampleData }) => {
|
||||
}}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 transition"
|
||||
>
|
||||
Qabul qilish
|
||||
{t("Qabul qilish")}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -267,7 +266,7 @@ const SupportAgency = ({ requests = sampleData }) => {
|
||||
}}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition"
|
||||
>
|
||||
Rad etish
|
||||
{t("Rad etish")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { getDetailAgency } from "@/pages/agencies/lib/api";
|
||||
import {
|
||||
deleteSupportUser,
|
||||
getSupportUser,
|
||||
updateSupportUser,
|
||||
} from "@/pages/support/lib/api"; // deleteSupportUser import qiling
|
||||
import type { GetSupportUserRes } from "@/pages/support/lib/types";
|
||||
import formatPhone from "@/shared/lib/formatPhone";
|
||||
import { Badge } from "@/shared/ui/badge";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
|
||||
@@ -10,129 +18,201 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { MessageCircle, Phone, User } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { AlertTriangle, Loader2, Phone, Trash2, User } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
||||
type SupportRequest = {
|
||||
id: number;
|
||||
name: string;
|
||||
phone: string;
|
||||
message: string;
|
||||
status: "Pending" | "Resolved";
|
||||
};
|
||||
|
||||
const initialRequests: SupportRequest[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Alisher Karimov",
|
||||
phone: "+998 90 123 45 67",
|
||||
message: "Sayohat uchun viza hujjatlarini tayyorlashda yordam kerak.",
|
||||
status: "Pending",
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Dilnoza Tursunova",
|
||||
phone: "+998 91 765 43 21",
|
||||
message: "To‘lov muvaffaqiyatli o‘tmadi, yordam bera olasizmi?",
|
||||
status: "Resolved",
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Jamshid Abdullayev",
|
||||
phone: "+998 93 555 22 11",
|
||||
message: "Sayohat sanasini o‘zgartirishni istayman.",
|
||||
status: "Pending",
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Jamshid Abdullayev",
|
||||
phone: "+998 93 555 22 11",
|
||||
message: "Sayohat sanasini o‘zgartirishni istayman.",
|
||||
status: "Pending",
|
||||
},
|
||||
];
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const SupportTours = () => {
|
||||
const [requests, setRequests] = useState<SupportRequest[]>(initialRequests);
|
||||
const [selected, setSelected] = useState<SupportRequest | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const [selected, setSelected] = useState<GetSupportUserRes | null>(null);
|
||||
const [selectedToDelete, setSelectedToDelete] =
|
||||
useState<GetSupportUserRes | null>(null);
|
||||
const queryClient = useQueryClient();
|
||||
const [filterStatus, setFilterStatus] = useState<
|
||||
"" | "pending" | "done" | "failed"
|
||||
>("");
|
||||
|
||||
const handleToggleStatus = (id: number) => {
|
||||
setRequests((prev) =>
|
||||
prev.map((req) =>
|
||||
req.id === id
|
||||
? {
|
||||
...req,
|
||||
status: req.status === "Pending" ? "Resolved" : "Pending",
|
||||
}
|
||||
: req,
|
||||
),
|
||||
);
|
||||
setSelected((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
status: prev.status === "Pending" ? "Resolved" : "Pending",
|
||||
}
|
||||
: prev,
|
||||
);
|
||||
const { data, isLoading, isError, refetch } = useQuery({
|
||||
queryKey: ["support_user", filterStatus],
|
||||
queryFn: () =>
|
||||
getSupportUser({ page: 1, page_size: 99, status: filterStatus }),
|
||||
});
|
||||
|
||||
const { data: agency } = useQuery({
|
||||
queryKey: ["detail_agency", selected?.travel_agency],
|
||||
queryFn: () => getDetailAgency({ id: Number(selected?.travel_agency) }),
|
||||
enabled: !!selected?.travel_agency,
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ body, id }: { id: number; body: GetSupportUserRes }) =>
|
||||
updateSupportUser({ body, id }),
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({ queryKey: ["support_user"] });
|
||||
setSelected(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("Xatolik yuz berdi"), {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => deleteSupportUser({ id }),
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({ queryKey: ["support_user"] });
|
||||
setSelectedToDelete(null);
|
||||
toast.success(t("Muvaffaqiyatli o'chirildi"), {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("O'chirishda xatolik yuz berdi"), {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const handleToggleStatus = (body: GetSupportUserRes) => {
|
||||
updateMutation.mutate({
|
||||
body: {
|
||||
...body,
|
||||
status: body.status === "pending" ? "done" : "pending",
|
||||
},
|
||||
id: body.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 bg-gray-900 text-gray-100 p-6 space-y-6 w-full">
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-4 text-white">
|
||||
Yordam so‘rovlari
|
||||
{t("Yordam so'rovlari")}
|
||||
</h1>
|
||||
|
||||
<div className="grid gap-5 sm:grid-cols-3 lg:grid-cols-3">
|
||||
{requests.map((req) => (
|
||||
<Card
|
||||
key={req.id}
|
||||
className="bg-gray-800/70 border border-gray-700 shadow-md hover:shadow-lg hover:bg-gray-800 transition-all duration-200"
|
||||
{/* Status Tabs */}
|
||||
<div className="flex gap-3 mb-4">
|
||||
{[
|
||||
{ label: t("Barchasi"), value: "" },
|
||||
{ label: t("Kutilmoqda"), value: "pending" },
|
||||
{ label: t("done"), value: "done" },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
className={`px-4 py-2 rounded-lg font-medium text-sm transition-colors ${
|
||||
filterStatus === tab.value
|
||||
? "bg-blue-600 text-white"
|
||||
: "bg-gray-700 text-gray-300 hover:bg-gray-600"
|
||||
}`}
|
||||
onClick={() => setFilterStatus(tab.value as any)}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="flex items-center justify-between">
|
||||
<span className="flex items-center gap-2 text-lg font-semibold text-white">
|
||||
<User className="w-5 h-5 text-blue-400" />
|
||||
{req.name}
|
||||
</span>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Cards */}
|
||||
<div className="grid gap-5 sm:grid-cols-3 lg:grid-cols-3">
|
||||
{data && data.data.data.results.length === 0 ? (
|
||||
<div className="col-span-full flex flex-col items-center justify-center min-h-[50vh] w-full text-center text-white gap-4">
|
||||
<p className="text-lg">{t("Natija topilmadi")}</p>
|
||||
</div>
|
||||
) : (
|
||||
data?.data.data.results.map((req) => (
|
||||
<Card
|
||||
key={req.id}
|
||||
className="bg-gray-800/70 border border-gray-700 shadow-md hover:shadow-lg hover:bg-gray-800 transition-all duration-200 justify-between"
|
||||
>
|
||||
<CardHeader className="pb-2 flex justify-between items-center">
|
||||
<div className="flex gap-2">
|
||||
<CardTitle className="flex items-center gap-2 text-lg font-semibold text-white">
|
||||
<User className="w-5 h-5 text-blue-400" />
|
||||
{req.name}
|
||||
</CardTitle>
|
||||
</div>
|
||||
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`px-2 py-1 rounded-md text-xs font-medium ${
|
||||
req.status === "Pending"
|
||||
req.status === "pending"
|
||||
? "bg-red-500/10 text-red-400 border-red-400/40"
|
||||
: "bg-green-500/10 text-green-400 border-green-400/40"
|
||||
}`}
|
||||
>
|
||||
{req.status === "Pending" ? "Kutilmoqda" : "Yakunlangan"}
|
||||
{req.status === "pending" ? t("Kutilmoqda") : t("done")}
|
||||
</Badge>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3 mt-1">
|
||||
<div className="flex items-center gap-2 text-sm text-gray-400">
|
||||
<Phone className="w-4 h-4 text-gray-400" />
|
||||
{req.phone}
|
||||
</div>
|
||||
|
||||
<div className="flex items-start gap-2 text-gray-300">
|
||||
<MessageCircle className="w-4 h-4 text-gray-400 mt-1" />
|
||||
<p className="text-sm leading-relaxed">{req.message}</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3 w-full border-gray-600 text-gray-200 hover:bg-gray-700 hover:text-white"
|
||||
onClick={() => setSelected(req)}
|
||||
>
|
||||
Batafsil ko‘rish
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<CardContent className="space-y-3 mt-1">
|
||||
{req.travel_agency !== null ? (
|
||||
<span className="text-md text-gray-400">
|
||||
{t("Agentlikka tegishli")}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-md text-gray-400">
|
||||
{t("Sayt bo'yicha")}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mt-2 text-sm text-gray-400">
|
||||
<Phone className="w-4 h-4 text-gray-400" />
|
||||
{formatPhone(req.phone_number)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 justify-end items-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-3 w-full border-gray-600 text-gray-200 hover:bg-gray-700 hover:text-white"
|
||||
onClick={() => setSelected(req)}
|
||||
>
|
||||
{t("Batafsil ko'rish")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="destructive"
|
||||
className="flex items-center gap-1"
|
||||
onClick={() => setSelectedToDelete(req)}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" /> {t("O'chirish")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Modal (Dialog) */}
|
||||
{/* Detail Modal */}
|
||||
<Dialog open={!!selected} onOpenChange={() => setSelected(null)}>
|
||||
<DialogContent className="bg-gray-800 border border-gray-700 text-gray-100 sm:max-w-lg">
|
||||
<DialogHeader>
|
||||
@@ -145,24 +225,26 @@ const SupportTours = () => {
|
||||
<div className="space-y-3 mt-2">
|
||||
<div className="flex items-center gap-2 text-gray-400">
|
||||
<Phone className="w-4 h-4" />
|
||||
{selected?.phone}
|
||||
</div>
|
||||
<div className="flex items-start gap-2 text-gray-300">
|
||||
<MessageCircle className="w-4 h-4 mt-1" />
|
||||
<p className="text-sm leading-relaxed">{selected?.message}</p>
|
||||
{selected && formatPhone(selected?.phone_number)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-400">Status:</span>
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
{selected && selected.travel_agency && agency?.data.data && (
|
||||
<span className="text-sm text-gray-400">
|
||||
{t("Agentlikka tegishli")}: {agency?.data.data.name}
|
||||
</span>
|
||||
)}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`px-2 py-1 rounded-md text-xs font-medium ${
|
||||
selected?.status === "Pending"
|
||||
selected?.status === "pending"
|
||||
? "bg-red-500/10 text-red-400 border-red-400/40"
|
||||
: "bg-green-500/10 text-green-400 border-green-400/40"
|
||||
}`}
|
||||
>
|
||||
{selected?.status === "Pending" ? "Kutilmoqda" : "Yakunlangan"}
|
||||
{selected?.status === "pending"
|
||||
? t("Kutilmoqda")
|
||||
: t("Yakunlangan")}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
@@ -173,25 +255,60 @@ const SupportTours = () => {
|
||||
className="border-gray-600 text-gray-200 hover:bg-gray-700 hover:text-white"
|
||||
onClick={() => setSelected(null)}
|
||||
>
|
||||
Yopish
|
||||
{t("Yopish")}
|
||||
</Button>
|
||||
{selected && (
|
||||
<Button
|
||||
onClick={() => handleToggleStatus(selected.id)}
|
||||
onClick={() => handleToggleStatus(selected)}
|
||||
className={`${
|
||||
selected.status === "Pending"
|
||||
selected.status === "pending"
|
||||
? "bg-green-600 hover:bg-green-700"
|
||||
: "bg-red-600 hover:bg-red-700"
|
||||
} text-white`}
|
||||
>
|
||||
{selected.status === "Pending"
|
||||
? "Yakunlandi deb belgilash"
|
||||
: "Kutilmoqda deb belgilash"}
|
||||
{selected.status === "pending"
|
||||
? t("Yakunlandi deb belgilash")
|
||||
: t("Kutilmoqda deb belgilash")}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={!!selectedToDelete}
|
||||
onOpenChange={() => setSelectedToDelete(null)}
|
||||
>
|
||||
<DialogContent className="bg-gray-800 border border-gray-700 text-gray-100 sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-semibold text-red-500 flex items-center gap-2">
|
||||
<Trash2 className="w-5 h-5" />
|
||||
{t("Diqqat! O'chirish")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<p className="text-gray-300 mt-2">
|
||||
{t("Siz rostdan ham ushbu so'rovni o'chirmoqchimisiz?")}
|
||||
</p>
|
||||
<DialogFooter className="flex justify-end gap-2 mt-4">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="border-gray-600 text-gray-200 hover:bg-gray-700 hover:text-white"
|
||||
onClick={() => setSelectedToDelete(null)}
|
||||
>
|
||||
{t("Bekor qilish")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
className="bg-red-600 hover:bg-red-700 text-white"
|
||||
onClick={() =>
|
||||
selectedToDelete && deleteMutation.mutate(selectedToDelete.id)
|
||||
}
|
||||
>
|
||||
{t("O'chirish")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
70
src/pages/tour-settings/lib/api.ts
Normal file
70
src/pages/tour-settings/lib/api.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import type {
|
||||
GetDetailSiteSetting,
|
||||
GetSiteSetting,
|
||||
} from "@/pages/tour-settings/lib/types";
|
||||
import httpClient from "@/shared/config/api/httpClient";
|
||||
import { SITE_SETTING } from "@/shared/config/api/URLs";
|
||||
import type { AxiosResponse } from "axios";
|
||||
|
||||
const createSiteSetting = async (body: {
|
||||
address: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
telegram: string;
|
||||
instagram: string;
|
||||
facebook: string;
|
||||
twitter: string;
|
||||
email: string;
|
||||
main_phone: string;
|
||||
other_phone: string;
|
||||
}) => {
|
||||
const res = await httpClient.post(SITE_SETTING, body);
|
||||
return res;
|
||||
};
|
||||
|
||||
const updateSiteSetting = async ({
|
||||
body,
|
||||
id,
|
||||
}: {
|
||||
id: number;
|
||||
body: {
|
||||
address: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
telegram: string;
|
||||
instagram: string;
|
||||
facebook: string;
|
||||
twitter: string;
|
||||
email: string;
|
||||
main_phone: string;
|
||||
other_phone: string;
|
||||
};
|
||||
}) => {
|
||||
const res = await httpClient.patch(`${SITE_SETTING}${id}/`, body);
|
||||
return res;
|
||||
};
|
||||
|
||||
const getSiteSetting = async (): Promise<AxiosResponse<GetSiteSetting>> => {
|
||||
const res = await httpClient.get(SITE_SETTING);
|
||||
return res;
|
||||
};
|
||||
|
||||
const getDetailSiteSetting = async (
|
||||
id: number,
|
||||
): Promise<AxiosResponse<GetDetailSiteSetting>> => {
|
||||
const res = await httpClient.get(`${SITE_SETTING}${id}/`);
|
||||
return res;
|
||||
};
|
||||
|
||||
const deleteSiteSetting = async (id: number) => {
|
||||
const res = await httpClient.delete(`${SITE_SETTING}${id}/`);
|
||||
return res;
|
||||
};
|
||||
|
||||
export {
|
||||
createSiteSetting,
|
||||
deleteSiteSetting,
|
||||
getDetailSiteSetting,
|
||||
getSiteSetting,
|
||||
updateSiteSetting,
|
||||
};
|
||||
43
src/pages/tour-settings/lib/types.ts
Normal file
43
src/pages/tour-settings/lib/types.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
export interface GetSiteSetting {
|
||||
status: boolean;
|
||||
data: {
|
||||
links: {
|
||||
previous: string;
|
||||
next: string;
|
||||
};
|
||||
total_items: number;
|
||||
total_pages: number;
|
||||
page_size: number;
|
||||
current_page: number;
|
||||
results: {
|
||||
id: number;
|
||||
address: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
telegram: string;
|
||||
instagram: string;
|
||||
facebook: string;
|
||||
twitter: string;
|
||||
email: string;
|
||||
main_phone: string;
|
||||
other_phone: string;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface GetDetailSiteSetting {
|
||||
status: boolean;
|
||||
data: {
|
||||
id: number;
|
||||
address: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
telegram: string;
|
||||
instagram: string;
|
||||
facebook: string;
|
||||
twitter: string;
|
||||
email: string;
|
||||
main_phone: string;
|
||||
other_phone: string;
|
||||
};
|
||||
}
|
||||
@@ -1,5 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createSiteSetting,
|
||||
deleteSiteSetting,
|
||||
getDetailSiteSetting,
|
||||
getSiteSetting,
|
||||
updateSiteSetting,
|
||||
} from "@/pages/tour-settings/lib/api";
|
||||
import formatPhone from "@/shared/lib/formatPhone";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
|
||||
import {
|
||||
@@ -12,27 +20,28 @@ import {
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { Label } from "@/shared/ui/label";
|
||||
import { Map, Placemark, YMaps } from "@pbe/react-yandex-maps";
|
||||
import { Edit, Plus, Trash } from "lucide-react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Edit, Loader2, Plus, Trash } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
|
||||
type MapClickEvent = {
|
||||
get: (key: "coords") => [number, number];
|
||||
};
|
||||
|
||||
type ContactInfo = {
|
||||
telegram?: string;
|
||||
instagram?: string;
|
||||
facebook?: string;
|
||||
twiter?: string;
|
||||
linkedin?: string;
|
||||
address?: string;
|
||||
email?: string;
|
||||
phonePrimary?: string;
|
||||
phoneSecondary?: string;
|
||||
telegram: string;
|
||||
instagram: string;
|
||||
facebook: string;
|
||||
twiter: string;
|
||||
linkedin: string;
|
||||
address: string;
|
||||
email: string;
|
||||
phonePrimary: string;
|
||||
phoneSecondary: string;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "site_contact_info";
|
||||
|
||||
async function getAddressFromCoords(lat: number, lon: number) {
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json`,
|
||||
@@ -42,26 +51,174 @@ async function getAddressFromCoords(lat: number, lon: number) {
|
||||
}
|
||||
|
||||
export default function ContactSettings() {
|
||||
const [contact, setContact] = useState<ContactInfo | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [form, setForm] = useState<ContactInfo>({});
|
||||
const [editing, setEditing] = useState<number | null>(null);
|
||||
const [form, setForm] = useState<ContactInfo>({
|
||||
telegram: "",
|
||||
instagram: "",
|
||||
facebook: "",
|
||||
twiter: "",
|
||||
linkedin: "",
|
||||
address: "",
|
||||
email: "",
|
||||
phonePrimary: "+998",
|
||||
phoneSecondary: "+998",
|
||||
});
|
||||
const [coords, setCoords] = useState({
|
||||
latitude: 41.311081,
|
||||
longitude: 69.240562,
|
||||
}); // Toshkent default
|
||||
});
|
||||
const { mutate: create, isPending } = useMutation({
|
||||
mutationFn: (body: {
|
||||
address: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
telegram: string;
|
||||
instagram: string;
|
||||
facebook: string;
|
||||
twitter: string;
|
||||
email: string;
|
||||
main_phone: string;
|
||||
other_phone: string;
|
||||
}) => createSiteSetting(body),
|
||||
onSuccess: () => {
|
||||
setForm({
|
||||
telegram: "",
|
||||
instagram: "",
|
||||
facebook: "",
|
||||
twiter: "",
|
||||
linkedin: "",
|
||||
address: "",
|
||||
email: "",
|
||||
phonePrimary: "",
|
||||
phoneSecondary: "",
|
||||
});
|
||||
setOpen(false);
|
||||
queryClient.refetchQueries({ queryKey: ["contact"] });
|
||||
queryClient.refetchQueries({ queryKey: ["contact_detail"] });
|
||||
setEditing(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("Xatolik yuz berdi"), {
|
||||
position: "top-center",
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Load saved contact
|
||||
useEffect(() => {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (raw) setContact(JSON.parse(raw));
|
||||
}, []);
|
||||
const { mutate: update } = useMutation({
|
||||
mutationFn: ({
|
||||
body,
|
||||
id,
|
||||
}: {
|
||||
id: number;
|
||||
body: {
|
||||
address: string;
|
||||
latitude: string;
|
||||
longitude: string;
|
||||
telegram: string;
|
||||
instagram: string;
|
||||
facebook: string;
|
||||
twitter: string;
|
||||
email: string;
|
||||
main_phone: string;
|
||||
other_phone: string;
|
||||
};
|
||||
}) => updateSiteSetting({ body, id }),
|
||||
onSuccess: () => {
|
||||
setForm({
|
||||
telegram: "",
|
||||
instagram: "",
|
||||
facebook: "",
|
||||
twiter: "",
|
||||
linkedin: "",
|
||||
address: "",
|
||||
email: "",
|
||||
phonePrimary: "",
|
||||
phoneSecondary: "",
|
||||
});
|
||||
setOpen(false);
|
||||
queryClient.refetchQueries({ queryKey: ["contact"] });
|
||||
queryClient.refetchQueries({ queryKey: ["contact_detail"] });
|
||||
setEditing(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("Xatolik yuz berdi"), {
|
||||
position: "top-center",
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: remover } = useMutation({
|
||||
mutationFn: (id: number) => deleteSiteSetting(id),
|
||||
onSuccess: () => {
|
||||
setForm({
|
||||
telegram: "",
|
||||
instagram: "",
|
||||
facebook: "",
|
||||
twiter: "",
|
||||
linkedin: "",
|
||||
address: "",
|
||||
email: "",
|
||||
phonePrimary: "",
|
||||
phoneSecondary: "",
|
||||
});
|
||||
setOpen(false);
|
||||
queryClient.refetchQueries({ queryKey: ["contact"] });
|
||||
queryClient.refetchQueries({ queryKey: ["contact_detail"] });
|
||||
setEditing(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("Xatolik yuz berdi"), {
|
||||
position: "top-center",
|
||||
richColors: true,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ["contact"],
|
||||
queryFn: () => {
|
||||
return getSiteSetting();
|
||||
},
|
||||
select: (data) => {
|
||||
return data.data.data;
|
||||
},
|
||||
});
|
||||
|
||||
const { data: detail } = useQuery({
|
||||
queryKey: ["contact_detail", editing],
|
||||
queryFn: () => {
|
||||
return getDetailSiteSetting(editing!);
|
||||
},
|
||||
select: (data) => {
|
||||
return data.data.data;
|
||||
},
|
||||
enabled: !!editing,
|
||||
});
|
||||
|
||||
// Populate form when editing
|
||||
useEffect(() => {
|
||||
if (open && editing && contact) setForm(contact);
|
||||
if (!open && !editing) setForm({});
|
||||
}, [open, editing, contact]);
|
||||
if (editing && detail) {
|
||||
setForm({
|
||||
address: detail.address,
|
||||
email: detail.email,
|
||||
facebook: detail.facebook,
|
||||
instagram: detail.instagram,
|
||||
linkedin: detail.twitter,
|
||||
phonePrimary: detail.main_phone,
|
||||
phoneSecondary: detail.other_phone,
|
||||
telegram: detail.telegram,
|
||||
twiter: detail.twitter,
|
||||
});
|
||||
setCoords({
|
||||
latitude: Number(detail.latitude),
|
||||
longitude: Number(detail.longitude),
|
||||
});
|
||||
}
|
||||
}, [editing, detail]);
|
||||
|
||||
const handleChange = <K extends keyof ContactInfo>(
|
||||
key: K,
|
||||
@@ -80,139 +237,196 @@ export default function ContactSettings() {
|
||||
};
|
||||
|
||||
const saveContact = () => {
|
||||
if (!form.email && !form.phonePrimary) {
|
||||
alert("Iltimos email yoki telefon kiriting");
|
||||
return;
|
||||
const shortLat = Number(coords.latitude.toFixed(6)); // 6ta raqamgacha
|
||||
const shortLon = Number(coords.longitude.toFixed(6));
|
||||
if (editing) {
|
||||
update({
|
||||
body: {
|
||||
address: form.address,
|
||||
email: form.email,
|
||||
facebook: form.facebook,
|
||||
instagram: form.instagram,
|
||||
latitude: String(shortLat),
|
||||
longitude: String(shortLon),
|
||||
main_phone: form.phonePrimary,
|
||||
other_phone: form.phoneSecondary,
|
||||
telegram: form.telegram,
|
||||
twitter: form.twiter,
|
||||
},
|
||||
id: editing,
|
||||
});
|
||||
} else {
|
||||
create({
|
||||
address: form.address,
|
||||
email: form.email,
|
||||
facebook: form.facebook,
|
||||
instagram: form.instagram,
|
||||
latitude: String(shortLat),
|
||||
longitude: String(shortLon),
|
||||
main_phone: form.phonePrimary,
|
||||
other_phone: form.phoneSecondary,
|
||||
telegram: form.telegram,
|
||||
twitter: form.twiter,
|
||||
});
|
||||
}
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(form));
|
||||
setContact(form);
|
||||
setOpen(false);
|
||||
setEditing(false);
|
||||
};
|
||||
|
||||
const startAdd = () => {
|
||||
setForm({});
|
||||
setEditing(false);
|
||||
setForm({
|
||||
telegram: "",
|
||||
instagram: "",
|
||||
facebook: "",
|
||||
twiter: "",
|
||||
linkedin: "",
|
||||
address: "",
|
||||
email: "",
|
||||
phonePrimary: "",
|
||||
phoneSecondary: "",
|
||||
});
|
||||
setEditing(null);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const startEdit = () => {
|
||||
setEditing(true);
|
||||
const startEdit = (id: number) => {
|
||||
setEditing(id);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const removeContact = () => {
|
||||
if (!confirm("Contact ma'lumotlarini o'chirishni xohlaysizmi?")) return;
|
||||
localStorage.removeItem(STORAGE_KEY);
|
||||
setContact(null);
|
||||
const removeContact = (id: number) => {
|
||||
remover(id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full mx-auto p-4">
|
||||
<div className="w-full h-screen mx-auto p-4">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-semibold text-gray-100">
|
||||
Contact settings
|
||||
{t("Contact settings")}
|
||||
</h2>
|
||||
{!contact && (
|
||||
{data && data?.total_items === 0 && (
|
||||
<Button onClick={startAdd} className="flex items-center gap-2">
|
||||
<Plus size={16} /> Qo'shish
|
||||
<Plus size={16} /> {t("Qo'shish")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!contact ? (
|
||||
<Card className="bg-gray-900">
|
||||
<CardHeader>
|
||||
<CardTitle>Hozircha kontakt ma'lumotlari qo'shilmagan</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sayt uchun telegram, instagram, manzil, email va telefonni bu
|
||||
yerda saqlang. Siz faqat bir marta qo'sha olasiz — keyin
|
||||
tahrirlash mumkin.
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button onClick={startAdd} className="flex items-center gap-2">
|
||||
<Plus size={14} /> Qo'shish
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center items-center h-60">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-gray-400" />
|
||||
<span className="ml-2 text-gray-400">{t("Yuklanmoqda...")}</span>
|
||||
</div>
|
||||
) : (
|
||||
<Card className="bg-gray-900">
|
||||
<CardHeader className="flex items-center justify-between">
|
||||
<CardTitle>Kontakt ma'lumotlari</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={startEdit}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Edit size={14} /> Tahrirlash
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={removeContact}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Trash size={14} /> O'chirish
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">Telegram</div>
|
||||
<div className="text-sm">{contact.telegram || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Instagram</div>
|
||||
<div className="text-sm">{contact.instagram || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Facebook</div>
|
||||
<div className="text-sm">{contact.facebook || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">LinkedIn</div>
|
||||
<div className="text-sm">{contact.linkedin || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Twitter</div>
|
||||
<div className="text-sm">{contact.twiter || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Manzil</div>
|
||||
<div className="text-sm">{contact.address || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Email</div>
|
||||
<div className="text-sm">{contact.email || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">Telefonlar</div>
|
||||
<div className="text-sm">
|
||||
{contact.phonePrimary || "—"}
|
||||
{contact.phoneSecondary ? ` • ${contact.phoneSecondary}` : ""}
|
||||
<>
|
||||
{data && data?.total_items === 0 ? (
|
||||
<Card className="bg-gray-900">
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
{t("Hozircha kontakt ma'lumotlari qo'shilmagan")}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"Sayt uchun telegram, instagram, manzil, email va telefonni bu yerda saqlang. Siz faqat bir marta qo'sha olasiz — keyin tahrirlash mumkin.",
|
||||
)}
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<Button
|
||||
onClick={startAdd}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus size={14} /> {t("Qo'shish")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<>
|
||||
{data &&
|
||||
data.results.map((e) => (
|
||||
<Card className="bg-gray-900">
|
||||
<CardHeader className="flex items-center justify-between">
|
||||
<CardTitle>{t("Kontakt ma'lumotlari")}</CardTitle>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => startEdit(e.id)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Edit size={14} /> {t("Tahrirlash")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => removeContact(e.id)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Trash size={14} /> {t("O'chirish")}
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Telegram
|
||||
</div>
|
||||
<div className="text-sm">{e.telegram || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Instagram
|
||||
</div>
|
||||
<div className="text-sm">{e.instagram || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Facebook
|
||||
</div>
|
||||
<div className="text-sm">{e.facebook || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Twitter
|
||||
</div>
|
||||
<div className="text-sm">{e.twitter || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("Manzil")}
|
||||
</div>
|
||||
<div className="text-sm">{e.address || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Email
|
||||
</div>
|
||||
<div className="text-sm">{e.email || "—"}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("Telefon raqam")}
|
||||
</div>
|
||||
<p>{e.main_phone}</p>
|
||||
<p>{e.other_phone}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Dialog */}
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
setOpen(v);
|
||||
if (!v) setEditing(false);
|
||||
if (!v) setEditing(null);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="h-[90%] overflow-y-scroll">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editing ? "Kontaktni tahrirlash" : "Kontakt qo'shish"}
|
||||
{editing ? t("Kontaktni tahrirlash") : t("Kontakt qo'shish")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -233,7 +447,7 @@ export default function ContactSettings() {
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6 mt-4">
|
||||
<div className="flex flex-col gap-2 sm:col-span-2">
|
||||
<Label>Manzil</Label>
|
||||
<Label>{t("Manzil")}</Label>
|
||||
<Input
|
||||
value={form.address || ""}
|
||||
onChange={(e) => handleChange("address", e.target.value)}
|
||||
@@ -282,16 +496,16 @@ export default function ContactSettings() {
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Asosiy telefon</Label>
|
||||
<Label>{t("Asosiy telefon")}</Label>
|
||||
<Input
|
||||
value={form.phonePrimary || ""}
|
||||
value={formatPhone(form.phonePrimary)}
|
||||
onChange={(e) => handleChange("phonePrimary", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<Label>Qo'shimcha telefon</Label>
|
||||
<Label>{t("Qo'shimcha telefon")}</Label>
|
||||
<Input
|
||||
value={form.phoneSecondary || ""}
|
||||
value={formatPhone(form.phoneSecondary)}
|
||||
onChange={(e) => handleChange("phoneSecondary", e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -302,12 +516,14 @@ export default function ContactSettings() {
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
setEditing(false);
|
||||
setEditing(null);
|
||||
}}
|
||||
>
|
||||
Bekor qilish
|
||||
{t("Bekor qilish")}
|
||||
</Button>
|
||||
<Button onClick={saveContact}>
|
||||
{isPending ? <Loader2 className="animate-spin" /> : t("Saqlash")}
|
||||
</Button>
|
||||
<Button onClick={saveContact}>Saqlash</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import type {
|
||||
CreateTourRes,
|
||||
GetAllTours,
|
||||
GetDetailTours,
|
||||
GetHotelRes,
|
||||
GetOneTours,
|
||||
Hotel_Badge,
|
||||
Hotel_BadgeId,
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
HOTEL_FEATURES_TYPE,
|
||||
HOTEL_TARIF,
|
||||
HPTEL_TYPES,
|
||||
POPULAR_TOURS,
|
||||
TOUR_TRANSPORT,
|
||||
} from "@/shared/config/api/URLs";
|
||||
import type { AxiosResponse } from "axios";
|
||||
@@ -31,14 +34,17 @@ import type { AxiosResponse } from "axios";
|
||||
const getAllTours = async ({
|
||||
page,
|
||||
page_size,
|
||||
featured_tickets,
|
||||
}: {
|
||||
page_size: number;
|
||||
page: number;
|
||||
featured_tickets?: boolean;
|
||||
}): Promise<AxiosResponse<GetAllTours>> => {
|
||||
const response = await httpClient.get(GET_TICKET, {
|
||||
params: {
|
||||
page,
|
||||
page_size,
|
||||
featured_tickets,
|
||||
},
|
||||
});
|
||||
return response;
|
||||
@@ -53,6 +59,15 @@ const getOneTours = async ({
|
||||
return response;
|
||||
};
|
||||
|
||||
const getDetailToursId = async ({
|
||||
id,
|
||||
}: {
|
||||
id: number;
|
||||
}): Promise<AxiosResponse<GetDetailTours>> => {
|
||||
const response = await httpClient.get(`tickets/${id}/`);
|
||||
return response;
|
||||
};
|
||||
|
||||
const createTours = async ({
|
||||
body,
|
||||
}: {
|
||||
@@ -66,6 +81,21 @@ const createTours = async ({
|
||||
return response;
|
||||
};
|
||||
|
||||
const updateTours = async ({
|
||||
body,
|
||||
id,
|
||||
}: {
|
||||
id: number;
|
||||
body: FormData;
|
||||
}): Promise<AxiosResponse<CreateTourRes>> => {
|
||||
const response = await httpClient.patch(`${GET_TICKET}${id}/`, body, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
const createHotel = async ({ body }: { body: FormData }) => {
|
||||
const response = await httpClient.post(`${HOTEL}`, body, {
|
||||
headers: {
|
||||
@@ -75,11 +105,39 @@ const createHotel = async ({ body }: { body: FormData }) => {
|
||||
return response;
|
||||
};
|
||||
|
||||
const getHotel = async (
|
||||
ticket: number,
|
||||
): Promise<AxiosResponse<GetHotelRes>> => {
|
||||
const res = await httpClient.get(HOTEL, { params: { ticket } });
|
||||
return res;
|
||||
};
|
||||
|
||||
const editHotel = async ({ body, id }: { id: number; body: FormData }) => {
|
||||
const response = await httpClient.patch(`${HOTEL}${id}/`, body, {
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
return response;
|
||||
};
|
||||
|
||||
const deleteTours = async ({ id }: { id: number }) => {
|
||||
const response = await httpClient.delete(`${GET_TICKET}${id}/`);
|
||||
return response;
|
||||
};
|
||||
|
||||
//added popular tours
|
||||
const addedPopularTours = async ({
|
||||
id,
|
||||
value,
|
||||
}: {
|
||||
id: number;
|
||||
value: number;
|
||||
}) => {
|
||||
const res = await httpClient.post(`${POPULAR_TOURS}${id}/${value}/`);
|
||||
return res;
|
||||
};
|
||||
|
||||
// htoel_badge api
|
||||
const hotelBadge = async ({
|
||||
page,
|
||||
@@ -387,10 +445,14 @@ const hotelFeatureTypeDelete = async ({ id }: { id: number }) => {
|
||||
};
|
||||
|
||||
export {
|
||||
addedPopularTours,
|
||||
createHotel,
|
||||
createTours,
|
||||
deleteTours,
|
||||
editHotel,
|
||||
getAllTours,
|
||||
getDetailToursId,
|
||||
getHotel,
|
||||
getOneTours,
|
||||
hotelBadge,
|
||||
hotelBadgeCreate,
|
||||
@@ -422,4 +484,5 @@ export {
|
||||
hotelTypeDelete,
|
||||
hotelTypeDetail,
|
||||
hotelTypeUpdate,
|
||||
updateTours,
|
||||
};
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import z from "zod";
|
||||
|
||||
const fileSchema = z.instanceof(File, { message: "Rasm faylini yuklang" });
|
||||
|
||||
export const TourformSchema = z.object({
|
||||
title: z.string().min(2, {
|
||||
message: "Sarlavha kamida 2 ta belgidan iborat bo'lishi kerak",
|
||||
@@ -171,7 +169,7 @@ export const TourformSchema = z.object({
|
||||
z.object({
|
||||
ticket_itinerary_image: z.array(
|
||||
z.object({
|
||||
image: fileSchema,
|
||||
image: z.union([z.instanceof(File), z.string()]),
|
||||
}),
|
||||
),
|
||||
title: z.string().min(1, "Sarlavha majburiy"),
|
||||
@@ -186,4 +184,24 @@ export const TourformSchema = z.object({
|
||||
}),
|
||||
)
|
||||
.min(1, { message: "Kamida bitta xizmat kiriting." }),
|
||||
extra_service: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().min(1, { message: "Xizmat nomi majburiy" }),
|
||||
name_ru: z.string().min(1, { message: "Xizmat nomi (RU) majburiy" }),
|
||||
}),
|
||||
)
|
||||
.min(1, { message: "Kamida bitta bepul xizmat kiriting." }),
|
||||
|
||||
paid_extra_service: z
|
||||
.array(
|
||||
z.object({
|
||||
name: z.string().min(1, { message: "Xizmat nomi majburiy" }),
|
||||
name_ru: z.string().min(1, { message: "Xizmat nomi (RU) majburiy" }),
|
||||
price: z
|
||||
.number()
|
||||
.min(0, { message: "Narx manfiy bo‘lishi mumkin emas." }),
|
||||
}),
|
||||
)
|
||||
.min(1, { message: "Kamida bitta pullik xizmat kiriting." }),
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface GetAllTours {
|
||||
results: {
|
||||
id: number;
|
||||
destination: string;
|
||||
featured_tickets: boolean;
|
||||
duration_days: number;
|
||||
hotel_name: string;
|
||||
price: number;
|
||||
@@ -24,25 +25,37 @@ export interface GetAllTours {
|
||||
export interface GetOneTours {
|
||||
status: boolean;
|
||||
data: {
|
||||
id: 0;
|
||||
id: number;
|
||||
hotel_name: string;
|
||||
hotel_rating: string;
|
||||
hotel_amenities: string;
|
||||
title: string;
|
||||
title_ru: string;
|
||||
title_uz: string;
|
||||
price: number;
|
||||
min_person: number;
|
||||
max_person: number;
|
||||
departure: string;
|
||||
departure_ru: string;
|
||||
departure_uz: string;
|
||||
destination: string;
|
||||
destination_ru: string;
|
||||
destination_uz: string;
|
||||
departure_time: string;
|
||||
travel_time: string;
|
||||
location_name: string;
|
||||
location_name_ru: string;
|
||||
location_name_uz: string;
|
||||
passenger_count: number;
|
||||
languages: string;
|
||||
hotel_info: string;
|
||||
hotel_info_ru: string;
|
||||
hotel_info_uz: string;
|
||||
duration_days: number;
|
||||
rating: number;
|
||||
hotel_meals: string;
|
||||
hotel_meals_ru: string;
|
||||
hotel_meals_uz: string;
|
||||
slug: string;
|
||||
visa_required: true;
|
||||
image_banner: string;
|
||||
@@ -50,7 +63,7 @@ export interface GetOneTours {
|
||||
transports: [
|
||||
{
|
||||
price: number;
|
||||
full_info: {
|
||||
transport: {
|
||||
name: string;
|
||||
icon_name: string;
|
||||
};
|
||||
@@ -65,6 +78,7 @@ export interface GetOneTours {
|
||||
{
|
||||
name: string;
|
||||
name_ru: string;
|
||||
name_uz: string;
|
||||
icon_name: string;
|
||||
},
|
||||
];
|
||||
@@ -73,26 +87,58 @@ export interface GetOneTours {
|
||||
image: string;
|
||||
title: string;
|
||||
title_ru: string;
|
||||
title_uz: string;
|
||||
desc: string;
|
||||
desc_ru: string;
|
||||
desc_uz: string;
|
||||
},
|
||||
];
|
||||
ticket_itinerary: [
|
||||
{
|
||||
title: string;
|
||||
title_ru: string;
|
||||
duration: number;
|
||||
},
|
||||
];
|
||||
ticket_itinerary: {
|
||||
title: string;
|
||||
title_ru: string;
|
||||
title_uz: string;
|
||||
duration: number;
|
||||
ticket_itinerary_image: [
|
||||
{
|
||||
image: string;
|
||||
},
|
||||
];
|
||||
ticket_itinerary_destinations: [
|
||||
{
|
||||
name: string;
|
||||
name_ru: string;
|
||||
name_uz: string;
|
||||
},
|
||||
];
|
||||
}[];
|
||||
ticket_hotel_meals: [
|
||||
{
|
||||
image: string;
|
||||
name: string;
|
||||
name_ru: string;
|
||||
name_uz: string;
|
||||
desc: string;
|
||||
desc_ru: string;
|
||||
desc_uz: string;
|
||||
},
|
||||
];
|
||||
tariff: [
|
||||
{
|
||||
tariff: number;
|
||||
price: number;
|
||||
},
|
||||
];
|
||||
extra_service: {
|
||||
name: string;
|
||||
name_ru: string;
|
||||
name_uz: string;
|
||||
}[];
|
||||
paid_extra_service: {
|
||||
name: string;
|
||||
name_ru: string;
|
||||
name_uz: string;
|
||||
price: number;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -154,6 +200,7 @@ export interface Hotel_BadgeId {
|
||||
export interface Badge {
|
||||
id: number;
|
||||
name: string;
|
||||
name_uz: string;
|
||||
name_ru: string;
|
||||
color: string;
|
||||
}
|
||||
@@ -206,6 +253,7 @@ export interface Hotel_TranportId {
|
||||
export interface Transport {
|
||||
id: number;
|
||||
name: string;
|
||||
name_uz: string;
|
||||
name_ru: string;
|
||||
icon_name: string;
|
||||
}
|
||||
@@ -233,6 +281,7 @@ export interface Hotel_TypeId {
|
||||
export interface Type {
|
||||
id: number;
|
||||
name: string;
|
||||
name_uz: string;
|
||||
name_ru: string;
|
||||
}
|
||||
|
||||
@@ -273,6 +322,7 @@ export interface HotelFeaturesDetail {
|
||||
export interface HotelFeatures {
|
||||
id: number;
|
||||
hotel_feature_type_name: string;
|
||||
hotel_feature_type_name_uz: string;
|
||||
hotel_feature_type_name_ru: string;
|
||||
}
|
||||
|
||||
@@ -293,6 +343,7 @@ export interface HotelFeaturesTypeDetail {
|
||||
id: number;
|
||||
feature_name: string;
|
||||
feature_name_ru: string;
|
||||
feature_name_uz: string;
|
||||
feature_type: {
|
||||
id: number;
|
||||
hotel_feature_type_name: string;
|
||||
@@ -300,3 +351,182 @@ export interface HotelFeaturesTypeDetail {
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface GetDetailTours {
|
||||
status: boolean;
|
||||
data: {
|
||||
id: number;
|
||||
hotel_name: string;
|
||||
hotel_rating: string;
|
||||
travel_agency_id: string;
|
||||
hotel_amenities: string;
|
||||
title: string;
|
||||
title_ru: string;
|
||||
title_uz: string;
|
||||
price: number;
|
||||
min_person: number;
|
||||
max_person: number;
|
||||
departure: string;
|
||||
departure_ru: string;
|
||||
departure_uz: string;
|
||||
destination: string;
|
||||
destination_ru: string;
|
||||
destination_uz: string;
|
||||
departure_time: string;
|
||||
travel_time: string;
|
||||
location_name: string;
|
||||
location_name_ru: string;
|
||||
location_name_uz: string;
|
||||
passenger_count: number;
|
||||
languages: string;
|
||||
hotel_info: string;
|
||||
hotel_info_ru: string;
|
||||
hotel_info_uz: string;
|
||||
duration_days: number;
|
||||
rating: number;
|
||||
hotel_meals: string;
|
||||
hotel_meals_ru: string;
|
||||
hotel_meals_uz: string;
|
||||
slug: string;
|
||||
visa_required: true;
|
||||
image_banner: string;
|
||||
badge: number[];
|
||||
transports: {
|
||||
price: number;
|
||||
transport: {
|
||||
name: string;
|
||||
icon_name: string;
|
||||
};
|
||||
}[];
|
||||
ticket_images: {
|
||||
image: string;
|
||||
}[];
|
||||
ticket_amenities: {
|
||||
name: string;
|
||||
name_ru: string;
|
||||
name_uz: string;
|
||||
icon_name: string;
|
||||
}[];
|
||||
ticket_included_services: {
|
||||
image: string;
|
||||
title: string;
|
||||
title_ru: string;
|
||||
title_uz: string;
|
||||
desc: string;
|
||||
desc_ru: string;
|
||||
desc_uz: string;
|
||||
}[];
|
||||
ticket_itinerary: {
|
||||
title: string;
|
||||
title_ru: string;
|
||||
title_uz: string;
|
||||
duration: number;
|
||||
ticket_itinerary_image: [
|
||||
{
|
||||
image: string;
|
||||
},
|
||||
];
|
||||
ticket_itinerary_destinations: [
|
||||
{
|
||||
name: string;
|
||||
name_ru: string;
|
||||
name_uz: string;
|
||||
},
|
||||
];
|
||||
}[];
|
||||
ticket_hotel_meals: [
|
||||
{
|
||||
image: string;
|
||||
name: string;
|
||||
name_ru: string;
|
||||
name_uz: string;
|
||||
desc: string;
|
||||
desc_ru: string;
|
||||
desc_uz: string;
|
||||
},
|
||||
];
|
||||
tariff: [
|
||||
{
|
||||
tariff: {
|
||||
name: string;
|
||||
};
|
||||
price: number;
|
||||
},
|
||||
];
|
||||
extra_service: [
|
||||
{
|
||||
name: string;
|
||||
name_ru: string;
|
||||
name_uz: string;
|
||||
},
|
||||
];
|
||||
paid_extra_service: [
|
||||
{
|
||||
name: string;
|
||||
name_ru: string;
|
||||
name_uz: string;
|
||||
price: number;
|
||||
},
|
||||
];
|
||||
ticket_comments: {
|
||||
user: {
|
||||
id: number;
|
||||
username: string;
|
||||
};
|
||||
text: string;
|
||||
rating: number;
|
||||
}[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface GetHotelRes {
|
||||
status: boolean;
|
||||
data: {
|
||||
links: {
|
||||
previous: null | string;
|
||||
next: null | string;
|
||||
};
|
||||
total_items: number;
|
||||
total_pages: number;
|
||||
page_size: number;
|
||||
current_page: number;
|
||||
results: [
|
||||
{
|
||||
id: number;
|
||||
name: string;
|
||||
rating: number;
|
||||
meal_plan: string;
|
||||
ticket: number;
|
||||
hotel_type: [
|
||||
{
|
||||
id: number;
|
||||
name: string;
|
||||
name_ru: string;
|
||||
name_uz: string;
|
||||
},
|
||||
];
|
||||
hotel_amenities: [
|
||||
{
|
||||
name: string;
|
||||
name_ru: string;
|
||||
name_uz: string;
|
||||
icon_name: string;
|
||||
},
|
||||
];
|
||||
hotel_features: [
|
||||
{
|
||||
id: number;
|
||||
feature_name: string;
|
||||
feature_name_uz: string;
|
||||
feature_name_ru: string;
|
||||
feature_type: {
|
||||
id: number;
|
||||
hotel_feature_type_name_ru: string;
|
||||
hotel_feature_type_name_uz: string;
|
||||
};
|
||||
},
|
||||
];
|
||||
},
|
||||
];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -160,7 +160,7 @@ const BadgeTable = ({
|
||||
useEffect(() => {
|
||||
if (badgeDetail) {
|
||||
form.setValue("color", badgeDetail.data.data.color);
|
||||
form.setValue("name", badgeDetail.data.data.name);
|
||||
form.setValue("name", badgeDetail.data.data.name_uz);
|
||||
form.setValue("name_ru", badgeDetail.data.data.name_ru);
|
||||
}
|
||||
}, [editId, badgeDetail]);
|
||||
|
||||
@@ -43,7 +43,12 @@ const CreateEditTour = () => {
|
||||
</div>
|
||||
</div>
|
||||
{step === 1 && (
|
||||
<StepOne setStep={setStep} data={data} isEditMode={isEditMode} />
|
||||
<StepOne
|
||||
setStep={setStep}
|
||||
data={data}
|
||||
isEditMode={isEditMode}
|
||||
id={id}
|
||||
/>
|
||||
)}
|
||||
{step === 2 && <StepTwo data={data} isEditMode={isEditMode} />}
|
||||
</div>
|
||||
|
||||
@@ -174,7 +174,7 @@ const FeaturesTable = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (badgeDetail) {
|
||||
form.setValue("name", badgeDetail.data.data.hotel_feature_type_name);
|
||||
form.setValue("name", badgeDetail.data.data.hotel_feature_type_name_uz);
|
||||
form.setValue(
|
||||
"name_ru",
|
||||
badgeDetail.data.data.hotel_feature_type_name_ru,
|
||||
|
||||
@@ -167,7 +167,7 @@ const FeaturesTableType = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (badgeDetail) {
|
||||
form.setValue("name", badgeDetail.data.data.feature_name);
|
||||
form.setValue("name", badgeDetail.data.data.feature_name_uz);
|
||||
form.setValue("name_ru", badgeDetail.data.data.feature_name_ru);
|
||||
}
|
||||
}, [editId, badgeDetail]);
|
||||
|
||||
@@ -155,7 +155,7 @@ const MealTable = ({
|
||||
|
||||
useEffect(() => {
|
||||
if (typeDetail) {
|
||||
form.setValue("name", typeDetail.data.data.name);
|
||||
form.setValue("name", typeDetail.data.data.name_uz);
|
||||
form.setValue("name_ru", typeDetail.data.data.name_ru);
|
||||
}
|
||||
}, [editId, typeDetail]);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
hotelBadge,
|
||||
hotelTarif,
|
||||
hotelTransport,
|
||||
updateTours,
|
||||
} from "@/pages/tours/lib/api";
|
||||
import { TourformSchema } from "@/pages/tours/lib/form";
|
||||
import { useTicketStore } from "@/pages/tours/lib/store";
|
||||
@@ -46,10 +47,12 @@ import z from "zod";
|
||||
const StepOne = ({
|
||||
setStep,
|
||||
data,
|
||||
id,
|
||||
isEditMode,
|
||||
}: {
|
||||
setStep: Dispatch<SetStateAction<number>>;
|
||||
data: GetOneTours | undefined;
|
||||
id: string | undefined;
|
||||
isEditMode: boolean;
|
||||
}) => {
|
||||
const [displayPrice, setDisplayPrice] = useState("");
|
||||
@@ -88,6 +91,8 @@ const StepOne = ({
|
||||
passenger_count: 1,
|
||||
min_person: 1,
|
||||
max_person: 1,
|
||||
extra_service: [],
|
||||
paid_extra_service: [],
|
||||
languages: "",
|
||||
duration: 1,
|
||||
badges: [],
|
||||
@@ -103,119 +108,152 @@ const StepOne = ({
|
||||
const { addAmenity, setId } = useTicketStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode && data?.data) {
|
||||
const tourData = data.data;
|
||||
if (!isEditMode || !data?.data) return;
|
||||
|
||||
form.setValue("title", tourData.title);
|
||||
form.setValue("title_ru", formatPrice(tourData.title));
|
||||
form.setValue("price", tourData.price);
|
||||
setDisplayPrice(formatPrice(tourData.price));
|
||||
form.setValue("passenger_count", tourData.passenger_count || 1);
|
||||
form.setValue("min_person", tourData.min_person || 1);
|
||||
form.setValue("max_person", tourData.max_person || 1);
|
||||
form.setValue("departure", tourData.departure || "");
|
||||
form.setValue("departure_ru", tourData.departure || "");
|
||||
form.setValue("destination", tourData.destination || "");
|
||||
form.setValue("destination_ru", tourData.destination || "");
|
||||
form.setValue("location_name", tourData.location_name || "");
|
||||
form.setValue("location_name_ru", tourData.location_name || "");
|
||||
form.setValue("hotel_info", tourData.hotel_info || "");
|
||||
form.setValue("hotel_info_ru", tourData.hotel_info || "");
|
||||
form.setValue("hotel_meals_info", tourData.hotel_meals || "");
|
||||
form.setValue("hotel_meals_info_ru", tourData.hotel_meals || "");
|
||||
form.setValue("languages", tourData.languages || "");
|
||||
form.setValue("duration", tourData.duration_days || 1);
|
||||
form.setValue("visa_required", tourData.visa_required ? "yes" : "no");
|
||||
form.setValue("badges", tourData.badge || []);
|
||||
const tour = data.data;
|
||||
|
||||
// DateTime fields
|
||||
if (tourData.departure_time) {
|
||||
const departureDate = new Date(tourData.departure_time);
|
||||
form.setValue("departureDateTime", {
|
||||
date: departureDate,
|
||||
time: departureDate.toTimeString().slice(0, 8), // HH:MM:SS
|
||||
});
|
||||
}
|
||||
// 🔹 Oddiy text maydonlar
|
||||
form.setValue("title", tour.title_uz ?? "");
|
||||
form.setValue("title_ru", tour.title_ru ?? "");
|
||||
form.setValue("price", tour.price ?? 0);
|
||||
setDisplayPrice(formatPrice(tour.price ?? 0));
|
||||
|
||||
if (tourData.travel_time) {
|
||||
const travelDate = new Date(tourData.travel_time);
|
||||
form.setValue("travelDateTime", {
|
||||
date: travelDate,
|
||||
time: travelDate.toTimeString().slice(0, 8),
|
||||
});
|
||||
}
|
||||
form.setValue("passenger_count", tour.passenger_count ?? 1);
|
||||
form.setValue("min_person", tour.min_person ?? 1);
|
||||
form.setValue("max_person", tour.max_person ?? 1);
|
||||
|
||||
// Amenities
|
||||
if (tourData.ticket_amenities && tourData.ticket_amenities.length > 0) {
|
||||
const amenities = tourData.ticket_amenities.map((item) => ({
|
||||
name: item.name,
|
||||
name_ru: item.name_ru,
|
||||
icon_name: item.icon_name,
|
||||
}));
|
||||
form.setValue("amenities", amenities);
|
||||
}
|
||||
form.setValue("departure", tour.departure_uz ?? "");
|
||||
form.setValue("departure_ru", tour.departure_ru ?? "");
|
||||
form.setValue("destination", tour.destination_uz ?? "");
|
||||
form.setValue("destination_ru", tour.destination_ru ?? "");
|
||||
form.setValue("location_name", tour.location_name_uz ?? "");
|
||||
form.setValue("location_name_ru", tour.location_name_ru ?? "");
|
||||
|
||||
if (
|
||||
tourData.ticket_included_services &&
|
||||
tourData.ticket_included_services.length > 0
|
||||
) {
|
||||
const services = tourData.ticket_included_services.map((item) => ({
|
||||
image: item.image,
|
||||
title: item.title,
|
||||
title_ru: item.title_ru,
|
||||
description: item.desc_uz || item.desc,
|
||||
desc_ru: item.desc || item.desc,
|
||||
}));
|
||||
form.setValue("hotel_services", services);
|
||||
}
|
||||
form.setValue("hotel_info", tour.hotel_info_uz ?? "");
|
||||
form.setValue("hotel_info_ru", tour.hotel_info_ru ?? "");
|
||||
form.setValue("hotel_meals_info", tour.hotel_meals_uz ?? "");
|
||||
form.setValue("hotel_meals_info_ru", tour.hotel_meals_ru ?? "");
|
||||
|
||||
if (
|
||||
tourData.ticket_hotel_meals &&
|
||||
tourData.ticket_hotel_meals.length > 0
|
||||
) {
|
||||
const meals = tourData.ticket_hotel_meals.map((item) => ({
|
||||
image: item.image,
|
||||
title: item.name,
|
||||
title_ru: item.name_ru,
|
||||
description: item.desc,
|
||||
desc_ru: item.desc_ru,
|
||||
}));
|
||||
form.setValue("hotel_meals", meals);
|
||||
}
|
||||
form.setValue("languages", tour.languages ?? "");
|
||||
form.setValue("duration", tour.duration_days ?? 1);
|
||||
form.setValue("visa_required", tour.visa_required ? "yes" : "no");
|
||||
form.setValue("badges", tour.badge ?? []);
|
||||
|
||||
// Transport
|
||||
if (tourData.transports && tourData.transports.length > 0) {
|
||||
const transports = tourData.transports.map((item, index) => ({
|
||||
transport: index + 1, // Agar transport ID bo'lsa, uni ishlatish kerak
|
||||
price: item.price,
|
||||
}));
|
||||
// const tariff = tourData.tar => ({
|
||||
// transport: index + 1,
|
||||
// price: item.price,
|
||||
// }));
|
||||
form.setValue("transport", transports);
|
||||
// form.setValue("tarif", );
|
||||
}
|
||||
|
||||
// Ticket itinerary
|
||||
if (tourData.ticket_itinerary && tourData.ticket_itinerary.length > 0) {
|
||||
const itinerary = tourData.ticket_itinerary.map((item) => ({
|
||||
ticket_itinerary_image: [], // Image fayllarni alohida handle qilish kerak
|
||||
title: item.title,
|
||||
title_ru: item.title_ru,
|
||||
duration: item.duration,
|
||||
ticket_itinerary_destinations: [], // Agar destinations bo'lsa qo'shish kerak
|
||||
}));
|
||||
form.setValue("ticket_itinerary", itinerary);
|
||||
}
|
||||
|
||||
form.setValue("banner", tourData.image_banner);
|
||||
if (tourData.ticket_images && tourData.ticket_images.length > 0) {
|
||||
const images = tourData.ticket_images.map((img) => img.image); // faqat linklarni olamiz
|
||||
form.setValue("images", images);
|
||||
}
|
||||
// 🔹 Jo‘nash vaqti
|
||||
if (tour.departure_time) {
|
||||
const d = new Date(tour.departure_time);
|
||||
form.setValue("departureDateTime", {
|
||||
date: d,
|
||||
time: d.toTimeString().slice(0, 8),
|
||||
});
|
||||
}
|
||||
}, [isEditMode, data, form]);
|
||||
|
||||
// 🔹 Qaytish vaqti
|
||||
if (tour.travel_time) {
|
||||
const d = new Date(tour.travel_time);
|
||||
form.setValue("travelDateTime", {
|
||||
date: d,
|
||||
time: d.toTimeString().slice(0, 8),
|
||||
});
|
||||
}
|
||||
|
||||
// 🔹 Qulayliklar (amenities)
|
||||
form.setValue(
|
||||
"amenities",
|
||||
tour.ticket_amenities?.map((a) => ({
|
||||
name: a.name ?? "",
|
||||
name_ru: a.name_ru ?? "",
|
||||
icon_name: a.icon_name ?? "",
|
||||
})) ?? [],
|
||||
);
|
||||
|
||||
// 🔹 Xizmatlar (hotel_services)
|
||||
form.setValue(
|
||||
"hotel_services",
|
||||
tour.ticket_included_services?.map((s) => ({
|
||||
image: s.image ?? null,
|
||||
title: s.title_uz ?? "",
|
||||
title_ru: s.title_ru ?? "",
|
||||
description: s.desc_uz ?? "",
|
||||
desc_ru: s.desc_ru ?? "",
|
||||
})) ?? [],
|
||||
);
|
||||
|
||||
// 🔹 Taomlar (hotel_meals)
|
||||
form.setValue(
|
||||
"hotel_meals",
|
||||
tour.ticket_hotel_meals?.map((m) => ({
|
||||
image: m.image ?? null,
|
||||
title: m.name ?? "",
|
||||
title_ru: m.name_ru ?? "",
|
||||
description: m.desc ?? "",
|
||||
desc_ru: m.desc_ru ?? "",
|
||||
})) ?? [],
|
||||
);
|
||||
|
||||
// 🔹 Transport
|
||||
const transports =
|
||||
tour.transports?.map((t, i) => ({
|
||||
transport: i + 1,
|
||||
price: t.price ?? 0,
|
||||
})) ?? [];
|
||||
form.setValue("transport", transports);
|
||||
setTransportPrices(transports.map((t) => formatPrice(t.price ?? 0))); // 👈 YANGI QO‘SHILGAN
|
||||
|
||||
// 🔹 Tarif
|
||||
const tariffs =
|
||||
tour.tariff?.map((t) => ({
|
||||
tariff: t.tariff ?? 0,
|
||||
price: t.price ?? 0,
|
||||
})) ?? [];
|
||||
form.setValue("tarif", tariffs);
|
||||
setTarifDisplayPrice(tariffs.map((t) => formatPrice(t.price ?? 0)));
|
||||
|
||||
// 🔹 Yo‘nalishlar (ticket_itinerary)
|
||||
form.setValue(
|
||||
"ticket_itinerary",
|
||||
tour.ticket_itinerary?.map((item) => ({
|
||||
ticket_itinerary_image:
|
||||
item.ticket_itinerary_image?.map((img) => ({
|
||||
image: img.image,
|
||||
})) ?? [],
|
||||
title: item.title ?? "",
|
||||
title_ru: item.title_ru ?? "",
|
||||
duration: item.duration ?? 1,
|
||||
ticket_itinerary_destinations:
|
||||
item.ticket_itinerary_destinations?.map((d) => ({
|
||||
name: d.name ?? "",
|
||||
name_ru: d.name_ru ?? "",
|
||||
})) ?? [],
|
||||
})) ?? [],
|
||||
);
|
||||
|
||||
// 🔹 Banner va rasmlar
|
||||
form.setValue("banner", tour.image_banner ?? null);
|
||||
form.setValue("images", tour.ticket_images?.map((img) => img.image) ?? []);
|
||||
|
||||
// 🔹 Bepul xizmatlar (extra_service)
|
||||
form.setValue(
|
||||
"extra_service",
|
||||
tour.extra_service?.map((s) => ({
|
||||
name: s.name_uz ?? s.name ?? "",
|
||||
name_ru: s.name_ru ?? "",
|
||||
})) ?? [],
|
||||
);
|
||||
|
||||
// 🔹 Pullik xizmatlar (paid_extra_service)
|
||||
form.setValue(
|
||||
"paid_extra_service",
|
||||
tour.paid_extra_service?.map((s) => ({
|
||||
name: s.name_uz ?? s.name ?? "",
|
||||
name_ru: s.name_ru ?? "",
|
||||
price: s.price ?? 0,
|
||||
})) ?? [],
|
||||
);
|
||||
|
||||
// 🔹 TicketStore uchun id
|
||||
setId(tour.id);
|
||||
}, [isEditMode, data, form, setId]);
|
||||
|
||||
const { watch, setValue } = form;
|
||||
const selectedDate = watch("departureDateTime.date");
|
||||
@@ -238,6 +276,22 @@ const StepOne = ({
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: update } = useMutation({
|
||||
mutationFn: ({ body, id }: { id: number; body: FormData }) => {
|
||||
return updateTours({ body, id });
|
||||
},
|
||||
onSuccess: (res) => {
|
||||
setId(res.data.data.id);
|
||||
setStep(2);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("Xatolik yuz berdi"), {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
function onSubmit(value: z.infer<typeof TourformSchema>) {
|
||||
const formData = new FormData();
|
||||
|
||||
@@ -273,7 +327,9 @@ const StepOne = ({
|
||||
formData.append("hotel_meals_ru", value.hotel_meals_info_ru);
|
||||
formData.append("duration_days", String(value.duration));
|
||||
formData.append("rating", String("0.0"));
|
||||
formData.append("image_banner", value.banner);
|
||||
if (value.banner instanceof File) {
|
||||
formData.append("image_banner", value.banner);
|
||||
}
|
||||
value.tarif.forEach((e, i) => {
|
||||
formData.append(`tariff[${i}]tariff`, String(e.tariff));
|
||||
formData.append(`tariff[${i}]price`, String(e.price));
|
||||
@@ -285,7 +341,11 @@ const StepOne = ({
|
||||
value.badges?.forEach((e, i) => {
|
||||
formData.append(`badge[${i}]`, String(e));
|
||||
});
|
||||
value.images.forEach((e) => formData.append("ticket_images", e));
|
||||
value.images.forEach((e) => {
|
||||
if (e instanceof File) {
|
||||
formData.append("ticket_images", e);
|
||||
}
|
||||
});
|
||||
value.amenities.forEach((e, i) => {
|
||||
formData.append(`ticket_amenities[${i}]name`, e.name);
|
||||
formData.append(`ticket_amenities[${i}]name_ru`, e.name_ru);
|
||||
@@ -297,43 +357,67 @@ const StepOne = ({
|
||||
});
|
||||
});
|
||||
value.hotel_services.forEach((e, i) => {
|
||||
formData.append(`ticket_included_services[${i}]image`, e.image);
|
||||
formData.append(`ticket_included_services[${i}]title`, e.title);
|
||||
formData.append(`ticket_included_services[${i}]title_ru`, e.title_ru);
|
||||
formData.append(`ticket_included_services[${i}]desc_ru`, e.desc_ru);
|
||||
formData.append(`ticket_included_services[${i}]desc`, e.description);
|
||||
if (e instanceof File) {
|
||||
formData.append(`ticket_included_services[${i}]image`, e.image);
|
||||
formData.append(`ticket_included_services[${i}]title`, e.title);
|
||||
formData.append(`ticket_included_services[${i}]title_ru`, e.title_ru);
|
||||
formData.append(`ticket_included_services[${i}]desc_ru`, e.desc_ru);
|
||||
formData.append(`ticket_included_services[${i}]desc`, e.description);
|
||||
}
|
||||
});
|
||||
value.ticket_itinerary.forEach((e, i) => {
|
||||
formData.append(`ticket_itinerary[${i}]title`, e.title);
|
||||
formData.append(`ticket_itinerary[${i}]title_ru`, e.title_ru);
|
||||
formData.append(`ticket_itinerary[${i}]duration`, String(e.duration));
|
||||
e.ticket_itinerary_image.forEach((e, f) => {
|
||||
formData.append(
|
||||
`ticket_itinerary[${i}]ticket_itinerary_image[${f}]image`,
|
||||
e.image,
|
||||
);
|
||||
});
|
||||
e.ticket_itinerary_destinations.forEach((e, f) => {
|
||||
formData.append(
|
||||
`ticket_itinerary[${i}]ticket_itinerary_destinations[${f}]name`,
|
||||
String(e.name),
|
||||
);
|
||||
formData.append(
|
||||
`ticket_itinerary[${i}]ticket_itinerary_destinations[${f}]name_ru`,
|
||||
String(e.name_ru),
|
||||
);
|
||||
e.ticket_itinerary_image.forEach((l, f) => {
|
||||
if (e instanceof File) {
|
||||
formData.append(`ticket_itinerary[${i}]title`, e.title);
|
||||
formData.append(`ticket_itinerary[${i}]title_ru`, e.title_ru);
|
||||
formData.append(`ticket_itinerary[${i}]duration`, String(e.duration));
|
||||
formData.append(
|
||||
`ticket_itinerary[${i}]ticket_itinerary_image[${f}]image`,
|
||||
l.image,
|
||||
);
|
||||
e.ticket_itinerary_destinations.forEach((e, f) => {
|
||||
formData.append(
|
||||
`ticket_itinerary[${i}]ticket_itinerary_destinations[${f}]name`,
|
||||
String(e.name),
|
||||
);
|
||||
formData.append(
|
||||
`ticket_itinerary[${i}]ticket_itinerary_destinations[${f}]name_ru`,
|
||||
String(e.name_ru),
|
||||
);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
value.hotel_meals.forEach((e, i) => {
|
||||
formData.append(`ticket_hotel_meals[${i}]image`, e.image);
|
||||
formData.append(`ticket_hotel_meals[${i}]name`, e.title);
|
||||
formData.append(`ticket_hotel_meals[${i}]name_ru`, e.title_ru);
|
||||
formData.append(`ticket_hotel_meals[${i}]desc`, e.description);
|
||||
formData.append(`ticket_hotel_meals[${i}]desc_ru`, e.desc_ru);
|
||||
if (e instanceof File) {
|
||||
formData.append(`ticket_hotel_meals[${i}]image`, e.image);
|
||||
formData.append(`ticket_hotel_meals[${i}]name`, e.title);
|
||||
formData.append(`ticket_hotel_meals[${i}]name_ru`, e.title_ru);
|
||||
formData.append(`ticket_hotel_meals[${i}]desc`, e.description);
|
||||
formData.append(`ticket_hotel_meals[${i}]desc_ru`, e.desc_ru);
|
||||
}
|
||||
});
|
||||
create(formData);
|
||||
value.extra_service.forEach((e, i) => {
|
||||
formData.append(`extra_service[${i}]name`, e.name);
|
||||
formData.append(`extra_service[${i}]name_ru`, e.name_ru);
|
||||
});
|
||||
value.paid_extra_service.forEach((e, i) => {
|
||||
formData.append(`paid_extra_service[${i}]name`, e.name);
|
||||
formData.append(`paid_extra_service[${i}]name_ru`, e.name_ru);
|
||||
formData.append(`paid_extra_service[${i}]price`, String(e.price));
|
||||
});
|
||||
if (isEditMode && id) {
|
||||
update({
|
||||
body: formData,
|
||||
id: Number(id),
|
||||
});
|
||||
} else {
|
||||
create(formData);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(form.formState.errors);
|
||||
|
||||
const { data: badge } = useQuery({
|
||||
queryKey: ["all_badge"],
|
||||
queryFn: () => hotelBadge({ page: 1, page_size: 10 }),
|
||||
@@ -392,7 +476,9 @@ const StepOne = ({
|
||||
name="price"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<Label className="text-md">{t("Narx")} (1 kishi uchun)</Label>
|
||||
<Label className="text-md">
|
||||
{t("Narx")} {t("(1 kishi uchun)")}
|
||||
</Label>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
@@ -1157,11 +1243,11 @@ const StepOne = ({
|
||||
name="visa_required"
|
||||
render={({ field }) => (
|
||||
<FormItem className="space-y-3">
|
||||
<Label>{t("Visa talab qilinadimi?")}</Label>
|
||||
<Label>{t("Visa talab qilinadimi")}?</Label>
|
||||
<FormControl>
|
||||
<RadioGroup
|
||||
onValueChange={field.onChange}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
className="flex gap-6"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -1170,7 +1256,7 @@ const StepOne = ({
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="no" id="visa_no" />
|
||||
<label htmlFor="visa_no">{t("Yo‘q")}</label>
|
||||
<label htmlFor="visa_no">{t("Yo'q")}</label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
@@ -1287,6 +1373,206 @@ const StepOne = ({
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="extra_service"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<Label className="text-md">{t("Bepul xizmatlar")}</Label>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Ko'rsatilayotgan xizmatlar */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{form.watch("extra_service").map((item, idx) => (
|
||||
<Badge
|
||||
key={idx}
|
||||
variant="secondary"
|
||||
className="px-3 py-1 text-sm flex items-center gap-2"
|
||||
>
|
||||
<span>{item.name}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = form.getValues("extra_service");
|
||||
form.setValue(
|
||||
"extra_service",
|
||||
current.filter((_, i) => i !== idx),
|
||||
);
|
||||
}}
|
||||
className="ml-1 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Yangi xizmat qo'shish */}
|
||||
<div className="flex gap-3 items-end flex-wrap">
|
||||
<Input
|
||||
id="extra_service_name"
|
||||
placeholder={t("Xizmat nomi (UZ)")}
|
||||
className="h-12 !text-md flex-1 min-w-[200px]"
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="extra_service_name_ru"
|
||||
placeholder={t("Xizmat nomi (RU)")}
|
||||
className="h-12 !text-md flex-1 min-w-[200px]"
|
||||
/>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nameInput = document.getElementById(
|
||||
"extra_service_name",
|
||||
) as HTMLInputElement;
|
||||
const nameRuInput = document.getElementById(
|
||||
"extra_service_name_ru",
|
||||
) as HTMLInputElement;
|
||||
|
||||
if (nameInput.value && nameRuInput.value) {
|
||||
const current = form.getValues("extra_service");
|
||||
form.setValue("extra_service", [
|
||||
...current,
|
||||
{
|
||||
name: nameInput.value,
|
||||
name_ru: nameRuInput.value,
|
||||
},
|
||||
]);
|
||||
nameInput.value = "";
|
||||
nameRuInput.value = "";
|
||||
}
|
||||
}}
|
||||
className="h-12"
|
||||
>
|
||||
{t("Qo'shish")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="paid_extra_service"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<Label className="text-md">{t("Pullik xizmatlar")}</Label>
|
||||
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Ro'yxat */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(form.watch("paid_extra_service") || []).map((item, idx) => (
|
||||
<Badge
|
||||
key={idx}
|
||||
variant="secondary"
|
||||
className="px-3 py-1 text-sm flex items-center gap-2"
|
||||
>
|
||||
<span>
|
||||
{item.name} —{" "}
|
||||
<strong>{formatPrice(item.price)} so‘m</strong>
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const current = form.getValues("paid_extra_service");
|
||||
form.setValue(
|
||||
"paid_extra_service",
|
||||
current.filter((_, i) => i !== idx),
|
||||
);
|
||||
}}
|
||||
className="ml-1 text-muted-foreground hover:text-destructive"
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Qo'shish formasi */}
|
||||
<div className="flex gap-3 items-end flex-wrap">
|
||||
<Input
|
||||
id="paid_service_name"
|
||||
placeholder={t("Xizmat nomi (UZ)")}
|
||||
className="h-12 !text-md flex-1 min-w-[200px]"
|
||||
/>
|
||||
|
||||
<Input
|
||||
id="paid_service_name_ru"
|
||||
placeholder={t("Xizmat nomi (RU)")}
|
||||
className="h-12 !text-md flex-1 min-w-[200px]"
|
||||
/>
|
||||
|
||||
{/* Narx maydoni */}
|
||||
<Input
|
||||
id="paid_service_price"
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
placeholder="1 500 000"
|
||||
className="h-12 !text-md w-[150px]"
|
||||
onInput={(e) => {
|
||||
const input = e.target as HTMLInputElement;
|
||||
const raw = input.value.replace(/\D/g, "");
|
||||
input.value = raw
|
||||
? Number(raw).toLocaleString("ru-RU")
|
||||
: "";
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Qo'shish tugmasi */}
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nameInput = document.getElementById(
|
||||
"paid_service_name",
|
||||
) as HTMLInputElement;
|
||||
const nameRuInput = document.getElementById(
|
||||
"paid_service_name_ru",
|
||||
) as HTMLInputElement;
|
||||
const priceInput = document.getElementById(
|
||||
"paid_service_price",
|
||||
) as HTMLInputElement;
|
||||
|
||||
const raw = priceInput.value.replace(/\D/g, "");
|
||||
const num = Number(raw);
|
||||
|
||||
if (
|
||||
nameInput.value.trim() &&
|
||||
nameRuInput.value.trim() &&
|
||||
!isNaN(num)
|
||||
) {
|
||||
const current = form.getValues("paid_extra_service");
|
||||
form.setValue("paid_extra_service", [
|
||||
...current,
|
||||
{
|
||||
name: nameInput.value.trim(),
|
||||
name_ru: nameRuInput.value.trim(),
|
||||
price: num, // 🟢 0 ham bo‘lishi mumkin
|
||||
},
|
||||
]);
|
||||
|
||||
// inputlarni tozalaymiz
|
||||
nameInput.value = "";
|
||||
nameRuInput.value = "";
|
||||
priceInput.value = "";
|
||||
}
|
||||
}}
|
||||
className="h-12"
|
||||
>
|
||||
{t("Qo'shish")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<FormMessage />
|
||||
</div>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="hotel_info"
|
||||
@@ -1313,7 +1599,7 @@ const StepOne = ({
|
||||
<Label className="text-md">{t("Mehmonxona haqida")} (ru)</Label>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t("Mehmonxona haqida (ru)")}
|
||||
placeholder={t("Mehmonxona haqida") + " (ru)"}
|
||||
{...field}
|
||||
className="min-h-48 max-h-60 !text-md"
|
||||
/>
|
||||
@@ -1389,7 +1675,7 @@ const StepOne = ({
|
||||
|
||||
<Input
|
||||
id="hotel_service_title_ru"
|
||||
placeholder={t("Xizmat nomi (ru)")}
|
||||
placeholder={t("Xizmat nomi") + " (ru)"}
|
||||
className="h-12 !text-md"
|
||||
/>
|
||||
|
||||
@@ -1401,7 +1687,7 @@ const StepOne = ({
|
||||
|
||||
<Textarea
|
||||
id="hotel_service_desc_ru"
|
||||
placeholder={t("Xizmat tavsifi (ru)")}
|
||||
placeholder={t("Xizmat tavsifi") + " (ru)"}
|
||||
className="min-h-24 !text-md"
|
||||
/>
|
||||
|
||||
@@ -1487,7 +1773,7 @@ const StepOne = ({
|
||||
</Label>
|
||||
<FormControl>
|
||||
<Textarea
|
||||
placeholder={t("Mehmonxona taomlari haqida (ru)")}
|
||||
placeholder={t("Mehmonxona taomlari haqida") + " (ru)"}
|
||||
{...field}
|
||||
className="min-h-48 max-h-60"
|
||||
/>
|
||||
@@ -1563,7 +1849,7 @@ const StepOne = ({
|
||||
|
||||
<Input
|
||||
id="hotel_meals_title_ru"
|
||||
placeholder={t("Taom nomi (ru)")}
|
||||
placeholder={t("Taom nomi") + " (ru)"}
|
||||
className="h-12 !text-md"
|
||||
/>
|
||||
|
||||
@@ -1575,7 +1861,7 @@ const StepOne = ({
|
||||
|
||||
<Textarea
|
||||
id="hotel_meals_desc_ru"
|
||||
placeholder={t("Taom tavsifi (ru)")}
|
||||
placeholder={t("Taom tavsifi") + " (ru)"}
|
||||
className="min-h-24 !text-md"
|
||||
/>
|
||||
|
||||
@@ -1662,7 +1948,7 @@ const StepOne = ({
|
||||
{item.ticket_itinerary_destinations[0]?.name}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{item.duration} kun
|
||||
{item.duration} {t("kun")}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
@@ -1704,7 +1990,7 @@ const StepOne = ({
|
||||
|
||||
<Input
|
||||
id="ticket_itinerary_title_ru"
|
||||
placeholder={t("Sarlavha (RU)")}
|
||||
placeholder={t("Sarlavha") + " (ru)"}
|
||||
className="h-12 !text-md"
|
||||
/>
|
||||
|
||||
@@ -1724,7 +2010,7 @@ const StepOne = ({
|
||||
|
||||
<Input
|
||||
id="ticket_itinerary_destination_ru"
|
||||
placeholder={t("Manzil (RU)")}
|
||||
placeholder={t("Manzil") + " (ru)"}
|
||||
className="h-12 !text-md"
|
||||
/>
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
import {
|
||||
createHotel,
|
||||
editHotel,
|
||||
getHotel,
|
||||
hotelFeature,
|
||||
hotelFeatureType,
|
||||
hotelType,
|
||||
@@ -30,7 +32,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/shared/ui/select";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { X } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
@@ -64,9 +66,18 @@ const StepTwo = ({
|
||||
isEditMode: boolean;
|
||||
}) => {
|
||||
const { amenities, id: ticketId } = useTicketStore();
|
||||
const navigator = useNavigate();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 🧩 Query - Hotel detail
|
||||
const { data: hotelDetail } = useQuery({
|
||||
queryKey: ["hotel_detail", data?.data.id],
|
||||
queryFn: () => getHotel(data?.data.id!),
|
||||
select: (res) => res.data.data.results,
|
||||
enabled: !!data?.data.id,
|
||||
});
|
||||
|
||||
// 🧩 React Hook Form
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
@@ -79,78 +90,94 @@ const StepTwo = ({
|
||||
},
|
||||
});
|
||||
|
||||
// 🧩 Edit holati uchun formni to‘ldirish
|
||||
useEffect(() => {
|
||||
if (isEditMode && data?.data) {
|
||||
const tourData = data.data;
|
||||
if (isEditMode && hotelDetail?.[0]) {
|
||||
const hotel = hotelDetail[0];
|
||||
|
||||
form.setValue("title", tourData.hotel_name);
|
||||
form.setValue("rating", tourData.hotel_rating);
|
||||
form.setValue("mealPlan", tourData.hotel_meals);
|
||||
form.setValue("title", hotel.name);
|
||||
form.setValue("rating", String(hotel.rating));
|
||||
|
||||
const mealPlan =
|
||||
hotel.meal_plan === "breakfast"
|
||||
? "Breakfast Only"
|
||||
: hotel.meal_plan === "all_inclusive"
|
||||
? "All Inclusive"
|
||||
: hotel.meal_plan === "half_board"
|
||||
? "Half Board"
|
||||
: hotel.meal_plan === "full_board"
|
||||
? "Full Board"
|
||||
: "All Inclusive";
|
||||
|
||||
form.setValue("mealPlan", mealPlan);
|
||||
|
||||
form.setValue(
|
||||
"hotelType",
|
||||
hotel.hotel_type?.map((t) => String(t.id)) ?? [],
|
||||
);
|
||||
form.setValue(
|
||||
"hotelFeatures",
|
||||
hotel.hotel_features?.map((f) => String(f.feature_type.id)) ?? [],
|
||||
);
|
||||
form.setValue("hotelFeaturesType", [
|
||||
...new Set(hotel.hotel_features?.map((f) => String(f.id)) ?? []),
|
||||
]);
|
||||
}
|
||||
}, [isEditMode, data, form]);
|
||||
}, [isEditMode, hotelDetail, form, data]);
|
||||
|
||||
const mealPlans = [
|
||||
"Breakfast Only",
|
||||
"Half Board",
|
||||
"Full Board",
|
||||
"All Inclusive",
|
||||
];
|
||||
// 🧩 Select ma'lumotlari
|
||||
const [allHotelTypes, setAllHotelTypes] = useState<Type[]>([]);
|
||||
const [allHotelFeature, setAllHotelFeature] = useState<HotelFeatures[]>([]);
|
||||
const [allHotelFeatureType, setAllHotelFeatureType] = useState<
|
||||
HotelFeaturesType[]
|
||||
>([]);
|
||||
|
||||
const [featureTypeMapping, setFeatureTypeMapping] = useState<
|
||||
Record<string, string[]>
|
||||
>({});
|
||||
|
||||
const selectedHotelFeatures = form.watch("hotelFeatures");
|
||||
|
||||
// 🔹 Hotel Types yuklash
|
||||
useEffect(() => {
|
||||
const loadAll = async () => {
|
||||
try {
|
||||
let page = 1;
|
||||
let results: Type[] = [];
|
||||
let hasNext = true;
|
||||
const loadHotelTypes = async () => {
|
||||
let page = 1;
|
||||
let results: Type[] = [];
|
||||
let hasNext = true;
|
||||
|
||||
while (hasNext) {
|
||||
const res = await hotelType({ page, page_size: 50 });
|
||||
const data = res.data.data;
|
||||
results = [...results, ...data.results];
|
||||
hasNext = !!data.links.next;
|
||||
page++;
|
||||
}
|
||||
setAllHotelTypes(results);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
while (hasNext) {
|
||||
const res = await hotelType({ page, page_size: 50 });
|
||||
const data = res.data.data;
|
||||
results = [...results, ...data.results];
|
||||
hasNext = !!data.links.next;
|
||||
page++;
|
||||
}
|
||||
|
||||
setAllHotelTypes(results);
|
||||
};
|
||||
loadAll();
|
||||
loadHotelTypes();
|
||||
}, []);
|
||||
|
||||
// 🔹 Hotel Features yuklash
|
||||
useEffect(() => {
|
||||
const loadAll = async () => {
|
||||
try {
|
||||
let page = 1;
|
||||
let results: HotelFeatures[] = [];
|
||||
let hasNext = true;
|
||||
const loadHotelFeatures = async () => {
|
||||
let page = 1;
|
||||
let results: HotelFeatures[] = [];
|
||||
let hasNext = true;
|
||||
|
||||
while (hasNext) {
|
||||
const res = await hotelFeature({ page, page_size: 50 });
|
||||
const data = res.data.data;
|
||||
results = [...results, ...data.results];
|
||||
hasNext = !!data.links.next;
|
||||
page++;
|
||||
}
|
||||
setAllHotelFeature(results);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
while (hasNext) {
|
||||
const res = await hotelFeature({ page, page_size: 50 });
|
||||
const data = res.data.data;
|
||||
results = [...results, ...data.results];
|
||||
hasNext = !!data.links.next;
|
||||
page++;
|
||||
}
|
||||
|
||||
setAllHotelFeature(results);
|
||||
};
|
||||
loadAll();
|
||||
loadHotelFeatures();
|
||||
}, []);
|
||||
|
||||
// 🔹 Feature type'larni yuklash (tanlangan feature bo‘yicha)
|
||||
useEffect(() => {
|
||||
if (selectedHotelFeatures.length === 0) {
|
||||
setAllHotelFeatureType([]);
|
||||
@@ -158,107 +185,118 @@ const StepTwo = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const loadAll = async () => {
|
||||
try {
|
||||
const selectedFeatureIds = selectedHotelFeatures
|
||||
.map((featureId) => Number(featureId))
|
||||
.filter((id) => !isNaN(id));
|
||||
const loadFeatureTypes = async () => {
|
||||
const selectedIds = selectedHotelFeatures.map(Number).filter(Boolean);
|
||||
let allResults: HotelFeaturesType[] = [];
|
||||
const mapping: Record<string, string[]> = {};
|
||||
|
||||
if (selectedFeatureIds.length === 0) return;
|
||||
for (const id of selectedIds) {
|
||||
let page = 1;
|
||||
let hasNext = true;
|
||||
const featureTypes: string[] = [];
|
||||
|
||||
let allResults: HotelFeaturesType[] = [];
|
||||
const newMapping: Record<string, string[]> = {};
|
||||
|
||||
for (const featureId of selectedFeatureIds) {
|
||||
let page = 1;
|
||||
let hasNext = true;
|
||||
const featureTypes: string[] = [];
|
||||
|
||||
while (hasNext) {
|
||||
const res = await hotelFeatureType({
|
||||
page,
|
||||
page_size: 50,
|
||||
feature_type: featureId,
|
||||
});
|
||||
const data = res.data.data;
|
||||
allResults = [...allResults, ...data.results];
|
||||
|
||||
data.results.forEach((item: HotelFeaturesType) => {
|
||||
featureTypes.push(String(item.id));
|
||||
});
|
||||
|
||||
hasNext = !!data.links.next;
|
||||
page++;
|
||||
}
|
||||
|
||||
newMapping[String(featureId)] = featureTypes;
|
||||
while (hasNext) {
|
||||
const res = await hotelFeatureType({
|
||||
page,
|
||||
page_size: 50,
|
||||
feature_type: id,
|
||||
});
|
||||
const data = res.data.data;
|
||||
allResults = [...allResults, ...data.results];
|
||||
data.results.forEach((ft: HotelFeaturesType) =>
|
||||
featureTypes.push(String(ft.id)),
|
||||
);
|
||||
hasNext = !!data.links.next;
|
||||
page++;
|
||||
}
|
||||
|
||||
const uniqueResults = allResults.filter(
|
||||
(item, index, self) =>
|
||||
index === self.findIndex((t) => t.id === item.id),
|
||||
);
|
||||
|
||||
setAllHotelFeatureType(uniqueResults);
|
||||
setFeatureTypeMapping(newMapping);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
mapping[String(id)] = featureTypes;
|
||||
}
|
||||
|
||||
const uniqueResults = allResults.filter(
|
||||
(v, i, a) => a.findIndex((t) => t.id === v.id) === i,
|
||||
);
|
||||
|
||||
setAllHotelFeatureType(uniqueResults);
|
||||
setFeatureTypeMapping(mapping);
|
||||
};
|
||||
|
||||
loadAll();
|
||||
loadFeatureTypes();
|
||||
}, [selectedHotelFeatures]);
|
||||
|
||||
const { mutate, isPending } = useMutation({
|
||||
mutationFn: (body: FormData) => createHotel({ body }),
|
||||
onSuccess: () => {
|
||||
navigator("/tours");
|
||||
toast.success(t("Muvaffaqiyatli saqlandi"), {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
toast.success(t("Muvaffaqiyatli saqlandi"));
|
||||
navigate("/tours");
|
||||
},
|
||||
onError: () => {
|
||||
onError: () =>
|
||||
toast.error(t("Xatolik yuz berdi"), {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const removeHotelType = (typeId: string) => {
|
||||
const current = form.getValues("hotelType");
|
||||
const { mutate: edit, isPending: editPending } = useMutation({
|
||||
mutationFn: ({ body, id }: { id: number; body: FormData }) =>
|
||||
editHotel({ body, id }),
|
||||
onSuccess: () => {
|
||||
toast.success(t("Muvaffaqiyatli saqlandi"));
|
||||
navigate("/tours");
|
||||
},
|
||||
onError: () =>
|
||||
toast.error(t("Xatolik yuz berdi"), {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
}),
|
||||
});
|
||||
|
||||
const removeHotelType = (id: string) =>
|
||||
form.setValue(
|
||||
"hotelType",
|
||||
current.filter((val) => val !== typeId),
|
||||
form.getValues("hotelType").filter((v) => v !== id),
|
||||
);
|
||||
|
||||
const removeHotelFeature = (id: string) => {
|
||||
const current = form.getValues("hotelFeatures");
|
||||
const types = form.getValues("hotelFeaturesType");
|
||||
const toRemove = featureTypeMapping[id] || [];
|
||||
|
||||
form.setValue(
|
||||
"hotelFeatures",
|
||||
current.filter((v) => v !== id),
|
||||
);
|
||||
form.setValue(
|
||||
"hotelFeaturesType",
|
||||
types.filter((v) => !toRemove.includes(v)),
|
||||
);
|
||||
};
|
||||
|
||||
const onSubmit = (data: z.infer<typeof formSchema>) => {
|
||||
const formData = new FormData();
|
||||
formData.append("ticket", ticketId ? ticketId?.toString() : "");
|
||||
formData.append("name", data.title);
|
||||
formData.append("rating", String(data.rating));
|
||||
formData.append(
|
||||
"meal_plan",
|
||||
data.mealPlan === "Breakfast Only"
|
||||
? "breakfast"
|
||||
: data.mealPlan === "All Inclusive"
|
||||
? "all_inclusive"
|
||||
: data.mealPlan === "Half Board"
|
||||
? "half_board"
|
||||
: data.mealPlan === "Full Board"
|
||||
? "full_board"
|
||||
: "all_inclusive",
|
||||
const removeFeatureType = (id: string) =>
|
||||
form.setValue(
|
||||
"hotelFeaturesType",
|
||||
form.getValues("hotelFeaturesType").filter((v) => v !== id),
|
||||
);
|
||||
|
||||
data.hotelType.forEach((typeId) => {
|
||||
formData.append("hotel_type", typeId);
|
||||
});
|
||||
// 🧩 Submit
|
||||
const onSubmit = (data: z.infer<typeof formSchema>) => {
|
||||
const formData = new FormData();
|
||||
|
||||
data.hotelFeaturesType.forEach((typeId) => {
|
||||
formData.append("hotel_features", typeId);
|
||||
});
|
||||
formData.append("ticket", ticketId ? String(ticketId) : "");
|
||||
formData.append("name", data.title);
|
||||
formData.append("rating", data.rating);
|
||||
|
||||
const mealPlan =
|
||||
data.mealPlan === "Breakfast Only"
|
||||
? "breakfast"
|
||||
: data.mealPlan === "Half Board"
|
||||
? "half_board"
|
||||
: data.mealPlan === "Full Board"
|
||||
? "full_board"
|
||||
: "all_inclusive";
|
||||
formData.append("meal_plan", mealPlan);
|
||||
|
||||
data.hotelType.forEach((id) => formData.append("hotel_type", id));
|
||||
data.hotelFeatures.forEach((id) => formData.append("hotel_features", id));
|
||||
|
||||
amenities.forEach((e, i) => {
|
||||
formData.append(`hotel_amenities[${i}]name`, e.name);
|
||||
@@ -266,33 +304,22 @@ const StepTwo = ({
|
||||
formData.append(`hotel_amenities[${i}]icon_name`, e.icon_name);
|
||||
});
|
||||
|
||||
mutate(formData);
|
||||
if (isEditMode && hotelDetail) {
|
||||
edit({
|
||||
body: formData,
|
||||
id: Number(hotelDetail[0].id),
|
||||
});
|
||||
} else {
|
||||
mutate(formData);
|
||||
}
|
||||
};
|
||||
|
||||
const removeHotelFeature = (featureId: string) => {
|
||||
const currentFeatures = form.getValues("hotelFeatures");
|
||||
const currentFeatureTypes = form.getValues("hotelFeaturesType");
|
||||
|
||||
const typesToRemove = featureTypeMapping[featureId] || [];
|
||||
|
||||
form.setValue(
|
||||
"hotelFeatures",
|
||||
currentFeatures.filter((val) => val !== featureId),
|
||||
);
|
||||
|
||||
form.setValue(
|
||||
"hotelFeaturesType",
|
||||
currentFeatureTypes.filter((val) => !typesToRemove.includes(val)),
|
||||
);
|
||||
};
|
||||
|
||||
const removeFeatureType = (typeId: string) => {
|
||||
const currentValues = form.getValues("hotelFeaturesType");
|
||||
form.setValue(
|
||||
"hotelFeaturesType",
|
||||
currentValues.filter((val) => val !== typeId),
|
||||
);
|
||||
};
|
||||
const mealPlans = [
|
||||
"Breakfast Only",
|
||||
"Half Board",
|
||||
"Full Board",
|
||||
"All Inclusive",
|
||||
];
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
@@ -465,6 +492,8 @@ const StepTwo = ({
|
||||
const selectedItem = allHotelFeature.find(
|
||||
(item) => String(item.id) === selectedValue,
|
||||
);
|
||||
console.log(allHotelFeature);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={selectedValue}
|
||||
@@ -608,7 +637,7 @@ const StepTwo = ({
|
||||
disabled={isPending}
|
||||
className="mt-6 px-6 py-3 bg-blue-600 text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isPending ? t("Yuklanmoqda...") : t("Saqlash")}
|
||||
{isPending || editPending ? t("Yuklanmoqda...") : t("Saqlash")}
|
||||
</button>
|
||||
</form>
|
||||
</Form>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { deleteTours, getAllTours } from "@/pages/tours/lib/api";
|
||||
import {
|
||||
addedPopularTours,
|
||||
deleteTours,
|
||||
getAllTours,
|
||||
} from "@/pages/tours/lib/api";
|
||||
import formatPrice from "@/shared/lib/formatPrice";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import {
|
||||
@@ -10,6 +14,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Switch } from "@/shared/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -28,15 +33,18 @@ import {
|
||||
Plane,
|
||||
PlusCircle,
|
||||
Trash2,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
|
||||
const Tours = () => {
|
||||
const { t } = useTranslation();
|
||||
const [page, setPage] = useState(1);
|
||||
const [deleteId, setDeleteId] = useState<number | null>(null);
|
||||
const [showPopularDialog, setShowPopularDialog] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -45,18 +53,54 @@ const Tours = () => {
|
||||
queryFn: () => getAllTours({ page: page, page_size: 10 }),
|
||||
});
|
||||
|
||||
const { data: popularTour } = useQuery({
|
||||
queryKey: ["popular_tours"],
|
||||
queryFn: () =>
|
||||
getAllTours({ page: 1, page_size: 10, featured_tickets: true }),
|
||||
});
|
||||
|
||||
const { mutate } = useMutation({
|
||||
mutationFn: (id: number) => deleteTours({ id }),
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({ queryKey: ["all_tours"] });
|
||||
setDeleteId(null);
|
||||
},
|
||||
onError: () => {
|
||||
toast.error(t("Xatolik yuz berdi"), {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const { mutate: popular } = useMutation({
|
||||
mutationFn: ({ id, value }: { id: number; value: number }) =>
|
||||
addedPopularTours({ id, value }),
|
||||
onSuccess: () => {
|
||||
queryClient.refetchQueries({ queryKey: ["all_tours"] });
|
||||
queryClient.refetchQueries({ queryKey: ["popular_tours"] });
|
||||
},
|
||||
onError: () => {
|
||||
if (popularTour?.data.data.results.length === 5) {
|
||||
setShowPopularDialog(true);
|
||||
} else {
|
||||
toast.error(t("Xatolik yuz berdi"), {
|
||||
richColors: true,
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const confirmDelete = (id: number) => {
|
||||
mutate(id);
|
||||
};
|
||||
|
||||
const removeFromPopular = (id: number) => {
|
||||
popular({ id, value: 0 });
|
||||
setShowPopularDialog(false);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white gap-4 w-full">
|
||||
@@ -105,6 +149,9 @@ const Tours = () => {
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[180px]">{t("Mehmonxona")}</TableHead>
|
||||
<TableHead className="min-w-[200px]">{t("Narxi")}</TableHead>
|
||||
<TableHead className="min-w-[120px] text-center">
|
||||
{t("Popular")}
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[150px] text-center">
|
||||
{t("Операции")}
|
||||
</TableHead>
|
||||
@@ -139,6 +186,18 @@ const Tours = () => {
|
||||
</span>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-center">
|
||||
<Switch
|
||||
checked={tour.featured_tickets}
|
||||
onCheckedChange={() =>
|
||||
popular({
|
||||
id: tour.id,
|
||||
value: tour.featured_tickets ? 0 : 1,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</TableCell>
|
||||
|
||||
<TableCell className="text-center">
|
||||
<div className="flex gap-2 justify-center">
|
||||
<Button
|
||||
@@ -172,6 +231,7 @@ const Tours = () => {
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Delete Tour Dialog */}
|
||||
<Dialog open={deleteId !== null} onOpenChange={() => setDeleteId(null)}>
|
||||
<DialogContent className="sm:max-w-[425px] bg-gray-900">
|
||||
<DialogHeader>
|
||||
@@ -201,6 +261,63 @@ const Tours = () => {
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Popular Tours Dialog */}
|
||||
<Dialog open={showPopularDialog} onOpenChange={setShowPopularDialog}>
|
||||
<DialogContent className="sm:max-w-[600px] bg-gray-900">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-xl">
|
||||
{t("Popular turlar (5/5)")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<p className="text-muted-foreground mb-4">
|
||||
{t(
|
||||
"Popular turlar ro'yxati to'lgan. Yangi tur qo'shish uchun biror turni o'chiring.",
|
||||
)}
|
||||
</p>
|
||||
<div className="space-y-2 max-h-[400px] overflow-y-auto">
|
||||
{popularTour?.data.data.results.map((tour) => (
|
||||
<div
|
||||
key={tour.id}
|
||||
className="flex items-center justify-between p-3 border border-slate-700 rounded-lg hover:bg-slate-800/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1">
|
||||
<Plane className="w-4 h-4 text-primary flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-semibold truncate">
|
||||
{tour.destination}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tour.duration_days} kun • {tour.hotel_name}
|
||||
</p>
|
||||
</div>
|
||||
<span className="text-green-600 font-bold text-sm flex-shrink-0">
|
||||
{formatPrice(tour.price, true)}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => removeFromPopular(tour.id)}
|
||||
className="ml-2 text-red-500 hover:text-red-600 hover:bg-red-500/10"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowPopularDialog(false)}
|
||||
>
|
||||
{t("Yopish")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="flex justify-end mt-10 gap-3">
|
||||
<button
|
||||
disabled={page === 1}
|
||||
|
||||
@@ -69,7 +69,9 @@ const TransportTable = ({
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editId, setEditId] = useState<number | null>(null);
|
||||
const [types, setTypes] = useState<"edit" | "create">("create");
|
||||
const [selectedIcon, setSelectedIcon] = useState("Bus");
|
||||
const [selectedIcon, setSelectedIcon] = useState("");
|
||||
console.log(selectedIcon);
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const form = useForm<z.infer<typeof formSchema>>({
|
||||
@@ -83,34 +85,35 @@ const TransportTable = ({
|
||||
|
||||
useEffect(() => {
|
||||
form.setValue("icon_name", selectedIcon);
|
||||
}, [selectedIcon]);
|
||||
}, [selectedIcon, form]);
|
||||
|
||||
const handleEdit = (id: number) => {
|
||||
setTypes("edit");
|
||||
setOpen(true);
|
||||
setEditId(id);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const { data: transportDetail } = useQuery({
|
||||
const { data: transportDetail, isLoading: isDetailLoading } = useQuery({
|
||||
queryKey: ["detail_transport", editId],
|
||||
queryFn: () => hotelTransportDetail({ id: editId! }),
|
||||
enabled: !!editId,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (transportDetail) {
|
||||
form.setValue("name", transportDetail.data.data.name);
|
||||
if (transportDetail && editId) {
|
||||
const iconName = transportDetail.data.data.icon_name || "HelpCircle";
|
||||
form.setValue("name", transportDetail.data.data.name_uz);
|
||||
form.setValue("name_ru", transportDetail.data.data.name_ru);
|
||||
form.setValue("icon_name", transportDetail.data.data.icon_name);
|
||||
setSelectedIcon(transportDetail.data.data.icon_name);
|
||||
form.setValue("icon_name", iconName);
|
||||
setSelectedIcon(iconName);
|
||||
}
|
||||
}, [transportDetail, editId, form]);
|
||||
}, [transportDetail, editId, form, selectedIcon]);
|
||||
|
||||
const { mutate: deleteMutate } = useMutation({
|
||||
mutationFn: ({ id }: { id: number }) => hotelTransportDelete({ id }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["all_transport"] });
|
||||
toast.success(t("O‘chirildi"), { position: "top-center" });
|
||||
toast.success(t("O'chirildi"), { position: "top-center" });
|
||||
},
|
||||
onError: () =>
|
||||
toast.error(t("Xatolik yuz berdi"), { position: "top-center" }),
|
||||
@@ -124,9 +127,8 @@ const TransportTable = ({
|
||||
}) => hotelTranportCreate({ body }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["all_transport"] });
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
toast.success(t("Muvaffaqiyatli qo‘shildi"), { position: "top-center" });
|
||||
handleCloseDialog();
|
||||
toast.success(t("Muvaffaqiyatli qo'shildi"), { position: "top-center" });
|
||||
},
|
||||
onError: () =>
|
||||
toast.error(t("Xatolik yuz berdi"), { position: "top-center" }),
|
||||
@@ -142,8 +144,7 @@ const TransportTable = ({
|
||||
}) => hotelTransportUpdate({ body, id }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["all_transport"] });
|
||||
setOpen(false);
|
||||
form.reset();
|
||||
handleCloseDialog();
|
||||
toast.success(t("Tahrirlandi"), { position: "top-center" });
|
||||
},
|
||||
onError: () =>
|
||||
@@ -163,6 +164,12 @@ const TransportTable = ({
|
||||
|
||||
const handleDelete = (id: number) => deleteMutate({ id });
|
||||
|
||||
const handleCloseDialog = () => {
|
||||
setOpen(false);
|
||||
setEditId(null);
|
||||
form.reset();
|
||||
};
|
||||
|
||||
const columns = TranportColumns(handleEdit, handleDelete, t);
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -185,14 +192,15 @@ const TransportTable = ({
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
setOpen(true);
|
||||
setTypes("create");
|
||||
setEditId(null);
|
||||
setSelectedIcon("HelpCircle");
|
||||
form.reset();
|
||||
setSelectedIcon("");
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
<PlusIcon className="mr-2" />
|
||||
{t("Qo‘shish")}
|
||||
{t("Qo'shish")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -250,81 +258,96 @@ const TransportTable = ({
|
||||
namePageSize="pageTransportSize"
|
||||
/>
|
||||
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(isOpen) => {
|
||||
if (!isOpen) {
|
||||
handleCloseDialog();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<p className="text-xl font-semibold mb-4">
|
||||
{types === "create"
|
||||
? t("Yangi transport qo‘shish")
|
||||
? t("Yangi transport qo'shish")
|
||||
: t("Tahrirlash")}
|
||||
</p>
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-6 p-2"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("Nomi (uz)")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t("Nomi (uz)")} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name_ru"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("Nomi (ru)")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t("Nomi (ru)")} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="icon_name"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("Belgi (Icon)")}</FormLabel>
|
||||
<FormControl className="w-full">
|
||||
<IconSelect
|
||||
setSelectedIcon={setSelectedIcon}
|
||||
selectedIcon={selectedIcon}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="border-slate-600 text-white hover:bg-gray-600 bg-gray-600"
|
||||
>
|
||||
{t("Bekor qilish")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending || updatePending}>
|
||||
{isPending || updatePending ? (
|
||||
<Loader className="animate-spin" />
|
||||
) : types === "create" ? (
|
||||
t("Saqlash")
|
||||
) : (
|
||||
t("Tahrirlash")
|
||||
{isDetailLoading && types === "edit" ? (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<Loader className="animate-spin w-8 h-8" />
|
||||
</div>
|
||||
) : (
|
||||
<Form {...form}>
|
||||
<form
|
||||
onSubmit={form.handleSubmit(onSubmit)}
|
||||
className="space-y-6 p-2"
|
||||
>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("Nomi (uz)")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t("Nomi (uz)")} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="name_ru"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("Nomi (ru)")}</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder={t("Nomi (ru)")} {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="icon_name"
|
||||
render={() => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("Belgi (Icon)")}</FormLabel>
|
||||
<FormControl className="w-full">
|
||||
<IconSelect
|
||||
setSelectedIcon={setSelectedIcon}
|
||||
selectedIcon={selectedIcon}
|
||||
defaultIcon="HelpCircle"
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="flex justify-end gap-4">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleCloseDialog}
|
||||
className="border-slate-600 text-white hover:bg-gray-600 bg-gray-600"
|
||||
>
|
||||
{t("Bekor qilish")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={isPending || updatePending}>
|
||||
{isPending || updatePending ? (
|
||||
<Loader className="animate-spin" />
|
||||
) : types === "create" ? (
|
||||
t("Saqlash")
|
||||
) : (
|
||||
t("Tahrirlash")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
|
||||
Reference in New Issue
Block a user