api ulandi

This commit is contained in:
Samandar Turgunboyev
2025-10-29 18:41:59 +05:00
parent a9e99f9755
commit 2d0285dafc
64 changed files with 6319 additions and 2352 deletions

View File

@@ -2,9 +2,9 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/fias.svg" /> <link rel="icon" href="/Logo_blue.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>FIAS - React Js app</title> <title>Simple Travel</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

59
package-lock.json generated
View File

@@ -23,6 +23,7 @@
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.7", "@tailwindcss/vite": "^4.1.7",
@@ -34,6 +35,7 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",
"i18next": "^25.5.2", "i18next": "^25.5.2",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",
@@ -2109,6 +2111,35 @@
} }
} }
}, },
"node_modules/@radix-ui/react-switch": {
"version": "1.2.6",
"resolved": "https://registry.npmjs.org/@radix-ui/react-switch/-/react-switch-1.2.6.tgz",
"integrity": "sha512-bByzr1+ep1zk4VubeEVViV592vu2lHE2BZY5OnzehZqOOgogN80+mNtCqPkhn2gklJqOpxWgPoYTSnhBCqpOXQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.3",
"@radix-ui/react-compose-refs": "1.1.2",
"@radix-ui/react-context": "1.1.2",
"@radix-ui/react-primitive": "2.1.3",
"@radix-ui/react-use-controllable-state": "1.2.2",
"@radix-ui/react-use-previous": "1.1.1",
"@radix-ui/react-use-size": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-tabs": { "node_modules/@radix-ui/react-tabs": {
"version": "1.1.13", "version": "1.1.13",
"resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz",
@@ -3860,6 +3891,34 @@
"integrity": "sha512-/0ybgsQd1muo8QlnuTpKwtl0oX5YMlUGbm8xyqgDU00motRkKFFbUJySAQBWcY79rVqNLWIWa87BGVGClwAB2w==", "integrity": "sha512-/0ybgsQd1muo8QlnuTpKwtl0oX5YMlUGbm8xyqgDU00motRkKFFbUJySAQBWcY79rVqNLWIWa87BGVGClwAB2w==",
"dev": true "dev": true
}, },
"node_modules/embla-carousel": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz",
"integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==",
"license": "MIT"
},
"node_modules/embla-carousel-react": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz",
"integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==",
"license": "MIT",
"dependencies": {
"embla-carousel": "8.6.0",
"embla-carousel-reactive-utils": "8.6.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc"
}
},
"node_modules/embla-carousel-reactive-utils": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz",
"integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==",
"license": "MIT",
"peerDependencies": {
"embla-carousel": "8.6.0"
}
},
"node_modules/emoji-regex": { "node_modules/emoji-regex": {
"version": "10.5.0", "version": "10.5.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.5.0.tgz",

View File

@@ -27,6 +27,7 @@
"@radix-ui/react-select": "^2.2.6", "@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.7", "@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-slot": "^1.2.3", "@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/vite": "^4.1.7", "@tailwindcss/vite": "^4.1.7",
@@ -38,6 +39,7 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"dayjs": "^1.11.18", "dayjs": "^1.11.18",
"embla-carousel-react": "^8.6.0",
"framer-motion": "^12.23.24", "framer-motion": "^12.23.24",
"i18next": "^25.5.2", "i18next": "^25.5.2",
"i18next-browser-languagedetector": "^8.2.0", "i18next-browser-languagedetector": "^8.2.0",

BIN
public/Logo_blue.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
public/navLogo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

@@ -16,6 +16,7 @@ import AddNews from "@/pages/news/ui/AddNews";
import News from "@/pages/news/ui/News"; import News from "@/pages/news/ui/News";
import NewsCategory from "@/pages/news/ui/NewsCategory"; import NewsCategory from "@/pages/news/ui/NewsCategory";
import Seo from "@/pages/seo/ui/Seo"; import Seo from "@/pages/seo/ui/Seo";
import SiteBannerAdmin from "@/pages/site-banner/ui/Banner";
import PolicyCrud from "@/pages/site-page/ui/PolicyCrud"; import PolicyCrud from "@/pages/site-page/ui/PolicyCrud";
import SitePage from "@/pages/site-page/ui/SitePage"; import SitePage from "@/pages/site-page/ui/SitePage";
import SupportAgency from "@/pages/support/ui/SupportAgency"; import SupportAgency from "@/pages/support/ui/SupportAgency";
@@ -88,6 +89,7 @@ const App = () => {
<Route path="/bookings" element={<Bookings />} /> <Route path="/bookings" element={<Bookings />} />
<Route path="/news" element={<News />} /> <Route path="/news" element={<News />} />
<Route path="/news/add" element={<AddNews />} /> <Route path="/news/add" element={<AddNews />} />
<Route path="/news/edit/:id" element={<AddNews />} />
<Route path="/news/categories" element={<NewsCategory />} /> <Route path="/news/categories" element={<NewsCategory />} />
<Route path="/faq" element={<Faq />} /> <Route path="/faq" element={<Faq />} />
<Route path="/faq/categories" element={<FaqCategory />} /> <Route path="/faq/categories" element={<FaqCategory />} />
@@ -97,6 +99,7 @@ const App = () => {
<Route path="/site-pages/" element={<SitePage />} /> <Route path="/site-pages/" element={<SitePage />} />
<Route path="/site-help/" element={<PolicyCrud />} /> <Route path="/site-help/" element={<PolicyCrud />} />
<Route path="/site-settings/" element={<TourSettings />} /> <Route path="/site-settings/" element={<TourSettings />} />
<Route path="/site-banner/" element={<SiteBannerAdmin />} />
</Routes> </Routes>
</div> </div>
</MainProvider> </MainProvider>

View File

@@ -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 httpClient from "@/shared/config/api/httpClient";
import { FAQ, FAQ_CATEGORIES } from "@/shared/config/api/URLs"; import { FAQ, FAQ_CATEGORIES } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
@@ -6,11 +11,50 @@ import type { AxiosResponse } from "axios";
const getAllFaq = async (params: { const getAllFaq = async (params: {
page: number; page: number;
page_size: number; page_size: number;
category: number;
}): Promise<AxiosResponse<Faq>> => { }): Promise<AxiosResponse<Faq>> => {
const res = await httpClient.get(FAQ, { params }); const res = await httpClient.get(FAQ, { params });
return res; 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: { const getAllFaqCategory = async (params: {
page: number; page: number;
page_size: number; page_size: number;
@@ -48,10 +92,14 @@ const deleteFaqCategory = async (id: number) => {
}; };
export { export {
createFaq,
createFaqCategory, createFaqCategory,
deleteFaq,
deleteFaqCategory, deleteFaqCategory,
getAllFaq, getAllFaq,
getAllFaqCategory, getAllFaqCategory,
getDetailFaqCategory, getDetailFaqCategory,
getOneFaq,
updateFaq,
updateFaqCategory, updateFaqCategory,
}; };

View File

@@ -21,6 +21,7 @@ export interface FaqCategoryDetail {
data: { data: {
id: number; id: number;
name: string; name: string;
name_uz: string;
name_ru: 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;
};
}

View File

@@ -1,6 +1,13 @@
"use client"; "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 { Button } from "@/shared/ui/button";
import { import {
Dialog, Dialog,
@@ -16,16 +23,9 @@ import {
FormItem, FormItem,
FormMessage, FormMessage,
} from "@/shared/ui/form"; } from "@/shared/ui/form";
import { InfiniteScrollSelect } from "@/shared/ui/infiniteScrollSelect";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { Label } from "@/shared/ui/label"; import { Label } from "@/shared/ui/label";
import {
Select,
SelectContent,
SelectGroup,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import { import {
Table, Table,
TableBody, TableBody,
@@ -37,70 +37,231 @@ import {
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import { Textarea } from "@/shared/ui/textarea"; import { Textarea } from "@/shared/ui/textarea";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query"; import {
import { Pencil, PlusCircle, Trash2 } from "lucide-react"; useInfiniteQuery,
import { useEffect, useState } from "react"; 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 { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import z from "zod"; import z from "zod";
const faqForm = z.object({ const faqForm = z.object({
categories: z.string().min(1, { message: "Majburiy maydon" }), categories: z.string().min(1, { message: "Majburiy maydon" }),
title: 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: z.string().min(1, { message: "Majburiy maydon" }),
answer_ru: z.string().min(1, { message: "Majburiy maydon" }),
}); });
const Faq = () => { 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 [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(() => { useEffect(() => {
if (category) { if (category.length > 0 && !activeTab) {
setActiveTab(String(category[0].id)); 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 [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>>({ const form = useForm<z.infer<typeof faqForm>>({
resolver: zodResolver(faqForm), resolver: zodResolver(faqForm),
defaultValues: { defaultValues: {
answer: "", answer: "",
answer_ru: "",
categories: "", categories: "",
title: "", 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>) { 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) => { const handleEdit = (faq: number) => {
setOpenModal(true);
setEditFaq(faq); setEditFaq(faq);
}; };
const handleDelete = () => { const handleDelete = () => {
if (deleteId) { if (deleteId) {
deleteFaqs({ id: deleteId });
} }
}; };
@@ -125,22 +286,42 @@ const Faq = () => {
setOpenModal(true); setOpenModal(true);
}} }}
> >
<PlusCircle className="w-4 h-4" /> {t("Yangi qoshish")} <PlusCircle className="w-4 h-4" /> {t("Yangi qo'shish")}
</Button> </Button>
</div> </div>
{/* Tabs */} {/* Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="flex flex-wrap gap-2 mb-4"> <div className="relative">
{category?.map((cat) => ( <TabsList
<TabsTrigger key={cat.id} value={String(cat.id)}> 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} {cat.name}
</TabsTrigger> </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> </TabsList>
</div>
{/* Tabs content */} {/* Tabs content */}
{category?.map((cat) => ( {category.map((cat) => (
<TabsContent key={cat.id} value={String(cat.id)}> <TabsContent key={cat.id} value={String(cat.id)}>
{faq && faq?.length > 0 ? ( {faq && faq?.length > 0 ? (
<div className="border rounded-md overflow-hidden shadow-sm"> <div className="border rounded-md overflow-hidden shadow-sm">
@@ -192,7 +373,7 @@ const Faq = () => {
</div> </div>
) : ( ) : (
<p className="text-gray-500 text-sm mt-4 text-center"> <p className="text-gray-500 text-sm mt-4 text-center">
{t("Bu bolimda savollar yoq.")} {t("Bu bo'limda savollar yo'q")}
</p> </p>
)} )}
</TabsContent> </TabsContent>
@@ -200,10 +381,10 @@ const Faq = () => {
</Tabs> </Tabs>
<Dialog open={openModal} onOpenChange={setOpenModal}> <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> <DialogHeader>
<DialogTitle> <DialogTitle>
{editFaq ? t("FAQni tahrirlash") : t("Yangi FAQ qoshish")} {editFaq ? t("FAQni tahrirlash") : t("Yangi FAQ qo'shish")}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
@@ -216,24 +397,37 @@ const Faq = () => {
<FormItem> <FormItem>
<Label className="text-md">{t("Kategoriya")}</Label> <Label className="text-md">{t("Kategoriya")}</Label>
<FormControl> <FormControl>
<Select <InfiniteScrollSelect
onValueChange={field.onChange}
value={field.value} value={field.value}
> onValueChange={field.onChange}
<SelectTrigger className="w-full !h-12 border-gray-700 text-white"> placeholder={t("Kategoriya tanlang")}
<SelectValue placeholder={t("Kategoriya tanlang")} /> label={t("Kategoriyalar")}
</SelectTrigger> data={category || []}
<SelectContent className="border-gray-700 text-white"> fetchNextPage={fetchNextPage}
<SelectGroup> renderOption={(cat) => ({
<SelectLabel>{t("Kategoriyalar")}</SelectLabel> key: cat.id,
{/* {categories.map((cat) => ( value: String(cat.id),
<SelectItem key={cat.value} value={cat.value}> label: cat.name,
{cat.label} })}
</SelectItem> />
))} */} </FormControl>
</SelectGroup> <FormMessage />
</SelectContent> </FormItem>
</Select> )}
/>
<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> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
@@ -241,10 +435,10 @@ const Faq = () => {
/> />
<FormField <FormField
control={form.control} control={form.control}
name="title" name="title_ru"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<Label className="text-md">{t("Savol")}</Label> <Label className="text-md">{t("Savol")} (ru)</Label>
<FormControl> <FormControl>
<Input <Input
placeholder={t("Savol")} placeholder={t("Savol")}
@@ -273,6 +467,23 @@ const Faq = () => {
</FormItem> </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"> <div className="flex justify-between">
<Button <Button
type="button" type="button"
@@ -288,7 +499,11 @@ const Faq = () => {
type="submit" type="submit"
className="bg-blue-600 px-5 py-5 hover:bg-blue-700 text-white mt-4 cursor-pointer" 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> </Button>
</div> </div>
</form> </form>
@@ -299,14 +514,18 @@ const Faq = () => {
<Dialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}> <Dialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<DialogContent className="sm:max-w-[400px]"> <DialogContent className="sm:max-w-[400px]">
<DialogHeader> <DialogHeader>
<DialogTitle>{t("Haqiqatan ham ochirmoqchimisiz?")}</DialogTitle> <DialogTitle>{t("Haqiqatan ham o'chirmoqchimisiz?")}</DialogTitle>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)}> <Button variant="outline" onClick={() => setDeleteId(null)}>
{t("Bekor qilish")} {t("Bekor qilish")}
</Button> </Button>
<Button variant="destructive" onClick={handleDelete}> <Button variant="destructive" onClick={handleDelete}>
{t("O'chirish")} {deletePending ? (
<Loader2 className="animate-spin" />
) : (
t("O'chirish")
)}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>

View File

@@ -34,7 +34,14 @@ import {
} from "@/shared/ui/table"; } from "@/shared/ui/table";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; 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 { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -42,21 +49,22 @@ import { toast } from "sonner";
import z from "zod"; import z from "zod";
const categoryFormSchema = z.object({ const categoryFormSchema = z.object({
name: z.string().min(1, { message: "Kategoriya nomi majburiy" }), name: z.string().min(1, { message: "Majburiy maydon" }),
name_ru: z.string().min(1, { message: "Kategoriya nomi majburiy" }), name_ru: z.string().min(1, { message: "Majburiy maydon" }),
}); });
const FaqCategory = () => { const FaqCategory = () => {
const [deleteId, setDeleteId] = useState<number | null>(null); const [deleteId, setDeleteId] = useState<number | null>(null);
const [currentPage, setCurrentPage] = useState(1);
const [categories, setCategories] = useState<number | null>(null); const [categories, setCategories] = useState<number | null>(null);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { data: category } = useQuery({ const { data: category } = useQuery({
queryKey: ["all_faqcategory"], queryKey: ["all_faqcategory", currentPage],
queryFn: () => { queryFn: () => {
return getAllFaqCategory({ page: 1, page_size: 99 }); return getAllFaqCategory({ page: currentPage, page_size: 10 });
}, },
select(data) { select(data) {
return data.data.data.results; return data.data.data;
}, },
}); });
const { data: oneCategory } = useQuery({ const { data: oneCategory } = useQuery({
@@ -135,7 +143,7 @@ const FaqCategory = () => {
useEffect(() => { useEffect(() => {
if (oneCategory && categories) { if (oneCategory && categories) {
form.setValue("name", oneCategory.name); form.setValue("name", oneCategory.name_uz);
form.setValue("name_ru", oneCategory.name_ru); form.setValue("name_ru", oneCategory.name_ru);
} }
}, [oneCategory, categories]); }, [oneCategory, categories]);
@@ -186,7 +194,7 @@ const FaqCategory = () => {
setOpenModal(true); setOpenModal(true);
}} }}
> >
<PlusCircle className="w-4 h-4" /> {t("Yangi kategoriya")} <PlusCircle className="w-4 h-4" /> {t("Qo'shish")}
</Button> </Button>
</div> </div>
@@ -196,15 +204,17 @@ const FaqCategory = () => {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-[50px] text-center">#</TableHead> <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="text-center">Savollar soni</TableHead> */}
<TableHead className="w-[120px] text-center">Amallar</TableHead> <TableHead className="w-[120px] text-center">
{t("Amallar")}
</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{category && category.length > 0 ? ( {category && category.results.length > 0 ? (
category.map((cat, index) => ( category.results.map((cat, index) => (
<TableRow key={cat.id}> <TableRow key={cat.id}>
<TableCell className="text-center font-medium"> <TableCell className="text-center font-medium">
{index + 1} {index + 1}
@@ -245,12 +255,48 @@ const FaqCategory = () => {
</Table> </Table>
</div> </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 */} {/* Modal */}
<Dialog open={openModal} onOpenChange={setOpenModal}> <Dialog open={openModal} onOpenChange={setOpenModal}>
<DialogContent className="sm:max-w-[400px] bg-gray-900"> <DialogContent className="sm:max-w-[400px] bg-gray-900">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{categories ? "Kategoriyani tahrirlash" : "Yangi kategoriya"} {categories ? t("Tahrirlash") : t("Qo'shish")}
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
@@ -261,10 +307,10 @@ const FaqCategory = () => {
name="name" name="name"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<Label className="text-md">Kategoriya nomi</Label> <Label className="text-md">{t("Kategoriya nomi")}</Label>
<FormControl> <FormControl>
<Input <Input
placeholder="Masalan: umumiy" placeholder={t("Kategoriya nomi")}
{...field} {...field}
className="h-12 bg-gray-800 border-gray-700 text-white" className="h-12 bg-gray-800 border-gray-700 text-white"
/> />
@@ -279,10 +325,12 @@ const FaqCategory = () => {
name="name_ru" name="name_ru"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<Label className="text-md">Kategoriya nomi (ru)</Label> <Label className="text-md">
{t("Kategoriya nomi")} (ru)
</Label>
<FormControl> <FormControl>
<Input <Input
placeholder="Masalan: umumiy" placeholder={t("Kategoriya nomi") + " (ru)"}
{...field} {...field}
className="h-12 bg-gray-800 border-gray-700 text-white" className="h-12 bg-gray-800 border-gray-700 text-white"
/> />
@@ -298,7 +346,7 @@ const FaqCategory = () => {
onClick={() => setOpenModal(false)} onClick={() => setOpenModal(false)}
className="bg-gray-600 hover:bg-gray-700 text-white" className="bg-gray-600 hover:bg-gray-700 text-white"
> >
Bekor qilish {t("Bekor qilish")}
</Button> </Button>
<Button <Button
type="submit" type="submit"
@@ -307,9 +355,9 @@ const FaqCategory = () => {
{createPending || updatePending ? ( {createPending || updatePending ? (
<Loader className="animate-spin" /> <Loader className="animate-spin" />
) : categories ? ( ) : categories ? (
"Saqlash" t("Saqlash")
) : ( ) : (
"Qoshish" t("Qoshish")
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>
@@ -322,17 +370,17 @@ const FaqCategory = () => {
<Dialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}> <Dialog open={!!deleteId} onOpenChange={() => setDeleteId(null)}>
<DialogContent className="sm:max-w-[400px]"> <DialogContent className="sm:max-w-[400px]">
<DialogHeader> <DialogHeader>
<DialogTitle>Haqiqatan ham ochirmoqchimisiz?</DialogTitle> <DialogTitle>{t("Haqiqatan ham ochirmoqchimisiz?")}</DialogTitle>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeleteId(null)}> <Button variant="outline" onClick={() => setDeleteId(null)}>
Bekor qilish {t("Bekor qilish")}
</Button> </Button>
<Button variant="destructive" onClick={handleDelete}> <Button variant="destructive" onClick={handleDelete}>
{deletePending ? ( {deletePending ? (
<Loader className="animate-spin" /> <Loader className="animate-spin" />
) : ( ) : (
t("Ochirish") t("O'chirish")
)} )}
</Button> </Button>
</DialogFooter> </DialogFooter>

View 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 };

View 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;
};
}

View File

@@ -1,11 +1,18 @@
"use client"; "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 formatPrice from "@/shared/lib/formatPrice";
import { Button } from "@/shared/ui/button";
import { useQuery } from "@tanstack/react-query";
import { import {
AlertTriangle,
CreditCard, CreditCard,
DollarSign, DollarSign,
Eye, Eye,
Hotel, Hotel,
Loader2,
MapPin, MapPin,
Plane, Plane,
TrendingUp, TrendingUp,
@@ -15,94 +22,6 @@ import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; 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() { export default function FinancePage() {
const { t } = useTranslation(); const { t } = useTranslation();
const [tab, setTab] = useState<"bookings" | "agencies">("bookings"); const [tab, setTab] = useState<"bookings" | "agencies">("bookings");
@@ -110,28 +29,56 @@ export default function FinancePage() {
"all" | "paid" | "pending" | "cancelled" | "refunded" "all" | "paid" | "pending" | "cancelled" | "refunded"
>("all"); >("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 = const base =
"px-3 py-1 rounded-full text-sm font-medium inline-flex items-center gap-2"; "px-3 py-1 rounded-full text-sm font-medium inline-flex items-center gap-2";
switch (status) { switch (status) {
case "paid": 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("Paid")}
</span>
);
case "pending":
return ( return (
<span <span
className={`${base} bg-yellow-900 text-yellow-400 border border-yellow-700`} className={`${base} bg-yellow-900 text-yellow-400 border border-yellow-700`}
> >
<div className="w-2 h-2 rounded-full bg-yellow-400"></div> <div className="w-2 h-2 rounded-full bg-yellow-400"></div>
{t("Pending")} {t("Pending Payment")}
</span> </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": case "cancelled":
return ( return (
<span <span
@@ -141,49 +88,37 @@ export default function FinancePage() {
{t("Cancelled")} {t("Cancelled")}
</span> </span>
); );
case "refunded":
return ( default:
<span return null;
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>
);
} }
}; };
const filteredPurchases = if (isLoading) {
filterStatus === "all" return (
? mockPurchases <div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white gap-4 w-full">
: mockPurchases.filter((p) => p.paymentStatus === filterStatus); <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 if (isError) {
.filter((p) => p.paymentStatus === "paid") return (
.reduce((sum, p) => sum + p.amount, 0); <div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 w-full text-center text-white gap-4">
const pendingRevenue = filteredPurchases <AlertTriangle className="w-10 h-10 text-red-500" />
.filter((p) => p.paymentStatus === "pending") <p className="text-lg">
.reduce((sum, p) => sum + p.amount, 0); {t("Ma'lumotlarni yuklashda xatolik yuz berdi.")}
</p>
const agencies = Array.from( <Button
new Set(mockPurchases.map((p) => p.agencyId)), onClick={() => refetch()}
).map((id) => { className="bg-gradient-to-r from-blue-600 to-cyan-600 text-white rounded-lg px-5 py-2 hover:opacity-90"
const agencyPurchases = mockPurchases.filter((p) => p.agencyId === id); >
return { {t("Qayta urinish")}
id, </Button>
name: agencyPurchases[0].agencyName, </div>
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)),
),
};
});
return ( return (
<div className="min-h-screen w-full bg-gray-900 text-gray-100"> <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" /> <DollarSign className="text-green-400 w-6 h-6" />
</div> </div>
<p className="text-2xl font-bold text-green-400 mt-3"> <p className="text-2xl font-bold text-green-400 mt-3">
{formatPrice(totalRevenue, true)} {/* {formatPrice(totalRevenue, true)} */}
</p> </p>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">
{t("Yakunlangan bandlovlardan")} {t("Yakunlangan bandlovlardan")}
@@ -289,7 +224,7 @@ export default function FinancePage() {
<TrendingUp className="text-yellow-400 w-6 h-6" /> <TrendingUp className="text-yellow-400 w-6 h-6" />
</div> </div>
<p className="text-2xl font-bold text-yellow-400 mt-3"> <p className="text-2xl font-bold text-yellow-400 mt-3">
{formatPrice(pendingRevenue, true)} {/* {formatPrice(pendingRevenue, true)} */}
</p> </p>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">
{t("Tasdiqlash kutilmoqda")} {t("Tasdiqlash kutilmoqda")}
@@ -303,10 +238,10 @@ export default function FinancePage() {
<CreditCard className="text-blue-400 w-6 h-6" /> <CreditCard className="text-blue-400 w-6 h-6" />
</div> </div>
<p className="text-2xl font-bold text-blue-400 mt-3"> <p className="text-2xl font-bold text-blue-400 mt-3">
{ {/* {
filteredPurchases.filter((p) => p.paymentStatus === "paid") filteredPurchases.filter((p) => p.paymentStatus === "paid")
.length .length
} } */}
</p> </p>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">
{t("Tasdiqlangan bandlovlar")} {t("Tasdiqlangan bandlovlar")}
@@ -320,11 +255,11 @@ export default function FinancePage() {
<Hotel className="text-purple-400 w-6 h-6" /> <Hotel className="text-purple-400 w-6 h-6" />
</div> </div>
<p className="text-2xl font-bold text-purple-400 mt-3"> <p className="text-2xl font-bold text-purple-400 mt-3">
{ {/* {
filteredPurchases.filter( filteredPurchases.filter(
(p) => p.paymentStatus === "pending", (p) => p.paymentStatus === "pending",
).length ).length
} } */}
</p> </p>
<p className="text-sm text-gray-500 mt-1"> <p className="text-sm text-gray-500 mt-1">
{t("Kutilayotgan tolovlar")} {t("Kutilayotgan tolovlar")}
@@ -335,46 +270,50 @@ export default function FinancePage() {
{/* Booking Cards */} {/* Booking Cards */}
<h2 className="text-xl font-bold mb-4">{t("Oxirgi bandlovlar")}</h2> <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"> <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 <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" className="bg-gray-800 p-5 rounded-xl shadow hover:shadow-md transition-all flex flex-col justify-between"
> >
<div> <div>
<div className="flex justify-between items-start mb-3"> <div className="flex justify-between items-start mb-3">
<h2 className="text-lg font-bold text-gray-100"> <h2 className="text-lg font-bold text-gray-100">
{p.userName} {p.user.first_name} {p.user.last_name}
</h2> </h2>
</div> </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 className="mt-3 font-semibold text-gray-200">
{p.tourName} {p.tour_name}
</p> </p>
<div className="flex items-center gap-1 mt-1 text-gray-400"> <div className="flex items-center gap-1 mt-1 text-gray-400">
<MapPin size={14} /> <MapPin size={14} />
<p className="text-sm">{p.destination}</p> <p className="text-sm">{p.destination}</p>
</div> </div>
<div className="flex justify-between mt-3"> <div className="flex justify-between items-center mt-3">
<div> {/* <div>
<p className="text-gray-500 text-sm"> <p className="text-gray-500 text-sm">
{t("Sayohat sanasi")} {t("Sayohat sanasi")}
</p> </p>
<p className="text-gray-100 font-medium"> <p className="text-gray-100 font-medium">
{p.travelDate} {p.travelDate}
</p> </p>
</div> </div> */}
<div className="text-right"> <div className="text-start">
<p className="text-gray-500 text-sm">{t("Miqdor")}</p> <p className="text-gray-500 text-sm">{t("Miqdor")}</p>
<p className="text-green-400 font-bold"> <p className="text-green-400 font-bold">
{formatPrice(p.amount, true)} {formatPrice(p.total_price, true)}
</p> </p>
</div> </div>
<div>{getStatusBadge(p.order_status)}</div>
</div> </div>
</div> </div>
<div className="mt-4 flex justify-between items-center"> <div className="mt-4 items-center">
{getStatusBadge(p.paymentStatus)}
<Link to={`/bookings/${p.id}`}> <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")} <Eye className="w-4 h-4" /> {t("Ko'rish")}
</button> </button>
</Link> </Link>
@@ -385,7 +324,7 @@ export default function FinancePage() {
</> </>
)} )}
{tab === "agencies" && ( {/* {tab === "agencies" && (
<> <>
<h2 className="text-xl font-bold mb-6">Partner Agencies</h2> <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"> <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> </div>
</div> </div>
); );

View File

@@ -13,7 +13,7 @@ import {
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Link } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
type TourPurchase = { type TourPurchase = {
id: number; id: number;
@@ -110,6 +110,14 @@ export default function FinanceDetailTour() {
const [activeTab, setActiveTab] = useState< const [activeTab, setActiveTab] = useState<
"overview" | "bookings" | "reviews" "overview" | "bookings" | "reviews"
>("overview"); >("overview");
const params = useParams();
console.log(params);
// const {} = useQuery({
// queryKey: ["detail_order"],
// queryFn: () => getDetailOrder()
// })
const getStatusBadge = (status: TourPurchase["paymentStatus"]) => { const getStatusBadge = (status: TourPurchase["paymentStatus"]) => {
const base = const base =

View File

@@ -1,7 +1,17 @@
"use client"; "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 formatPhone from "@/shared/lib/formatPhone";
import formatPrice from "@/shared/lib/formatPrice"; 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 { import {
ArrowLeft, ArrowLeft,
Calendar, Calendar,
@@ -10,129 +20,80 @@ import {
Mail, Mail,
MapPin, MapPin,
Phone, Phone,
TrendingUp,
User, User,
UsersIcon,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { toast } from "sonner";
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",
},
];
export default function FinanceDetailUser() { export default function FinanceDetailUser() {
const { t } = useTranslation(); const { t } = useTranslation();
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<"bookings" | "details">( const [activeTab, setActiveTab] = useState<"bookings" | "details">(
"bookings", "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 = const base =
"px-3 py-1 rounded-full text-sm font-medium inline-flex items-center gap-2"; "px-3 py-1 rounded-full text-sm font-medium inline-flex items-center gap-2";
switch (status) { switch (status) {
case "paid": case "pending_confirmation":
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":
return ( return (
<span <span
className={`${base} bg-yellow-900 text-yellow-400 border border-yellow-700`} className={`${base} bg-yellow-900 text-yellow-400 border border-yellow-700`}
> >
<div className="w-2 h-2 rounded-full bg-yellow-400"></div> <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")} {t("Pending")}
</span> </span>
); );
@@ -145,39 +106,18 @@ export default function FinanceDetailUser() {
{t("Cancelled")} {t("Cancelled")}
</span> </span>
); );
case "refunded": case "confirmed":
return ( return (
<span <span
className={`${base} bg-blue-900 text-blue-400 border border-blue-700`} className={`${base} bg-blue-900 text-blue-400 border border-blue-700`}
> >
<div className="w-2 h-2 rounded-full bg-blue-400"></div> <div className="w-2 h-2 rounded-full bg-blue-400"></div>
Refunded {t("Refunded")}
</span> </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 ( return (
<div className="min-h-screen w-full bg-gray-900 text-gray-100"> <div className="min-h-screen w-full bg-gray-900 text-gray-100">
<div className="w-[90%] mx-auto py-6"> <div className="w-[90%] mx-auto py-6">
@@ -195,67 +135,12 @@ export default function FinanceDetailUser() {
{t("Foydalanuvchi moliyaviy tafsilotlari")} {t("Foydalanuvchi moliyaviy tafsilotlari")}
</h1> </h1>
<p className="text-gray-400 mt-1"> <p className="text-gray-400 mt-1">
{mockUserData.userName} {t("uchun batafsil moliyaviy sharh")} {data?.user.username} {t("uchun batafsil moliyaviy sharh")}
</p> </p>
</div> </div>
</div> </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 */} {/* Main Content */}
<div className="bg-gray-800 rounded-xl shadow"> <div className="bg-gray-800 rounded-xl shadow">
{/* Tabs */} {/* Tabs */}
@@ -290,21 +175,55 @@ export default function FinanceDetailUser() {
<h2 className="text-xl font-bold mb-4"> <h2 className="text-xl font-bold mb-4">
{t("Booking History")} {t("Booking History")}
</h2> </h2>
{mockUserPurchases.map((purchase) => ( <div className="bg-gray-700 p-6 rounded-lg hover:bg-gray-600 transition-colors">
<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="flex justify-between items-start mb-4">
<div> <div>
<h3 className="text-lg font-bold text-gray-100"> <h3 className="text-lg font-bold text-gray-100">
{purchase.tourName} {data?.departure}
</h3> </h3>
<p className="text-gray-400 text-sm">
{t("Booking Ref")}: {purchase.bookingReference}
</p>
</div> </div>
{getStatusBadge(purchase.paymentStatus)} <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>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-4">
@@ -314,9 +233,7 @@ export default function FinanceDetailUser() {
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">
{t("Destination")} {t("Destination")}
</p> </p>
<p className="text-gray-100"> <p className="text-gray-100">{data?.destination}</p>
{purchase.destination}
</p>
</div> </div>
</div> </div>
@@ -327,7 +244,7 @@ export default function FinanceDetailUser() {
{t("Travel Dates")} {t("Travel Dates")}
</p> </p>
<p className="text-gray-100"> <p className="text-gray-100">
{purchase.travelDate} - {purchase.returnDate} {data?.departure_date} - {data?.arrival_time}
</p> </p>
</div> </div>
</div> </div>
@@ -338,7 +255,9 @@ export default function FinanceDetailUser() {
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">
{t("Travelers")} {t("Travelers")}
</p> </p>
<p className="text-gray-100">{purchase.travelers}</p> <p className="text-gray-100">
{data?.participant.length}
</p>
</div> </div>
</div> </div>
@@ -347,7 +266,7 @@ export default function FinanceDetailUser() {
<div> <div>
<p className="text-sm text-gray-400">{t("Amount")}</p> <p className="text-sm text-gray-400">{t("Amount")}</p>
<p className="text-green-400 font-bold"> <p className="text-green-400 font-bold">
{formatPrice(purchase.amount, true)} {data && formatPrice(data?.total_price, true)}
</p> </p>
</div> </div>
</div> </div>
@@ -355,17 +274,15 @@ export default function FinanceDetailUser() {
<div className="flex justify-between items-center pt-4 border-t border-gray-600"> <div className="flex justify-between items-center pt-4 border-t border-gray-600">
<div className="text-sm text-gray-400"> <div className="text-sm text-gray-400">
{t("Booked on")} {purchase.purchaseDate}{" "} {t("Booked on")} {data?.departure_date}
{getPaymentMethod(purchase.paymentMethod)}
</div> </div>
</div> </div>
</div> </div>
))}
</div> </div>
)} )}
{activeTab === "details" && ( {activeTab === "details" && (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 gap-8">
{/* Personal Information */} {/* Personal Information */}
<div> <div>
<h3 className="text-lg font-bold mb-4"> <h3 className="text-lg font-bold mb-4">
@@ -378,7 +295,9 @@ export default function FinanceDetailUser() {
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">
{t("Full Name")} {t("Full Name")}
</p> </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>
</div> </div>
@@ -389,71 +308,113 @@ export default function FinanceDetailUser() {
{t("Phone Number")} {t("Phone Number")}
</p> </p>
<p className="text-gray-100"> <p className="text-gray-100">
{formatPhone(mockUserData.userPhone)} {data && formatPhone(data?.user.phone)}
</p> </p>
</div> </div>
</div> </div>
{data?.user.email && (
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg"> <div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg">
<Mail className="w-5 h-5 text-yellow-400" /> <Mail className="w-5 h-5 text-yellow-400" />
<div> <div>
<p className="text-sm text-gray-400"> <p className="text-sm text-gray-400">
{t("Email Address")} {t("Email Address")}
</p> </p>
<p className="text-gray-100"> <p className="text-gray-100">{data?.user.email}</p>
{mockUserData.userEmail}
</p>
</div> </div>
</div> </div>
)}
<div className="flex items-center gap-3 p-3 bg-gray-700 rounded-lg"> </div>
<Calendar className="w-5 h-5 text-purple-400" /> </div>
<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> <div>
<p className="text-sm text-gray-400"> <h3 className="font-semibold text-slate-100 text-lg">
{t("Member Since")} {companion.first_name} {companion.last_name}
</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> </h3>
<div className="space-y-4"> <div className="flex items-center gap-2 mt-1">
<div className="p-4 bg-gray-700 rounded-lg"> <span
<p className="text-sm text-gray-400 mb-2"> className={`px-2 py-1 text-xs rounded-full border ${
{t("Favorite Destination")} 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>
<p className="text-gray-100 font-medium">Dubai, UAE</p> <p className="text-slate-200 font-medium">
<p className="text-sm text-gray-400 mt-1"> {companion.birth_date}
2 {t("bookings")}
</p> </p>
</div> </div>
<div>
<div className="p-4 bg-gray-700 rounded-lg"> <p className="text-xs text-slate-500">
<p className="text-sm text-gray-400 mb-2"> {t("Telefon raqami")}
{t("Preferred Agency")}
</p> </p>
<p className="text-gray-100 font-medium"> <p className="text-slate-200 font-medium">
Silk Road Travel {formatPhone(companion.phone_number)}
</p>
<p className="text-sm text-gray-400 mt-1">
2 {t("out of")} 3 {t("bookings")}
</p> </p>
</div> </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>
{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>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -2,6 +2,7 @@ import type {
GetAllNewsCategory, GetAllNewsCategory,
GetDetailNewsCategory, GetDetailNewsCategory,
NewsAll, NewsAll,
NewsDetail,
} from "@/pages/news/lib/type"; } from "@/pages/news/lib/type";
import httpClient from "@/shared/config/api/httpClient"; import httpClient from "@/shared/config/api/httpClient";
import { NEWS, NEWS_CATEGORY } from "@/shared/config/api/URLs"; import { NEWS, NEWS_CATEGORY } from "@/shared/config/api/URLs";
@@ -18,6 +19,13 @@ const getAllNews = async ({
return response; 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 addNews = async (body: FormData) => {
const response = await httpClient.post(NEWS, body, { const response = await httpClient.post(NEWS, body, {
headers: { headers: {
@@ -27,7 +35,20 @@ const addNews = async (body: FormData) => {
return response; 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: { const getAllNewsCategory = async (params: {
page: number; page: number;
page_size: number; page_size: number;
@@ -67,9 +88,12 @@ const deleteNewsCategory = async (id: number) => {
export { export {
addNews, addNews,
addNewsCategory, addNewsCategory,
deleteNews,
deleteNewsCategory, deleteNewsCategory,
getAllNews, getAllNews,
getAllNewsCategory, getAllNewsCategory,
getDetailNews,
getDetailNewsCategory, getDetailNewsCategory,
updateNews,
updateNewsCategory, updateNewsCategory,
}; };

View File

@@ -7,7 +7,7 @@ interface NewsData {
title_ru: string; title_ru: string;
desc_ru: string; desc_ru: string;
category: string; category: string;
banner: File | undefined; banner: File | undefined | string;
} }
interface NewsStore { interface NewsStore {

View File

@@ -1,6 +1,9 @@
import z from "zod"; 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({ export const newsForm = z.object({
title: z.string().min(2, { title: z.string().min(2, {

View File

@@ -82,5 +82,42 @@ export interface GetDetailNewsCategory {
id: number; id: number;
name: string; name: string;
name_ru: 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;
},
];
}; };
} }

View File

@@ -1,6 +1,8 @@
"use client"; "use client";
import { getDetailNews } from "@/pages/news/lib/api";
import StepOne from "@/pages/news/ui/StepOne"; import StepOne from "@/pages/news/ui/StepOne";
import StepTwo from "@/pages/news/ui/StepTwo"; import StepTwo from "@/pages/news/ui/StepTwo";
import { useQuery } from "@tanstack/react-query";
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@@ -11,6 +13,14 @@ const AddNews = () => {
const isEditMode = useMemo(() => !!id, [id]); const isEditMode = useMemo(() => !!id, [id]);
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
const { t } = useTranslation(); const { t } = useTranslation();
const { data } = useQuery({
queryKey: ["news_detail", id],
queryFn: () => getDetailNews(Number(id)),
select(data) {
return data.data.data;
},
enabled: !!id,
});
return ( return (
<div className="p-8 w-full mx-auto bg-gray-900 text-white rounded-2xl shadow-lg"> <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")} 2. {t("Yangilik ma'lumotlari")}
</div> </div>
</div> </div>
{step === 1 && <StepOne isEditMode={isEditMode} setStep={setStep} />} {step === 1 && (
{step === 2 && <StepTwo />} <StepOne isEditMode={isEditMode} setStep={setStep} data={data!} />
)}
{step === 2 && <StepTwo data={data!} isEditMode={isEditMode} id={id!} />}
</div> </div>
); );
}; };

View File

@@ -1,5 +1,5 @@
"use client"; "use client";
import { getAllNews } from "@/pages/news/lib/api"; import { deleteNews, getAllNews } from "@/pages/news/lib/api";
import { Badge } from "@/shared/ui/badge"; import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Card } from "@/shared/ui/card"; import { Card } from "@/shared/ui/card";
@@ -10,27 +10,56 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/shared/ui/dialog"; } from "@/shared/ui/dialog";
import { useQuery } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import clsx from "clsx"; 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 { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
const News = () => { const News = () => {
const [currentPage, setCurrentPage] = useState(1);
const { t } = useTranslation(); const { t } = useTranslation();
const [deleteId, setDeleteId] = useState<number | null>(null); const [deleteId, setDeleteId] = useState<number | null>(null);
const queryClient = useQueryClient();
const navigate = useNavigate(); const navigate = useNavigate();
const { const {
data: allNews, data: allNews,
isLoading, isLoading,
isError, isError,
} = useQuery({ } = useQuery({
queryKey: ["all_news"], queryKey: ["all_news", currentPage],
queryFn: () => getAllNews({ page: 1, page_size: 2 }), 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) { if (isLoading) {
return ( return (
@@ -157,7 +186,7 @@ const News = () => {
{/* Actions */} {/* Actions */}
<div className="flex justify-end gap-2 pt-3"> <div className="flex justify-end gap-2 pt-3">
<Button <Button
onClick={() => navigate(`/news/add`)} onClick={() => navigate(`/news/edit/${item.id}`)}
size="sm" size="sm"
variant="outline" variant="outline"
className="hover:bg-neutral-700 hover:text-blue-400" className="hover:bg-neutral-700 hover:text-blue-400"
@@ -201,11 +230,51 @@ const News = () => {
</Button> </Button>
<Button variant="destructive" onClick={confirmDelete}> <Button variant="destructive" onClick={confirmDelete}>
<Trash2 className="w-4 h-4 mr-2" /> <Trash2 className="w-4 h-4 mr-2" />
{t("O'chirish")} {isPending ? (
<Loader2 className="animate-spin" />
) : (
t("O'chirish")
)}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </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> </div>
); );
}; };

View File

@@ -86,7 +86,7 @@ const NewsCategory = () => {
useEffect(() => { useEffect(() => {
if (detail) { 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); form.setValue("title_ru", detail.data.data.name_ru);
} }
}, [editItem, form, detail]); }, [editItem, form, detail]);

View File

@@ -1,3 +1,5 @@
"use client";
import { getAllNewsCategory } from "@/pages/news/lib/api"; import { getAllNewsCategory } from "@/pages/news/lib/api";
import { useNewsStore } from "@/pages/news/lib/data"; import { useNewsStore } from "@/pages/news/lib/data";
import { newsForm } from "@/pages/news/lib/form"; import { newsForm } from "@/pages/news/lib/form";
@@ -9,40 +11,68 @@ import {
FormItem, FormItem,
FormMessage, FormMessage,
} from "@/shared/ui/form"; } from "@/shared/ui/form";
import { InfiniteScrollSelect } from "@/shared/ui/infiniteScrollSelect";
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { Label } from "@/shared/ui/label"; 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 { Textarea } from "@/shared/ui/textarea";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useInfiniteQuery } from "@tanstack/react-query"; import { useInfiniteQuery } from "@tanstack/react-query";
import { XIcon } from "lucide-react"; 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 { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import type z from "zod"; 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 = ({ const StepOne = ({
setStep, setStep,
data: detail,
}: { }: {
setStep: Dispatch<SetStateAction<number>>; setStep: Dispatch<SetStateAction<number>>;
isEditMode: boolean; isEditMode: boolean;
data: Data;
}) => { }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const { setStepOneData, stepOneData } = useNewsStore(); const { setStepOneData, stepOneData } = useNewsStore();
const hasReset = useRef(false); // 👈 infinite loopni oldini olish uchun
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery({ useInfiniteQuery({
queryKey: ["news_category"], queryKey: ["news_category", detail],
queryFn: ({ pageParam = 1 }) => queryFn: ({ pageParam = 1 }) =>
getAllNewsCategory({ page: pageParam, page_size: 5 }), getAllNewsCategory({ page: pageParam, page_size: 10 }),
getNextPageParam: (lastPage) => { getNextPageParam: (lastPage) => {
const currentPage = lastPage.data.data.current_page; const currentPage = lastPage.data.data.current_page;
const totalPages = lastPage.data.data.total_pages; const totalPages = lastPage.data.data.total_pages;
@@ -66,42 +96,39 @@ const StepOne = ({
}, },
}); });
// ✅ Haqiqiy scroll elementni topish va scroll eventni qoshish // ✅ reset faqat bir marta, ma'lumot tayyor bo'lganda ishlaydi
useEffect(() => { useEffect(() => {
const interval = setInterval(() => {
const viewport = document.querySelector(
"[data-radix-select-viewport]",
) as HTMLDivElement | null;
if (viewport) {
const handleScroll = () => {
const { scrollTop, scrollHeight, clientHeight } = viewport;
if ( if (
scrollHeight - scrollTop - clientHeight < 50 && detail &&
hasNextPage && allCategories.length > 0 &&
!isFetchingNextPage !hasReset.current // faqat bir marta
) { ) {
fetchNextPage(); const foundCategory = allCategories.find(
(cat) => cat.name === detail.category.name,
);
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,
});
hasReset.current = true; // ✅ qayta reset bolmasin
} }
}; }, [detail, allCategories, form]);
viewport.addEventListener("scroll", handleScroll);
clearInterval(interval);
return () => {
viewport.removeEventListener("scroll", handleScroll);
};
}
}, 200);
return () => clearInterval(interval);
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
function onSubmit(values: z.infer<typeof newsForm>) { function onSubmit(values: z.infer<typeof newsForm>) {
setStepOneData(values); setStepOneData(values);
setStep(2); setStep(2);
} }
const banner = form.watch("banner");
const bannerSrc =
banner instanceof File ? URL.createObjectURL(banner) : String(banner);
return ( return (
<Form {...form}> <Form {...form}>
<form <form
@@ -127,6 +154,7 @@ const StepOne = ({
)} )}
/> />
{/* title_ru */}
<FormField <FormField
control={form.control} control={form.control}
name="title_ru" name="title_ru"
@@ -164,6 +192,7 @@ const StepOne = ({
)} )}
/> />
{/* desc_ru */}
<FormField <FormField
control={form.control} control={form.control}
name="desc_ru" name="desc_ru"
@@ -182,6 +211,7 @@ const StepOne = ({
)} )}
/> />
{/* category */}
<FormField <FormField
control={form.control} control={form.control}
name="category" name="category"
@@ -189,38 +219,28 @@ const StepOne = ({
<FormItem> <FormItem>
<Label className="text-md">{t("Kategoriya")}</Label> <Label className="text-md">{t("Kategoriya")}</Label>
<FormControl> <FormControl>
<Select onValueChange={field.onChange} value={field.value}> <InfiniteScrollSelect
<SelectTrigger className="w-full !h-12 bg-gray-800 border-gray-700 text-white"> value={field.value}
<SelectValue placeholder={t("Kategoriya tanlang")} /> onValueChange={field.onChange}
</SelectTrigger> placeholder={t("Kategoriya tanlang")}
<SelectContent className="bg-gray-800 border-gray-700 text-white max-h-[180px] overflow-y-auto"> label={t("Kategoriyalar")}
<SelectGroup> data={allCategories}
<SelectLabel>{t("Kategoriyalar")}</SelectLabel> hasNextPage={hasNextPage}
{allCategories.map((cat) => ( isFetchingNextPage={isFetchingNextPage}
<SelectItem key={cat.id} value={String(cat.id)}> fetchNextPage={fetchNextPage}
{cat.name} renderOption={(cat) => ({
</SelectItem> key: cat.id,
))} value: String(cat.id),
{isFetchingNextPage && ( label: cat.name,
<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>
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
/> />
{/* Banner */} {/* banner */}
<FormField <FormField
control={form.control} control={form.control}
name="banner" name="banner"
@@ -254,11 +274,10 @@ const StepOne = ({
</p> </p>
</label> </label>
{/* ✅ Preview (URL.createObjectURL bilan) */} {form.watch("banner") && (
{form.watch("banner") instanceof File && (
<div className="relative w-32 h-32 rounded-md overflow-hidden border border-gray-600"> <div className="relative w-32 h-32 rounded-md overflow-hidden border border-gray-600">
<img <img
src={URL.createObjectURL(form.watch("banner"))} src={bannerSrc}
alt="Banner preview" alt="Banner preview"
className="object-cover w-full h-full" className="object-cover w-full h-full"
/> />

View File

@@ -1,6 +1,6 @@
"use client"; "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 { useNewsStore } from "@/pages/news/lib/data";
import { newsPostForm, type NewsPostFormType } from "@/pages/news/lib/form"; import { newsPostForm, type NewsPostFormType } from "@/pages/news/lib/form";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
@@ -17,15 +17,57 @@ import { Textarea } from "@/shared/ui/textarea";
import { zodResolver } from "@hookform/resolvers/zod"; import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { ImagePlus, PlusCircle, Trash2 } from "lucide-react"; import { ImagePlus, PlusCircle, Trash2 } from "lucide-react";
import { useEffect, useRef } from "react";
import { useFieldArray, useForm } from "react-hook-form"; import { useFieldArray, useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner"; 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 { t } = useTranslation();
const hasReset = useRef(false);
const navigate = useNavigate(); const navigate = useNavigate();
const { stepOneData } = useNewsStore(); const { stepOneData, resetStepOneData } = useNewsStore();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const form = useForm<NewsPostFormType>({ 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({ const { fields, append, remove } = useFieldArray({
control: form.control, control: form.control,
name: "sections", name: "sections",
@@ -56,7 +125,26 @@ const StepTwo = () => {
mutationFn: (body: FormData) => addNews(body), mutationFn: (body: FormData) => addNews(body),
onSuccess: () => { onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["all_news"] }); queryClient.refetchQueries({ queryKey: ["all_news"] });
queryClient.refetchQueries({ queryKey: ["news_detail"] });
navigate("/news"); 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: () => { onError: () => {
toast.error(t("Xatolik yuz berdi"), { toast.error(t("Xatolik yuz berdi"), {
@@ -79,14 +167,20 @@ const StepTwo = () => {
formData.append("title", stepOneData.title); formData.append("title", stepOneData.title);
formData.append("title_ru", stepOneData.title_ru); formData.append("title_ru", stepOneData.title_ru);
formData.append("image", stepOneData.banner ?? "");
formData.append("text", stepOneData.desc); formData.append("text", stepOneData.desc);
formData.append("text_ru", stepOneData.desc_ru); formData.append("text_ru", stepOneData.desc_ru);
formData.append("is_public", values.is_public === "no" ? "false" : "true"); formData.append("is_public", values.is_public === "no" ? "false" : "true");
formData.append("category", stepOneData.category); formData.append("category", stepOneData.category);
if (stepOneData.banner instanceof File) {
formData.append("image", stepOneData.banner);
}
// 🔥 sections
values.sections.forEach((section, i) => { values.sections.forEach((section, i) => {
if (section.image instanceof File) {
formData.append(`post_images[${i}]`, section.image); formData.append(`post_images[${i}]`, section.image);
}
formData.append(`post_text[${i}]`, section.text); formData.append(`post_text[${i}]`, section.text);
formData.append(`post_text_ru[${i}]`, section.text_ru); formData.append(`post_text_ru[${i}]`, section.text_ru);
}); });
@@ -94,8 +188,14 @@ const StepTwo = () => {
values.post_tags.forEach((tag, i) => { values.post_tags.forEach((tag, i) => {
formData.append(`post_tags[${i}]`, tag); formData.append(`post_tags[${i}]`, tag);
}); });
if (id) {
update({
body: formData,
id: Number(id),
});
} else {
added(formData); added(formData);
}
}; };
return ( return (
@@ -201,9 +301,13 @@ const StepTwo = () => {
{form.watch(`sections.${index}.image`) ? ( {form.watch(`sections.${index}.image`) ? (
<div className="relative mt-2 w-48"> <div className="relative mt-2 w-48">
<img <img
src={URL.createObjectURL( src={
form.watch(`sections.${index}.image`), form.watch(`sections.${index}.image`) instanceof File
)} ? URL.createObjectURL(
form.watch(`sections.${index}.image`) as File,
)
: String(form.watch(`sections.${index}.image`) || "")
}
alt="preview" alt="preview"
className="rounded-lg border border-gray-700 object-cover h-40 w-full" className="rounded-lg border border-gray-700 object-cover h-40 w-full"
/> />

40
src/pages/seo/lib/api.ts Normal file
View 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 };

View 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;
};
}

View File

@@ -1,13 +1,26 @@
"use client"; "use client";
import {
createSeo,
deleteSeo,
getAllSeo,
getDetailSeo,
updateSeo,
} from "@/pages/seo/lib/api";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { import {
AlertCircle, AlertCircle,
CheckCircle, CheckCircle,
FileText, FileText,
Image as ImageIcon, Image as ImageIcon,
Loader2,
Pencil,
Trash2,
TrendingUp, TrendingUp,
} from "lucide-react"; } 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 = { type SeoData = {
title: string; title: string;
@@ -15,7 +28,7 @@ type SeoData = {
keywords: string; keywords: string;
ogTitle: string; ogTitle: string;
ogDescription: string; ogDescription: string;
ogImage: string; ogImage: File | null | string;
}; };
export default function Seo() { export default function Seo() {
@@ -25,10 +38,107 @@ export default function Seo() {
keywords: "", keywords: "",
ogTitle: "", ogTitle: "",
ogDescription: "", 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("Malumotlar 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("Malumotlar 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 [imagePreview, setImagePreview] = useState<string | null>(null);
const handleChange = ( const handleChange = (
@@ -44,37 +154,59 @@ export default function Seo() {
const handleImageUpload = (e: ChangeEvent<HTMLInputElement>) => { const handleImageUpload = (e: ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (file) { if (file) {
const reader = new FileReader(); setFormData((prev) => ({ ...prev, ogImage: file }));
reader.onload = (event) => { setImagePreview(URL.createObjectURL(file));
const result = event.target?.result as string;
setImagePreview(result);
setFormData((prev) => ({
...prev,
ogImage: result,
}));
};
reader.readAsDataURL(file);
} }
}; };
const handleSave = () => { const handleEdit = (id: number) => {
setSavedSeo(formData); setEdit(id);
setFormData({
description: "",
keywords: "",
ogDescription: "",
ogImage: "",
ogTitle: "",
title: "",
});
}; };
const getTitleLength = () => formData.title.length; const handleDelete = (id: number) => {
const getDescriptionLength = () => formData.description.length; 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 bolsa qoshamiz
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 = const isValidDescription =
getDescriptionLength() > 120 && getDescriptionLength() <= 160; getDescriptionLength() > 60 && getDescriptionLength() <= 160;
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 to-slate-800 p-8 w-full"> <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="mb-8">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<TrendingUp className="w-8 h-8 text-blue-400" /> <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> </div>
<p className="text-slate-400"> <p className="text-slate-400">
Saytingizni qidiruv tizimida yaxshi pozitsiyaga keltiring {t("Saytingizni qidiruv tizimida yaxshi pozitsiyaga keltiring")}
</p> </p>
</div> </div>
{/* Main Content */} {/* Main Form */}
<div className="grid grid-cols-1 gap-8"> <div className="grid grid-cols-1 gap-8">
<div className="bg-slate-800 rounded-lg p-6 space-y-6"> <div className="bg-slate-800 rounded-lg p-6 space-y-6">
{/* Title */} {/* Title */}
<div> <div>
<label className="block text-sm font-semibold text-white mb-2"> <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> </label>
<input <input
type="text" type="text"
name="title" name="title"
value={formData.title} value={formData.title}
onChange={handleChange} onChange={handleChange}
placeholder="Sahifa sarlavhasi (3060 belgi)" placeholder={t("Sahifa sarlavhasi (3060 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" 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"> <div className="flex items-center justify-between mt-2">
<span className="text-sm text-slate-400">
{getTitleLength()} / 60
</span>
{isValidTitle && ( {isValidTitle && (
<CheckCircle className="w-5 h-5 text-green-400" /> <CheckCircle className="w-5 h-5 text-green-400" />
)} )}
@@ -122,13 +253,13 @@ export default function Seo() {
{/* Description */} {/* Description */}
<div> <div>
<label className="block text-sm font-semibold text-white mb-2"> <label className="block text-sm font-semibold text-white mb-2">
Meta Description {t("Meta Description")}
</label> </label>
<textarea <textarea
name="description" name="description"
value={formData.description} value={formData.description}
onChange={handleChange} onChange={handleChange}
placeholder="Sahifa tavsifi (120160 belgi)" placeholder={t("Sahifa tavsifi (120160 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" 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"> <div className="flex items-center justify-between mt-2">
@@ -147,58 +278,59 @@ export default function Seo() {
{/* Keywords */} {/* Keywords */}
<div> <div>
<label className="block text-sm font-semibold text-white mb-2"> <label className="block text-sm font-semibold text-white mb-2">
Keywords {t("Keywords")}
</label> </label>
<input <input
type="text" type="text"
name="keywords" name="keywords"
value={formData.keywords} value={formData.keywords}
onChange={handleChange} 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" 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"> <p className="text-xs text-slate-400 mt-1">
Masalan: Python, Web Development, Coding {t("Masalan: Python, Web Development, Coding")}
</p> </p>
</div> </div>
{/* OG Tags */} {/* OG Tags */}
<div className="border-t border-slate-700 pt-6"> <div className="border-t border-slate-700 pt-6">
<h3 className="text-sm font-semibold text-white mb-4"> <h3 className="text-sm font-semibold text-white mb-4">
Open Graph (Ijtimoiy Tarmoqlar) {t("Open Graph (Ijtimoiy Tarmoqlar)")}
</h3> </h3>
<div className="space-y-4"> <div className="space-y-4">
<div> <div>
<label className="block text-sm text-slate-300 mb-2"> <label className="block text-sm text-slate-300 mb-2">
OG Title {t("OG Title")}
</label> </label>
<input <input
type="text" type="text"
name="ogTitle" name="ogTitle"
value={formData.ogTitle} value={formData.ogTitle}
onChange={handleChange} 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" 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>
<div> <div>
<label className="block text-sm text-slate-300 mb-2"> <label className="block text-sm text-slate-300 mb-2">
OG Description {t("OG Description")}
</label> </label>
<textarea <textarea
name="ogDescription" name="ogDescription"
value={formData.ogDescription} value={formData.ogDescription}
onChange={handleChange} 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" 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>
<div> <div>
<label className="block text-sm text-slate-300 mb-2"> <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> </label>
<div className="space-y-3"> <div className="space-y-3">
<input <input
@@ -220,12 +352,12 @@ export default function Seo() {
setImagePreview(null); setImagePreview(null);
setFormData((prev) => ({ setFormData((prev) => ({
...prev, ...prev,
ogImage: "", ogImage: null,
})); }));
}} }}
className="mt-2 text-xs bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded" className="mt-2 text-xs bg-red-600 hover:bg-red-700 text-white px-3 py-1 rounded"
> >
Ochirish {t("O'chirish")}
</button> </button>
</div> </div>
)} )}
@@ -236,34 +368,74 @@ export default function Seo() {
<button <button
onClick={handleSave} 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> </button>
</div> </div>
</div> </div>
{/* Saved SEO Data (Preview) */} {/* Saved SEO Data */}
{savedSeo && (
<div className="mt-8 bg-slate-700 rounded-lg p-6 text-slate-200"> <div className="mt-8 bg-slate-700 rounded-lg p-6 text-slate-200">
<h3 className="text-lg font-semibold mb-2"> <h3 className="text-lg font-semibold mb-4">
Saqlangan SEO Malumotlari {t("Saqlangan SEO Malumotlari")}
</h3> </h3>
<pre className="bg-slate-800 p-4 rounded text-xs overflow-auto">
{JSON.stringify( {isLoading ? (
{ <p>{t("Yuklanmoqda...")}</p>
...savedSeo, ) : allSeo && allSeo.length > 0 ? (
ogImage: savedSeo.ogImage <div className="space-y-4 max-h-[400px] overflow-y-auto">
? savedSeo.ogImage.substring(0, 100) + "..." {allSeo.map((seo) => (
: "", <div
}, key={seo.id}
null, className="bg-slate-800 p-4 rounded-lg border border-slate-600 relative"
2, >
)} <div className="absolute top-2 right-2 flex gap-2">
</pre> <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> </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>
))}
</div>
) : (
<p className="text-slate-400">
{t("Hozircha SEO malumotlari mavjud emas.")}
</p>
)}
</div>
</div>
</div> </div>
); );
} }

View 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 };

View 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";
}[];
};
}

View 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 bolishi kerak"),
title_ru: z
.string()
.min(3, "Sarlavha kamida 3 ta belgidan iborat bolishi kerak"),
description: z
.string()
.min(5, "Tavsif kamida 5 ta belgidan iborat bolishi kerak"),
description_ru: z
.string()
.min(5, "Tavsif kamida 5 ta belgidan iborat bolishi 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 yonalishlar" },
{ 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" : "Qoshish"}</>
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
</div>
);
};
export default SiteBannerAdmin;

View 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;

View 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,
};

View 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;
};
}

View File

@@ -1,3 +1,10 @@
import {
createHelpPage,
deleteHelpPage,
getAllHelpPage,
getDetailHelpPage,
updateHelpPage,
} from "@/pages/site-page/lib/api";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
import { Checkbox } from "@/shared/ui/checkbox"; import { Checkbox } from "@/shared/ui/checkbox";
@@ -16,84 +23,150 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/shared/ui/select"; } from "@/shared/ui/select";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Edit2, Trash2 } from "lucide-react"; import { Edit2, Trash2 } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import ReactQuill from "react-quill-new"; import ReactQuill from "react-quill-new";
import "react-quill-new/dist/quill.snow.css"; import "react-quill-new/dist/quill.snow.css";
import { toast } from "sonner";
type Offer = {
id: string;
title: string;
audience: "Foydalanuvchi qollanmasi" | "Maxfiylik siyosati";
content: string;
active: boolean;
createdAt: string;
};
const FAKE_DATA: Offer[] = [
{
id: "of-1",
title: "Ommaviy oferta - Standart shartlar",
audience: "Foydalanuvchi qollanmasi",
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 qollanmasi uchun oferta",
audience: "Foydalanuvchi qollanmasi",
content: "Foydalanuvchi qollanmasi uchun maxsus shartlar va kafolatlar.",
active: false,
createdAt: new Date(Date.now() - 86400000).toISOString(),
},
];
const STORAGE_KEY = "ommaviy_oferta_v1";
export default function PolicyCrud() { export default function PolicyCrud() {
const [items, setItems] = useState<Offer[]>([]); const { t } = useTranslation();
const [query, setQuery] = useState(""); const [deleteOpen, setDeleteOpen] = useState<boolean>(false);
const [editing, setEditing] = useState<Offer | null>(null); const queryClient = useQueryClient();
const [form, setForm] = useState<Partial<Offer>>({ const { data: items } = useQuery({
title: "", queryKey: ["help_page"],
audience: "Foydalanuvchi qollanmasi", queryFn: () => {
content: "", return getAllHelpPage({ page: 1, page_size: 99 });
active: true, },
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>>({}); const [errors, setErrors] = useState<Record<string, string>>({});
useEffect(() => { const { mutate: create } = useMutation({
const raw = localStorage.getItem(STORAGE_KEY); mutationFn: ({
if (raw) { body,
try { }: {
const parsed = JSON.parse(raw) as Offer[]; body: {
setItems(parsed); title: string;
} catch { content: string;
setItems(FAKE_DATA); page_type: "privacy_policy" | "user_agreement";
} is_active: boolean;
} else { };
setItems(FAKE_DATA); }) => 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(() => { const { mutate: update } = useMutation({
localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); mutationFn: ({
}, [items]); 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() { function resetForm() {
setForm({ setForm({
title: "", title: "",
audience: "Foydalanuvchi qollanmasi",
content: "", content: "",
active: true, is_active: true,
page_type: "privacy_policy",
}); });
setErrors({}); setErrors({});
setEditing(null); 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> = {}; const e: Record<string, string> = {};
if (!f.title || f.title.trim().length < 3) if (!f.title || f.title.trim().length < 3)
e.title = "Sarlavha kamida 3 ta belgidan iborat bo'lishi kerak"; e.title = "Sarlavha kamida 3 ta belgidan iborat bo'lishi kerak";
@@ -110,83 +183,78 @@ export default function PolicyCrud() {
} }
if (editing) { if (editing) {
setItems((prev) => update({
prev.map((it) => body: {
it.id === editing.id ? { ...it, ...(form as Offer) } : it, content: form.content,
), is_active: form.is_active,
); page_type: form.page_type,
resetForm(); title: form.title,
} else { },
const newItem: Offer = { id: editing,
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();
}
}
function startEdit(item: Offer) {
setEditing(item);
setForm({ ...item });
setErrors({});
window.scrollTo({ top: 0, behavior: "smooth" });
}
function removeItem(id: string) {
setItems((prev) => prev.filter((p) => p.id !== id));
}
function toggleActive(id: string) {
setItems((prev) =>
prev.map((p) => (p.id === id ? { ...p, active: !p.active } : p)),
);
}
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)
);
}); });
} else {
create({
body: {
content: form.content,
is_active: form.is_active,
page_type: form.page_type,
title: form.title,
},
});
}
}
function startEdit(item: number) {
setEditing(item);
}
function removeItem(id: number) {
deleteHelp(id);
}
function toggleActive(id: number, currentStatus: boolean) {
update({
id: id,
body: {
is_active: !currentStatus,
},
});
}
return ( return (
<div className="min-h-screen w-full p-6 bg-gray-900"> <div className="min-h-screen w-full p-6 bg-gray-900">
<div className="max-w-[90%] mx-auto space-y-6"> <div className="max-w-[90%] mx-auto space-y-6">
<div className="flex items-center justify-between"> <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> </div>
<Card className="bg-gray-900"> <Card className="bg-gray-900">
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
{editing ? "Tahrirish" : "Yangi oferta yaratish"} {editing ? t("Tahrirlash") : t("Yangi yordam sahifasi yaratish")}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div> <div>
<label className="text-sm font-medium">Sarlavha</label> <label className="text-sm font-medium">{t("Sarlavha")}</label>
<Input <Input
value={form.title || ""} value={form.title || ""}
onChange={(e) => onChange={(e) =>
setForm((s) => ({ ...s, title: e.target.value })) setForm((s) => ({ ...s, title: e.target.value }))
} }
placeholder="Ommaviy oferta sarlavhasi" placeholder={t("Yordam sahifasi sarlavhasi")}
className="mt-1" className="mt-1"
/> />
{errors.title && ( {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>
<div className="h-full w-[100%]"> <div className="h-[280px] w-[100%]">
<label className="text-sm font-medium">Kontent</label> <label className="text-sm font-medium">{t("Kontent")}</label>
<div className="mt-1"> <div className="mt-1">
<ReactQuill <ReactQuill
value={form.content || ""} value={form.content || ""}
@@ -194,37 +262,39 @@ export default function PolicyCrud() {
setForm((s) => ({ ...s, content: value })) setForm((s) => ({ ...s, content: value }))
} }
className="bg-gray-900 h-48" className="bg-gray-900 h-48"
placeholder="Oferta matnini kiriting..." placeholder={t("Yordam matnini kiriting...")}
/> />
</div> </div>
{errors.content && ( {errors.content && (
<p className="text-destructive text-sm mt-1"> <p className="text-destructive text-sm mt-12">
{errors.content} {t(errors.content)}
</p> </p>
)} )}
</div> </div>
<div className="grid grid-cols-1 gap-4 mt-24"> <div className="grid grid-cols-1 gap-4 mt-5">
<div> <div>
<label className="text-sm font-medium">Kimlar uchun</label> <label className="text-sm font-medium">
{t("Sahifa turi")}
</label>
<Select <Select
value={form.audience || "Barcha"} value={form.page_type}
onValueChange={(value) => onValueChange={(value) =>
setForm((s) => ({ setForm((s) => ({
...s, ...s,
audience: value as Offer["audience"], page_type: value as "privacy_policy" | "user_agreement",
})) }))
} }
> >
<SelectTrigger className="mt-1 w-full !h-12"> <SelectTrigger className="mt-1 w-full !h-12">
<SelectValue /> <SelectValue placeholder={t("Sahifa turini tanlang")} />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="Foydalanuvchi qollanmasi"> <SelectItem value="user_agreement">
Foydalanuvchi qollanmasi {t("Qollanma")}
</SelectItem> </SelectItem>
<SelectItem value="Maxfiylik siyosati"> <SelectItem value="privacy_policy">
Maxfiylik siyosati {t("Maxfiylik siyosati")}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -233,12 +303,12 @@ export default function PolicyCrud() {
<div className="flex items-end"> <div className="flex items-end">
<label className="flex items-center gap-2 text-sm cursor-pointer"> <label className="flex items-center gap-2 text-sm cursor-pointer">
<Checkbox <Checkbox
checked={!!form.active} checked={!!form.is_active}
onCheckedChange={(checked) => onCheckedChange={(checked) =>
setForm((s) => ({ ...s, active: checked ? true : false })) setForm((s) => ({ ...s, active: checked ? true : false }))
} }
/> />
<span>Faol</span> <span>{t("Faol")}</span>
</label> </label>
</div> </div>
</div> </div>
@@ -248,92 +318,87 @@ export default function PolicyCrud() {
onClick={handleCreateOrUpdate} onClick={handleCreateOrUpdate}
className="bg-blue-600 hover:bg-blue-700" className="bg-blue-600 hover:bg-blue-700"
> >
{editing ? "Saqlash" : "Yaratish"} {editing ? t("Saqlash") : t("Yaratish")}
</Button> </Button>
<Button variant="outline" onClick={resetForm}> <Button variant="outline" onClick={resetForm}>
Bekor qilish {t("Bekor qilish")}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
</Card> </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"> <div className="space-y-3">
{filtered.length === 0 && ( {items?.length === 0 && (
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<p className="text-muted-foreground text-center"> <p className="text-muted-foreground text-center">
Natija topilmadi. {t("Natija topilmadi.")}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
)} )}
{filtered.map((it) => ( {items?.map((it) => (
<Card key={it.id} className="overflow-hidden"> <Card key={it.id} className="overflow-hidden">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4"> <div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
<div className="flex-1"> <div className="flex-1">
<h3 className="font-semibold text-lg">{it.title}</h3> <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> <p className="mt-3 text-sm line-clamp-3">{it.content}</p>
</div> </div>
<div className="flex gap-2 items-center flex-wrap md:flex-col md:flex-nowrap"> <div className="flex gap-2 items-center flex-wrap md:flex-col md:flex-nowrap">
<Button <Button
onClick={() => startEdit(it)} onClick={() => startEdit(it.id)}
variant="outline" variant="outline"
className="w-full"
size="sm" size="sm"
> >
<Edit2 className="w-4 h-4 mr-1" /> <Edit2 className="w-4 h-4 mr-1" />
Tahrirlash {t("Tahrirlash")}
</Button> </Button>
<Button <Button
onClick={() => toggleActive(it.id)} onClick={() => toggleActive(it.id, it.is_active)}
variant={it.active ? "default" : "outline"} variant={it.is_active ? "default" : "outline"}
size="sm" size="sm"
className={ 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> </Button>
<Dialog> <Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogTrigger asChild> <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" /> <Trash2 className="w-4 h-4 mr-1" />
O'chirish {t("O'chirish")}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogTitle>O'chirish tasdiqlash</DialogTitle> <DialogTitle>{t("Ochirishni tasdiqlash")}</DialogTitle>
<DialogDescription> <DialogDescription>
Haqiqatan ham bu ofertani o'chirmoqchimisiz? Bu amalni {t(
bekor qilib bo'lmaydi. "Haqiqatan ham bu yordam sahifasini ochirmoqchimisiz? Bu amalni bekor qilib bolmaydi.",
)}
</DialogDescription> </DialogDescription>
<div className="flex gap-3 justify-end pt-4"> <div className="flex gap-3 justify-end pt-4">
<Button>Bekor qilish</Button> <Button onClick={() => setDeleteOpen(false)}>
{t("Bekor qilish")}
</Button>
<Button <Button
variant={"destructive"} variant={"destructive"}
onClick={() => removeItem(it.id)} onClick={() => removeItem(it.id)}
> >
O'chirish {t("O'chirish")}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>

View File

@@ -1,6 +1,12 @@
import {
createOfferta,
deleteOfferta,
getAllOfferta,
getOneOfferta,
updateOfferta,
} from "@/pages/site-page/lib/api";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
import { Checkbox } from "@/shared/ui/checkbox";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -16,47 +22,25 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/shared/ui/select"; } from "@/shared/ui/select";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Edit2, Trash2 } from "lucide-react"; import { Edit2, Trash2 } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import ReactQuill from "react-quill-new"; import ReactQuill from "react-quill-new";
import "react-quill-new/dist/quill.snow.css"; import "react-quill-new/dist/quill.snow.css";
import { toast } from "sonner";
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";
export default function OmmaviyOfertaCRUD() { export default function OmmaviyOfertaCRUD() {
const [items, setItems] = useState<Offer[]>([]); const { t } = useTranslation();
const [query, setQuery] = useState(""); const [deleteOpen, setDeleteOpen] = useState<boolean>(false);
const [editing, setEditing] = useState<Offer | null>(null); const queryClient = useQueryClient();
const [form, setForm] = useState<Partial<Offer>>({ const [editing, setEditing] = useState<number | null>(null);
const [form, setForm] = useState<{
title: string;
content: string;
audience: string;
active: boolean;
}>({
title: "", title: "",
audience: "Jismoniy shaxslar", audience: "Jismoniy shaxslar",
content: "", content: "",
@@ -64,24 +48,6 @@ export default function OmmaviyOfertaCRUD() {
}); });
const [errors, setErrors] = useState<Record<string, string>>({}); 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() { function resetForm() {
setForm({ setForm({
title: "", title: "",
@@ -93,100 +59,214 @@ export default function OmmaviyOfertaCRUD() {
setEditing(null); setEditing(null);
} }
function validate(f: Partial<Offer>) { const { mutate: create } = useMutation({
const e: Record<string, string> = {}; mutationFn: (body: {
if (!f.title || f.title.trim().length < 3) title: string;
e.title = "Sarlavha kamida 3 ta belgidan iborat bo'lishi kerak"; content: string;
if (!f.content || f.content.trim().length < 10) person_type: "individual" | "legal_entity";
e.content = "Kontent kamida 10 ta belgidan iborat bo'lishi kerak"; is_active: boolean;
return e; }) => 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() { function handleCreateOrUpdate() {
const validation = validate(form); const newErrors: Record<string, string> = {};
if (Object.keys(validation).length) { if (!form.title.trim()) {
setErrors(validation); 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; return;
} }
if (editing === null) {
if (editing) { create({
setItems((prev) => content: form.content,
prev.map((it) => is_active: form.active,
it.id === editing.id ? { ...it, ...(form as Offer) } : it, person_type:
), form.audience === "Jismoniy shaxslar" ? "individual" : "legal_entity",
); title: form.title,
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();
}
}
function startEdit(item: Offer) {
setEditing(item);
setForm({ ...item });
setErrors({});
window.scrollTo({ top: 0, behavior: "smooth" });
}
function removeItem(id: string) {
setItems((prev) => prev.filter((p) => p.id !== id));
}
function toggleActive(id: string) {
setItems((prev) =>
prev.map((p) => (p.id === id ? { ...p, active: !p.active } : p)),
);
}
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)
);
}); });
} 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: number) {
setEditing(item);
}
function removeItem(id: number) {
removeOfferta({ id });
}
function toggleActive(id: number, currentStatus: boolean) {
update({
id: id,
body: {
is_active: !currentStatus,
},
});
}
return ( return (
<div className="min-h-screen w-full p-6 bg-gray-900"> <div className="min-h-screen w-full p-6 bg-gray-900">
<div className="max-w-[90%] mx-auto space-y-6"> <div className="max-w-[90%] mx-auto space-y-6">
<div className="flex items-center justify-between"> <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> </div>
<Card className="bg-gray-900"> <Card className="bg-gray-900">
<CardHeader> <CardHeader>
<CardTitle> <CardTitle>
{editing ? "Tahrirish" : "Yangi oferta yaratish"} {editing ? t("Tahrirlash") : t("Yangi oferta yaratish")}
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div> <div>
<label className="text-sm font-medium">Sarlavha</label> <label className="text-sm font-medium">{t("Sarlavha")}</label>
<Input <Input
value={form.title || ""} value={form.title || ""}
onChange={(e) => onChange={(e) =>
setForm((s) => ({ ...s, title: e.target.value })) setForm((s) => ({ ...s, title: e.target.value }))
} }
placeholder="Ommaviy oferta sarlavhasi" placeholder={t("Ommaviy oferta sarlavhasi")}
className="mt-1" className="mt-1"
/> />
{errors.title && ( {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>
<div className="h-full w-[100%]"> <div className="h-[280px] w-[100%]">
<label className="text-sm font-medium">Kontent</label> <label className="text-sm font-medium">{t("Kontent")}</label>
<div className="mt-1"> <div className="mt-1">
<ReactQuill <ReactQuill
value={form.content || ""} value={form.content || ""}
@@ -194,54 +274,41 @@ export default function OmmaviyOfertaCRUD() {
setForm((s) => ({ ...s, content: value })) setForm((s) => ({ ...s, content: value }))
} }
className="bg-gray-900 h-48" className="bg-gray-900 h-48"
placeholder="Oferta matnini kiriting..." placeholder={t("Oferta matnini kiriting...")}
/> />
</div> </div>
{errors.content && ( {errors.content && (
<p className="text-destructive text-sm mt-1"> <p className="text-destructive text-sm mt-12">
{errors.content} {t(errors.content)}
</p> </p>
)} )}
</div> </div>
<div className="grid grid-cols-1 gap-4 mt-24">
<div> <div>
<label className="text-sm font-medium">Kimlar uchun</label> <label className="text-sm font-medium">{t("Kimlar uchun")}</label>
<Select <Select
value={form.audience || "Barcha"} value={form.audience || t("Barcha")}
onValueChange={(value) => onValueChange={(value) =>
setForm((s) => ({ setForm((s) => ({ ...s, audience: value }))
...s,
audience: value as Offer["audience"],
}))
} }
> >
<SelectTrigger className="mt-1 w-full !h-12"> <SelectTrigger className="mt-1 w-full !h-12">
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="Barcha">Barcha</SelectItem>
<SelectItem value="Jismoniy shaxslar"> <SelectItem value="Jismoniy shaxslar">
Jismoniy shaxslar uchun {t("Jismoniy shaxslar uchun")}
</SelectItem> </SelectItem>
<SelectItem value="Yuridik shaxslar"> <SelectItem value="Yuridik shaxslar">
Yuridik shaxslar uchun {t("Yuridik shaxslar uchun")}
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> {errors.audience && (
<p className="text-destructive text-sm mt-1">
<div className="flex items-end"> {t(errors.audience)}
<label className="flex items-center gap-2 text-sm cursor-pointer"> </p>
<Checkbox )}
checked={!!form.active}
onCheckedChange={(checked) =>
setForm((s) => ({ ...s, active: checked ? true : false }))
}
/>
<span>Faol</span>
</label>
</div>
</div> </div>
<div className="flex gap-2 pt-4"> <div className="flex gap-2 pt-4">
@@ -249,92 +316,91 @@ export default function OmmaviyOfertaCRUD() {
onClick={handleCreateOrUpdate} onClick={handleCreateOrUpdate}
className="bg-blue-600 hover:bg-blue-700" className="bg-blue-600 hover:bg-blue-700"
> >
{editing ? "Saqlash" : "Yaratish"} {editing ? t("Saqlash") : t("Qo'shish")}
</Button> </Button>
<Button variant="outline" onClick={resetForm}> <Button variant="outline" onClick={resetForm}>
Bekor qilish {t("Bekor qilish")}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
</Card> </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"> <div className="space-y-3">
{filtered.length === 0 && ( {allOfferta && allOfferta?.results.length === 0 && (
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<p className="text-muted-foreground text-center"> <p className="text-muted-foreground text-center">
Natija topilmadi. {t("Natija topilmadi.")}
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
)} )}
{filtered.map((it) => ( {allOfferta?.results.map((it) => (
<Card key={it.id} className="overflow-hidden"> <Card key={it.id} className="overflow-hidden">
<CardContent className="pt-6"> <CardContent className="pt-6">
<div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4"> <div className="flex flex-col md:flex-row md:items-start md:justify-between gap-4">
<div className="flex-1"> <div className="flex-1">
<h3 className="font-semibold text-lg">{it.title}</h3> <h3 className="font-semibold text-lg">{it.title}</h3>
<p className="text-sm text-muted-foreground mt-1"> <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>
<p className="mt-3 text-sm line-clamp-3">{it.content}</p> <p className="mt-3 text-sm line-clamp-3">{it.content}</p>
</div> </div>
<div className="flex gap-2 items-center flex-wrap md:flex-col md:flex-nowrap"> <div className="flex gap-2 items-center flex-wrap md:flex-col md:flex-nowrap">
<Button <Button
onClick={() => startEdit(it)} onClick={() => startEdit(it.id)}
variant="outline" variant="outline"
size="sm" size="sm"
> >
<Edit2 className="w-4 h-4 mr-1" /> <Edit2 className="w-4 h-4 mr-1" />
Tahrirlash {t("Tahrirlash")}
</Button> </Button>
<Button <Button
onClick={() => toggleActive(it.id)} onClick={() => toggleActive(it.id, it.is_active)}
variant={it.active ? "default" : "outline"} variant={it.is_active ? "default" : "outline"}
size="sm" size="sm"
className={ 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> </Button>
<Dialog> <Dialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<DialogTrigger asChild> <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" /> <Trash2 className="w-4 h-4 mr-1" />
O'chirish {t("O'chirish")}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogTitle>O'chirish tasdiqlash</DialogTitle> <DialogTitle>{t("O'chirish tasdiqlash")}</DialogTitle>
<DialogDescription> <DialogDescription>
Haqiqatan ham bu ofertani o'chirmoqchimisiz? Bu amalni {t(
bekor qilib bo'lmaydi. "Haqiqatan ham bu ofertani o'chirmoqchimisiz? Bu amalni bekor qilib bo'lmaydi.",
)}
</DialogDescription> </DialogDescription>
<div className="flex gap-3 justify-end pt-4"> <div className="flex gap-3 justify-end pt-4">
<Button>Bekor qilish</Button> <Button onClick={() => setDeleteOpen(false)}>
{t("Bekor qilish")}
</Button>
<Button <Button
variant={"destructive"} variant={"destructive"}
onClick={() => removeItem(it.id)} onClick={() => removeItem(it.id)}
> >
O'chirish {t("O'chirish")}
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>

View 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,
};

View 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;
},
];
}[];
};
}

View File

@@ -1,5 +1,11 @@
import { XIcon } from "lucide-react"; import { getSupportAgency } from "@/pages/support/lib/api";
import { useMemo, useState } from "react"; 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"; import { Link } from "react-router-dom";
interface Data { interface Data {
@@ -43,43 +49,68 @@ const sampleData: Data[] = [
const SupportAgency = ({ requests = sampleData }) => { const SupportAgency = ({ requests = sampleData }) => {
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [selected, setSelected] = useState<Data | null>(null); const { t } = useTranslation();
const [selected, setSelected] = useState<GetSupportAgencyRes | null>(null);
const filtered = useMemo(() => { const { data, isLoading, isError, refetch } = useQuery({
if (!query.trim()) return requests; queryKey: ["support_agency"],
const q = query.toLowerCase(); queryFn: () =>
return requests.filter( getSupportAgency({ page: 1, page_size: 10, search: "", status: "" }),
(r) => });
(r.name && r.name.toLowerCase().includes(q)) ||
(r.email && r.email.toLowerCase().includes(q)) || if (isLoading) {
(r.phone && r.phone.toLowerCase().includes(q)), 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 ( return (
<div className="p-4 w-full mx-auto"> <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"> <div className="flex gap-3 mb-6">
<input <input
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} 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" className="flex-1 p-2 border rounded-md focus:outline-none focus:ring"
/> />
<button <button
onClick={() => setQuery("")} onClick={() => setQuery("")}
className="px-4 py-2 bg-gray-500 rounded-md hover:bg-gray-300 transition" className="px-4 py-2 bg-gray-500 rounded-md hover:bg-gray-300 transition"
> >
Tozalash {t("Tozalash")}
</button> </button>
</div> </div>
{filtered.length === 0 ? ( {data && data.data.data.results.length === 0 ? (
<div className="text-center text-gray-500">Soʻrov topilmadi.</div> <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"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{filtered.map((r) => ( {data &&
data.data.data.results.map((r) => (
<div <div
key={r.id} key={r.id}
className="border rounded-lg p-4 shadow-sm hover:shadow-md transition bg-gray-800 text-white" className="border rounded-lg p-4 shadow-sm hover:shadow-md transition bg-gray-800 text-white"
@@ -87,38 +118,21 @@ const SupportAgency = ({ requests = sampleData }) => {
<div className="flex items-start justify-between"> <div className="flex items-start justify-between">
<div> <div>
<h3 className="text-lg font-medium">{r.name}</h3> <h3 className="text-lg font-medium">{r.name}</h3>
<p className="text-md">{r.address}</p> <p className="text-md">{r.addres}</p>
</div> </div>
<div className="text-md">{r.phone}</div> <div className="text-md">{formatPhone(r.phone)}</div>
</div> </div>
<div className="mt-3 text-sm text-white"> <div className="mt-3 text-sm text-white">
<div> <div>
<strong>Email:</strong>{" "} <strong>{t("Email")}:</strong>{" "}
<Link to={`mailto:${r.email}`} className="text-white"> <Link to={`mailto:${r.email}`} className="text-white">
{r.email} {r.email}
</Link> </Link>
</div> </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 && ( {r.web_site && (
<div> <div>
<strong>Website:</strong>{" "} <strong>{t("Veb-sayt")}:</strong>{" "}
<a <a
href={r.web_site} href={r.web_site}
target="_blank" target="_blank"
@@ -136,13 +150,13 @@ const SupportAgency = ({ requests = sampleData }) => {
onClick={() => setSelected(r)} onClick={() => setSelected(r)}
className="px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition" className="px-3 py-1 rounded bg-blue-600 text-white text-sm hover:bg-blue-700 transition"
> >
Tafsilotlar {t("Tafsilotlar")}
</button> </button>
<Link <Link
to={`mailto:${r.email}`} to={`mailto:${r.email}`}
className="px-3 py-1 rounded border text-sm transition" className="px-3 py-1 rounded border text-sm transition"
> >
Javob yozish {t("Javob yozish")}
</Link> </Link>
</div> </div>
</div> </div>
@@ -167,11 +181,11 @@ const SupportAgency = ({ requests = sampleData }) => {
</button> </button>
<h3 className="text-xl font-semibold mb-2">{selected.name}</h3> <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 className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div> <div>
<div className="text-md text-white">Email</div> <div className="text-md text-white">{t("Email")}</div>
<a <a
href={`mailto:${selected.email}`} href={`mailto:${selected.email}`}
className="block text-white hover:underline" className="block text-white hover:underline"
@@ -181,28 +195,12 @@ const SupportAgency = ({ requests = sampleData }) => {
</div> </div>
<div> <div>
<div className="text-md text-white">Telefon</div> <div className="text-md text-white">{t("Telefon raqam")}</div>
<div>{selected.phone}</div> <div>{formatPhone(selected.phone)}</div>
</div> </div>
<div> <div>
<div className="text-md text-white">Instagram</div> <div className="text-xs text-white">{t("Veb-sayt")}</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>
{selected.web_site ? ( {selected.web_site ? (
<a <a
href={selected.web_site} href={selected.web_site}
@@ -219,26 +217,27 @@ const SupportAgency = ({ requests = sampleData }) => {
</div> </div>
<div className="mt-5"> <div className="mt-5">
<div className="text-sm font-medium mb-2">Hujjatlar</div> <div className="text-sm font-medium mb-2">{t("Hujjatlar")}</div>
{selected.documents && selected.documents.length > 0 ? ( {selected.travel_agency_documents &&
selected.travel_agency_documents.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3"> <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 <a
key={i} key={i}
href={doc} href={doc.file}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="group relative aspect-square border-2 border-gray-200 rounded-lg overflow-hidden hover:border-blue-500 transition" className="group relative aspect-square border-2 border-gray-200 rounded-lg overflow-hidden hover:border-blue-500 transition"
> >
<img <img
src={doc} src={doc.file}
alt={`Hujjat ${i + 1}`} alt={`Hujjat ${i + 1}`}
className="w-full h-full object-cover" 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="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"> <div className="w-full bg-gradient-to-t from-black/70 to-transparent p-2">
<span className="text-white text-xs font-medium"> <span className="text-white text-xs font-medium">
Hujjat {i + 1} {t("Hujjat")} {i + 1}
</span> </span>
</div> </div>
</div> </div>
@@ -246,7 +245,7 @@ const SupportAgency = ({ requests = sampleData }) => {
))} ))}
</div> </div>
) : ( ) : (
<div className="text-gray-500">Hujjat topilmadi</div> <div className="text-gray-500">{t("Hujjat topilmadi")}</div>
)} )}
</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" className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700 transition"
> >
Qabul qilish {t("Qabul qilish")}
</button> </button>
<button <button
onClick={() => { 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" className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition"
> >
Rad etish {t("Rad etish")}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,13 @@
"use client"; "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 { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
@@ -10,129 +18,201 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/shared/ui/dialog"; } 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"; import { useState } from "react";
import { useTranslation } from "react-i18next";
type SupportRequest = { import { toast } from "sonner";
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: "Tolov muvaffaqiyatli otmadi, yordam bera olasizmi?",
status: "Resolved",
},
{
id: 3,
name: "Jamshid Abdullayev",
phone: "+998 93 555 22 11",
message: "Sayohat sanasini ozgartirishni istayman.",
status: "Pending",
},
{
id: 4,
name: "Jamshid Abdullayev",
phone: "+998 93 555 22 11",
message: "Sayohat sanasini ozgartirishni istayman.",
status: "Pending",
},
];
const SupportTours = () => { const SupportTours = () => {
const [requests, setRequests] = useState<SupportRequest[]>(initialRequests); const { t } = useTranslation();
const [selected, setSelected] = useState<SupportRequest | null>(null); 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) => { const { data, isLoading, isError, refetch } = useQuery({
setRequests((prev) => queryKey: ["support_user", filterStatus],
prev.map((req) => queryFn: () =>
req.id === id getSupportUser({ page: 1, page_size: 99, status: filterStatus }),
? { });
...req,
status: req.status === "Pending" ? "Resolved" : "Pending", const { data: agency } = useQuery({
} queryKey: ["detail_agency", selected?.travel_agency],
: req, queryFn: () => getDetailAgency({ id: Number(selected?.travel_agency) }),
), enabled: !!selected?.travel_agency,
); });
setSelected((prev) =>
prev const updateMutation = useMutation({
? { mutationFn: ({ body, id }: { id: number; body: GetSupportUserRes }) =>
...prev, updateSupportUser({ body, id }),
status: prev.status === "Pending" ? "Resolved" : "Pending", onSuccess: () => {
} queryClient.refetchQueries({ queryKey: ["support_user"] });
: prev, 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 ( return (
<div className="min-h-screen bg-gray-900 text-gray-100 p-6 space-y-6 w-full"> <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"> <h1 className="text-3xl font-bold tracking-tight mb-4 text-white">
Yordam sorovlari {t("Yordam so'rovlari")}
</h1> </h1>
{/* 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)}
>
{tab.label}
</button>
))}
</div>
{/* Cards */}
<div className="grid gap-5 sm:grid-cols-3 lg:grid-cols-3"> <div className="grid gap-5 sm:grid-cols-3 lg:grid-cols-3">
{requests.map((req) => ( {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 <Card
key={req.id} 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" 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"> <CardHeader className="pb-2 flex justify-between items-center">
<CardTitle className="flex items-center justify-between"> <div className="flex gap-2">
<span className="flex items-center gap-2 text-lg font-semibold text-white"> <CardTitle className="flex items-center gap-2 text-lg font-semibold text-white">
<User className="w-5 h-5 text-blue-400" /> <User className="w-5 h-5 text-blue-400" />
{req.name} {req.name}
</span> </CardTitle>
</div>
<Badge <Badge
variant="outline" variant="outline"
className={`px-2 py-1 rounded-md text-xs font-medium ${ 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-red-500/10 text-red-400 border-red-400/40"
: "bg-green-500/10 text-green-400 border-green-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> </Badge>
</CardTitle>
</CardHeader> </CardHeader>
<CardContent className="space-y-3 mt-1"> <CardContent className="space-y-3 mt-1">
<div className="flex items-center gap-2 text-sm text-gray-400"> {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" /> <Phone className="w-4 h-4 text-gray-400" />
{req.phone} {formatPhone(req.phone_number)}
</div> </div>
<div className="grid grid-cols-2 justify-end items-end gap-2">
<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 <Button
variant="outline" variant="outline"
size="sm" size="sm"
className="mt-3 w-full border-gray-600 text-gray-200 hover:bg-gray-700 hover:text-white" className="mt-3 w-full border-gray-600 text-gray-200 hover:bg-gray-700 hover:text-white"
onClick={() => setSelected(req)} onClick={() => setSelected(req)}
> >
Batafsil korish {t("Batafsil ko'rish")}
</Button> </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> </CardContent>
</Card> </Card>
))} ))
)}
</div> </div>
{/* Modal (Dialog) */} {/* Detail Modal */}
<Dialog open={!!selected} onOpenChange={() => setSelected(null)}> <Dialog open={!!selected} onOpenChange={() => setSelected(null)}>
<DialogContent className="bg-gray-800 border border-gray-700 text-gray-100 sm:max-w-lg"> <DialogContent className="bg-gray-800 border border-gray-700 text-gray-100 sm:max-w-lg">
<DialogHeader> <DialogHeader>
@@ -145,24 +225,26 @@ const SupportTours = () => {
<div className="space-y-3 mt-2"> <div className="space-y-3 mt-2">
<div className="flex items-center gap-2 text-gray-400"> <div className="flex items-center gap-2 text-gray-400">
<Phone className="w-4 h-4" /> <Phone className="w-4 h-4" />
{selected?.phone} {selected && formatPhone(selected?.phone_number)}
</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>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex justify-between items-center gap-2">
<span className="text-sm text-gray-400">Status:</span> {selected && selected.travel_agency && agency?.data.data && (
<span className="text-sm text-gray-400">
{t("Agentlikka tegishli")}: {agency?.data.data.name}
</span>
)}
<Badge <Badge
variant="outline" variant="outline"
className={`px-2 py-1 rounded-md text-xs font-medium ${ 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-red-500/10 text-red-400 border-red-400/40"
: "bg-green-500/10 text-green-400 border-green-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> </Badge>
</div> </div>
</div> </div>
@@ -173,25 +255,60 @@ const SupportTours = () => {
className="border-gray-600 text-gray-200 hover:bg-gray-700 hover:text-white" className="border-gray-600 text-gray-200 hover:bg-gray-700 hover:text-white"
onClick={() => setSelected(null)} onClick={() => setSelected(null)}
> >
Yopish {t("Yopish")}
</Button> </Button>
{selected && ( {selected && (
<Button <Button
onClick={() => handleToggleStatus(selected.id)} onClick={() => handleToggleStatus(selected)}
className={`${ className={`${
selected.status === "Pending" selected.status === "pending"
? "bg-green-600 hover:bg-green-700" ? "bg-green-600 hover:bg-green-700"
: "bg-red-600 hover:bg-red-700" : "bg-red-600 hover:bg-red-700"
} text-white`} } text-white`}
> >
{selected.status === "Pending" {selected.status === "pending"
? "Yakunlandi deb belgilash" ? t("Yakunlandi deb belgilash")
: "Kutilmoqda deb belgilash"} : t("Kutilmoqda deb belgilash")}
</Button> </Button>
)} )}
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </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> </div>
); );
}; };

View 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,
};

View 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;
};
}

View File

@@ -1,5 +1,13 @@
"use client"; "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 { Button } from "@/shared/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
import { import {
@@ -12,27 +20,28 @@ import {
import { Input } from "@/shared/ui/input"; import { Input } from "@/shared/ui/input";
import { Label } from "@/shared/ui/label"; import { Label } from "@/shared/ui/label";
import { Map, Placemark, YMaps } from "@pbe/react-yandex-maps"; 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 { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
type MapClickEvent = { type MapClickEvent = {
get: (key: "coords") => [number, number]; get: (key: "coords") => [number, number];
}; };
type ContactInfo = { type ContactInfo = {
telegram?: string; telegram: string;
instagram?: string; instagram: string;
facebook?: string; facebook: string;
twiter?: string; twiter: string;
linkedin?: string; linkedin: string;
address?: string; address: string;
email?: string; email: string;
phonePrimary?: string; phonePrimary: string;
phoneSecondary?: string; phoneSecondary: string;
}; };
const STORAGE_KEY = "site_contact_info";
async function getAddressFromCoords(lat: number, lon: number) { async function getAddressFromCoords(lat: number, lon: number) {
const response = await fetch( const response = await fetch(
`https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json`, `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() { export default function ContactSettings() {
const [contact, setContact] = useState<ContactInfo | null>(null); const { t } = useTranslation();
const queryClient = useQueryClient();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState<number | null>(null);
const [form, setForm] = useState<ContactInfo>({}); const [form, setForm] = useState<ContactInfo>({
telegram: "",
instagram: "",
facebook: "",
twiter: "",
linkedin: "",
address: "",
email: "",
phonePrimary: "+998",
phoneSecondary: "+998",
});
const [coords, setCoords] = useState({ const [coords, setCoords] = useState({
latitude: 41.311081, latitude: 41.311081,
longitude: 69.240562, 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 const { mutate: update } = useMutation({
useEffect(() => { mutationFn: ({
const raw = localStorage.getItem(STORAGE_KEY); body,
if (raw) setContact(JSON.parse(raw)); 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(() => { useEffect(() => {
if (open && editing && contact) setForm(contact); if (editing && detail) {
if (!open && !editing) setForm({}); setForm({
}, [open, editing, contact]); 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>( const handleChange = <K extends keyof ContactInfo>(
key: K, key: K,
@@ -80,139 +237,196 @@ export default function ContactSettings() {
}; };
const saveContact = () => { const saveContact = () => {
if (!form.email && !form.phonePrimary) { const shortLat = Number(coords.latitude.toFixed(6)); // 6ta raqamgacha
alert("Iltimos email yoki telefon kiriting"); const shortLon = Number(coords.longitude.toFixed(6));
return; 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 = () => { const startAdd = () => {
setForm({}); setForm({
setEditing(false); telegram: "",
instagram: "",
facebook: "",
twiter: "",
linkedin: "",
address: "",
email: "",
phonePrimary: "",
phoneSecondary: "",
});
setEditing(null);
setOpen(true); setOpen(true);
}; };
const startEdit = () => { const startEdit = (id: number) => {
setEditing(true); setEditing(id);
setOpen(true); setOpen(true);
}; };
const removeContact = () => { const removeContact = (id: number) => {
if (!confirm("Contact ma'lumotlarini o'chirishni xohlaysizmi?")) return; remover(id);
localStorage.removeItem(STORAGE_KEY);
setContact(null);
}; };
return ( 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"> <div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-semibold text-gray-100"> <h2 className="text-2xl font-semibold text-gray-100">
Contact settings {t("Contact settings")}
</h2> </h2>
{!contact && ( {data && data?.total_items === 0 && (
<Button onClick={startAdd} className="flex items-center gap-2"> <Button onClick={startAdd} className="flex items-center gap-2">
<Plus size={16} /> Qo'shish <Plus size={16} /> {t("Qo'shish")}
</Button> </Button>
)} )}
</div> </div>
{isLoading ? (
{!contact ? ( <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>
) : (
<>
{data && data?.total_items === 0 ? (
<Card className="bg-gray-900"> <Card className="bg-gray-900">
<CardHeader> <CardHeader>
<CardTitle>Hozircha kontakt ma'lumotlari qo'shilmagan</CardTitle> <CardTitle>
{t("Hozircha kontakt ma'lumotlari qo'shilmagan")}
</CardTitle>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Sayt uchun telegram, instagram, manzil, email va telefonni bu {t(
yerda saqlang. Siz faqat bir marta qo'sha olasiz keyin "Sayt uchun telegram, instagram, manzil, email va telefonni bu yerda saqlang. Siz faqat bir marta qo'sha olasiz — keyin tahrirlash mumkin.",
tahrirlash mumkin. )}
</p> </p>
<div className="mt-4"> <div className="mt-4">
<Button onClick={startAdd} className="flex items-center gap-2"> <Button
<Plus size={14} /> Qo'shish onClick={startAdd}
className="flex items-center gap-2"
>
<Plus size={14} /> {t("Qo'shish")}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
) : ( ) : (
<>
{data &&
data.results.map((e) => (
<Card className="bg-gray-900"> <Card className="bg-gray-900">
<CardHeader className="flex items-center justify-between"> <CardHeader className="flex items-center justify-between">
<CardTitle>Kontakt ma'lumotlari</CardTitle> <CardTitle>{t("Kontakt ma'lumotlari")}</CardTitle>
<div className="flex gap-2"> <div className="flex gap-2">
<Button <Button
variant="outline" variant="outline"
onClick={startEdit} onClick={() => startEdit(e.id)}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<Edit size={14} /> Tahrirlash <Edit size={14} /> {t("Tahrirlash")}
</Button> </Button>
<Button <Button
variant="destructive" variant="destructive"
onClick={removeContact} onClick={() => removeContact(e.id)}
className="flex items-center gap-2" className="flex items-center gap-2"
> >
<Trash size={14} /> O'chirish <Trash size={14} /> {t("O'chirish")}
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div> <div>
<div className="text-sm text-muted-foreground">Telegram</div> <div className="text-sm text-muted-foreground">
<div className="text-sm">{contact.telegram || "—"}</div> Telegram
</div>
<div className="text-sm">{e.telegram || "—"}</div>
</div> </div>
<div> <div>
<div className="text-xs text-muted-foreground">Instagram</div> <div className="text-xs text-muted-foreground">
<div className="text-sm">{contact.instagram || "—"}</div> Instagram
</div>
<div className="text-sm">{e.instagram || "—"}</div>
</div> </div>
<div> <div>
<div className="text-xs text-muted-foreground">Facebook</div> <div className="text-xs text-muted-foreground">
<div className="text-sm">{contact.facebook || "—"}</div> Facebook
</div>
<div className="text-sm">{e.facebook || "—"}</div>
</div> </div>
<div> <div>
<div className="text-xs text-muted-foreground">LinkedIn</div> <div className="text-xs text-muted-foreground">
<div className="text-sm">{contact.linkedin || "—"}</div> Twitter
</div>
<div className="text-sm">{e.twitter || "—"}</div>
</div> </div>
<div> <div>
<div className="text-xs text-muted-foreground">Twitter</div> <div className="text-xs text-muted-foreground">
<div className="text-sm">{contact.twiter || "—"}</div> {t("Manzil")}
</div>
<div className="text-sm">{e.address || "—"}</div>
</div> </div>
<div> <div>
<div className="text-xs text-muted-foreground">Manzil</div> <div className="text-xs text-muted-foreground">
<div className="text-sm">{contact.address || "—"}</div> Email
</div>
<div className="text-sm">{e.email || "—"}</div>
</div> </div>
<div> <div>
<div className="text-xs text-muted-foreground">Email</div> <div className="text-xs text-muted-foreground">
<div className="text-sm">{contact.email || "—"}</div> {t("Telefon raqam")}
</div>
<div>
<div className="text-xs text-muted-foreground">Telefonlar</div>
<div className="text-sm">
{contact.phonePrimary || "—"}
{contact.phoneSecondary ? ` • ${contact.phoneSecondary}` : ""}
</div> </div>
<p>{e.main_phone}</p>
<p>{e.other_phone}</p>
</div> </div>
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
))}
</>
)}
</>
)} )}
{/* Dialog */} {/* Dialog */}
<Dialog <Dialog
open={open} open={open}
onOpenChange={(v) => { onOpenChange={(v) => {
setOpen(v); setOpen(v);
if (!v) setEditing(false); if (!v) setEditing(null);
}} }}
> >
<DialogContent className="h-[90%] overflow-y-scroll"> <DialogContent className="h-[90%] overflow-y-scroll">
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>
{editing ? "Kontaktni tahrirlash" : "Kontakt qo'shish"} {editing ? t("Kontaktni tahrirlash") : t("Kontakt qo'shish")}
</DialogTitle> </DialogTitle>
</DialogHeader> </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="grid grid-cols-1 sm:grid-cols-2 gap-6 mt-4">
<div className="flex flex-col gap-2 sm:col-span-2"> <div className="flex flex-col gap-2 sm:col-span-2">
<Label>Manzil</Label> <Label>{t("Manzil")}</Label>
<Input <Input
value={form.address || ""} value={form.address || ""}
onChange={(e) => handleChange("address", e.target.value)} onChange={(e) => handleChange("address", e.target.value)}
@@ -282,16 +496,16 @@ export default function ContactSettings() {
/> />
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<Label>Asosiy telefon</Label> <Label>{t("Asosiy telefon")}</Label>
<Input <Input
value={form.phonePrimary || ""} value={formatPhone(form.phonePrimary)}
onChange={(e) => handleChange("phonePrimary", e.target.value)} onChange={(e) => handleChange("phonePrimary", e.target.value)}
/> />
</div> </div>
<div className="flex flex-col gap-2 w-full"> <div className="flex flex-col gap-2 w-full">
<Label>Qo'shimcha telefon</Label> <Label>{t("Qo'shimcha telefon")}</Label>
<Input <Input
value={form.phoneSecondary || ""} value={formatPhone(form.phoneSecondary)}
onChange={(e) => handleChange("phoneSecondary", e.target.value)} onChange={(e) => handleChange("phoneSecondary", e.target.value)}
/> />
</div> </div>
@@ -302,12 +516,14 @@ export default function ContactSettings() {
variant="ghost" variant="ghost"
onClick={() => { onClick={() => {
setOpen(false); setOpen(false);
setEditing(false); setEditing(null);
}} }}
> >
Bekor qilish {t("Bekor qilish")}
</Button>
<Button onClick={saveContact}>
{isPending ? <Loader2 className="animate-spin" /> : t("Saqlash")}
</Button> </Button>
<Button onClick={saveContact}>Saqlash</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>

View File

@@ -1,6 +1,8 @@
import type { import type {
CreateTourRes, CreateTourRes,
GetAllTours, GetAllTours,
GetDetailTours,
GetHotelRes,
GetOneTours, GetOneTours,
Hotel_Badge, Hotel_Badge,
Hotel_BadgeId, Hotel_BadgeId,
@@ -24,6 +26,7 @@ import {
HOTEL_FEATURES_TYPE, HOTEL_FEATURES_TYPE,
HOTEL_TARIF, HOTEL_TARIF,
HPTEL_TYPES, HPTEL_TYPES,
POPULAR_TOURS,
TOUR_TRANSPORT, TOUR_TRANSPORT,
} from "@/shared/config/api/URLs"; } from "@/shared/config/api/URLs";
import type { AxiosResponse } from "axios"; import type { AxiosResponse } from "axios";
@@ -31,14 +34,17 @@ import type { AxiosResponse } from "axios";
const getAllTours = async ({ const getAllTours = async ({
page, page,
page_size, page_size,
featured_tickets,
}: { }: {
page_size: number; page_size: number;
page: number; page: number;
featured_tickets?: boolean;
}): Promise<AxiosResponse<GetAllTours>> => { }): Promise<AxiosResponse<GetAllTours>> => {
const response = await httpClient.get(GET_TICKET, { const response = await httpClient.get(GET_TICKET, {
params: { params: {
page, page,
page_size, page_size,
featured_tickets,
}, },
}); });
return response; return response;
@@ -53,6 +59,15 @@ const getOneTours = async ({
return response; return response;
}; };
const getDetailToursId = async ({
id,
}: {
id: number;
}): Promise<AxiosResponse<GetDetailTours>> => {
const response = await httpClient.get(`tickets/${id}/`);
return response;
};
const createTours = async ({ const createTours = async ({
body, body,
}: { }: {
@@ -66,6 +81,21 @@ const createTours = async ({
return response; 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 createHotel = async ({ body }: { body: FormData }) => {
const response = await httpClient.post(`${HOTEL}`, body, { const response = await httpClient.post(`${HOTEL}`, body, {
headers: { headers: {
@@ -75,11 +105,39 @@ const createHotel = async ({ body }: { body: FormData }) => {
return response; 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 deleteTours = async ({ id }: { id: number }) => {
const response = await httpClient.delete(`${GET_TICKET}${id}/`); const response = await httpClient.delete(`${GET_TICKET}${id}/`);
return response; 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 // htoel_badge api
const hotelBadge = async ({ const hotelBadge = async ({
page, page,
@@ -387,10 +445,14 @@ const hotelFeatureTypeDelete = async ({ id }: { id: number }) => {
}; };
export { export {
addedPopularTours,
createHotel, createHotel,
createTours, createTours,
deleteTours, deleteTours,
editHotel,
getAllTours, getAllTours,
getDetailToursId,
getHotel,
getOneTours, getOneTours,
hotelBadge, hotelBadge,
hotelBadgeCreate, hotelBadgeCreate,
@@ -422,4 +484,5 @@ export {
hotelTypeDelete, hotelTypeDelete,
hotelTypeDetail, hotelTypeDetail,
hotelTypeUpdate, hotelTypeUpdate,
updateTours,
}; };

View File

@@ -1,7 +1,5 @@
import z from "zod"; import z from "zod";
const fileSchema = z.instanceof(File, { message: "Rasm faylini yuklang" });
export const TourformSchema = z.object({ export const TourformSchema = z.object({
title: z.string().min(2, { title: z.string().min(2, {
message: "Sarlavha kamida 2 ta belgidan iborat bo'lishi kerak", message: "Sarlavha kamida 2 ta belgidan iborat bo'lishi kerak",
@@ -171,7 +169,7 @@ export const TourformSchema = z.object({
z.object({ z.object({
ticket_itinerary_image: z.array( ticket_itinerary_image: z.array(
z.object({ z.object({
image: fileSchema, image: z.union([z.instanceof(File), z.string()]),
}), }),
), ),
title: z.string().min(1, "Sarlavha majburiy"), title: z.string().min(1, "Sarlavha majburiy"),
@@ -186,4 +184,24 @@ export const TourformSchema = z.object({
}), }),
) )
.min(1, { message: "Kamida bitta xizmat kiriting." }), .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 bolishi mumkin emas." }),
}),
)
.min(1, { message: "Kamida bitta pullik xizmat kiriting." }),
}); });

View File

@@ -12,6 +12,7 @@ export interface GetAllTours {
results: { results: {
id: number; id: number;
destination: string; destination: string;
featured_tickets: boolean;
duration_days: number; duration_days: number;
hotel_name: string; hotel_name: string;
price: number; price: number;
@@ -24,25 +25,37 @@ export interface GetAllTours {
export interface GetOneTours { export interface GetOneTours {
status: boolean; status: boolean;
data: { data: {
id: 0; id: number;
hotel_name: string; hotel_name: string;
hotel_rating: string; hotel_rating: string;
hotel_amenities: string; hotel_amenities: string;
title: string; title: string;
title_ru: string;
title_uz: string;
price: number; price: number;
min_person: number; min_person: number;
max_person: number; max_person: number;
departure: string; departure: string;
departure_ru: string;
departure_uz: string;
destination: string; destination: string;
destination_ru: string;
destination_uz: string;
departure_time: string; departure_time: string;
travel_time: string; travel_time: string;
location_name: string; location_name: string;
location_name_ru: string;
location_name_uz: string;
passenger_count: number; passenger_count: number;
languages: string; languages: string;
hotel_info: string; hotel_info: string;
hotel_info_ru: string;
hotel_info_uz: string;
duration_days: number; duration_days: number;
rating: number; rating: number;
hotel_meals: string; hotel_meals: string;
hotel_meals_ru: string;
hotel_meals_uz: string;
slug: string; slug: string;
visa_required: true; visa_required: true;
image_banner: string; image_banner: string;
@@ -50,7 +63,7 @@ export interface GetOneTours {
transports: [ transports: [
{ {
price: number; price: number;
full_info: { transport: {
name: string; name: string;
icon_name: string; icon_name: string;
}; };
@@ -65,6 +78,7 @@ export interface GetOneTours {
{ {
name: string; name: string;
name_ru: string; name_ru: string;
name_uz: string;
icon_name: string; icon_name: string;
}, },
]; ];
@@ -73,26 +87,58 @@ export interface GetOneTours {
image: string; image: string;
title: string; title: string;
title_ru: string; title_ru: string;
title_uz: string;
desc: string; desc: string;
desc_ru: string;
desc_uz: string; desc_uz: string;
}, },
]; ];
ticket_itinerary: [ ticket_itinerary: {
{
title: string; title: string;
title_ru: string; title_ru: string;
title_uz: string;
duration: number; duration: number;
ticket_itinerary_image: [
{
image: string;
}, },
]; ];
ticket_itinerary_destinations: [
{
name: string;
name_ru: string;
name_uz: string;
},
];
}[];
ticket_hotel_meals: [ ticket_hotel_meals: [
{ {
image: string; image: string;
name: string; name: string;
name_ru: string; name_ru: string;
name_uz: string;
desc: string; desc: string;
desc_ru: 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 { export interface Badge {
id: number; id: number;
name: string; name: string;
name_uz: string;
name_ru: string; name_ru: string;
color: string; color: string;
} }
@@ -206,6 +253,7 @@ export interface Hotel_TranportId {
export interface Transport { export interface Transport {
id: number; id: number;
name: string; name: string;
name_uz: string;
name_ru: string; name_ru: string;
icon_name: string; icon_name: string;
} }
@@ -233,6 +281,7 @@ export interface Hotel_TypeId {
export interface Type { export interface Type {
id: number; id: number;
name: string; name: string;
name_uz: string;
name_ru: string; name_ru: string;
} }
@@ -273,6 +322,7 @@ export interface HotelFeaturesDetail {
export interface HotelFeatures { export interface HotelFeatures {
id: number; id: number;
hotel_feature_type_name: string; hotel_feature_type_name: string;
hotel_feature_type_name_uz: string;
hotel_feature_type_name_ru: string; hotel_feature_type_name_ru: string;
} }
@@ -293,6 +343,7 @@ export interface HotelFeaturesTypeDetail {
id: number; id: number;
feature_name: string; feature_name: string;
feature_name_ru: string; feature_name_ru: string;
feature_name_uz: string;
feature_type: { feature_type: {
id: number; id: number;
hotel_feature_type_name: string; 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;
};
},
];
},
];
};
}

View File

@@ -160,7 +160,7 @@ const BadgeTable = ({
useEffect(() => { useEffect(() => {
if (badgeDetail) { if (badgeDetail) {
form.setValue("color", badgeDetail.data.data.color); 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); form.setValue("name_ru", badgeDetail.data.data.name_ru);
} }
}, [editId, badgeDetail]); }, [editId, badgeDetail]);

View File

@@ -43,7 +43,12 @@ const CreateEditTour = () => {
</div> </div>
</div> </div>
{step === 1 && ( {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} />} {step === 2 && <StepTwo data={data} isEditMode={isEditMode} />}
</div> </div>

View File

@@ -174,7 +174,7 @@ const FeaturesTable = ({
useEffect(() => { useEffect(() => {
if (badgeDetail) { 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( form.setValue(
"name_ru", "name_ru",
badgeDetail.data.data.hotel_feature_type_name_ru, badgeDetail.data.data.hotel_feature_type_name_ru,

View File

@@ -167,7 +167,7 @@ const FeaturesTableType = ({
useEffect(() => { useEffect(() => {
if (badgeDetail) { 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); form.setValue("name_ru", badgeDetail.data.data.feature_name_ru);
} }
}, [editId, badgeDetail]); }, [editId, badgeDetail]);

View File

@@ -155,7 +155,7 @@ const MealTable = ({
useEffect(() => { useEffect(() => {
if (typeDetail) { 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); form.setValue("name_ru", typeDetail.data.data.name_ru);
} }
}, [editId, typeDetail]); }, [editId, typeDetail]);

View File

@@ -5,6 +5,7 @@ import {
hotelBadge, hotelBadge,
hotelTarif, hotelTarif,
hotelTransport, hotelTransport,
updateTours,
} from "@/pages/tours/lib/api"; } from "@/pages/tours/lib/api";
import { TourformSchema } from "@/pages/tours/lib/form"; import { TourformSchema } from "@/pages/tours/lib/form";
import { useTicketStore } from "@/pages/tours/lib/store"; import { useTicketStore } from "@/pages/tours/lib/store";
@@ -46,10 +47,12 @@ import z from "zod";
const StepOne = ({ const StepOne = ({
setStep, setStep,
data, data,
id,
isEditMode, isEditMode,
}: { }: {
setStep: Dispatch<SetStateAction<number>>; setStep: Dispatch<SetStateAction<number>>;
data: GetOneTours | undefined; data: GetOneTours | undefined;
id: string | undefined;
isEditMode: boolean; isEditMode: boolean;
}) => { }) => {
const [displayPrice, setDisplayPrice] = useState(""); const [displayPrice, setDisplayPrice] = useState("");
@@ -88,6 +91,8 @@ const StepOne = ({
passenger_count: 1, passenger_count: 1,
min_person: 1, min_person: 1,
max_person: 1, max_person: 1,
extra_service: [],
paid_extra_service: [],
languages: "", languages: "",
duration: 1, duration: 1,
badges: [], badges: [],
@@ -103,119 +108,152 @@ const StepOne = ({
const { addAmenity, setId } = useTicketStore(); const { addAmenity, setId } = useTicketStore();
useEffect(() => { useEffect(() => {
if (isEditMode && data?.data) { if (!isEditMode || !data?.data) return;
const tourData = data.data;
form.setValue("title", tourData.title); const tour = data.data;
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 || []);
// DateTime fields // 🔹 Oddiy text maydonlar
if (tourData.departure_time) { form.setValue("title", tour.title_uz ?? "");
const departureDate = new Date(tourData.departure_time); form.setValue("title_ru", tour.title_ru ?? "");
form.setValue("price", tour.price ?? 0);
setDisplayPrice(formatPrice(tour.price ?? 0));
form.setValue("passenger_count", tour.passenger_count ?? 1);
form.setValue("min_person", tour.min_person ?? 1);
form.setValue("max_person", tour.max_person ?? 1);
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 ?? "");
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 ?? "");
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 ?? []);
// 🔹 Jonash vaqti
if (tour.departure_time) {
const d = new Date(tour.departure_time);
form.setValue("departureDateTime", { form.setValue("departureDateTime", {
date: departureDate, date: d,
time: departureDate.toTimeString().slice(0, 8), // HH:MM:SS time: d.toTimeString().slice(0, 8),
}); });
} }
if (tourData.travel_time) { // 🔹 Qaytish vaqti
const travelDate = new Date(tourData.travel_time); if (tour.travel_time) {
const d = new Date(tour.travel_time);
form.setValue("travelDateTime", { form.setValue("travelDateTime", {
date: travelDate, date: d,
time: travelDate.toTimeString().slice(0, 8), time: d.toTimeString().slice(0, 8),
}); });
} }
// Amenities // 🔹 Qulayliklar (amenities)
if (tourData.ticket_amenities && tourData.ticket_amenities.length > 0) { form.setValue(
const amenities = tourData.ticket_amenities.map((item) => ({ "amenities",
name: item.name, tour.ticket_amenities?.map((a) => ({
name_ru: item.name_ru, name: a.name ?? "",
icon_name: item.icon_name, name_ru: a.name_ru ?? "",
})); icon_name: a.icon_name ?? "",
form.setValue("amenities", amenities); })) ?? [],
} );
if ( // 🔹 Xizmatlar (hotel_services)
tourData.ticket_included_services && form.setValue(
tourData.ticket_included_services.length > 0 "hotel_services",
) { tour.ticket_included_services?.map((s) => ({
const services = tourData.ticket_included_services.map((item) => ({ image: s.image ?? null,
image: item.image, title: s.title_uz ?? "",
title: item.title, title_ru: s.title_ru ?? "",
title_ru: item.title_ru, description: s.desc_uz ?? "",
description: item.desc_uz || item.desc, desc_ru: s.desc_ru ?? "",
desc_ru: item.desc || item.desc, })) ?? [],
})); );
form.setValue("hotel_services", services);
}
if ( // 🔹 Taomlar (hotel_meals)
tourData.ticket_hotel_meals && form.setValue(
tourData.ticket_hotel_meals.length > 0 "hotel_meals",
) { tour.ticket_hotel_meals?.map((m) => ({
const meals = tourData.ticket_hotel_meals.map((item) => ({ image: m.image ?? null,
image: item.image, title: m.name ?? "",
title: item.name, title_ru: m.name_ru ?? "",
title_ru: item.name_ru, description: m.desc ?? "",
description: item.desc, desc_ru: m.desc_ru ?? "",
desc_ru: item.desc_ru, })) ?? [],
})); );
form.setValue("hotel_meals", meals);
}
// Transport // 🔹 Transport
if (tourData.transports && tourData.transports.length > 0) { const transports =
const transports = tourData.transports.map((item, index) => ({ tour.transports?.map((t, i) => ({
transport: index + 1, // Agar transport ID bo'lsa, uni ishlatish kerak transport: i + 1,
price: item.price, price: t.price ?? 0,
})); })) ?? [];
// const tariff = tourData.tar => ({
// transport: index + 1,
// price: item.price,
// }));
form.setValue("transport", transports); form.setValue("transport", transports);
// form.setValue("tarif", ); setTransportPrices(transports.map((t) => formatPrice(t.price ?? 0))); // 👈 YANGI QOSHILGAN
}
// Ticket itinerary // 🔹 Tarif
if (tourData.ticket_itinerary && tourData.ticket_itinerary.length > 0) { const tariffs =
const itinerary = tourData.ticket_itinerary.map((item) => ({ tour.tariff?.map((t) => ({
ticket_itinerary_image: [], // Image fayllarni alohida handle qilish kerak tariff: t.tariff ?? 0,
title: item.title, price: t.price ?? 0,
title_ru: item.title_ru, })) ?? [];
duration: item.duration, form.setValue("tarif", tariffs);
ticket_itinerary_destinations: [], // Agar destinations bo'lsa qo'shish kerak setTarifDisplayPrice(tariffs.map((t) => formatPrice(t.price ?? 0)));
}));
form.setValue("ticket_itinerary", itinerary);
}
form.setValue("banner", tourData.image_banner); // 🔹 Yonalishlar (ticket_itinerary)
if (tourData.ticket_images && tourData.ticket_images.length > 0) { form.setValue(
const images = tourData.ticket_images.map((img) => img.image); // faqat linklarni olamiz "ticket_itinerary",
form.setValue("images", images); tour.ticket_itinerary?.map((item) => ({
} ticket_itinerary_image:
} item.ticket_itinerary_image?.map((img) => ({
}, [isEditMode, data, form]); 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 { watch, setValue } = form;
const selectedDate = watch("departureDateTime.date"); 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>) { function onSubmit(value: z.infer<typeof TourformSchema>) {
const formData = new FormData(); const formData = new FormData();
@@ -273,7 +327,9 @@ const StepOne = ({
formData.append("hotel_meals_ru", value.hotel_meals_info_ru); formData.append("hotel_meals_ru", value.hotel_meals_info_ru);
formData.append("duration_days", String(value.duration)); formData.append("duration_days", String(value.duration));
formData.append("rating", String("0.0")); formData.append("rating", String("0.0"));
if (value.banner instanceof File) {
formData.append("image_banner", value.banner); formData.append("image_banner", value.banner);
}
value.tarif.forEach((e, i) => { value.tarif.forEach((e, i) => {
formData.append(`tariff[${i}]tariff`, String(e.tariff)); formData.append(`tariff[${i}]tariff`, String(e.tariff));
formData.append(`tariff[${i}]price`, String(e.price)); formData.append(`tariff[${i}]price`, String(e.price));
@@ -285,7 +341,11 @@ const StepOne = ({
value.badges?.forEach((e, i) => { value.badges?.forEach((e, i) => {
formData.append(`badge[${i}]`, String(e)); 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) => { value.amenities.forEach((e, i) => {
formData.append(`ticket_amenities[${i}]name`, e.name); formData.append(`ticket_amenities[${i}]name`, e.name);
formData.append(`ticket_amenities[${i}]name_ru`, e.name_ru); formData.append(`ticket_amenities[${i}]name_ru`, e.name_ru);
@@ -297,22 +357,24 @@ const StepOne = ({
}); });
}); });
value.hotel_services.forEach((e, i) => { value.hotel_services.forEach((e, i) => {
if (e instanceof File) {
formData.append(`ticket_included_services[${i}]image`, e.image); 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`, e.title);
formData.append(`ticket_included_services[${i}]title_ru`, e.title_ru); 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_ru`, e.desc_ru);
formData.append(`ticket_included_services[${i}]desc`, e.description); formData.append(`ticket_included_services[${i}]desc`, e.description);
}
}); });
value.ticket_itinerary.forEach((e, i) => { value.ticket_itinerary.forEach((e, i) => {
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`, e.title);
formData.append(`ticket_itinerary[${i}]title_ru`, e.title_ru); formData.append(`ticket_itinerary[${i}]title_ru`, e.title_ru);
formData.append(`ticket_itinerary[${i}]duration`, String(e.duration)); formData.append(`ticket_itinerary[${i}]duration`, String(e.duration));
e.ticket_itinerary_image.forEach((e, f) => {
formData.append( formData.append(
`ticket_itinerary[${i}]ticket_itinerary_image[${f}]image`, `ticket_itinerary[${i}]ticket_itinerary_image[${f}]image`,
e.image, l.image,
); );
});
e.ticket_itinerary_destinations.forEach((e, f) => { e.ticket_itinerary_destinations.forEach((e, f) => {
formData.append( formData.append(
`ticket_itinerary[${i}]ticket_itinerary_destinations[${f}]name`, `ticket_itinerary[${i}]ticket_itinerary_destinations[${f}]name`,
@@ -323,16 +385,38 @@ const StepOne = ({
String(e.name_ru), String(e.name_ru),
); );
}); });
}
});
}); });
value.hotel_meals.forEach((e, i) => { value.hotel_meals.forEach((e, i) => {
if (e instanceof File) {
formData.append(`ticket_hotel_meals[${i}]image`, e.image); 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`, e.title);
formData.append(`ticket_hotel_meals[${i}]name_ru`, e.title_ru); 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`, e.description);
formData.append(`ticket_hotel_meals[${i}]desc_ru`, e.desc_ru); formData.append(`ticket_hotel_meals[${i}]desc_ru`, e.desc_ru);
}
}); });
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); create(formData);
} }
}
console.log(form.formState.errors);
const { data: badge } = useQuery({ const { data: badge } = useQuery({
queryKey: ["all_badge"], queryKey: ["all_badge"],
@@ -392,7 +476,9 @@ const StepOne = ({
name="price" name="price"
render={() => ( render={() => (
<FormItem> <FormItem>
<Label className="text-md">{t("Narx")} (1 kishi uchun)</Label> <Label className="text-md">
{t("Narx")} {t("(1 kishi uchun)")}
</Label>
<FormControl> <FormControl>
<Input <Input
type="text" type="text"
@@ -1157,11 +1243,11 @@ const StepOne = ({
name="visa_required" name="visa_required"
render={({ field }) => ( render={({ field }) => (
<FormItem className="space-y-3"> <FormItem className="space-y-3">
<Label>{t("Visa talab qilinadimi?")}</Label> <Label>{t("Visa talab qilinadimi")}?</Label>
<FormControl> <FormControl>
<RadioGroup <RadioGroup
onValueChange={field.onChange} onValueChange={field.onChange}
defaultValue={field.value} value={field.value}
className="flex gap-6" className="flex gap-6"
> >
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@@ -1170,7 +1256,7 @@ const StepOne = ({
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<RadioGroupItem value="no" id="visa_no" /> <RadioGroupItem value="no" id="visa_no" />
<label htmlFor="visa_no">{t("Yoq")}</label> <label htmlFor="visa_no">{t("Yo'q")}</label>
</div> </div>
</RadioGroup> </RadioGroup>
</FormControl> </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)} som</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 bolishi mumkin
},
]);
// inputlarni tozalaymiz
nameInput.value = "";
nameRuInput.value = "";
priceInput.value = "";
}
}}
className="h-12"
>
{t("Qo'shish")}
</Button>
</div>
<FormMessage />
</div>
</FormItem>
)}
/>
<FormField <FormField
control={form.control} control={form.control}
name="hotel_info" name="hotel_info"
@@ -1313,7 +1599,7 @@ const StepOne = ({
<Label className="text-md">{t("Mehmonxona haqida")} (ru)</Label> <Label className="text-md">{t("Mehmonxona haqida")} (ru)</Label>
<FormControl> <FormControl>
<Textarea <Textarea
placeholder={t("Mehmonxona haqida (ru)")} placeholder={t("Mehmonxona haqida") + " (ru)"}
{...field} {...field}
className="min-h-48 max-h-60 !text-md" className="min-h-48 max-h-60 !text-md"
/> />
@@ -1389,7 +1675,7 @@ const StepOne = ({
<Input <Input
id="hotel_service_title_ru" id="hotel_service_title_ru"
placeholder={t("Xizmat nomi (ru)")} placeholder={t("Xizmat nomi") + " (ru)"}
className="h-12 !text-md" className="h-12 !text-md"
/> />
@@ -1401,7 +1687,7 @@ const StepOne = ({
<Textarea <Textarea
id="hotel_service_desc_ru" id="hotel_service_desc_ru"
placeholder={t("Xizmat tavsifi (ru)")} placeholder={t("Xizmat tavsifi") + " (ru)"}
className="min-h-24 !text-md" className="min-h-24 !text-md"
/> />
@@ -1487,7 +1773,7 @@ const StepOne = ({
</Label> </Label>
<FormControl> <FormControl>
<Textarea <Textarea
placeholder={t("Mehmonxona taomlari haqida (ru)")} placeholder={t("Mehmonxona taomlari haqida") + " (ru)"}
{...field} {...field}
className="min-h-48 max-h-60" className="min-h-48 max-h-60"
/> />
@@ -1563,7 +1849,7 @@ const StepOne = ({
<Input <Input
id="hotel_meals_title_ru" id="hotel_meals_title_ru"
placeholder={t("Taom nomi (ru)")} placeholder={t("Taom nomi") + " (ru)"}
className="h-12 !text-md" className="h-12 !text-md"
/> />
@@ -1575,7 +1861,7 @@ const StepOne = ({
<Textarea <Textarea
id="hotel_meals_desc_ru" id="hotel_meals_desc_ru"
placeholder={t("Taom tavsifi (ru)")} placeholder={t("Taom tavsifi") + " (ru)"}
className="min-h-24 !text-md" className="min-h-24 !text-md"
/> />
@@ -1662,7 +1948,7 @@ const StepOne = ({
{item.ticket_itinerary_destinations[0]?.name} {item.ticket_itinerary_destinations[0]?.name}
</p> </p>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
{item.duration} kun {item.duration} {t("kun")}
</p> </p>
</div> </div>
<button <button
@@ -1704,7 +1990,7 @@ const StepOne = ({
<Input <Input
id="ticket_itinerary_title_ru" id="ticket_itinerary_title_ru"
placeholder={t("Sarlavha (RU)")} placeholder={t("Sarlavha") + " (ru)"}
className="h-12 !text-md" className="h-12 !text-md"
/> />
@@ -1724,7 +2010,7 @@ const StepOne = ({
<Input <Input
id="ticket_itinerary_destination_ru" id="ticket_itinerary_destination_ru"
placeholder={t("Manzil (RU)")} placeholder={t("Manzil") + " (ru)"}
className="h-12 !text-md" className="h-12 !text-md"
/> />

View File

@@ -2,6 +2,8 @@
import { import {
createHotel, createHotel,
editHotel,
getHotel,
hotelFeature, hotelFeature,
hotelFeatureType, hotelFeatureType,
hotelType, hotelType,
@@ -30,7 +32,7 @@ import {
SelectValue, SelectValue,
} from "@/shared/ui/select"; } from "@/shared/ui/select";
import { zodResolver } from "@hookform/resolvers/zod"; 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 { X } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -64,9 +66,18 @@ const StepTwo = ({
isEditMode: boolean; isEditMode: boolean;
}) => { }) => {
const { amenities, id: ticketId } = useTicketStore(); const { amenities, id: ticketId } = useTicketStore();
const navigator = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation(); 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>>({ const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema), resolver: zodResolver(formSchema),
defaultValues: { defaultValues: {
@@ -79,37 +90,56 @@ const StepTwo = ({
}, },
}); });
// 🧩 Edit holati uchun formni toldirish
useEffect(() => { useEffect(() => {
if (isEditMode && data?.data) { if (isEditMode && hotelDetail?.[0]) {
const tourData = data.data; const hotel = hotelDetail[0];
form.setValue("title", tourData.hotel_name); form.setValue("title", hotel.name);
form.setValue("rating", tourData.hotel_rating); form.setValue("rating", String(hotel.rating));
form.setValue("mealPlan", tourData.hotel_meals);
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 = [ // 🧩 Select ma'lumotlari
"Breakfast Only",
"Half Board",
"Full Board",
"All Inclusive",
];
const [allHotelTypes, setAllHotelTypes] = useState<Type[]>([]); const [allHotelTypes, setAllHotelTypes] = useState<Type[]>([]);
const [allHotelFeature, setAllHotelFeature] = useState<HotelFeatures[]>([]); const [allHotelFeature, setAllHotelFeature] = useState<HotelFeatures[]>([]);
const [allHotelFeatureType, setAllHotelFeatureType] = useState< const [allHotelFeatureType, setAllHotelFeatureType] = useState<
HotelFeaturesType[] HotelFeaturesType[]
>([]); >([]);
const [featureTypeMapping, setFeatureTypeMapping] = useState< const [featureTypeMapping, setFeatureTypeMapping] = useState<
Record<string, string[]> Record<string, string[]>
>({}); >({});
const selectedHotelFeatures = form.watch("hotelFeatures"); const selectedHotelFeatures = form.watch("hotelFeatures");
// 🔹 Hotel Types yuklash
useEffect(() => { useEffect(() => {
const loadAll = async () => { const loadHotelTypes = async () => {
try {
let page = 1; let page = 1;
let results: Type[] = []; let results: Type[] = [];
let hasNext = true; let hasNext = true;
@@ -121,17 +151,15 @@ const StepTwo = ({
hasNext = !!data.links.next; hasNext = !!data.links.next;
page++; page++;
} }
setAllHotelTypes(results); setAllHotelTypes(results);
} catch (err) {
console.error(err);
}
}; };
loadAll(); loadHotelTypes();
}, []); }, []);
// 🔹 Hotel Features yuklash
useEffect(() => { useEffect(() => {
const loadAll = async () => { const loadHotelFeatures = async () => {
try {
let page = 1; let page = 1;
let results: HotelFeatures[] = []; let results: HotelFeatures[] = [];
let hasNext = true; let hasNext = true;
@@ -143,14 +171,13 @@ const StepTwo = ({
hasNext = !!data.links.next; hasNext = !!data.links.next;
page++; page++;
} }
setAllHotelFeature(results); setAllHotelFeature(results);
} catch (err) {
console.error(err);
}
}; };
loadAll(); loadHotelFeatures();
}, []); }, []);
// 🔹 Feature type'larni yuklash (tanlangan feature boyicha)
useEffect(() => { useEffect(() => {
if (selectedHotelFeatures.length === 0) { if (selectedHotelFeatures.length === 0) {
setAllHotelFeatureType([]); setAllHotelFeatureType([]);
@@ -158,18 +185,12 @@ const StepTwo = ({
return; return;
} }
const loadAll = async () => { const loadFeatureTypes = async () => {
try { const selectedIds = selectedHotelFeatures.map(Number).filter(Boolean);
const selectedFeatureIds = selectedHotelFeatures
.map((featureId) => Number(featureId))
.filter((id) => !isNaN(id));
if (selectedFeatureIds.length === 0) return;
let allResults: HotelFeaturesType[] = []; let allResults: HotelFeaturesType[] = [];
const newMapping: Record<string, string[]> = {}; const mapping: Record<string, string[]> = {};
for (const featureId of selectedFeatureIds) { for (const id of selectedIds) {
let page = 1; let page = 1;
let hasNext = true; let hasNext = true;
const featureTypes: string[] = []; const featureTypes: string[] = [];
@@ -178,87 +199,104 @@ const StepTwo = ({
const res = await hotelFeatureType({ const res = await hotelFeatureType({
page, page,
page_size: 50, page_size: 50,
feature_type: featureId, feature_type: id,
}); });
const data = res.data.data; const data = res.data.data;
allResults = [...allResults, ...data.results]; allResults = [...allResults, ...data.results];
data.results.forEach((ft: HotelFeaturesType) =>
data.results.forEach((item: HotelFeaturesType) => { featureTypes.push(String(ft.id)),
featureTypes.push(String(item.id)); );
});
hasNext = !!data.links.next; hasNext = !!data.links.next;
page++; page++;
} }
mapping[String(id)] = featureTypes;
newMapping[String(featureId)] = featureTypes;
} }
const uniqueResults = allResults.filter( const uniqueResults = allResults.filter(
(item, index, self) => (v, i, a) => a.findIndex((t) => t.id === v.id) === i,
index === self.findIndex((t) => t.id === item.id),
); );
setAllHotelFeatureType(uniqueResults); setAllHotelFeatureType(uniqueResults);
setFeatureTypeMapping(newMapping); setFeatureTypeMapping(mapping);
} catch (err) {
console.error(err);
}
}; };
loadAll(); loadFeatureTypes();
}, [selectedHotelFeatures]); }, [selectedHotelFeatures]);
const { mutate, isPending } = useMutation({ const { mutate, isPending } = useMutation({
mutationFn: (body: FormData) => createHotel({ body }), mutationFn: (body: FormData) => createHotel({ body }),
onSuccess: () => { onSuccess: () => {
navigator("/tours"); toast.success(t("Muvaffaqiyatli saqlandi"));
toast.success(t("Muvaffaqiyatli saqlandi"), { navigate("/tours");
richColors: true,
position: "top-center",
});
}, },
onError: () => { onError: () =>
toast.error(t("Xatolik yuz berdi"), { toast.error(t("Xatolik yuz berdi"), {
richColors: true, richColors: true,
position: "top-center", position: "top-center",
}); }),
},
}); });
const removeHotelType = (typeId: string) => { const { mutate: edit, isPending: editPending } = useMutation({
const current = form.getValues("hotelType"); 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( form.setValue(
"hotelType", "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 removeFeatureType = (id: string) =>
form.setValue(
"hotelFeaturesType",
form.getValues("hotelFeaturesType").filter((v) => v !== id),
);
// 🧩 Submit
const onSubmit = (data: z.infer<typeof formSchema>) => { const onSubmit = (data: z.infer<typeof formSchema>) => {
const formData = new FormData(); const formData = new FormData();
formData.append("ticket", ticketId ? ticketId?.toString() : "");
formData.append("ticket", ticketId ? String(ticketId) : "");
formData.append("name", data.title); formData.append("name", data.title);
formData.append("rating", String(data.rating)); formData.append("rating", data.rating);
formData.append(
"meal_plan", const mealPlan =
data.mealPlan === "Breakfast Only" data.mealPlan === "Breakfast Only"
? "breakfast" ? "breakfast"
: data.mealPlan === "All Inclusive"
? "all_inclusive"
: data.mealPlan === "Half Board" : data.mealPlan === "Half Board"
? "half_board" ? "half_board"
: data.mealPlan === "Full Board" : data.mealPlan === "Full Board"
? "full_board" ? "full_board"
: "all_inclusive", : "all_inclusive";
); formData.append("meal_plan", mealPlan);
data.hotelType.forEach((typeId) => { data.hotelType.forEach((id) => formData.append("hotel_type", id));
formData.append("hotel_type", typeId); data.hotelFeatures.forEach((id) => formData.append("hotel_features", id));
});
data.hotelFeaturesType.forEach((typeId) => {
formData.append("hotel_features", typeId);
});
amenities.forEach((e, i) => { amenities.forEach((e, i) => {
formData.append(`hotel_amenities[${i}]name`, e.name); formData.append(`hotel_amenities[${i}]name`, e.name);
@@ -266,33 +304,22 @@ const StepTwo = ({
formData.append(`hotel_amenities[${i}]icon_name`, e.icon_name); formData.append(`hotel_amenities[${i}]icon_name`, e.icon_name);
}); });
if (isEditMode && hotelDetail) {
edit({
body: formData,
id: Number(hotelDetail[0].id),
});
} else {
mutate(formData); mutate(formData);
}
}; };
const removeHotelFeature = (featureId: string) => { const mealPlans = [
const currentFeatures = form.getValues("hotelFeatures"); "Breakfast Only",
const currentFeatureTypes = form.getValues("hotelFeaturesType"); "Half Board",
"Full Board",
const typesToRemove = featureTypeMapping[featureId] || []; "All Inclusive",
];
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),
);
};
return ( return (
<Form {...form}> <Form {...form}>
@@ -465,6 +492,8 @@ const StepTwo = ({
const selectedItem = allHotelFeature.find( const selectedItem = allHotelFeature.find(
(item) => String(item.id) === selectedValue, (item) => String(item.id) === selectedValue,
); );
console.log(allHotelFeature);
return ( return (
<div <div
key={selectedValue} key={selectedValue}
@@ -608,7 +637,7 @@ const StepTwo = ({
disabled={isPending} disabled={isPending}
className="mt-6 px-6 py-3 bg-blue-600 text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed" 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> </button>
</form> </form>
</Form> </Form>

View File

@@ -1,190 +1,68 @@
"use client"; "use client";
import { getDetailToursId } from "@/pages/tours/lib/api";
import formatPrice from "@/shared/lib/formatPrice"; import formatPrice from "@/shared/lib/formatPrice";
import { Badge } from "@/shared/ui/badge"; import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/shared/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/shared/ui/tabs";
import { useQuery } from "@tanstack/react-query";
import { import {
AlertTriangle,
ArrowLeft, ArrowLeft,
Calendar,
CheckCircle2, CheckCircle2,
Clock, Clock,
DollarSign, DollarSign,
Globe, Globe,
Heart,
Hotel, Hotel,
Loader2,
MapPin, MapPin,
Star, Star,
Users, Users,
Utensils, Utensils,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
type TourDetail = {
id: number;
title: string;
price: number;
departure_date: string;
departure: string;
destination: string;
passenger_count: number;
languages: string;
rating: number;
hotel_info: string;
duration_days: number;
hotel_meals: string;
ticket_images: Array<{ image: string }>;
ticket_amenities: Array<{ name: string; icon_name: string }>;
ticket_included_services: Array<{
image: string;
title: string;
desc: string;
}>;
ticket_itinerary: Array<{
title: string;
duration: number;
ticket_itinerary_image: Array<{ image: string }>;
ticket_itinerary_destinations: Array<{ name: string }>;
}>;
ticket_hotel_meals: Array<{ image: string; name: string; desc: string }>;
travel_agency_id: string;
ticket_comments: Array<{
user: { id: number; username: string };
text: string;
rating: number;
}>;
tariff: Array<{ name: string }>;
is_liked: string;
};
export default function TourDetailPage() { export default function TourDetailPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const params = useParams(); const params = useParams();
const router = useNavigate(); const router = useNavigate();
const [tour, setTour] = useState<TourDetail | null>(null); const {
data: tour,
useEffect(() => { isLoading,
setTour({ isError,
id: Number(params.id), refetch,
title: "Dubai Hashamatli Sayohati", } = useQuery({
price: 1500000, queryKey: ["tours_detail", params.id],
departure_date: "2025-11-15", queryFn: () => getDetailToursId({ id: Number(params.id) }),
departure: "Toshkent, O'zbekiston", select(data) {
destination: "Dubai, BAA", return data.data.data;
passenger_count: 30,
languages: "O'zbek, Rus, Ingliz",
rating: 4.8,
hotel_info: "5 yulduzli Atlantis The Palm mehmonxonasi",
duration_days: 7,
hotel_meals: "Nonushta va kechki ovqat kiritilgan",
ticket_images: [
{ image: "/dubai-burj-khalifa.png" },
{ image: "/dubai-palm-jumeirah.jpg" },
{ image: "/dubai-marina.jpg" },
{ image: "/dubai-desert-safari.png" },
],
ticket_amenities: [
{ name: "Wi-Fi", icon_name: "wifi" },
{ name: "Konditsioner", icon_name: "air-vent" },
{ name: "Basseyn", icon_name: "waves" },
{ name: "Fitnes zal", icon_name: "dumbbell" },
{ name: "Spa markaz", icon_name: "sparkles" },
{ name: "Restoran", icon_name: "utensils" },
],
ticket_included_services: [
{
image: "/airplane-ticket.jpg",
title: "Aviachiptalar",
desc: "Toshkent-Dubai-Toshkent yo'nalishi bo'yicha qatnov chiptalar",
}, },
{
image: "/comfortable-hotel-room.png",
title: "Mehmonxona",
desc: "5 yulduzli mehmonxonada 6 kecha turar joy",
},
{
image: "/diverse-tour-group.png",
title: "Gid xizmati",
desc: "Professional gid bilan barcha ekskursiyalar",
},
{
image: "/transfer-car.jpg",
title: "Transfer",
desc: "Aeroport-mehmonxona-aeroport transferi",
},
],
ticket_itinerary: [
{
title: "Dubayga kelish va mehmonxonaga joylashish",
duration: 1,
ticket_itinerary_image: [{ image: "/dubai-airport.jpg" }],
ticket_itinerary_destinations: [
{ name: "Dubai Xalqaro Aeroporti" },
{ name: "Atlantis The Palm" },
],
},
{
title: "Burj Khalifa va Dubai Mall sayohati",
duration: 1,
ticket_itinerary_image: [{ image: "/burj-khalifa-inside.jpg" }],
ticket_itinerary_destinations: [
{ name: "Burj Khalifa" },
{ name: "Dubai Mall" },
{ name: "Dubai Fountain" },
],
},
{
title: "Sahro safari va beduinlar lageri",
duration: 1,
ticket_itinerary_image: [{ image: "/dubai-desert-safari.png" }],
ticket_itinerary_destinations: [
{ name: "Dubai sahro" },
{ name: "Beduinlar lageri" },
],
},
],
ticket_hotel_meals: [
{
image: "/breakfast-buffet.png",
name: "Nonushta",
desc: "Xalqaro bufet nonushtasi har kuni ertalab",
},
{
image: "/dinner-restaurant.jpg",
name: "Kechki ovqat",
desc: "Mehmonxona restoranida 3 xil menyu tanlovli kechki ovqat",
},
],
travel_agency_id: "1",
ticket_comments: [
{
user: { id: 1, username: "Aziza Karimova" },
text: "Ajoyib sayohat bo'ldi! Barcha xizmatlar yuqori darajada. Gid juda professional va mehribon edi.",
rating: 5,
},
{
user: { id: 2, username: "Sardor Rahimov" },
text: "Mehmonxona va ovqatlar juda yaxshi. Faqat transfer biroz kechikdi, lekin umuman olganda juda yoqdi.",
rating: 4,
},
{
user: { id: 3, username: "Nilufar Toshmatova" },
text: "Hayotimning eng yaxshi sayohati! Barcha narsani juda yaxshi tashkil qilishgan. Rahmat!",
rating: 5,
},
],
tariff: [{ name: "standart" }],
is_liked: "true",
}); });
}, [params.id]);
if (!tour) { if (isLoading) {
return ( return (
<div className="min-h-screen bg-gray-900 flex items-center justify-center"> <div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white gap-4 w-full">
<p className="text-gray-400">Yuklanmoqda...</p> <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> </div>
); );
} }
@@ -200,6 +78,7 @@ export default function TourDetailPage() {
return ( return (
<div className="min-h-screen bg-gray-900 w-full"> <div className="min-h-screen bg-gray-900 w-full">
{tour && (
<div className="container mx-auto px-4 py-8 max-w-full"> <div className="container mx-auto px-4 py-8 max-w-full">
<div className="flex items-center gap-4 mb-8"> <div className="flex items-center gap-4 mb-8">
<Button <Button
@@ -213,19 +92,10 @@ export default function TourDetailPage() {
<div className="flex-1"> <div className="flex-1">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<h1 className="text-4xl font-bold text-white">{tour.title}</h1> <h1 className="text-4xl font-bold text-white">{tour.title}</h1>
<Button
variant="ghost"
size="icon"
className="rounded-full hover:bg-gray-800"
>
<Heart
className={`w-6 h-6 ${tour.is_liked === "true" ? "fill-red-500 text-red-500" : "text-gray-400"}`}
/>
</Button>
</div> </div>
<div className="flex items-center gap-4 text-gray-400"> <div className="flex items-center gap-4 text-gray-400">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
{renderStars(Math.floor(tour.rating))} {renderStars(tour.rating)}
<span className="ml-2 font-semibold">{tour.rating}</span> <span className="ml-2 font-semibold">{tour.rating}</span>
</div> </div>
<span></span> <span></span>
@@ -249,7 +119,7 @@ export default function TourDetailPage() {
))} ))}
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-8">
<Card className="border-gray-700 shadow-lg bg-gray-800"> <Card className="border-gray-700 shadow-lg bg-gray-800">
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
@@ -286,17 +156,17 @@ export default function TourDetailPage() {
</CardContent> </CardContent>
</Card> </Card>
<Card className="border-gray-700 shadow-lg bg-gray-800"> {/* <Card className="border-gray-700 shadow-lg bg-gray-800">
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex items-center gap-3 mb-2"> <div className="flex items-center gap-3 mb-2">
<Calendar className="w-5 h-5 text-yellow-400" /> <Calendar className="w-5 h-5 text-yellow-400" />
<p className="text-sm text-gray-400">{t("Jo'nash sanasi")}</p> <p className="text-sm text-gray-400">{t("Jo'nash sanasi")}</p>
</div> </div>
<p className="text-xl font-bold text-white"> <p className="text-xl font-bold text-white">
{new Date(tour.departure_date).toLocaleDateString("uz-UZ")} {new Date(tour.departure_time).toLocaleDateString("uz-UZ")}
</p> </p>
</CardContent> </CardContent>
</Card> </Card> */}
</div> </div>
<Tabs defaultValue="overview" className="space-y-6"> <Tabs defaultValue="overview" className="space-y-6">
@@ -408,7 +278,7 @@ export default function TourDetailPage() {
<div> <div>
<p className="text-sm text-gray-400">{t("Tarif")}</p> <p className="text-sm text-gray-400">{t("Tarif")}</p>
<p className="font-semibold text-white capitalize"> <p className="font-semibold text-white capitalize">
{tour.tariff[0]?.name} {tour.tariff[0]?.tariff.name}
</p> </p>
</div> </div>
</div> </div>
@@ -524,7 +394,9 @@ export default function TourDetailPage() {
<h3 className="text-lg font-semibold mb-2 text-white"> <h3 className="text-lg font-semibold mb-2 text-white">
{service.title} {service.title}
</h3> </h3>
<p className="text-gray-400 text-sm">{service.desc}</p> <p className="text-gray-400 text-sm">
{service.desc}
</p>
</div> </div>
</div> </div>
))} ))}
@@ -628,7 +500,7 @@ export default function TourDetailPage() {
</TabsContent> </TabsContent>
</Tabs> </Tabs>
<Card className="border-gray-700 shadow-lg mt-8 bg-gray-800"> {/* <Card className="border-gray-700 shadow-lg mt-8 bg-gray-800">
<CardContent className="p-6"> <CardContent className="p-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@@ -639,15 +511,16 @@ export default function TourDetailPage() {
</div> </div>
<Button <Button
variant="outline" variant="outline"
onClick={() => router(`/agencies/${tour.travel_agency_id}`)} onClick={() => router(`/agencies/${tour.id}`)}
className="border-gray-700 bg-gray-800 hover:bg-gray-700 text-gray-300" className="border-gray-700 bg-gray-800 hover:bg-gray-700 text-gray-300"
> >
{t("Firma sahifasiga o'tish")} {t("Firma sahifasiga o'tish")}
</Button> </Button>
</div> </div>
</CardContent> </CardContent>
</Card> </Card> */}
</div> </div>
)}
</div> </div>
); );
} }

View File

@@ -1,6 +1,10 @@
"use client"; "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 formatPrice from "@/shared/lib/formatPrice";
import { Button } from "@/shared/ui/button"; import { Button } from "@/shared/ui/button";
import { import {
@@ -10,6 +14,7 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from "@/shared/ui/dialog"; } from "@/shared/ui/dialog";
import { Switch } from "@/shared/ui/switch";
import { import {
Table, Table,
TableBody, TableBody,
@@ -28,15 +33,18 @@ import {
Plane, Plane,
PlusCircle, PlusCircle,
Trash2, Trash2,
X,
} from "lucide-react"; } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { toast } from "sonner";
const Tours = () => { const Tours = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [deleteId, setDeleteId] = useState<number | null>(null); const [deleteId, setDeleteId] = useState<number | null>(null);
const [showPopularDialog, setShowPopularDialog] = useState(false);
const navigate = useNavigate(); const navigate = useNavigate();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@@ -45,18 +53,54 @@ const Tours = () => {
queryFn: () => getAllTours({ page: page, page_size: 10 }), 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({ const { mutate } = useMutation({
mutationFn: (id: number) => deleteTours({ id }), mutationFn: (id: number) => deleteTours({ id }),
onSuccess: () => { onSuccess: () => {
queryClient.refetchQueries({ queryKey: ["all_tours"] }); queryClient.refetchQueries({ queryKey: ["all_tours"] });
setDeleteId(null); 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) => { const confirmDelete = (id: number) => {
mutate(id); mutate(id);
}; };
const removeFromPopular = (id: number) => {
popular({ id, value: 0 });
setShowPopularDialog(false);
};
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex flex-col items-center justify-center min-h-screen bg-slate-900 text-white gap-4 w-full"> <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>
<TableHead className="min-w-[180px]">{t("Mehmonxona")}</TableHead> <TableHead className="min-w-[180px]">{t("Mehmonxona")}</TableHead>
<TableHead className="min-w-[200px]">{t("Narxi")}</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"> <TableHead className="min-w-[150px] text-center">
{t("Операции")} {t("Операции")}
</TableHead> </TableHead>
@@ -139,6 +186,18 @@ const Tours = () => {
</span> </span>
</TableCell> </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"> <TableCell className="text-center">
<div className="flex gap-2 justify-center"> <div className="flex gap-2 justify-center">
<Button <Button
@@ -172,6 +231,7 @@ const Tours = () => {
</Table> </Table>
</div> </div>
{/* Delete Tour Dialog */}
<Dialog open={deleteId !== null} onOpenChange={() => setDeleteId(null)}> <Dialog open={deleteId !== null} onOpenChange={() => setDeleteId(null)}>
<DialogContent className="sm:max-w-[425px] bg-gray-900"> <DialogContent className="sm:max-w-[425px] bg-gray-900">
<DialogHeader> <DialogHeader>
@@ -201,6 +261,63 @@ const Tours = () => {
</DialogContent> </DialogContent>
</Dialog> </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"> <div className="flex justify-end mt-10 gap-3">
<button <button
disabled={page === 1} disabled={page === 1}

View File

@@ -69,7 +69,9 @@ const TransportTable = ({
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [editId, setEditId] = useState<number | null>(null); const [editId, setEditId] = useState<number | null>(null);
const [types, setTypes] = useState<"edit" | "create">("create"); const [types, setTypes] = useState<"edit" | "create">("create");
const [selectedIcon, setSelectedIcon] = useState("Bus"); const [selectedIcon, setSelectedIcon] = useState("");
console.log(selectedIcon);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
@@ -83,34 +85,35 @@ const TransportTable = ({
useEffect(() => { useEffect(() => {
form.setValue("icon_name", selectedIcon); form.setValue("icon_name", selectedIcon);
}, [selectedIcon]); }, [selectedIcon, form]);
const handleEdit = (id: number) => { const handleEdit = (id: number) => {
setTypes("edit"); setTypes("edit");
setOpen(true);
setEditId(id); setEditId(id);
setOpen(true);
}; };
const { data: transportDetail } = useQuery({ const { data: transportDetail, isLoading: isDetailLoading } = useQuery({
queryKey: ["detail_transport", editId], queryKey: ["detail_transport", editId],
queryFn: () => hotelTransportDetail({ id: editId! }), queryFn: () => hotelTransportDetail({ id: editId! }),
enabled: !!editId, enabled: !!editId,
}); });
useEffect(() => { useEffect(() => {
if (transportDetail) { if (transportDetail && editId) {
form.setValue("name", transportDetail.data.data.name); 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("name_ru", transportDetail.data.data.name_ru);
form.setValue("icon_name", transportDetail.data.data.icon_name); form.setValue("icon_name", iconName);
setSelectedIcon(transportDetail.data.data.icon_name); setSelectedIcon(iconName);
} }
}, [transportDetail, editId, form]); }, [transportDetail, editId, form, selectedIcon]);
const { mutate: deleteMutate } = useMutation({ const { mutate: deleteMutate } = useMutation({
mutationFn: ({ id }: { id: number }) => hotelTransportDelete({ id }), mutationFn: ({ id }: { id: number }) => hotelTransportDelete({ id }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["all_transport"] }); queryClient.invalidateQueries({ queryKey: ["all_transport"] });
toast.success(t("Ochirildi"), { position: "top-center" }); toast.success(t("O'chirildi"), { position: "top-center" });
}, },
onError: () => onError: () =>
toast.error(t("Xatolik yuz berdi"), { position: "top-center" }), toast.error(t("Xatolik yuz berdi"), { position: "top-center" }),
@@ -124,9 +127,8 @@ const TransportTable = ({
}) => hotelTranportCreate({ body }), }) => hotelTranportCreate({ body }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["all_transport"] }); queryClient.invalidateQueries({ queryKey: ["all_transport"] });
setOpen(false); handleCloseDialog();
form.reset(); toast.success(t("Muvaffaqiyatli qo'shildi"), { position: "top-center" });
toast.success(t("Muvaffaqiyatli qoshildi"), { position: "top-center" });
}, },
onError: () => onError: () =>
toast.error(t("Xatolik yuz berdi"), { position: "top-center" }), toast.error(t("Xatolik yuz berdi"), { position: "top-center" }),
@@ -142,8 +144,7 @@ const TransportTable = ({
}) => hotelTransportUpdate({ body, id }), }) => hotelTransportUpdate({ body, id }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["all_transport"] }); queryClient.invalidateQueries({ queryKey: ["all_transport"] });
setOpen(false); handleCloseDialog();
form.reset();
toast.success(t("Tahrirlandi"), { position: "top-center" }); toast.success(t("Tahrirlandi"), { position: "top-center" });
}, },
onError: () => onError: () =>
@@ -163,6 +164,12 @@ const TransportTable = ({
const handleDelete = (id: number) => deleteMutate({ id }); const handleDelete = (id: number) => deleteMutate({ id });
const handleCloseDialog = () => {
setOpen(false);
setEditId(null);
form.reset();
};
const columns = TranportColumns(handleEdit, handleDelete, t); const columns = TranportColumns(handleEdit, handleDelete, t);
const table = useReactTable({ const table = useReactTable({
@@ -185,14 +192,15 @@ const TransportTable = ({
<Button <Button
variant="default" variant="default"
onClick={() => { onClick={() => {
setOpen(true);
setTypes("create"); setTypes("create");
setEditId(null);
setSelectedIcon("HelpCircle");
form.reset(); form.reset();
setSelectedIcon(""); setOpen(true);
}} }}
> >
<PlusIcon className="mr-2" /> <PlusIcon className="mr-2" />
{t("Qoshish")} {t("Qo'shish")}
</Button> </Button>
</div> </div>
@@ -250,13 +258,26 @@ const TransportTable = ({
namePageSize="pageTransportSize" namePageSize="pageTransportSize"
/> />
<Dialog open={open} onOpenChange={setOpen}> <Dialog
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) {
handleCloseDialog();
}
}}
>
<DialogContent> <DialogContent>
<p className="text-xl font-semibold mb-4"> <p className="text-xl font-semibold mb-4">
{types === "create" {types === "create"
? t("Yangi transport qoshish") ? t("Yangi transport qo'shish")
: t("Tahrirlash")} : t("Tahrirlash")}
</p> </p>
{isDetailLoading && types === "edit" ? (
<div className="flex justify-center items-center h-64">
<Loader className="animate-spin w-8 h-8" />
</div>
) : (
<Form {...form}> <Form {...form}>
<form <form
onSubmit={form.handleSubmit(onSubmit)} onSubmit={form.handleSubmit(onSubmit)}
@@ -298,6 +319,7 @@ const TransportTable = ({
<IconSelect <IconSelect
setSelectedIcon={setSelectedIcon} setSelectedIcon={setSelectedIcon}
selectedIcon={selectedIcon} selectedIcon={selectedIcon}
defaultIcon="HelpCircle"
/> />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
@@ -308,7 +330,7 @@ const TransportTable = ({
<div className="flex justify-end gap-4"> <div className="flex justify-end gap-4">
<Button <Button
type="button" type="button"
onClick={() => setOpen(false)} onClick={handleCloseDialog}
className="border-slate-600 text-white hover:bg-gray-600 bg-gray-600" className="border-slate-600 text-white hover:bg-gray-600 bg-gray-600"
> >
{t("Bekor qilish")} {t("Bekor qilish")}
@@ -325,6 +347,7 @@ const TransportTable = ({
</div> </div>
</form> </form>
</Form> </Form>
)}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</> </>

View File

@@ -20,9 +20,19 @@ const NEWS_CATEGORY = "dashboard/dashboard-category/";
const HOTEL = "dashboard/dashboard-hotel/"; const HOTEL = "dashboard/dashboard-hotel/";
const FAQ = "dashboard/dashboard-faq/"; const FAQ = "dashboard/dashboard-faq/";
const FAQ_CATEGORIES = "dashboard/dashboard-faq-category/"; const FAQ_CATEGORIES = "dashboard/dashboard-faq-category/";
const SITE_SEO = "dashboard/dashboard-site-seo/";
const OFFERTA = "dashboard/dashboard-site-offerta/";
const HELP_PAGE = "dashboard/dashboard-site-help-page/";
const SITE_SETTING = "dashboard/dashboard-site-settings/";
const SUPPORT_USER = "dashboard/dashboard-support/";
const SUPPORT_AGENCY = "dashboard/dashboard-travel-agency-request/";
const USER_ORDERS = "dashboard/dashboard-ticket-order/";
const POPULAR_TOURS = "dashboard/dashboard-ticket-featured/";
const BANNER = "dashboard/dashboard-site-banner/";
export { export {
AUTH_LOGIN, AUTH_LOGIN,
BANNER,
BASE_URL, BASE_URL,
DOWNLOAD_PDF, DOWNLOAD_PDF,
FAQ, FAQ,
@@ -32,6 +42,7 @@ export {
GET_ALL_USERS, GET_ALL_USERS,
GET_ME, GET_ME,
GET_TICKET, GET_TICKET,
HELP_PAGE,
HOTEL, HOTEL,
HOTEL_BADGE, HOTEL_BADGE,
HOTEL_FEATURES, HOTEL_FEATURES,
@@ -40,6 +51,13 @@ export {
HPTEL_TYPES, HPTEL_TYPES,
NEWS, NEWS,
NEWS_CATEGORY, NEWS_CATEGORY,
OFFERTA,
POPULAR_TOURS,
SITE_SEO,
SITE_SETTING,
SUPPORT_AGENCY,
SUPPORT_USER,
TOUR_TRANSPORT, TOUR_TRANSPORT,
UPDATE_USER, UPDATE_USER,
USER_ORDERS,
}; };

View File

@@ -328,5 +328,143 @@
"Breakfast Only": "Только завтрак", "Breakfast Only": "Только завтрак",
"Half Board": "Полупансион (завтрак и обед или ужин)", "Half Board": "Полупансион (завтрак и обед или ужин)",
"Full Board": "Полный пансион (завтрак, обед и ужин)", "Full Board": "Полный пансион (завтрак, обед и ужин)",
"All Inclusive": "Всё включено (питание, напитки и услуги полностью)" "All Inclusive": "Всё включено (питание, напитки и услуги полностью)",
"(1 kishi uchun)": "(на 1 человека)",
"hamrohlar soni (eng kamida)": "количество компаньонов (минимум)",
"hamrohlar soni (eng ko'pida)": "количество компаньонов (максимальное)",
"Yo'lovchilar soni": "Количество пассажиров",
"Tarifni tanlang": "Выберите тариф",
"Mavjud tariflar": "Доступные тарифы",
"Transport tanlang": "Выбрать транспорт",
"Mavjud transportlar": "Доступные транспортные средства",
"Visa talab qilinadimi": "Требуется ли виза",
"Ha": "Да",
"Yo'q": "Нет",
"Banner": "Баннер",
"Faqat bitta rasm yuklash mumkin": "Можно загрузить только одно изображение",
"Qo'shimcha rasmlar": "Дополнительные изображения",
"Qulaylik nomi (ru)": "Название удобства (ru)",
"Yangi xizmat qo'shish": "Добавить новую услугу",
"Taom nomi": "Название блюда",
"Taom tavsifi": "Описание блюда",
"Yo'nalishlar": "Направления",
"Yo'nalish qo'shish": "Добавить маршрут",
"Davomiylik (kun)": "Продолжительность (дни)",
"Bu bo'limda savollar yo'q": "В этом разделе нет вопросов",
"Yangi FAQ qo'shish": "Добавить новый FAQ",
"Pending Payment": "Ожидается оплата",
"Pending Confirmation": "Ожидается подтверждение",
"Confirmed": "Подтверждено",
"Completed": "Выполнено",
"pending": "В ожидании",
"done": "Выполнено",
"failed": "Неудачно",
"Yordam so'rovlari": "Запросы помощи",
"Batafsil ko'rish": "Подробнее",
"Agentlikka tegishli": "Принадлежащий агентству",
"Yopish": "Закрыть",
"Yakunlandi deb belgilash": "Отметить как завершенное",
"Kutilmoqda deb belgilash": "Отметить как ожидаемое",
"Natija topilmadi": "Результат не найден",
"Barchasi": "Все",
"Sayt bo'yicha": "На сайте",
"Diqqat! O'chirish": "Внимание! Удалить",
"Siz rostdan ham ushbu so'rovni o'chirmoqchimisiz?": "Вы уверены, что хотите удалить этот запрос?",
"O'chirishda xatolik yuz berdi": "Ошибка при удалении",
"Muvaffaqiyatli o'chirildi": "Удалено успешно",
"Agentlik so'rovlari": "Агентские запросы",
"Qidiruv (ism, email yoki telefon)...": "Поиск (имя, email или телефон)...",
"Tozalash": "Очистка",
"So'rov topilmadi": "Запрос не найден",
"Tafsilotlar": "Подробности",
"Javob yozish": "Написать ответ",
"Hujjatlar": "Документы",
"Hujjat": "Документ",
"Hujjat topilmadi": "Документ не найден",
"Qabul qilish": "Принять",
"Rad etish": "Отказ",
"Popular": "Популярный",
"SEO Manager": "SEO Менеджер",
"Saytingizni qidiruv tizimida yaxshi pozitsiyaga keltiring": "Продвиньте свой сайт в поисковых системах",
"Page Title": "Заголовок страницы",
"Sahifa sarlavhasi (3060 belgi)": "Заголовок страницы (3060 символов)",
"Meta Description": "Мета описание",
"Sahifa tavsifi (120160 belgi)": "Описание страницы (120160 символов)",
"Keywords": "Ключевые слова",
"Kalit so'zlar (vergul bilan ajratilgan)": "Ключевые слова (через запятую)",
"Masalan: Python, Web Development, Coding": "Например: Python, Web Development, Coding",
"Open Graph (Ijtimoiy Tarmoqlar)": "Open Graph (Социальные сети)",
"OG Title": "OG Заголовок",
"Ijtimoiy tarmoqdagi sarlavha": "Заголовок в социальных сетях",
"OG Description": "OG Описание",
"Ijtimoiy tarmoqdagi tavsif": "Описание в социальных сетях",
"OG Image": "OG Изображение",
"Saqlangan SEO Malumotlari": "Сохранённые SEO данные",
"Hozircha SEO malumotlari mavjud emas.": "Пока нет данных по SEO.",
"Malumotlar muvaffaqiyatli saqlandi": "Данные успешно сохранены",
"Muvaffaqiyatli yaratildi": "Успешно создано",
"Muvaffaqiyatli yangilandi": "Успешно обновлено",
"Sarlavha kiritish majburiy": "Необходимо ввести заголовок",
"Sarlavha kamida 3 ta belgidan iborat bo'lishi kerak": "Заголовок должен содержать не менее 3 символов",
"Kontent kiritish majburiy": "Необходимо ввести содержимое",
"Kontent kamida 10 ta belgidan iborat bo'lishi kerak": "Содержимое должно содержать не менее 10 символов",
"Kimlar uchun degan maydonni tanlang": "Выберите поле «Для кого»",
"Iltimos, barcha majburiy maydonlarni to'ldiring": "Пожалуйста, заполните все обязательные поля",
"Ommaviy oferta": "Публичная оферта",
"Yangi oferta yaratish": "Создать новую оферту",
"Ommaviy oferta sarlavhasi": "Заголовок публичной оферты",
"Kontent": "Содержимое",
"Oferta matnini kiriting...": "Введите текст оферты...",
"Kimlar uchun": "Для кого",
"Barcha": "Все",
"Jismoniy shaxslar uchun": "Для физических лиц",
"Yuridik shaxslar uchun": "Для юридических лиц",
"O'chirish tasdiqlash": "Подтверждение удаления",
"Haqiqatan ham bu ofertani o'chirmoqchimisiz? Bu amalni bekor qilib bo'lmaydi.": "Вы действительно хотите удалить эту оферту? Это действие нельзя отменить.",
"Yordam sahifalari boshqaruvi": "Управление страницами помощи",
"Yangi yordam sahifasi yaratish": "Создать новую страницу помощи",
"Yordam sahifasi sarlavhasi": "Заголовок страницы помощи",
"Yordam matnini kiriting...": "Введите текст помощи...",
"Sahifa turi": "Тип страницы",
"Sahifa turini tanlang": "Выберите тип страницы",
"Qollanma": "Инструкция",
"Maxfiylik siyosati": "Политика конфиденциальности",
"Faol emas": "Неактивно",
"Yaratish": "Создать",
"Natija topilmadi.": "Результаты не найдены.",
"Ochirishni tasdiqlash": "Подтверждение удаления",
"Haqiqatan ham bu yordam sahifasini ochirmoqchimisiz? Bu amalni bekor qilib bolmaydi.": "Вы действительно хотите удалить эту страницу помощи? Это действие нельзя отменить.",
"Contact settings": "Настройки контактов",
"Hozircha kontakt ma'lumotlari qo'shilmagan": "Пока нет контактных данных",
"Sayt uchun telegram, instagram, manzil, email va telefonni bu yerda saqlang. Siz faqat bir marta qo'sha olasiz — keyin tahrirlash mumkin.": "Сохраните свои Telegram, Instagram, адрес, адрес электронной почты и телефон для сайта здесь. Вы можете добавить только один раз - затем вы можете редактировать.",
"Kontakt ma'lumotlari": "Контактная информация",
"Kontaktni tahrirlash": "Редактировать контакт",
"Kontakt qo'shish": "Добавить контакт",
"Asosiy telefon": "Основной телефон",
"Qo'shimcha telefon": "Дополнительный телефон",
"Muvaffaqiyatli saqlandi": "Успешно сохранено",
"Mehmonxona reytingi": "Рейтинг отеля",
"Taom rejasi": "План питания",
"Tanlang": "Выберите",
"Mehmonxona turlari": "Типы отелей",
"Yana tanlang...": "Выберите ещё...",
"Mehmonxona xususiyatlari": "Характеристики отеля",
"Xususiyat turlari": "Типы свойств",
"Sayt uchun Banner": "Баннер для сайта",
"Sayt Bannerlari": "Баннеры сайта",
"Bannerlarni boshqarish": "Управление баннерами",
"Rasm": "Изображение",
"Tavsif": "Описание",
"Joylashuvi": "Расположение",
"Hozircha bannerlar mavjud emas": "Пока нет баннеров",
"Bannerni tahrirlash": "Редактировать баннер",
"Yangi banner qo'shish": "Добавить новый баннер",
"Havola URL": "URL адрес",
"Asosiy": "Основная",
"Kun taklifi": "Приглашение дня",
"Mashhur yonalishlar": "Известные направления",
"Reytingi baland turlar": "Высокорейтинговые туры",
"Status muvaffaqiyatli yangilandi": "Статус успешно обновлён",
"Statusni yangilashda xatolik yuz berdi": "Ошибка обновления статуса",
"Refunded": "Подтверждено"
} }

View File

@@ -38,6 +38,8 @@
"FAQ Kategoriyalar": "FAQ Kategoriyalar", "FAQ Kategoriyalar": "FAQ Kategoriyalar",
"Foydalanuvchini o'chirish": "Foydalanuvchini o'chirish", "Foydalanuvchini o'chirish": "Foydalanuvchini o'chirish",
"Siz": "Siz", "Siz": "Siz",
"(1 kishi uchun)": "(1 kishi uchun)",
"hamrohlar soni (eng kamida)": "hamrohlar soni (eng kamida)",
"foydalanuvchini o'chirmoqchimisiz?": "foydalanuvchini o'chirmoqchimisiz?", "foydalanuvchini o'chirmoqchimisiz?": "foydalanuvchini o'chirmoqchimisiz?",
"Ushbu amalni qaytarib bo'lmaydi": "Ushbu amalni qaytarib bo'lmaydi.", "Ushbu amalni qaytarib bo'lmaydi": "Ushbu amalni qaytarib bo'lmaydi.",
"Bekor qilish": "Bekor qilish", "Bekor qilish": "Bekor qilish",
@@ -303,6 +305,8 @@
"Yangiliklar soni": "Yangiliklar soni", "Yangiliklar soni": "Yangiliklar soni",
"Harakatlar": "Harakatlar", "Harakatlar": "Harakatlar",
"Hech qanday kategoriya topilmadi": "Hech qanday kategoriya topilmadi", "Hech qanday kategoriya topilmadi": "Hech qanday kategoriya topilmadi",
"Natija topilmadi": "Natija topilmadi",
"Yangi kategoriya": "Yangi kategoriya",
"Kategoriya tahrirlash": "Kategoriya tahrirlash", "Kategoriya tahrirlash": "Kategoriya tahrirlash",
"Yangi kategoriya qoshish": "Yangi kategoriya qoshish", "Yangi kategoriya qoshish": "Yangi kategoriya qoshish",
"FAQ (Savol va javoblar)": "FAQ (Savol va javoblar)", "FAQ (Savol va javoblar)": "FAQ (Savol va javoblar)",
@@ -328,5 +332,140 @@
"Breakfast Only": "Faqat nonushta", "Breakfast Only": "Faqat nonushta",
"Half Board": "Yarim pansion (nonushta va tushlik yoki kechki ovqat)", "Half Board": "Yarim pansion (nonushta va tushlik yoki kechki ovqat)",
"Full Board": "Toliq pansion (nonushta, tushlik va kechki ovqat)", "Full Board": "Toliq pansion (nonushta, tushlik va kechki ovqat)",
"All Inclusive": "Toliq pansion (nonushta, tushlik va kechki ovqat)" "All Inclusive": "Toliq pansion (nonushta, tushlik va kechki ovqat)",
"hamrohlar soni (eng ko'pida)": "hamrohlar soni (eng ko'pida)",
"Yo'lovchilar soni": "Yo'lovchilar soni",
"Tarifni tanlang": "Tarifni tanlang",
"Mavjud tariflar": "Mavjud tariflar",
"Transport tanlang": "Transport tanlang",
"Mavjud transportlar": "Mavjud transportlar",
"Visa talab qilinadimi": "Visa talab qilinadimi",
"Ha": "Ha",
"Yo'q": "Yo'q",
"Banner": "Banner",
"Faqat bitta rasm yuklash mumkin": "Faqat bitta rasm yuklash mumkin",
"Qo'shimcha rasmlar": "Qo'shimcha rasmlar",
"Qulaylik nomi (ru)": "Qulaylik nomi (ru)",
"Yangi xizmat qo'shish": "Yangi xizmat qo'shish",
"Taom nomi": "Taom nomi",
"Taom tavsifi": "Taom tavsifi",
"Yo'nalishlar": "Yo'nalishlar",
"Yo'nalish qo'shish": "Yo'nalish qo'shish",
"Davomiylik (kun)": "Davomiylik (kun)",
"Bu bo'limda savollar yo'q": "Bu bo'limda savollar yo'q",
"Yangi FAQ qo'shish": "Yangi FAQ qo'shish",
"Pending Payment": "Tolov kutilmoqda",
"Pending Confirmation": "Tasdiqlash kutilmoqda",
"Confirmed": "Tasdiqlangan",
"Completed": "Bajarilgan",
"pending": "Kutilmoqda",
"done": "Yakunlangan",
"failed": "Muvaffaqiyatsiz",
"Yordam so'rovlari": "Yordam so'rovlari",
"Batafsil ko'rish": "Batafsil ko'rish",
"Agentlikka tegishli": "Agentlikka tegishli",
"Yopish": "Yopish",
"Yakunlandi deb belgilash": "Yakunlandi deb belgilash",
"Kutilmoqda deb belgilash": "Kutilmoqda deb belgilash",
"Barchasi": "Barchasi",
"Sayt bo'yicha": "Sayt bo'yicha",
"Diqqat! O'chirish": "Diqqat! O'chirish",
"Siz rostdan ham ushbu so'rovni o'chirmoqchimisiz?": "Siz rostdan ham ushbu so'rovni o'chirmoqchimisiz?",
"O'chirishda xatolik yuz berdi": "O'chirishda xatolik yuz berdi",
"Muvaffaqiyatli o'chirildi": "Muvaffaqiyatli o'chirildi",
"Agentlik so'rovlari": "Agentlik so'rovlari",
"Qidiruv (ism, email yoki telefon)...": "Qidiruv (ism, email yoki telefon)...",
"Tozalash": "Tozalash",
"So'rov topilmadi": "So'rov topilmadi",
"Tafsilotlar": "Tafsilotlar",
"Javob yozish": "Javob yozish",
"Hujjatlar": "Hujjatlar",
"Hujjat": "Hujjat",
"Hujjat topilmadi": "Hujjat topilmadi",
"Qabul qilish": "Qabul qilish",
"Rad etish": "Rad etish",
"Popular": "Mashhur",
"SEO Manager": "SEO Menejer",
"Saytingizni qidiruv tizimida yaxshi pozitsiyaga keltiring": "Saytingizni qidiruv tizimida yaxshi pozitsiyaga keltiring",
"Page Title": "Sahifa sarlavhasi",
"Sahifa sarlavhasi (3060 belgi)": "Sahifa sarlavhasi (3060 belgi)",
"Meta Description": "Meta tavsif",
"Sahifa tavsifi (120160 belgi)": "Sahifa tavsifi (120160 belgi)",
"Keywords": "Kalit sozlar",
"Kalit so'zlar (vergul bilan ajratilgan)": "Kalit sozlar (vergul bilan ajratilgan)",
"Masalan: Python, Web Development, Coding": "Masalan: Python, Web Development, Coding",
"Open Graph (Ijtimoiy Tarmoqlar)": "Open Graph (Ijtimoiy tarmoqlar)",
"OG Title": "OG Sarlavha",
"Ijtimoiy tarmoqdagi sarlavha": "Ijtimoiy tarmoqdagi sarlavha",
"OG Description": "OG Tavsif",
"Ijtimoiy tarmoqdagi tavsif": "Ijtimoiy tarmoqdagi tavsif",
"OG Image": "OG Rasm",
"Saqlangan SEO Malumotlari": "Saqlangan SEO malumotlari",
"Hozircha SEO malumotlari mavjud emas.": "Hozircha SEO malumotlari mavjud emas.",
"Malumotlar muvaffaqiyatli saqlandi": "Malumotlar muvaffaqiyatli saqlandi",
"Muvaffaqiyatli yaratildi": "Muvaffaqiyatli yaratildi",
"Muvaffaqiyatli yangilandi": "Muvaffaqiyatli yangilandi",
"Sarlavha kiritish majburiy": "Sarlavha kiritish majburiy",
"Sarlavha kamida 3 ta belgidan iborat bo'lishi kerak": "Sarlavha kamida 3 ta belgidan iborat bo'lishi kerak",
"Kontent kiritish majburiy": "Kontent kiritish majburiy",
"Kontent kamida 10 ta belgidan iborat bo'lishi kerak": "Kontent kamida 10 ta belgidan iborat bo'lishi kerak",
"Kimlar uchun degan maydonni tanlang": "Kimlar uchun degan maydonni tanlang",
"Iltimos, barcha majburiy maydonlarni to'ldiring": "Iltimos, barcha majburiy maydonlarni to'ldiring",
"Ommaviy oferta": "Ommaviy oferta",
"Yangi oferta yaratish": "Yangi oferta yaratish",
"Ommaviy oferta sarlavhasi": "Ommaviy oferta sarlavhasi",
"Kontent": "Kontent",
"Oferta matnini kiriting...": "Oferta matnini kiriting...",
"Kimlar uchun": "Kimlar uchun",
"Barcha": "Barcha",
"Jismoniy shaxslar uchun": "Jismoniy shaxslar uchun",
"Yuridik shaxslar uchun": "Yuridik shaxslar uchun",
"O'chirish tasdiqlash": "O'chirish tasdiqlash",
"Haqiqatan ham bu ofertani o'chirmoqchimisiz? Bu amalni bekor qilib bo'lmaydi.": "Haqiqatan ham bu ofertani o'chirmoqchimisiz? Bu amalni bekor qilib bo'lmaydi.",
"Yordam sahifalari boshqaruvi": "Yordam sahifalari boshqaruvi",
"Yangi yordam sahifasi yaratish": "Yangi yordam sahifasi yaratish",
"Yordam sahifasi sarlavhasi": "Yordam sahifasi sarlavhasi",
"Yordam matnini kiriting...": "Yordam matnini kiriting...",
"Sahifa turi": "Sahifa turi",
"Sahifa turini tanlang": "Sahifa turini tanlang",
"Qollanma": "Qollanma",
"Maxfiylik siyosati": "Maxfiylik siyosati",
"Faol emas": "Faol emas",
"Yaratish": "Yaratish",
"Natija topilmadi.": "Natija topilmadi.",
"Ochirishni tasdiqlash": "Ochirishni tasdiqlash",
"Haqiqatan ham bu yordam sahifasini ochirmoqchimisiz? Bu amalni bekor qilib bolmaydi.": "Haqiqatan ham bu yordam sahifasini ochirmoqchimisiz? Bu amalni bekor qilib bolmaydi.",
"Contact settings": "Kontakt sozlamalari",
"Hozircha kontakt ma'lumotlari qo'shilmagan": "Hozircha kontakt ma'lumotlari qo'shilmagan",
"Sayt uchun telegram, instagram, manzil, email va telefonni bu yerda saqlang. Siz faqat bir marta qo'sha olasiz — keyin tahrirlash mumkin.": "Sayt uchun telegram, instagram, manzil, email va telefonni bu yerda saqlang. Siz faqat bir marta qo'sha olasiz — keyin tahrirlash mumkin.",
"Kontakt ma'lumotlari": "Kontakt ma'lumotlari",
"Kontaktni tahrirlash": "Kontaktni tahrirlash",
"Kontakt qo'shish": "Kontakt qo'shish",
"Asosiy telefon": "Asosiy telefon",
"Qo'shimcha telefon": "Qo'shimcha telefon",
"Muvaffaqiyatli saqlandi": "Muvaffaqiyatli saqlandi",
"Mehmonxona reytingi": "Mehmonxona reytingi",
"Taom rejasi": "Taom rejasi",
"Tanlang": "Tanlang",
"Mehmonxona turlari": "Mehmonxona turlari",
"Yana tanlang...": "Yana tanlang...",
"Mehmonxona xususiyatlari": "Mehmonxona xususiyatlari",
"Xususiyat turlari": "Xususiyat turlari",
"Sayt uchun Banner": "Sayt uchun Banner",
"Sayt Bannerlari": "Sayt Bannerlari",
"Bannerlarni boshqarish": "Bannerlarni boshqarish",
"Rasm": "Rasm",
"Tavsif": "Tavsif",
"Joylashuvi": "Joylashuvi",
"Hozircha bannerlar mavjud emas": "Hozircha bannerlar mavjud emas",
"Bannerni tahrirlash": "Bannerni tahrirlash",
"Yangi banner qo'shish": "Yangi banner qo'shish",
"Havola URL": "Havola URL",
"Asosiy": "Asosiy",
"Kun taklifi": "Kun taklifi",
"Mashhur yonalishlar": "Mashhur yonalishlar",
"Reytingi baland turlar": "Reytingi baland turlar",
"Status muvaffaqiyatli yangilandi": "Status muvaffaqiyatli yangilandi",
"Statusni yangilashda xatolik yuz berdi": "Statusni yangilashda xatolik yuz berdi",
"Refunded": "Tasdiqlangan"
} }

239
src/shared/ui/carousel.tsx Normal file
View File

@@ -0,0 +1,239 @@
import * as React from "react"
import useEmblaCarousel, {
type UseEmblaCarouselType,
} from "embla-carousel-react"
import { ArrowLeft, ArrowRight } from "lucide-react"
import { cn } from "@/shared/lib/utils"
import { Button } from "@/shared/ui/button"
type CarouselApi = UseEmblaCarouselType[1]
type UseCarouselParameters = Parameters<typeof useEmblaCarousel>
type CarouselOptions = UseCarouselParameters[0]
type CarouselPlugin = UseCarouselParameters[1]
type CarouselProps = {
opts?: CarouselOptions
plugins?: CarouselPlugin
orientation?: "horizontal" | "vertical"
setApi?: (api: CarouselApi) => void
}
type CarouselContextProps = {
carouselRef: ReturnType<typeof useEmblaCarousel>[0]
api: ReturnType<typeof useEmblaCarousel>[1]
scrollPrev: () => void
scrollNext: () => void
canScrollPrev: boolean
canScrollNext: boolean
} & CarouselProps
const CarouselContext = React.createContext<CarouselContextProps | null>(null)
function useCarousel() {
const context = React.useContext(CarouselContext)
if (!context) {
throw new Error("useCarousel must be used within a <Carousel />")
}
return context
}
function Carousel({
orientation = "horizontal",
opts,
setApi,
plugins,
className,
children,
...props
}: React.ComponentProps<"div"> & CarouselProps) {
const [carouselRef, api] = useEmblaCarousel(
{
...opts,
axis: orientation === "horizontal" ? "x" : "y",
},
plugins
)
const [canScrollPrev, setCanScrollPrev] = React.useState(false)
const [canScrollNext, setCanScrollNext] = React.useState(false)
const onSelect = React.useCallback((api: CarouselApi) => {
if (!api) return
setCanScrollPrev(api.canScrollPrev())
setCanScrollNext(api.canScrollNext())
}, [])
const scrollPrev = React.useCallback(() => {
api?.scrollPrev()
}, [api])
const scrollNext = React.useCallback(() => {
api?.scrollNext()
}, [api])
const handleKeyDown = React.useCallback(
(event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") {
event.preventDefault()
scrollPrev()
} else if (event.key === "ArrowRight") {
event.preventDefault()
scrollNext()
}
},
[scrollPrev, scrollNext]
)
React.useEffect(() => {
if (!api || !setApi) return
setApi(api)
}, [api, setApi])
React.useEffect(() => {
if (!api) return
onSelect(api)
api.on("reInit", onSelect)
api.on("select", onSelect)
return () => {
api?.off("select", onSelect)
}
}, [api, onSelect])
return (
<CarouselContext.Provider
value={{
carouselRef,
api: api,
opts,
orientation:
orientation || (opts?.axis === "y" ? "vertical" : "horizontal"),
scrollPrev,
scrollNext,
canScrollPrev,
canScrollNext,
}}
>
<div
onKeyDownCapture={handleKeyDown}
className={cn("relative", className)}
role="region"
aria-roledescription="carousel"
data-slot="carousel"
{...props}
>
{children}
</div>
</CarouselContext.Provider>
)
}
function CarouselContent({ className, ...props }: React.ComponentProps<"div">) {
const { carouselRef, orientation } = useCarousel()
return (
<div
ref={carouselRef}
className="overflow-hidden"
data-slot="carousel-content"
>
<div
className={cn(
"flex",
orientation === "horizontal" ? "-ml-4" : "-mt-4 flex-col",
className
)}
{...props}
/>
</div>
)
}
function CarouselItem({ className, ...props }: React.ComponentProps<"div">) {
const { orientation } = useCarousel()
return (
<div
role="group"
aria-roledescription="slide"
data-slot="carousel-item"
className={cn(
"min-w-0 shrink-0 grow-0 basis-full",
orientation === "horizontal" ? "pl-4" : "pt-4",
className
)}
{...props}
/>
)
}
function CarouselPrevious({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollPrev, canScrollPrev } = useCarousel()
return (
<Button
data-slot="carousel-previous"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -left-12 -translate-y-1/2"
: "-top-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollPrev}
onClick={scrollPrev}
{...props}
>
<ArrowLeft />
<span className="sr-only">Previous slide</span>
</Button>
)
}
function CarouselNext({
className,
variant = "outline",
size = "icon",
...props
}: React.ComponentProps<typeof Button>) {
const { orientation, scrollNext, canScrollNext } = useCarousel()
return (
<Button
data-slot="carousel-next"
variant={variant}
size={size}
className={cn(
"absolute size-8 rounded-full",
orientation === "horizontal"
? "top-1/2 -right-12 -translate-y-1/2"
: "-bottom-12 left-1/2 -translate-x-1/2 rotate-90",
className
)}
disabled={!canScrollNext}
onClick={scrollNext}
{...props}
>
<ArrowRight />
<span className="sr-only">Next slide</span>
</Button>
)
}
export {
type CarouselApi,
Carousel,
CarouselContent,
CarouselItem,
CarouselPrevious,
CarouselNext,
}

View File

@@ -0,0 +1,104 @@
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/shared/ui/select";
import { useTranslation } from "react-i18next";
interface InfiniteScrollSelectProps<T> {
value: string;
onValueChange: (value: string) => void;
placeholder: string;
label: string;
data: T[];
hasNextPage?: boolean;
isFetchingNextPage?: boolean;
fetchNextPage: () => void;
renderOption: (item: T) => {
key: string | number;
value: string;
label: string;
};
isLoading?: boolean;
className?: string;
}
export function InfiniteScrollSelect<T>({
value,
onValueChange,
placeholder,
label,
data,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
renderOption,
isLoading = false,
className = "",
}: InfiniteScrollSelectProps<T>) {
const { t } = useTranslation();
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
// Scroll oxiriga 50px qolganda keyingi page ni yuklash
if (
scrollHeight - scrollTop - clientHeight < 50 &&
hasNextPage &&
!isFetchingNextPage
) {
fetchNextPage();
}
};
return (
<Select onValueChange={onValueChange} value={value} disabled={isLoading}>
<SelectTrigger
className={`w-full !h-12 bg-gray-800 border-gray-700 text-white ${className}`}
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent className="bg-gray-800 border-gray-700 text-white max-h-[200px] overflow-hidden">
<SelectGroup>
<SelectLabel>{label}</SelectLabel>
<div
data-radix-select-viewport
className="overflow-y-auto max-h-[180px]"
onScroll={handleScroll}
>
{data && data?.length === 0 && !isLoading && (
<div className="text-center py-4 text-gray-400 text-sm">
{t("Ma'lumot topilmadi")}
</div>
)}
{data.map((item) => {
const option = renderOption(item);
return (
<SelectItem key={option.key} value={option.value}>
{option.label}
</SelectItem>
);
})}
{isFetchingNextPage && (
<div className="text-center py-2 text-gray-400 text-sm">
{t("Yuklanmoqda...")}
</div>
)}
{!hasNextPage && data && data.length > 0 && (
<div className="text-center py-2 text-gray-500 text-xs">
{t("Barcha ma'lumotlar yuklandi")}
</div>
)}
</div>
</SelectGroup>
</SelectContent>
</Select>
);
}

View File

@@ -42,11 +42,13 @@ const LazyIcon: React.FC<{ name: string }> = ({ name }) => {
interface IconSelectProps { interface IconSelectProps {
selectedIcon?: string; selectedIcon?: string;
defaultIcon?: string;
setSelectedIcon: (value: string) => void; setSelectedIcon: (value: string) => void;
} }
const IconSelect: React.FC<IconSelectProps> = ({ const IconSelect: React.FC<IconSelectProps> = ({
selectedIcon, selectedIcon,
defaultIcon = "HelpCircle",
setSelectedIcon, setSelectedIcon,
}) => { }) => {
const [icons, setIcons] = useState<string[]>([]); const [icons, setIcons] = useState<string[]>([]);
@@ -125,7 +127,10 @@ const IconSelect: React.FC<IconSelectProps> = ({
{selectedIcon} {selectedIcon}
</div> </div>
) : ( ) : (
t("Ikonka tanlang") <div className="flex items-center gap-2 text-gray-500">
<LazyIcon name={defaultIcon} />
{defaultIcon}
</div>
)} )}
</SelectValue> </SelectValue>
</SelectTrigger> </SelectTrigger>

29
src/shared/ui/switch.tsx Normal file
View File

@@ -0,0 +1,29 @@
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/shared/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -155,6 +155,7 @@ const MENU_ITEMS = [
{ label: "Offerta", path: "/site-pages/" }, { label: "Offerta", path: "/site-pages/" },
{ label: "Yordam pagelari", path: "/site-help/" }, { label: "Yordam pagelari", path: "/site-help/" },
{ label: "Sayt sozlamalari", path: "/site-settings/" }, { label: "Sayt sozlamalari", path: "/site-settings/" },
{ label: "Sayt uchun Banner", path: "/site-banner/" },
], ],
}, },
]; ];

View File

@@ -16,8 +16,8 @@ export default defineConfig({
}, },
}, },
server: { server: {
host: true, // Lokal tarmoqda test uchun host: true,
port: 5173, // Default port port: 5173,
}, },
build: { build: {
outDir: "dist", // Vercel build chiqishini shu papkadan oladi outDir: "dist", // Vercel build chiqishini shu papkadan oladi